Compare commits

...

4 Commits

Author SHA1 Message Date
Sri Pasumarthi 287c167eab fix 1566 2026-03-27 19:20:43 -07:00
Gal Zahavi ae123c547c fix(sandbox): implement Windows Mandatory Integrity Control for GeminiSandbox (#24057) 2026-03-28 00:14:35 +00:00
Keith Guerin c2705e8332 fix(cli): resolve layout contention and flashing loop in StatusRow (#24065) 2026-03-28 00:06:07 +00:00
Sam Roberts 9574855435 Re-word intro to Gemini 3 page. (#24069) 2026-03-28 00:01:22 +00:00
15 changed files with 386 additions and 40 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Gemini 3 Pro and Gemini 3 Flash on Gemini CLI
Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users!
Learn about how you can use Gemini 3 Pro and Gemini 3 Flash on Gemini CLI.
<!-- prettier-ignore -->
> [!NOTE]
+71 -1
View File
@@ -178,9 +178,10 @@ describe('GeminiAgent', () => {
isPlanEnabled: vi.fn().mockReturnValue(true),
getGemini31LaunchedSync: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getDisableAlwaysAllow: vi.fn().mockReturnValue(false),
validatePathAccess: vi.fn().mockReturnValue(null),
getExtensions: vi.fn().mockReturnValue([]),
isTrustedFolder: vi.fn().mockReturnValue(true),
getWorkspaceContext: vi.fn().mockReturnValue({
addReadOnlyPath: vi.fn(),
}),
@@ -198,6 +199,9 @@ describe('GeminiAgent', () => {
setClientName: vi.fn(),
},
setClientName: vi.fn(),
getSkillManager: vi.fn().mockReturnValue({
discoverSkills: vi.fn().mockResolvedValue(undefined),
}),
get config() {
return this;
},
@@ -510,6 +514,27 @@ describe('GeminiAgent', () => {
);
});
it('should create a new session with additional roots', async () => {
const _meta = { additionalRoots: ['/extra/path'] };
await agent.newSession({
cwd: '/tmp',
mcpServers: [],
_meta,
} as acp.NewSessionRequest & { _meta?: Record<string, unknown> });
expect(loadCliConfig).toHaveBeenCalledWith(
expect.objectContaining({
context: expect.objectContaining({
includeDirectories: expect.arrayContaining(['/extra/path']),
}),
}),
'test-session-id',
mockArgv,
{ cwd: '/tmp' },
);
});
it('should handle authentication failure gracefully', async () => {
mockConfig.refreshAuth.mockRejectedValue(new Error('Auth failed'));
const debugSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
@@ -579,6 +604,51 @@ describe('GeminiAgent', () => {
expect(result).toMatchObject({ stopReason: 'end_turn' });
});
it('should support additional roots mid-session in prompt', async () => {
const _meta = { additionalRoots: ['/extra/path'] };
// Mock chat.sendMessageStream to avoid crash
const mockChatInTest = {
sendMessageStream: vi
.fn()
.mockResolvedValue(
createMockStream([
{
type: StreamEventType.CHUNK,
value: { candidates: [{ content: { parts: [{ text: 'Hi' }] } }] },
},
]),
),
};
(
mockConfig.getGeminiClient().startChat as unknown as import('vitest').Mock
).mockResolvedValue(mockChatInTest);
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
const session = (
agent as unknown as { sessions: Map<string, Session> }
).sessions.get('test-session-id');
if (!session) throw new Error('Session not found');
// Stub addDirectories and getDirectories on workspaceContext
const mockWorkspaceContext = mockConfig.getWorkspaceContext();
mockWorkspaceContext.addDirectories = vi.fn();
mockWorkspaceContext.getDirectories = vi
.fn()
.mockReturnValue(['/tmp', '/extra/path']);
await agent.prompt({
sessionId: 'test-session-id',
prompt: [],
_meta,
} as acp.PromptRequest & { _meta?: Record<string, unknown> });
expect(mockWorkspaceContext.addDirectories).toHaveBeenCalledWith([
'/extra/path',
]);
expect(mockConfig.getSkillManager().discoverSkills).toHaveBeenCalled();
});
it('should delegate setMode to session', async () => {
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
const session = (
+47 -4
View File
@@ -259,10 +259,21 @@ export class GeminiAgent {
);
}
async newSession({
cwd,
mcpServers,
}: acp.NewSessionRequest): Promise<acp.NewSessionResponse> {
async newSession(
req: acp.NewSessionRequest,
): Promise<acp.NewSessionResponse> {
const { cwd, mcpServers } = req;
const meta = hasMeta(req) ? req._meta : undefined;
const additionalRootsRaw = meta?.['additionalRoots'];
const additionalRoots: string[] = [];
if (Array.isArray(additionalRootsRaw)) {
for (const root of additionalRootsRaw) {
if (typeof root === 'string') {
additionalRoots.push(root);
}
}
}
const sessionId = randomUUID();
const loadedSettings = loadSettings(cwd);
const config = await this.newSessionConfig(
@@ -270,6 +281,7 @@ export class GeminiAgent {
cwd,
mcpServers,
loadedSettings,
additionalRoots,
);
const authType =
@@ -473,6 +485,7 @@ export class GeminiAgent {
cwd: string,
mcpServers: acp.McpServer[],
loadedSettings?: LoadedSettings,
additionalRoots: string[] = [],
): Promise<Config> {
const currentSettings = loadedSettings || this.settings;
const mergedMcpServers = { ...currentSettings.merged.mcpServers };
@@ -513,6 +526,13 @@ export class GeminiAgent {
const settings = {
...currentSettings.merged,
mcpServers: mergedMcpServers,
context: {
...currentSettings.merged.context,
includeDirectories: [
...(currentSettings.merged.context?.includeDirectories || []),
...additionalRoots,
],
},
};
const config = await loadCliConfig(settings, sessionId, this.argv, { cwd });
@@ -693,6 +713,29 @@ export class Session {
const pendingSend = new AbortController();
this.pendingPrompt = pendingSend;
const meta = hasMeta(params) ? params._meta : undefined;
const additionalRootsRaw = meta?.['additionalRoots'];
const additionalRoots: string[] = [];
if (Array.isArray(additionalRootsRaw)) {
for (const root of additionalRootsRaw) {
if (typeof root === 'string') {
additionalRoots.push(root);
}
}
}
if (additionalRoots.length > 0) {
this.context.config.getWorkspaceContext().addDirectories(additionalRoots);
await this.context.config
.getSkillManager()
.discoverSkills(
this.context.config.storage,
this.context.config.getExtensions(),
this.context.config.isTrustedFolder(),
[...this.context.config.getWorkspaceContext().getDirectories()],
);
}
await this.context.config.waitForMcpInit();
const promptId = Math.random().toString(16).slice(2);
@@ -0,0 +1,145 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { StatusRow } from './StatusRow.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
import { type UIState } from '../contexts/UIStateContext.js';
import { type TextBuffer } from '../components/shared/text-buffer.js';
import { type SessionStatsState } from '../contexts/SessionContext.js';
import { type ThoughtSummary } from '../types.js';
import { ApprovalMode } from '@google/gemini-cli-core';
vi.mock('../hooks/useComposerStatus.js', () => ({
useComposerStatus: vi.fn(),
}));
describe('<StatusRow />', () => {
beforeEach(() => {
vi.clearAllMocks();
});
const defaultUiState: Partial<UIState> = {
currentTip: undefined,
thought: null,
elapsedTime: 0,
currentWittyPhrase: undefined,
activeHooks: [],
buffer: { text: '' } as unknown as TextBuffer,
sessionStats: { lastPromptTokenCount: 0 } as unknown as SessionStatsState,
shortcutsHelpVisible: false,
contextFileNames: [],
showApprovalModeIndicator: ApprovalMode.DEFAULT,
allowPlanMode: false,
shellModeActive: false,
renderMarkdown: true,
currentModel: 'gemini-3',
};
it('renders status and tip correctly when they both fit', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: true,
showTips: true,
showWit: true,
modeContentObj: null,
showMinimalContext: false,
});
const uiState: Partial<UIState> = {
...defaultUiState,
currentTip: 'Test Tip',
thought: { subject: 'Thinking...' } as unknown as ThoughtSummary,
elapsedTime: 5,
currentWittyPhrase: 'I am witty',
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Thinking...');
expect(output).toContain('I am witty');
expect(output).toContain('Tip: Test Tip');
});
it('renders correctly when interactive shell is waiting', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: true,
showLoadingIndicator: false,
showTips: false,
showWit: false,
modeContentObj: null,
showMinimalContext: false,
});
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={true}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState: defaultUiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('! Shell awaiting input (Tab to focus)');
});
it('renders tip with absolute positioning when it fits but might collide (verification of container logic)', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: true,
showTips: true,
showWit: true,
modeContentObj: null,
showMinimalContext: false,
});
const uiState: Partial<UIState> = {
...defaultUiState,
currentTip: 'Test Tip',
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('Tip: Test Tip');
});
});
+24 -11
View File
@@ -179,7 +179,13 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setTipWidth(Math.round(entry.contentRect.width));
const width = Math.round(entry.contentRect.width);
// Only update if width > 0 to prevent layout feedback loops
// when the tip is hidden. This ensures we always use the
// intrinsic width for collision detection.
if (width > 0) {
setTipWidth(width);
}
}
});
observer.observe(node);
@@ -230,6 +236,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const showRow1 = showUiDetails || showRow1Minimal;
const showRow2 = showUiDetails || showRow2Minimal;
const onStatusResize = useCallback((width: number) => {
if (width > 0) setStatusWidth(width);
}, []);
const statusNode = (
<StatusNode
showTips={showTips}
@@ -242,7 +252,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
errorVerbosity={
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
}
onResize={setStatusWidth}
onResize={onStatusResize}
/>
);
@@ -322,20 +332,23 @@ export const StatusRow: React.FC<StatusRowProps> = ({
<Box
flexShrink={0}
marginLeft={LAYOUT.TIP_LEFT_MARGIN}
marginLeft={showTipLine ? LAYOUT.TIP_LEFT_MARGIN : 0}
marginRight={
isNarrow
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
showTipLine
? isNarrow
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
: 0
}
position={showTipLine ? 'relative' : 'absolute'}
{...(showTipLine ? {} : { top: -100, left: -100 })}
>
{/*
We always render the tip node so it can be measured by ResizeObserver,
but we control its visibility based on the collision detection.
We always render the tip node so it can be measured by ResizeObserver.
When hidden, we use absolute positioning so it can still be measured
but doesn't affect the layout of Row 1. This prevents layout loops.
*/}
<Box display={showTipLine ? 'flex' : 'none'}>
{!isNarrow && tipContentStr && renderTipNode()}
</Box>
{!isNarrow && tipContentStr && renderTipNode()}
</Box>
</Box>
)}
+2
View File
@@ -1413,6 +1413,7 @@ export class Config implements McpContext, AgentLoopContext {
this.storage,
this.getExtensions(),
this.isTrustedFolder(),
[...this.workspaceContext.getDirectories()],
);
this.getSkillManager().setDisabledSkills(this.disabledSkills);
@@ -3111,6 +3112,7 @@ export class Config implements McpContext, AgentLoopContext {
this.storage,
this.getExtensions(),
this.isTrustedFolder(),
[...this.workspaceContext.getDirectories()],
);
this.getSkillManager().setDisabledSkills(this.disabledSkills);
@@ -7,13 +7,14 @@ allowOverrides = false
[modes.default]
network = false
readonly = true
approvedTools = []
approvedTools = ['cat', 'ls', 'grep', 'head', 'tail', 'less', 'Get-Content', 'dir', 'type', 'findstr', 'Get-ChildItem', 'echo']
allowOverrides = true
[modes.accepting_edits]
network = false
readonly = false
approvedTools = ['sed', 'grep', 'awk', 'perl', 'cat', 'echo']
approvedTools = ['sed', 'grep', 'awk', 'perl', 'cat', 'echo', 'Add-Content', 'Set-Content']
allowOverrides = true
[commands]
@@ -187,7 +187,7 @@ export class LinuxSandboxManager implements SandboxManager {
: false;
const workspaceWrite = !isReadonlyMode || isApproved;
const networkAccess =
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
this.options.modeConfig?.network || req.policy?.networkAccess || false;
const persistentPermissions = allowOverrides
? this.options.policyManager?.getCommandPermissions(commandName)
@@ -78,7 +78,7 @@ export class MacOsSandboxManager implements SandboxManager {
const workspaceWrite = !isReadonlyMode || isApproved;
const defaultNetwork =
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
this.options.modeConfig?.network || req.policy?.networkAccess || false;
// Fetch persistent approvals for this command
const commandName = await getCommandName(req.command, req.args);
@@ -58,6 +58,13 @@ public class GeminiSandbox {
public ulong OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
struct JOBOBJECT_NET_RATE_CONTROL_INFORMATION {
public ulong MaxBandwidth;
public uint ControlFlags;
public byte DscpTag;
}
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName);
@@ -70,6 +77,9 @@ public class GeminiSandbox {
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool DuplicateTokenEx(IntPtr hExistingToken, uint dwDesiredAccess, IntPtr lpTokenAttributes, uint ImpersonationLevel, uint TokenType, out IntPtr phNewToken);
[DllImport("advapi32.dll", SetLastError = true)]
static extern bool CreateRestrictedToken(IntPtr ExistingTokenHandle, uint Flags, uint DisableSidCount, IntPtr SidsToDisable, uint DeletePrivilegeCount, IntPtr PrivilegesToDelete, uint RestrictedSidCount, IntPtr SidsToRestrict, out IntPtr NewTokenHandle);
@@ -143,6 +153,8 @@ public class GeminiSandbox {
private const int TokenIntegrityLevel = 25;
private const uint SE_GROUP_INTEGRITY = 0x00000020;
private const uint TOKEN_ALL_ACCESS = 0xF01FF;
private const uint DISABLE_MAX_PRIVILEGE = 0x1;
static int Main(string[] args) {
if (args.Length < 3) {
@@ -182,14 +194,14 @@ public class GeminiSandbox {
IntPtr lowIntegritySid = IntPtr.Zero;
try {
// 1. Create Restricted Token
if (!OpenProcessToken(GetCurrentProcess(), 0x0002 /* TOKEN_DUPLICATE */ | 0x0008 /* TOKEN_QUERY */ | 0x0080 /* TOKEN_ADJUST_DEFAULT */, out hToken)) {
// 1. Duplicate Primary Token
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ALL_ACCESS, out hToken)) {
Console.WriteLine("Error: OpenProcessToken failed (" + Marshal.GetLastWin32Error() + ")");
return 1;
}
// Flags: 0x1 (DISABLE_MAX_PRIVILEGE)
if (!CreateRestrictedToken(hToken, 1, 0, IntPtr.Zero, 0, IntPtr.Zero, 0, IntPtr.Zero, out hRestrictedToken)) {
// Create a restricted token to strip administrative privileges
if (!CreateRestrictedToken(hToken, DISABLE_MAX_PRIVILEGE, 0, IntPtr.Zero, 0, IntPtr.Zero, 0, IntPtr.Zero, out hRestrictedToken)) {
Console.WriteLine("Error: CreateRestrictedToken failed (" + Marshal.GetLastWin32Error() + ")");
return 1;
}
@@ -223,6 +235,18 @@ public class GeminiSandbox {
SetInformationJobObject(hJob, 9 /* JobObjectExtendedLimitInformation */, lpJobLimits, (uint)Marshal.SizeOf(jobLimits));
Marshal.FreeHGlobal(lpJobLimits);
if (!networkAccess) {
JOBOBJECT_NET_RATE_CONTROL_INFORMATION netLimits = new JOBOBJECT_NET_RATE_CONTROL_INFORMATION();
netLimits.MaxBandwidth = 1;
netLimits.ControlFlags = 0x1 | 0x2; // ENABLE | MAX_BANDWIDTH
netLimits.DscpTag = 0;
IntPtr lpNetLimits = Marshal.AllocHGlobal(Marshal.SizeOf(netLimits));
Marshal.StructureToPtr(netLimits, lpNetLimits, false);
SetInformationJobObject(hJob, 32 /* JobObjectNetRateControlInformation */, lpNetLimits, (uint)Marshal.SizeOf(netLimits));
Marshal.FreeHGlobal(lpNetLimits);
}
// 4. Handle Internal Commands or External Process
if (command == "__read") {
if (argIndex + 1 >= args.Length) {
@@ -158,7 +158,7 @@ describe('WindowsSandboxManager', () => {
expect(icaclsArgs).toContainEqual([
persistentPath,
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
});
@@ -227,13 +227,13 @@ describe('WindowsSandboxManager', () => {
expect(icaclsArgs).toContainEqual([
path.resolve(testCwd),
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
expect(icaclsArgs).toContainEqual([
path.resolve(allowedPath),
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
} finally {
fs.rmSync(allowedPath, { recursive: true, force: true });
@@ -273,7 +273,7 @@ describe('WindowsSandboxManager', () => {
expect(icaclsArgs).toContainEqual([
path.resolve(extraWritePath),
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
} finally {
fs.rmSync(extraWritePath, { recursive: true, force: true });
@@ -308,7 +308,7 @@ describe('WindowsSandboxManager', () => {
expect(icaclsArgs).not.toContainEqual([
uncPath,
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
},
);
@@ -343,12 +343,12 @@ describe('WindowsSandboxManager', () => {
expect(icaclsArgs).toContainEqual([
longPath,
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
expect(icaclsArgs).toContainEqual([
devicePath,
'/setintegritylevel',
'Low',
'(OI)(CI)Low',
]);
},
);
@@ -236,6 +236,10 @@ export class WindowsSandboxManager implements SandboxManager {
false,
};
const defaultNetwork =
this.options.modeConfig?.network || req.policy?.networkAccess || false;
const networkAccess = defaultNetwork || mergedAdditional.network;
// 1. Handle filesystem permissions for Low Integrity
// Grant "Low Mandatory Level" write access to the workspace.
// If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes
@@ -251,7 +255,7 @@ export class WindowsSandboxManager implements SandboxManager {
await this.grantLowIntegrityAccess(this.options.workspace);
}
// Grant "Low Mandatory Level" read access to allowedPaths.
// Grant "Low Mandatory Level" read/write access to allowedPaths.
const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || [];
for (const allowedPath of allowedPaths) {
await this.grantLowIntegrityAccess(allowedPath);
@@ -342,10 +346,6 @@ export class WindowsSandboxManager implements SandboxManager {
// GeminiSandbox.exe <network:0|1> <cwd> --forbidden-manifest <path> <command> [args...]
const program = this.helperPath;
const defaultNetwork =
this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false;
const networkAccess = defaultNetwork || mergedAdditional.network;
const args = [
networkAccess ? '1' : '0',
req.cwd,
@@ -395,7 +395,11 @@ export class WindowsSandboxManager implements SandboxManager {
}
try {
await spawnAsync('icacls', [resolvedPath, '/setintegritylevel', 'Low']);
await spawnAsync('icacls', [
resolvedPath,
'/setintegritylevel',
'(OI)(CI)Low',
]);
this.allowedCache.add(resolvedPath);
} catch (e) {
debugLogger.log(
@@ -385,5 +385,14 @@ describe('SandboxManager', () => {
expect(manager).toBeInstanceOf(expected);
},
);
it('should return WindowsSandboxManager if sandboxing is enabled on win32', () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
const manager = createSandboxManager(
{ enabled: true },
{ workspace: '/workspace' },
);
expect(manager).toBeInstanceOf(WindowsSandboxManager);
});
});
});
+21
View File
@@ -48,6 +48,7 @@ export class SkillManager {
storage: Storage,
extensions: GeminiCLIExtension[] = [],
isTrusted: boolean = false,
additionalRoots: string[] = [],
): Promise<void> {
this.clearSkills();
@@ -89,6 +90,26 @@ export class SkillManager {
storage.getProjectAgentSkillsDir(),
);
this.addSkillsWithPrecedence(projectAgentSkills);
// 5. Additional roots (e.g. from includeDirectories)
if (additionalRoots && additionalRoots.length > 0) {
for (const root of additionalRoots) {
// Direct search (looks for SKILL.md and */SKILL.md)
const rootSkills = await loadSkillsFromDir(root);
this.addSkillsWithPrecedence(rootSkills);
// Search in standard subdirectories inside the root
const agentSkills = await loadSkillsFromDir(
path.join(root, '.agents', 'skills'),
);
this.addSkillsWithPrecedence(agentSkills);
const geminiSkills = await loadSkillsFromDir(
path.join(root, '.gemini', 'skills'),
);
this.addSkillsWithPrecedence(geminiSkills);
}
}
}
/**
+16 -2
View File
@@ -408,7 +408,9 @@ function hasPromptCommandTransform(root: Node): boolean {
return false;
}
function parseBashCommandDetails(command: string): CommandParseResult | null {
export function parseBashCommandDetails(
command: string,
): CommandParseResult | null {
if (treeSitterInitializationError) {
debugLogger.debug(
'Bash parser not initialized:',
@@ -557,7 +559,19 @@ export function parseCommandDetails(
const configuration = getShellConfiguration();
if (configuration.shell === 'powershell') {
return parsePowerShellCommandDetails(command, configuration.executable);
const result = parsePowerShellCommandDetails(
command,
configuration.executable,
);
if (!result || result.hasError) {
// Fallback to bash parser which is usually good enough for simple commands
// and doesn't rely on the host PowerShell environment restrictions (e.g., ConstrainedLanguage)
const bashResult = parseBashCommandDetails(command);
if (bashResult && !bashResult.hasError) {
return bashResult;
}
}
return result;
}
if (configuration.shell === 'bash') {