diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b6b850f71..a0811306be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,12 +179,16 @@ jobs: - name: 'Smoke test npx installation' run: | - # Create a temporary directory to avoid picking up local node_modules - mkdir -p ../npx-test - cd ../npx-test - # Run npx pointing to the checkout directory. This simulates a user - # installing the package from a git reference. - npx ${{ github.workspace }} --version + # 1. Package the project into a tarball + TARBALL=$(npm pack | tail -n 1) + + # 2. Move to a fresh directory for isolation + mkdir -p ../smoke-test-dir + mv "$TARBALL" ../smoke-test-dir/ + cd ../smoke-test-dir + + # 3. Run npx from the tarball + npx "./$TARBALL" --version - name: 'Wait for file system sync' run: 'sleep 2' @@ -263,12 +267,16 @@ jobs: - name: 'Smoke test npx installation' run: | - # Create a temporary directory to avoid picking up local node_modules - mkdir -p ../npx-test - cd ../npx-test - # Run npx pointing to the checkout directory. This simulates a user - # installing the package from a git reference. - npx ${{ github.workspace }} --version + # 1. Package the project into a tarball + TARBALL=$(npm pack | tail -n 1) + + # 2. Move to a fresh directory for isolation + mkdir -p ../smoke-test-dir + mv "$TARBALL" ../smoke-test-dir/ + cd ../smoke-test-dir + + # 3. Run npx from the tarball + npx "./$TARBALL" --version - name: 'Wait for file system sync' run: 'sleep 2' @@ -416,12 +424,17 @@ jobs: - name: 'Smoke test npx installation' run: | - # Create a temporary directory to avoid picking up local node_modules - New-Item -ItemType Directory -Force -Path ../npx-test - Set-Location ../npx-test - # Run npx pointing to the checkout directory. This simulates a user - # installing the package from a git reference. - npx ${{ github.workspace }} --version + # 1. Package the project into a tarball + $PACK_OUTPUT = npm pack + $TARBALL = $PACK_OUTPUT[-1] + + # 2. Move to a fresh directory for isolation + New-Item -ItemType Directory -Force -Path ../smoke-test-dir + Move-Item $TARBALL ../smoke-test-dir/ + Set-Location ../smoke-test-dir + + # 3. Run npx from the tarball + npx "./$TARBALL" --version shell: 'pwsh' ci: diff --git a/docs/cli/commands.md b/docs/cli/commands.md index e79c374e71..fe0198d626 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -264,6 +264,9 @@ Slash commands provide meta-level control over the CLI itself. modify them as desired. Changes to some settings are applied immediately, while others require a restart. +- **`/shells`** (or **`/bashes`**) + - **Description:** Toggle the background shells view. This allows you to view + and manage long-running processes that you've sent to the background. - **`/setup-github`** - **Description:** Set up GitHub Actions to triage issues and review PRs with Gemini. diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index 5cfa26cf92..a1a28665b9 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -23,7 +23,7 @@ available combinations. | Move the cursor to the end of the line. | `Ctrl + E`
`End (no Shift, Ctrl)` | | Move the cursor up one line. | `Up Arrow (no Shift, Alt, Ctrl, Cmd)` | | Move the cursor down one line. | `Down Arrow (no Shift, Alt, Ctrl, Cmd)` | -| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)`
`Ctrl + B` | +| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)` | | Move the cursor one character to the right. | `Right Arrow (no Shift, Alt, Ctrl, Cmd)`
`Ctrl + F` | | Move the cursor one word to the left. | `Ctrl + Left Arrow`
`Alt + Left Arrow`
`Alt + B` | | Move the cursor one word to the right. | `Ctrl + Right Arrow`
`Alt + Right Arrow`
`Alt + F` | @@ -106,6 +106,14 @@ available combinations. | Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` | | Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` | | Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`
`Ctrl + S` | +| Ctrl+B | `Ctrl + B` | +| Ctrl+L | `Ctrl + L` | +| Ctrl+K | `Ctrl + K` | +| Enter | `Enter` | +| Esc | `Esc` | +| Shift+Tab | `Shift + Tab` | +| Tab | `Tab (no Shift)` | +| Tab | `Tab (no Shift)` | | Focus the shell input from the gemini input. | `Tab (no Shift)` | | Focus the Gemini input from the shell input. | `Tab` | | Clear the terminal screen and redraw the UI. | `Ctrl + L` | diff --git a/docs/cli/settings.md b/docs/cli/settings.md index a581875a35..ab637aed3e 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -107,13 +107,14 @@ they appear in the UI. ### Security -| UI Label | Setting | Description | Default | -| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------- | ------- | -| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` | -| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` | -| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` | -| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` | -| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` | +| UI Label | Setting | Description | Default | +| ------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` | +| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` | +| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` | +| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` | +| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` | +| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` | ### Experimental diff --git a/docs/cli/skills.md b/docs/cli/skills.md index b8a71fde86..297bd80ed4 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -108,5 +108,5 @@ gemini skills disable my-expertise --scope workspace ## Creating your own skills -To create your own skills, see the -[Create Agent Skills](./guides/creating-skills.md) guide. +To create your own skills, see the [Create Agent Skills](./creating-skills.md) +guide. diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 75bb9c46c4..5ce8231a51 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -773,6 +773,13 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`security.allowedExtensions`** (array): + - **Description:** List of Regex patterns for allowed extensions. If nonempty, + only extensions that match the patterns in this list are allowed. Overrides + the blockGitExtensions setting. + - **Default:** `[]` + - **Requires restart:** Yes + - **`security.folderTrust.enabled`** (boolean): - **Description:** Setting to track whether Folder trust is enabled. - **Default:** `false` diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 54bb3e704a..2ca11be668 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1255,7 +1255,7 @@ describe('Approval mode tool exclusion logic', () => { }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( - 'Cannot start in YOLO mode since it is disabled by your admin', + 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); }); @@ -2928,7 +2928,7 @@ describe('loadCliConfig disableYoloMode', () => { security: { disableYoloMode: true }, }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( - 'Cannot start in YOLO mode since it is disabled by your admin', + 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); }); }); @@ -2960,7 +2960,7 @@ describe('loadCliConfig secureModeEnabled', () => { }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( - 'Cannot start in YOLO mode since it is disabled by your admin', + 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); }); @@ -2974,7 +2974,7 @@ describe('loadCliConfig secureModeEnabled', () => { }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( - 'Cannot start in YOLO mode since it is disabled by your admin', + 'YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 6d79b1d4c1..0c5063faee 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -38,6 +38,7 @@ import { type OutputFormat, coreEvents, GEMINI_MODEL_ALIAS_AUTO, + getAdminErrorMessage, } from '@google/gemini-cli-core'; import { type Settings, @@ -550,7 +551,7 @@ export async function loadCliConfig( ); } throw new FatalConfigError( - 'Cannot start in YOLO mode since it is disabled by your admin', + getAdminErrorMessage('YOLO mode', undefined /* config */), ); } } else if (approvalMode === ApprovalMode.YOLO) { diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 75c4924c32..9e19109eda 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -144,6 +144,26 @@ export class ExtensionManager extends ExtensionLoader { previousExtensionConfig?: ExtensionConfig, ): Promise { if ( + this.settings.security?.allowedExtensions && + this.settings.security?.allowedExtensions.length > 0 + ) { + const extensionAllowed = this.settings.security?.allowedExtensions.some( + (pattern) => { + try { + return new RegExp(pattern).test(installMetadata.source); + } catch (e) { + throw new Error( + `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`, + ); + } + }, + ); + if (!extensionAllowed) { + throw new Error( + `Installing extension from source "${installMetadata.source}" is not allowed by the "allowedExtensions" security setting.`, + ); + } + } else if ( (installMetadata.type === 'git' || installMetadata.type === 'github-release') && this.settings.security.blockGitExtensions @@ -152,6 +172,7 @@ export class ExtensionManager extends ExtensionLoader { 'Installing extensions from remote sources is disallowed by your current settings.', ); } + const isUpdate = !!previousExtensionConfig; let newExtensionConfig: ExtensionConfig | null = null; let localSourcePath: string | undefined; @@ -522,10 +543,39 @@ Would you like to attempt to install via "git clone" instead?`, const installMetadata = loadInstallMetadata(extensionDir); let effectiveExtensionPath = extensionDir; if ( + this.settings.security?.allowedExtensions && + this.settings.security?.allowedExtensions.length > 0 + ) { + if (!installMetadata?.source) { + throw new Error( + `Failed to load extension ${extensionDir}. The ${INSTALL_METADATA_FILENAME} file is missing or misconfigured.`, + ); + } + const extensionAllowed = this.settings.security?.allowedExtensions.some( + (pattern) => { + try { + return new RegExp(pattern).test(installMetadata?.source); + } catch (e) { + throw new Error( + `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`, + ); + } + }, + ); + if (!extensionAllowed) { + debugLogger.warn( + `Failed to load extension ${extensionDir}. This extension is not allowed by the "allowedExtensions" security setting.`, + ); + return null; + } + } else if ( (installMetadata?.type === 'git' || installMetadata?.type === 'github-release') && this.settings.security.blockGitExtensions ) { + debugLogger.warn( + `Failed to load extension ${extensionDir}. Extensions from remote sources is disallowed by your current settings.`, + ); return null; } diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 7acaf2cc67..0148fc7729 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -622,6 +622,7 @@ describe('extension tests', () => { }); it('should not load github extensions if blockGitExtensions is set', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); createExtension({ extensionsDir: userExtensionsDir, name: 'my-ext', @@ -645,6 +646,73 @@ describe('extension tests', () => { const extension = extensions.find((e) => e.name === 'my-ext'); expect(extension).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Extensions from remote sources is disallowed by your current settings.', + ), + ); + consoleSpy.mockRestore(); + }); + + it('should load allowed extensions if the allowlist is set.', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-ext', + version: '1.0.0', + installMetadata: { + type: 'git', + source: 'http://allowed.com/foo/bar', + }, + }); + const extensionAllowlistSetting = createTestMergedSettings({ + security: { + allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'], + }, + }); + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: extensionAllowlistSetting, + }); + const extensions = await extensionManager.loadExtensions(); + + expect(extensions).toHaveLength(1); + expect(extensions[0].name).toBe('my-ext'); + }); + + it('should not load disallowed extensions if the allowlist is set.', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-ext', + version: '1.0.0', + installMetadata: { + type: 'git', + source: 'http://notallowed.com/foo/bar', + }, + }); + const extensionAllowlistSetting = createTestMergedSettings({ + security: { + allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'], + }, + }); + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: extensionAllowlistSetting, + }); + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === 'my-ext'); + + expect(extension).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'This extension is not allowed by the "allowedExtensions" security setting', + ), + ); + consoleSpy.mockRestore(); }); it('should not load any extensions if admin.extensions.enabled is false', async () => { @@ -1116,6 +1184,30 @@ describe('extension tests', () => { ); }); + it('should not install a disallowed extension if the allowlist is set', async () => { + const gitUrl = 'https://somehost.com/somerepo.git'; + const allowedExtensionsSetting = createTestMergedSettings({ + security: { + allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'], + }, + }); + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: allowedExtensionsSetting, + }); + await extensionManager.loadExtensions(); + await expect( + extensionManager.installOrUpdateExtension({ + source: gitUrl, + type: 'git', + }), + ).rejects.toThrow( + `Installing extension from source "${gitUrl}" is not allowed by the "allowedExtensions" security setting.`, + ); + }); + it('should prompt for trust if workspace is not trusted', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 0d32ae2922..9b6a903a4b 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -72,6 +72,15 @@ export enum Command { OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor', PASTE_CLIPBOARD = 'input.paste', + BACKGROUND_SHELL_ESCAPE = 'backgroundShellEscape', + BACKGROUND_SHELL_SELECT = 'backgroundShellSelect', + TOGGLE_BACKGROUND_SHELL = 'toggleBackgroundShell', + TOGGLE_BACKGROUND_SHELL_LIST = 'toggleBackgroundShellList', + KILL_BACKGROUND_SHELL = 'backgroundShell.kill', + UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus', + UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus', + SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning', + // App Controls SHOW_ERROR_DETAILS = 'app.showErrorDetails', SHOW_FULL_TODOS = 'app.showFullTodos', @@ -139,7 +148,6 @@ export const defaultKeyBindings: KeyBindingConfig = { ], [Command.MOVE_LEFT]: [ { key: 'left', shift: false, alt: false, ctrl: false, cmd: false }, - { key: 'b', ctrl: true }, ], [Command.MOVE_RIGHT]: [ { key: 'right', shift: false, alt: false, ctrl: false, cmd: false }, @@ -265,6 +273,16 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }], [Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }], [Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: true }], + [Command.TOGGLE_BACKGROUND_SHELL]: [{ key: 'b', ctrl: true }], + [Command.TOGGLE_BACKGROUND_SHELL_LIST]: [{ key: 'l', ctrl: true }], + [Command.KILL_BACKGROUND_SHELL]: [{ key: 'k', ctrl: true }], + [Command.UNFOCUS_BACKGROUND_SHELL]: [{ key: 'tab', shift: true }], + [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [{ key: 'tab', shift: false }], + [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [ + { key: 'tab', shift: false }, + ], + [Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }], + [Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }], [Command.SHOW_MORE_LINES]: [ { key: 'o', ctrl: true }, { key: 's', ctrl: true }, @@ -379,6 +397,14 @@ export const commandCategories: readonly CommandCategory[] = [ Command.TOGGLE_YOLO, Command.CYCLE_APPROVAL_MODE, Command.SHOW_MORE_LINES, + Command.TOGGLE_BACKGROUND_SHELL, + Command.TOGGLE_BACKGROUND_SHELL_LIST, + Command.KILL_BACKGROUND_SHELL, + Command.BACKGROUND_SHELL_SELECT, + Command.BACKGROUND_SHELL_ESCAPE, + Command.UNFOCUS_BACKGROUND_SHELL, + Command.UNFOCUS_BACKGROUND_SHELL_LIST, + Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING, Command.FOCUS_SHELL_INPUT, Command.UNFOCUS_SHELL_INPUT, Command.CLEAR_SCREEN, @@ -470,6 +496,14 @@ export const commandDescriptions: Readonly> = { 'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).', [Command.SHOW_MORE_LINES]: 'Expand a height-constrained response to show additional lines when not in alternate buffer mode.', + [Command.BACKGROUND_SHELL_SELECT]: 'Enter', + [Command.BACKGROUND_SHELL_ESCAPE]: 'Esc', + [Command.TOGGLE_BACKGROUND_SHELL]: 'Ctrl+B', + [Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Ctrl+L', + [Command.KILL_BACKGROUND_SHELL]: 'Ctrl+K', + [Command.UNFOCUS_BACKGROUND_SHELL]: 'Shift+Tab', + [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: 'Tab', + [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Tab', [Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.', [Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.', [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 65e42332d6..a34163ccb3 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1268,6 +1268,17 @@ const SETTINGS_SCHEMA = { description: 'Blocks installing and loading extensions from Git.', showInDialog: true, }, + allowedExtensions: { + type: 'array', + label: 'Extension Source Regex Allowlist', + category: 'Security', + requiresRestart: true, + default: [] as string[], + description: + 'List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting.', + showInDialog: true, + items: { type: 'string' }, + }, folderTrust: { type: 'object', label: 'Folder Trust', diff --git a/packages/cli/src/deferred.test.ts b/packages/cli/src/deferred.test.ts index 4ea5eb791d..8b9fb87f7a 100644 --- a/packages/cli/src/deferred.test.ts +++ b/packages/cli/src/deferred.test.ts @@ -16,11 +16,10 @@ import type { ArgumentsCamelCase, CommandModule } from 'yargs'; import type { MergedSettings } from './config/settings.js'; import type { MockInstance } from 'vitest'; -const { mockRunExitCleanup, mockDebugLogger } = vi.hoisted(() => ({ +const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({ mockRunExitCleanup: vi.fn(), - mockDebugLogger: { - log: vi.fn(), - error: vi.fn(), + mockCoreEvents: { + emitFeedback: vi.fn(), }, })); @@ -28,7 +27,7 @@ vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual('@google/gemini-cli-core'); return { ...actual, - debugLogger: mockDebugLogger, + coreEvents: mockCoreEvents, }; }); @@ -55,8 +54,7 @@ describe('deferred', () => { describe('runDeferredCommand', () => { it('should do nothing if no deferred command is set', async () => { await runDeferredCommand(createMockSettings()); - expect(mockDebugLogger.log).not.toHaveBeenCalled(); - expect(mockDebugLogger.error).not.toHaveBeenCalled(); + expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled(); expect(mockExit).not.toHaveBeenCalled(); }); @@ -85,8 +83,9 @@ describe('deferred', () => { const settings = createMockSettings({ mcp: { enabled: false } }); await runDeferredCommand(settings); - expect(mockDebugLogger.error).toHaveBeenCalledWith( - 'Error: MCP is disabled by your admin.', + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); expect(mockRunExitCleanup).toHaveBeenCalled(); expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR); @@ -102,8 +101,9 @@ describe('deferred', () => { const settings = createMockSettings({ extensions: { enabled: false } }); await runDeferredCommand(settings); - expect(mockDebugLogger.error).toHaveBeenCalledWith( - 'Error: Extensions are disabled by your admin.', + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'Extensions is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); expect(mockRunExitCleanup).toHaveBeenCalled(); expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR); @@ -119,8 +119,9 @@ describe('deferred', () => { const settings = createMockSettings({ skills: { enabled: false } }); await runDeferredCommand(settings); - expect(mockDebugLogger.error).toHaveBeenCalledWith( - 'Error: Agent skills are disabled by your admin.', + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); expect(mockRunExitCleanup).toHaveBeenCalled(); expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR); @@ -183,8 +184,9 @@ describe('deferred', () => { const mcpSettings = createMockSettings({ mcp: { enabled: false } }); await runDeferredCommand(mcpSettings); - expect(mockDebugLogger.error).toHaveBeenCalledWith( - 'Error: MCP is disabled by your admin.', + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', ); }); diff --git a/packages/cli/src/deferred.ts b/packages/cli/src/deferred.ts index 73fac6d1ce..309233ba45 100644 --- a/packages/cli/src/deferred.ts +++ b/packages/cli/src/deferred.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ import type { ArgumentsCamelCase, CommandModule } from 'yargs'; -import { debugLogger, ExitCodes } from '@google/gemini-cli-core'; +import { + coreEvents, + ExitCodes, + getAdminErrorMessage, +} from '@google/gemini-cli-core'; import { runExitCleanup } from './utils/cleanup.js'; import type { MergedSettings } from './config/settings.js'; import process from 'node:process'; @@ -30,7 +34,10 @@ export async function runDeferredCommand(settings: MergedSettings) { const commandName = deferredCommand.commandName; if (commandName === 'mcp' && adminSettings?.mcp?.enabled === false) { - debugLogger.error('Error: MCP is disabled by your admin.'); + coreEvents.emitFeedback( + 'error', + getAdminErrorMessage('MCP', undefined /* config */), + ); await runExitCleanup(); process.exit(ExitCodes.FATAL_CONFIG_ERROR); } @@ -39,13 +46,19 @@ export async function runDeferredCommand(settings: MergedSettings) { commandName === 'extensions' && adminSettings?.extensions?.enabled === false ) { - debugLogger.error('Error: Extensions are disabled by your admin.'); + coreEvents.emitFeedback( + 'error', + getAdminErrorMessage('Extensions', undefined /* config */), + ); await runExitCleanup(); process.exit(ExitCodes.FATAL_CONFIG_ERROR); } if (commandName === 'skills' && adminSettings?.skills?.enabled === false) { - debugLogger.error('Error: Agent skills are disabled by your admin.'); + coreEvents.emitFeedback( + 'error', + getAdminErrorMessage('Agent skills', undefined /* config */), + ); await runExitCleanup(); process.exit(ExitCodes.FATAL_CONFIG_ERROR); } diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 3217645211..d0e21b6b6d 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -258,6 +258,9 @@ describe('runNonInteractive', () => { [{ text: 'Test input' }], expect.any(AbortSignal), 'prompt-id-1', + undefined, + false, + 'Test input', ); expect(getWrittenOutput()).toBe('Hello World\n'); // Note: Telemetry shutdown is now handled in runExitCleanup() in cleanup.ts @@ -374,6 +377,9 @@ describe('runNonInteractive', () => { [{ text: 'Tool response' }], expect.any(AbortSignal), 'prompt-id-2', + undefined, + false, + undefined, ); expect(getWrittenOutput()).toBe('Final answer\n'); }); @@ -531,6 +537,9 @@ describe('runNonInteractive', () => { ], expect.any(AbortSignal), 'prompt-id-3', + undefined, + false, + undefined, ); expect(getWrittenOutput()).toBe('Sorry, let me try again.\n'); }); @@ -670,6 +679,9 @@ describe('runNonInteractive', () => { processedParts, expect.any(AbortSignal), 'prompt-id-7', + undefined, + false, + rawInput, ); // 6. Assert the final output is correct @@ -703,6 +715,9 @@ describe('runNonInteractive', () => { [{ text: 'Test input' }], expect.any(AbortSignal), 'prompt-id-1', + undefined, + false, + 'Test input', ); expect(processStdoutSpy).toHaveBeenCalledWith( JSON.stringify( @@ -833,6 +848,9 @@ describe('runNonInteractive', () => { [{ text: 'Empty response test' }], expect.any(AbortSignal), 'prompt-id-empty', + undefined, + false, + 'Empty response test', ); // This should output JSON with empty response but include stats @@ -967,6 +985,9 @@ describe('runNonInteractive', () => { [{ text: 'Prompt from command' }], expect.any(AbortSignal), 'prompt-id-slash', + undefined, + false, + '/testcommand', ); expect(getWrittenOutput()).toBe('Response from command\n'); @@ -1010,6 +1031,9 @@ describe('runNonInteractive', () => { [{ text: 'Slash command output' }], expect.any(AbortSignal), 'prompt-id-slash', + undefined, + false, + '/help', ); expect(getWrittenOutput()).toBe('Response to slash command\n'); handleSlashCommandSpy.mockRestore(); @@ -1184,6 +1208,9 @@ describe('runNonInteractive', () => { [{ text: '/unknowncommand' }], expect.any(AbortSignal), 'prompt-id-unknown', + undefined, + false, + '/unknowncommand', ); expect(getWrittenOutput()).toBe('Response to unknown\n'); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 9535fbded2..a2ca92a4e8 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -301,6 +301,9 @@ export async function runNonInteractive({ currentMessages[0]?.parts || [], abortController.signal, prompt_id, + undefined, + false, + turnCount === 1 ? input : undefined, ); let responseText = ''; diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 3c32549ef2..75cbe74cc2 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -12,7 +12,11 @@ import { type CommandContext, } from '../ui/commands/types.js'; import type { MessageActionReturn, Config } from '@google/gemini-cli-core'; -import { isNightly, startupProfiler } from '@google/gemini-cli-core'; +import { + isNightly, + startupProfiler, + getAdminErrorMessage, +} from '@google/gemini-cli-core'; import { aboutCommand } from '../ui/commands/aboutCommand.js'; import { agentsCommand } from '../ui/commands/agentsCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; @@ -47,6 +51,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; import { skillsCommand } from '../ui/commands/skillsCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; +import { shellsCommand } from '../ui/commands/shellsCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js'; @@ -101,7 +106,10 @@ export class BuiltinCommandLoader implements ICommandLoader { ): Promise => ({ type: 'message', messageType: 'error', - content: 'Extensions are disabled by your admin.', + content: getAdminErrorMessage( + 'Extensions', + this.config ?? undefined, + ), }), }, ] @@ -126,7 +134,7 @@ export class BuiltinCommandLoader implements ICommandLoader { ): Promise => ({ type: 'message', messageType: 'error', - content: 'MCP is disabled by your admin.', + content: getAdminErrorMessage('MCP', this.config ?? undefined), }), }, ] @@ -157,13 +165,17 @@ export class BuiltinCommandLoader implements ICommandLoader { ): Promise => ({ type: 'message', messageType: 'error', - content: 'Agent skills are disabled by your admin.', + content: getAdminErrorMessage( + 'Agent skills', + this.config ?? undefined, + ), }), }, ] : [skillsCommand] : []), settingsCommand, + shellsCommand, vimCommand, setupGithubCommand, terminalSetupCommand, diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 18744ee2b4..a9e997a859 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -157,6 +157,9 @@ const baseMockUiState = { terminalHeight: 40, currentModel: 'gemini-pro', terminalBackgroundColor: undefined, + activePtyId: undefined, + backgroundShells: new Map(), + backgroundShellHeight: 0, }; export const mockAppState: AppState = { @@ -201,7 +204,11 @@ const mockUIActions: UIActions = { handleApiKeyCancel: vi.fn(), setBannerVisible: vi.fn(), setEmbeddedShellFocused: vi.fn(), + dismissBackgroundShell: vi.fn(), + setActiveBackgroundShellPid: vi.fn(), + setIsBackgroundShellListOpen: vi.fn(), setAuthContext: vi.fn(), + handleWarning: vi.fn(), handleRestart: vi.fn(), handleNewAgentsSelect: vi.fn(), }; diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 81df8b9574..bd663ba195 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -88,6 +88,7 @@ describe('App', () => { defaultText: 'Mock Banner Text', warningText: '', }, + backgroundShells: new Map(), }; it('should render main content and composer when not quitting', () => { @@ -234,7 +235,6 @@ describe('App', () => { expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue - expect(lastFrame()).toContain('1 of 1'); expect(lastFrame()).toContain('Composer'); expect(lastFrame()).toMatchSnapshot(); }); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 5170b60f62..ef0a24cd92 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -32,13 +32,7 @@ import { UserAccountManager, type ContentGeneratorConfig, type AgentDefinition, - MessageBusType, - QuestionType, } from '@google/gemini-cli-core'; -import { - AskUserActionsContext, - type AskUserState, -} from './contexts/AskUserActionsContext.js'; // Mock coreEvents const mockCoreEvents = vi.hoisted(() => ({ @@ -124,11 +118,9 @@ vi.mock('ink', async (importOriginal) => { // so we can assert against them in our tests. let capturedUIState: UIState; let capturedUIActions: UIActions; -let capturedAskUserRequest: AskUserState | null; function TestContextConsumer() { capturedUIState = useContext(UIStateContext)!; capturedUIActions = useContext(UIActionsContext)!; - capturedAskUserRequest = useContext(AskUserActionsContext)?.request ?? null; return null; } @@ -269,6 +261,25 @@ describe('AppContainer State Management', () => { const mockedUseInputHistoryStore = useInputHistoryStore as Mock; const mockedUseHookDisplayState = useHookDisplayState as Mock; + const DEFAULT_GEMINI_STREAM_MOCK = { + streamingState: 'idle', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: null, + cancelOngoingRequest: vi.fn(), + handleApprovalModeChange: vi.fn(), + activePtyId: null, + loopDetectionConfirmationRequest: null, + backgroundShellCount: 0, + isBackgroundShellVisible: false, + toggleBackgroundShell: vi.fn(), + backgroundCurrentShell: vi.fn(), + backgroundShells: new Map(), + registerBackgroundShell: vi.fn(), + dismissBackgroundShell: vi.fn(), + }; + beforeEach(() => { vi.clearAllMocks(); @@ -279,7 +290,6 @@ describe('AppContainer State Management', () => { mocks.mockStdout.write.mockClear(); capturedUIState = null!; - capturedAskUserRequest = null; // **Provide a default return value for EVERY mocked hook.** mockedUseQuotaAndFallback.mockReturnValue({ @@ -334,14 +344,7 @@ describe('AppContainer State Management', () => { handleNewMessage: vi.fn(), clearConsoleMessages: vi.fn(), }); - mockedUseGeminiStream.mockReturnValue({ - streamingState: 'idle', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], - thought: null, - cancelOngoingRequest: vi.fn(), - }); + mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); mockedUseVim.mockReturnValue({ handleInput: vi.fn() }); mockedUseFolderTrust.mockReturnValue({ isFolderTrustDialogOpen: false, @@ -1193,12 +1196,9 @@ describe('AppContainer State Management', () => { // Mock the streaming state as Active mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], thought: { subject: 'Some thought' }, - cancelOngoingRequest: vi.fn(), }); // Act: Render the container @@ -1234,12 +1234,9 @@ describe('AppContainer State Management', () => { // Mock the streaming state mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], thought: { subject: 'Some thought' }, - cancelOngoingRequest: vi.fn(), }); // Act: Render the container @@ -1306,12 +1303,9 @@ describe('AppContainer State Management', () => { // Mock the streaming state and thought const thoughtSubject = 'Processing request'; mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], thought: { subject: thoughtSubject }, - cancelOngoingRequest: vi.fn(), }); // Act: Render the container @@ -1347,14 +1341,7 @@ describe('AppContainer State Management', () => { } as unknown as LoadedSettings; // Mock the streaming state as Idle with no thought - mockedUseGeminiStream.mockReturnValue({ - streamingState: 'idle', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], - thought: null, - cancelOngoingRequest: vi.fn(), - }); + mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); // Act: Render the container const { unmount } = renderAppContainer({ @@ -1391,12 +1378,9 @@ describe('AppContainer State Management', () => { // Mock the streaming state and thought const thoughtSubject = 'Confirm tool execution'; mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'waiting_for_confirmation', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], thought: { subject: thoughtSubject }, - cancelOngoingRequest: vi.fn(), }); // Act: Render the container @@ -1448,16 +1432,11 @@ describe('AppContainer State Management', () => { // Mock an active shell pty but not focused mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], thought: { subject: 'Executing shell command' }, - cancelOngoingRequest: vi.fn(), pendingToolCalls: [], - handleApprovalModeChange: vi.fn(), activePtyId: 'pty-1', - loopDetectionConfirmationRequest: null, lastOutputTime: startTime + 100, // Trigger aggressive delay retryStatus: null, }); @@ -1512,12 +1491,9 @@ describe('AppContainer State Management', () => { // Mock an active shell pty with redirection active mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], thought: { subject: 'Executing shell command' }, - cancelOngoingRequest: vi.fn(), pendingToolCalls: [ { request: { @@ -1527,9 +1503,7 @@ describe('AppContainer State Management', () => { status: 'executing', } as unknown as TrackedToolCall, ], - handleApprovalModeChange: vi.fn(), activePtyId: 'pty-1', - loopDetectionConfirmationRequest: null, lastOutputTime: startTime, retryStatus: null, }); @@ -1587,16 +1561,11 @@ describe('AppContainer State Management', () => { // Mock an active shell pty with NO output since operation started (silent) mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], thought: { subject: 'Executing shell command' }, - cancelOngoingRequest: vi.fn(), pendingToolCalls: [], - handleApprovalModeChange: vi.fn(), activePtyId: 'pty-1', - loopDetectionConfirmationRequest: null, lastOutputTime: startTime, // lastOutputTime <= operationStartTime retryStatus: null, }); @@ -1643,12 +1612,9 @@ describe('AppContainer State Management', () => { // Mock an active shell pty but not focused let lastOutputTime = startTime + 1000; mockedUseGeminiStream.mockImplementation(() => ({ + ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], thought: { subject: 'Executing shell command' }, - cancelOngoingRequest: vi.fn(), activePtyId: 'pty-1', lastOutputTime, })); @@ -1669,12 +1635,9 @@ describe('AppContainer State Management', () => { // Update lastOutputTime to simulate new output lastOutputTime = startTime + 21000; mockedUseGeminiStream.mockImplementation(() => ({ + ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], thought: { subject: 'Executing shell command' }, - cancelOngoingRequest: vi.fn(), activePtyId: 'pty-1', lastOutputTime, })); @@ -1734,12 +1697,9 @@ describe('AppContainer State Management', () => { // Mock the streaming state and thought with a short subject const shortTitle = 'Short'; mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], thought: { subject: shortTitle }, - cancelOngoingRequest: vi.fn(), }); // Act: Render the container @@ -1778,12 +1738,9 @@ describe('AppContainer State Management', () => { // Mock the streaming state and thought const title = 'Test Title'; mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], thought: { subject: title }, - cancelOngoingRequest: vi.fn(), }); // Act: Render the container @@ -1821,12 +1778,8 @@ describe('AppContainer State Management', () => { // Mock the streaming state mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], - thought: null, - cancelOngoingRequest: vi.fn(), }); // Act: Render the container @@ -1928,12 +1881,7 @@ describe('AppContainer State Management', () => { mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen mockedUseGeminiStream.mockReturnValue({ - streamingState: 'idle', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], - thought: null, - cancelOngoingRequest: vi.fn(), + ...DEFAULT_GEMINI_STREAM_MOCK, activePtyId: 'some-id', }); @@ -2005,11 +1953,7 @@ describe('AppContainer State Management', () => { // Mock request cancellation mockCancelOngoingRequest = vi.fn(); mockedUseGeminiStream.mockReturnValue({ - streamingState: 'idle', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], - thought: null, + ...DEFAULT_GEMINI_STREAM_MOCK, cancelOngoingRequest: mockCancelOngoingRequest, }); @@ -2030,11 +1974,8 @@ describe('AppContainer State Management', () => { describe('CTRL+C', () => { it('should cancel ongoing request on first press', async () => { mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, streamingState: 'responding', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], - thought: null, cancelOngoingRequest: mockCancelOngoingRequest, }); await setupKeypressTest(); @@ -2574,12 +2515,7 @@ describe('AppContainer State Management', () => { }); mockedUseGeminiStream.mockReturnValue({ - streamingState: 'idle', - submitQuery: vi.fn(), - initError: null, - pendingHistoryItems: [], - thought: null, - cancelOngoingRequest: vi.fn(), + ...DEFAULT_GEMINI_STREAM_MOCK, activePtyId: 'some-pty-id', // Make sure activePtyId is set }); @@ -2729,41 +2665,6 @@ describe('AppContainer State Management', () => { unmount!(); }); - - it('should show ask user dialog when request is received', async () => { - let unmount: () => void; - await act(async () => { - const result = renderAppContainer(); - unmount = result.unmount; - }); - - const questions = [ - { - question: 'What is your favorite color?', - header: 'Color Preference', - type: QuestionType.TEXT, - }, - ]; - - await act(async () => { - await mockConfig.getMessageBus().publish({ - type: MessageBusType.ASK_USER_REQUEST, - questions, - correlationId: 'test-id', - }); - }); - - await waitFor( - () => { - expect(capturedAskUserRequest).not.toBeNull(); - expect(capturedAskUserRequest?.questions).toEqual(questions); - expect(capturedAskUserRequest?.correlationId).toBe('test-id'); - }, - { timeout: 2000 }, - ); - - unmount!(); - }); }); describe('Regression Tests', () => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c792094969..e1d23115ca 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -31,10 +31,6 @@ import { } from './types.js'; import { MessageType, StreamingState } from './types.js'; import { ToolActionsProvider } from './contexts/ToolActionsContext.js'; -import { - AskUserActionsProvider, - type AskUserState, -} from './contexts/AskUserActionsContext.js'; import { type EditorType, type Config, @@ -70,8 +66,6 @@ import { SessionEndReason, generateSummary, type ConsentRequestPayload, - MessageBusType, - type AskUserRequest, type AgentsDiscoveredPayload, ChangeAuthRequestedError, } from '@google/gemini-cli-core'; @@ -99,6 +93,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; +import { type BackgroundShell } from './hooks/shellCommandProcessor.js'; import { useVim } from './hooks/vim.js'; import { type LoadableSettingScope, SettingScope } from '../config/settings.js'; import { type InitializationResult } from '../core/initializer.js'; @@ -138,6 +133,7 @@ import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js' import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; import { useBanner } from './hooks/useBanner.js'; import { useHookDisplayState } from './hooks/useHookDisplayState.js'; +import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js'; import { WARNING_PROMPT_DURATION_MS, QUEUE_ERROR_DISPLAY_DURATION_MS, @@ -259,6 +255,10 @@ export const AppContainer = (props: AppContainerProps) => { ); const [copyModeEnabled, setCopyModeEnabled] = useState(false); const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false); + const toggleBackgroundShellRef = useRef<() => void>(() => {}); + const isBackgroundShellVisibleRef = useRef(false); + const backgroundShellsRef = useRef>(new Map()); + const [adminSettingsChanged, setAdminSettingsChanged] = useState(false); const [shellModeActive, setShellModeActive] = useState(false); @@ -338,11 +338,6 @@ export const AppContainer = (props: AppContainerProps) => { AgentDefinition | undefined >(); - // AskUser dialog state - const [askUserRequest, setAskUserRequest] = useState( - null, - ); - const openAgentConfigDialog = useCallback( (name: string, displayName: string, definition: AgentDefinition) => { setSelectedAgentName(name); @@ -360,56 +355,6 @@ export const AppContainer = (props: AppContainerProps) => { setSelectedAgentDefinition(undefined); }, []); - // Subscribe to ASK_USER_REQUEST messages from the message bus - useEffect(() => { - const messageBus = config.getMessageBus(); - - const handler = (msg: AskUserRequest) => { - setAskUserRequest({ - questions: msg.questions, - correlationId: msg.correlationId, - }); - }; - - messageBus.subscribe(MessageBusType.ASK_USER_REQUEST, handler); - - return () => { - messageBus.unsubscribe(MessageBusType.ASK_USER_REQUEST, handler); - }; - }, [config]); - - // Handler to submit ask_user answers - const handleAskUserSubmit = useCallback( - async (answers: { [questionIndex: string]: string }) => { - if (!askUserRequest) return; - - const messageBus = config.getMessageBus(); - await messageBus.publish({ - type: MessageBusType.ASK_USER_RESPONSE, - correlationId: askUserRequest.correlationId, - answers, - }); - - setAskUserRequest(null); - }, - [config, askUserRequest], - ); - - // Handler to cancel ask_user dialog - const handleAskUserCancel = useCallback(async () => { - if (!askUserRequest) return; - - const messageBus = config.getMessageBus(); - await messageBus.publish({ - type: MessageBusType.ASK_USER_RESPONSE, - correlationId: askUserRequest.correlationId, - answers: {}, - cancelled: true, - }); - - setAskUserRequest(null); - }, [config, askUserRequest]); - const toggleDebugProfiler = useCallback( () => setShowDebugProfiler((prev) => !prev), [], @@ -489,6 +434,12 @@ export const AppContainer = (props: AppContainerProps) => { registerCleanup(async () => { // Turn off mouse scroll. disableMouseEvents(); + + // Kill all background shells + for (const pid of backgroundShellsRef.current.keys()) { + ShellExecutionService.kill(pid); + } + const ideClient = await IdeClient.getInstance(); await ideClient.disconnect(); @@ -837,6 +788,10 @@ Logging in with Google... Restarting Gemini CLI to continue. const { toggleVimEnabled } = useVimMode(); + const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>( + () => {}, + ); + const slashCommandActions = useMemo( () => ({ openAuthDialog: () => setAuthState(AuthState.Updating), @@ -860,6 +815,17 @@ Logging in with Google... Restarting Gemini CLI to continue. toggleDebugProfiler, dispatchExtensionStateUpdate, addConfirmUpdateExtensionRequest, + toggleBackgroundShell: () => { + toggleBackgroundShellRef.current(); + if (!isBackgroundShellVisibleRef.current) { + setEmbeddedShellFocused(true); + if (backgroundShellsRef.current.size > 1) { + setIsBackgroundShellListOpenRef.current(true); + } else { + setIsBackgroundShellListOpenRef.current(false); + } + } + }, setText: (text: string) => buffer.setText(text), }), [ @@ -1011,6 +977,12 @@ Logging in with Google... Restarting Gemini CLI to continue. activePtyId, loopDetectionConfirmationRequest, lastOutputTime, + backgroundShellCount, + isBackgroundShellVisible, + toggleBackgroundShell, + backgroundCurrentShell, + backgroundShells, + dismissBackgroundShell, retryStatus, } = useGeminiStream( config.getGeminiClient(), @@ -1033,7 +1005,30 @@ Logging in with Google... Restarting Gemini CLI to continue. embeddedShellFocused, ); + toggleBackgroundShellRef.current = toggleBackgroundShell; + isBackgroundShellVisibleRef.current = isBackgroundShellVisible; + backgroundShellsRef.current = backgroundShells; + + const { + activeBackgroundShellPid, + setIsBackgroundShellListOpen, + isBackgroundShellListOpen, + setActiveBackgroundShellPid, + backgroundShellHeight, + } = useBackgroundShellManager({ + backgroundShells, + backgroundShellCount, + isBackgroundShellVisible, + activePtyId, + embeddedShellFocused, + setEmbeddedShellFocused, + terminalHeight, + }); + + setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen; + const lastOutputTimeRef = useRef(0); + useEffect(() => { lastOutputTimeRef.current = lastOutputTime; }, [lastOutputTime]); @@ -1182,7 +1177,11 @@ Logging in with Google... Restarting Gemini CLI to continue. // Compute available terminal height based on controls measurement const availableTerminalHeight = Math.max( 0, - terminalHeight - controlsHeight - staticExtraHeight - 2, + terminalHeight - + controlsHeight - + staticExtraHeight - + 2 - + backgroundShellHeight, ); config.setShellExecutionConfig({ @@ -1486,10 +1485,6 @@ Logging in with Google... Restarting Gemini CLI to continue. } if (keyMatchers[Command.QUIT](key)) { - // Skip when ask_user dialog is open (use Esc to cancel instead) - if (askUserRequest) { - return; - } // If the user presses Ctrl+C, we want to cancel any ongoing requests. // This should happen regardless of the count. cancelOngoingRequest?.(); @@ -1542,9 +1537,10 @@ Logging in with Google... Restarting Gemini CLI to continue. setConstrainHeight(false); return true; } else if ( - keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) && - activePtyId && - embeddedShellFocused + keyMatchers[Command.FOCUS_SHELL_INPUT](key) && + (activePtyId || + (isBackgroundShellVisible && backgroundShells.size > 0)) && + buffer.text.length === 0 ) { if (key.name === 'tab' && key.shift) { // Always change focus @@ -1552,26 +1548,72 @@ Logging in with Google... Restarting Gemini CLI to continue. return true; } + if (embeddedShellFocused) { + handleWarning('Press Shift+Tab to focus out.'); + return true; + } + const now = Date.now(); // If the shell hasn't produced output in the last 100ms, it's considered idle. const isIdle = now - lastOutputTimeRef.current >= 100; - if (isIdle) { + if (isIdle && !activePtyId) { if (tabFocusTimeoutRef.current) { clearTimeout(tabFocusTimeoutRef.current); } - tabFocusTimeoutRef.current = setTimeout(() => { - tabFocusTimeoutRef.current = null; - // If the shell produced output since the tab press, we assume it handled the tab - // (e.g. autocomplete) so we should not toggle focus. - if (lastOutputTimeRef.current > now) { - handleWarning('Press Shift+Tab to focus out.'); - return; + toggleBackgroundShell(); + if (!isBackgroundShellVisible) { + // We are about to show it, so focus it + setEmbeddedShellFocused(true); + if (backgroundShells.size > 1) { + setIsBackgroundShellListOpen(true); } - setEmbeddedShellFocused(false); - }, 100); + } else { + // We are about to hide it + tabFocusTimeoutRef.current = setTimeout(() => { + tabFocusTimeoutRef.current = null; + // If the shell produced output since the tab press, we assume it handled the tab + // (e.g. autocomplete) so we should not toggle focus. + if (lastOutputTimeRef.current > now) { + handleWarning('Press Shift+Tab to focus out.'); + return; + } + setEmbeddedShellFocused(false); + }, 100); + } return true; } - handleWarning('Press Shift+Tab to focus out.'); + + // Not idle, just focus it + setEmbeddedShellFocused(true); + return true; + } else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { + if (activePtyId) { + backgroundCurrentShell(); + // After backgrounding, we explicitly do NOT show or focus the background UI. + } else { + if (isBackgroundShellVisible && !embeddedShellFocused) { + setEmbeddedShellFocused(true); + } else { + toggleBackgroundShell(); + // Toggle focus based on intent: if we were hiding, unfocus; if showing, focus. + if (!isBackgroundShellVisible && backgroundShells.size > 0) { + setEmbeddedShellFocused(true); + if (backgroundShells.size > 1) { + setIsBackgroundShellListOpen(true); + } + } else { + setEmbeddedShellFocused(false); + } + } + } + return true; + } else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { + if (backgroundShells.size > 0 && isBackgroundShellVisible) { + if (!embeddedShellFocused) { + setEmbeddedShellFocused(true); + } + setIsBackgroundShellListOpen(true); + } return true; } return false; @@ -1587,7 +1629,6 @@ Logging in with Google... Restarting Gemini CLI to continue. setCtrlDPressCount, handleSlashCommand, cancelOngoingRequest, - askUserRequest, activePtyId, embeddedShellFocused, settings.merged.general.debugKeystrokeLogging, @@ -1595,11 +1636,18 @@ Logging in with Google... Restarting Gemini CLI to continue. setCopyModeEnabled, copyModeEnabled, isAlternateBuffer, + backgroundCurrentShell, + toggleBackgroundShell, + backgroundShells, + isBackgroundShellVisible, + setIsBackgroundShellListOpen, + lastOutputTimeRef, + tabFocusTimeoutRef, handleWarning, ], ); - useKeypress(handleGlobalKeypress, { isActive: true }); + useKeypress(handleGlobalKeypress, { isActive: true, priority: true }); useEffect(() => { // Respect hideWindowTitle settings @@ -1701,7 +1749,6 @@ Logging in with Google... Restarting Gemini CLI to continue. const nightly = props.version.includes('nightly'); const dialogsVisible = - !!askUserRequest || shouldShowIdePrompt || isFolderTrustDialogOpen || adminSettingsChanged || @@ -1878,6 +1925,8 @@ Logging in with Google... Restarting Gemini CLI to continue. isRestarting, extensionsUpdateState, activePtyId, + backgroundShellCount, + isBackgroundShellVisible, embeddedShellFocused, showDebugProfiler, customDialog, @@ -1887,6 +1936,10 @@ Logging in with Google... Restarting Gemini CLI to continue. bannerVisible, terminalBackgroundColor: config.getTerminalBackground(), settingsNonce, + backgroundShells, + activeBackgroundShellPid, + backgroundShellHeight, + isBackgroundShellListOpen, adminSettingsChanged, newAgents, }), @@ -1977,6 +2030,8 @@ Logging in with Google... Restarting Gemini CLI to continue. currentModel, extensionsUpdateState, activePtyId, + backgroundShellCount, + isBackgroundShellVisible, historyManager, embeddedShellFocused, showDebugProfiler, @@ -1989,6 +2044,10 @@ Logging in with Google... Restarting Gemini CLI to continue. bannerVisible, config, settingsNonce, + backgroundShellHeight, + isBackgroundShellListOpen, + activeBackgroundShellPid, + backgroundShells, adminSettingsChanged, newAgents, ], @@ -2036,7 +2095,11 @@ Logging in with Google... Restarting Gemini CLI to continue. handleApiKeySubmit, handleApiKeyCancel, setBannerVisible, + handleWarning, setEmbeddedShellFocused, + dismissBackgroundShell, + setActiveBackgroundShellPid, + setIsBackgroundShellListOpen, setAuthContext, handleRestart: async () => { if (process.send) { @@ -2108,7 +2171,11 @@ Logging in with Google... Restarting Gemini CLI to continue. handleApiKeySubmit, handleApiKeyCancel, setBannerVisible, + handleWarning, setEmbeddedShellFocused, + dismissBackgroundShell, + setActiveBackgroundShellPid, + setIsBackgroundShellListOpen, setAuthContext, newAgents, config, @@ -2139,15 +2206,9 @@ Logging in with Google... Restarting Gemini CLI to continue. }} > - - - - - + + + diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap index 2587751008..f8451ee353 100644 --- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap @@ -125,7 +125,7 @@ Tips for getting started: 4. /help for more information. HistoryItemDisplay ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Action Required 1 of 1 │ +│ Action Required │ │ │ │ ? ls list directory │ │ │ diff --git a/packages/cli/src/ui/commands/shellsCommand.test.ts b/packages/cli/src/ui/commands/shellsCommand.test.ts new file mode 100644 index 0000000000..794d162d6e --- /dev/null +++ b/packages/cli/src/ui/commands/shellsCommand.test.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { shellsCommand } from './shellsCommand.js'; +import type { CommandContext } from './types.js'; + +describe('shellsCommand', () => { + it('should call toggleBackgroundShell', async () => { + const toggleBackgroundShell = vi.fn(); + const context = { + ui: { + toggleBackgroundShell, + }, + } as unknown as CommandContext; + + if (shellsCommand.action) { + await shellsCommand.action(context, ''); + } + + expect(toggleBackgroundShell).toHaveBeenCalled(); + }); + + it('should have correct name and altNames', () => { + expect(shellsCommand.name).toBe('shells'); + expect(shellsCommand.altNames).toContain('bashes'); + }); + + it('should auto-execute', () => { + expect(shellsCommand.autoExecute).toBe(true); + }); +}); diff --git a/packages/cli/src/ui/commands/shellsCommand.ts b/packages/cli/src/ui/commands/shellsCommand.ts new file mode 100644 index 0000000000..80645bbf8e --- /dev/null +++ b/packages/cli/src/ui/commands/shellsCommand.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandKind, type SlashCommand } from './types.js'; + +export const shellsCommand: SlashCommand = { + name: 'shells', + altNames: ['bashes'], + kind: CommandKind.BUILT_IN, + description: 'Toggle background shells view', + autoExecute: true, + action: async (context) => { + context.ui.toggleBackgroundShell(); + }, +}; diff --git a/packages/cli/src/ui/commands/skillsCommand.test.ts b/packages/cli/src/ui/commands/skillsCommand.test.ts index fb62f567b7..3a82639923 100644 --- a/packages/cli/src/ui/commands/skillsCommand.test.ts +++ b/packages/cli/src/ui/commands/skillsCommand.test.ts @@ -58,6 +58,7 @@ describe('skillsCommand', () => { (name: string) => skills.find((s) => s.name === name) ?? null, ), }), + getContentGenerator: vi.fn(), } as unknown as Config, settings: { merged: createTestMergedSettings({ skills: { disabled: [] } }), @@ -367,7 +368,7 @@ describe('skillsCommand', () => { expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, - text: 'Agent skills are disabled by your admin.', + text: 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', }), expect.any(Number), ); @@ -385,7 +386,7 @@ describe('skillsCommand', () => { expect(context.ui.addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.ERROR, - text: 'Agent skills are disabled by your admin.', + text: 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', }), expect.any(Number), ); diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 46be6d86f5..74372d2179 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -11,13 +11,15 @@ import { CommandKind, } from './types.js'; import { - MessageType, - type HistoryItemSkillsList, type HistoryItemInfo, + type HistoryItemSkillsList, + MessageType, } from '../types.js'; -import { SettingScope } from '../../config/settings.js'; -import { enableSkill, disableSkill } from '../../utils/skillSettings.js'; +import { disableSkill, enableSkill } from '../../utils/skillSettings.js'; + +import { getAdminErrorMessage } from '@google/gemini-cli-core'; import { renderSkillActionFeedback } from '../../utils/skillUtils.js'; +import { SettingScope } from '../../config/settings.js'; async function listAction( context: CommandContext, @@ -83,7 +85,10 @@ async function disableAction( context.ui.addItem( { type: MessageType.ERROR, - text: 'Agent skills are disabled by your admin.', + text: getAdminErrorMessage( + 'Agent skills', + context.services.config ?? undefined, + ), }, Date.now(), ); @@ -141,7 +146,10 @@ async function enableAction( context.ui.addItem( { type: MessageType.ERROR, - text: 'Agent skills are disabled by your admin.', + text: getAdminErrorMessage( + 'Agent skills', + context.services.config ?? undefined, + ), }, Date.now(), ); diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 9f5ca8eb41..283cc9b6e1 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -84,6 +84,7 @@ export interface CommandContext { dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void; removeComponent: () => void; + toggleBackgroundShell: () => void; }; // Session-specific data session: { diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 7ee45f96bd..645321dfc0 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -41,6 +41,8 @@ describe('AskUserDialog', () => { questions={authQuestion} onSubmit={vi.fn()} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -105,6 +107,8 @@ describe('AskUserDialog', () => { questions={questions} onSubmit={onSubmit} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -124,6 +128,8 @@ describe('AskUserDialog', () => { questions={authQuestion} onSubmit={onSubmit} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -153,12 +159,42 @@ describe('AskUserDialog', () => { }); }); + it('shows scroll arrows when options exceed available height', async () => { + const questions: Question[] = [ + { + question: 'Choose an option', + header: 'Scroll Test', + options: Array.from({ length: 15 }, (_, i) => ({ + label: `Option ${i + 1}`, + description: `Description ${i + 1}`, + })), + multiSelect: false, + }, + ]; + + const { lastFrame } = renderWithProviders( + , + ); + + await waitFor(() => { + expect(lastFrame()).toMatchSnapshot(); + }); + }); + it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => { const { stdin, lastFrame } = renderWithProviders( , { width: 120 }, ); @@ -209,6 +245,8 @@ describe('AskUserDialog', () => { questions={multiQuestions} onSubmit={vi.fn()} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -222,6 +260,8 @@ describe('AskUserDialog', () => { questions={authQuestion} onSubmit={vi.fn()} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -235,6 +275,8 @@ describe('AskUserDialog', () => { questions={authQuestion} onSubmit={vi.fn()} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -265,6 +307,8 @@ describe('AskUserDialog', () => { questions={multiQuestions} onSubmit={vi.fn()} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -306,6 +350,8 @@ describe('AskUserDialog', () => { questions={multiQuestions} onSubmit={onSubmit} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -373,6 +419,8 @@ describe('AskUserDialog', () => { questions={multiQuestions} onSubmit={vi.fn()} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -401,6 +449,8 @@ describe('AskUserDialog', () => { questions={multiQuestions} onSubmit={vi.fn()} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -445,6 +495,8 @@ describe('AskUserDialog', () => { questions={multiQuestions} onSubmit={vi.fn()} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -480,6 +532,8 @@ describe('AskUserDialog', () => { questions={multiQuestions} onSubmit={onSubmit} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -512,6 +566,8 @@ describe('AskUserDialog', () => { questions={textQuestion} onSubmit={vi.fn()} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -533,6 +589,8 @@ describe('AskUserDialog', () => { questions={textQuestion} onSubmit={vi.fn()} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -554,6 +612,8 @@ describe('AskUserDialog', () => { questions={textQuestion} onSubmit={vi.fn()} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -588,6 +648,8 @@ describe('AskUserDialog', () => { questions={textQuestion} onSubmit={vi.fn()} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -618,6 +680,8 @@ describe('AskUserDialog', () => { questions={mixedQuestions} onSubmit={vi.fn()} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -664,6 +728,8 @@ describe('AskUserDialog', () => { questions={mixedQuestions} onSubmit={onSubmit} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -713,6 +779,8 @@ describe('AskUserDialog', () => { questions={textQuestion} onSubmit={onSubmit} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -738,6 +806,8 @@ describe('AskUserDialog', () => { questions={textQuestion} onSubmit={vi.fn()} onCancel={onCancel} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -783,6 +853,8 @@ describe('AskUserDialog', () => { questions={multiQuestions} onSubmit={vi.fn()} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); @@ -841,6 +913,8 @@ describe('AskUserDialog', () => { questions={multiQuestions} onSubmit={onSubmit} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 4c74f2fd37..e2892feade 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -5,14 +5,7 @@ */ import type React from 'react'; -import { - useCallback, - useMemo, - useRef, - useEffect, - useReducer, - useContext, -} from 'react'; +import { useCallback, useMemo, useRef, useEffect, useReducer } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import type { Question } from '@google/gemini-cli-core'; @@ -24,10 +17,10 @@ import { keyMatchers, Command } from '../keyMatchers.js'; import { checkExhaustive } from '../../utils/checks.js'; import { TextInput } from './shared/TextInput.js'; import { useTextBuffer } from './shared/text-buffer.js'; -import { UIStateContext } from '../contexts/UIStateContext.js'; import { getCachedStringWidth } from '../utils/textUtils.js'; import { useTabbedNavigation } from '../hooks/useTabbedNavigation.js'; import { DialogFooter } from './shared/DialogFooter.js'; +import { MaxSizedBox } from './shared/MaxSizedBox.js'; interface AskUserDialogState { answers: { [key: string]: string }; @@ -121,6 +114,14 @@ interface AskUserDialogProps { * Useful for managing global keypress handlers. */ onActiveTextInputChange?: (active: boolean) => void; + /** + * Width of the dialog. + */ + width: number; + /** + * Height constraint for scrollable content. + */ + availableHeight: number; } interface ReviewViewProps { @@ -152,12 +153,7 @@ const ReviewView: React.FC = ({ ); return ( - + {progressHeader} @@ -174,15 +170,19 @@ const ReviewView: React.FC = ({ )} - {questions.map((q, i) => ( - - {q.header} - - - {answers[i] || '(not answered)'} - - - ))} + + {questions.map((q, i) => ( + + {q.header} + + + {answers[i] || '(not answered)'} + + + ))} + void; onEditingCustomOption?: (editing: boolean) => void; availableWidth: number; + availableHeight: number; initialAnswer?: string; progressHeader?: React.ReactNode; keyboardHints?: React.ReactNode; @@ -210,12 +211,13 @@ const TextQuestionView: React.FC = ({ onSelectionChange, onEditingCustomOption, availableWidth, + availableHeight, initialAnswer, progressHeader, keyboardHints, }) => { const prefix = '> '; - const horizontalPadding = 4 + 1; // Padding from Box (2) and border (2) + 1 for cursor + const horizontalPadding = 1; // 1 for cursor const bufferWidth = availableWidth - getCachedStringWidth(prefix) - horizontalPadding; @@ -241,12 +243,15 @@ const TextQuestionView: React.FC = ({ const handleExtraKeys = useCallback( (key: Key) => { if (keyMatchers[Command.QUIT](key)) { + if (textValue === '') { + return false; + } buffer.setText(''); return true; } return false; }, - [buffer], + [buffer, textValue], ); useKeypress(handleExtraKeys, { isActive: true, priority: true }); @@ -270,18 +275,21 @@ const TextQuestionView: React.FC = ({ const placeholder = question.placeholder || 'Enter your response'; + const HEADER_HEIGHT = progressHeader ? 2 : 0; + const INPUT_HEIGHT = 2; // TextInput + margin + const FOOTER_HEIGHT = 2; // DialogFooter + margin + const overhead = HEADER_HEIGHT + INPUT_HEIGHT + FOOTER_HEIGHT; + const questionHeight = Math.max(1, availableHeight - overhead); + return ( - + {progressHeader} - - {question.question} - + + + {question.question} + + @@ -381,6 +389,7 @@ interface ChoiceQuestionViewProps { onSelectionChange?: (answer: string) => void; onEditingCustomOption?: (editing: boolean) => void; availableWidth: number; + availableHeight: number; initialAnswer?: string; progressHeader?: React.ReactNode; keyboardHints?: React.ReactNode; @@ -391,14 +400,12 @@ const ChoiceQuestionView: React.FC = ({ onAnswer, onSelectionChange, onEditingCustomOption, + availableWidth, + availableHeight, initialAnswer, progressHeader, keyboardHints, }) => { - const uiState = useContext(UIStateContext); - const terminalWidth = uiState?.terminalWidth ?? 80; - const availableWidth = terminalWidth; - const numOptions = (question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0); const numLen = String(numOptions).length; @@ -407,15 +414,9 @@ const ChoiceQuestionView: React.FC = ({ const checkboxWidth = question.multiSelect ? 4 : 1; // "[x] " or " " const checkmarkWidth = question.multiSelect ? 0 : 2; // "" or " ✓" const cursorPadding = 1; // Extra character for cursor at end of line - const outerBoxPadding = 4; // border (2) + paddingX (2) const horizontalPadding = - outerBoxPadding + - radioWidth + - numberWidth + - checkboxWidth + - checkmarkWidth + - cursorPadding; + radioWidth + numberWidth + checkboxWidth + checkmarkWidth + cursorPadding; const bufferWidth = availableWidth - horizontalPadding; @@ -544,6 +545,9 @@ const ChoiceQuestionView: React.FC = ({ (key: Key) => { // If focusing custom option, handle Ctrl+C if (isCustomOptionFocused && keyMatchers[Command.QUIT](key)) { + if (customOptionText === '') { + return false; + } customBuffer.setText(''); return true; } @@ -586,7 +590,12 @@ const ChoiceQuestionView: React.FC = ({ } return false; }, - [isCustomOptionFocused, customBuffer, onEditingCustomOption], + [ + isCustomOptionFocused, + customBuffer, + onEditingCustomOption, + customOptionText, + ], ); useKeypress(handleExtraKeys, { isActive: true, priority: true }); @@ -698,31 +707,41 @@ const ChoiceQuestionView: React.FC = ({ } }, [customOptionText, isCustomOptionSelected, question.multiSelect]); + const HEADER_HEIGHT = progressHeader ? 2 : 0; + const TITLE_MARGIN = 1; + const FOOTER_HEIGHT = 2; // DialogFooter + margin + const overhead = HEADER_HEIGHT + TITLE_MARGIN + FOOTER_HEIGHT; + const listHeight = Math.max(1, availableHeight - overhead); + const questionHeight = Math.min(3, Math.max(1, listHeight - 4)); + const maxItemsToShow = Math.max( + 1, + Math.floor((listHeight - questionHeight) / 2), + ); + return ( - + {progressHeader} - - - {question.question} - + + + + {question.question} + {question.multiSelect && ( + + {' '} + (Select all that apply) + + )} + + - {question.multiSelect && ( - - {' '} - (Select all that apply) - - )} items={selectionItems} onSelect={handleSelect} onHighlight={handleHighlight} focusKey={isCustomOptionFocused ? 'other' : undefined} + maxItemsToShow={maxItemsToShow} + showScrollArrows={true} renderItem={(item, context) => { const optionItem = item.value; const isChecked = @@ -804,14 +823,12 @@ export const AskUserDialog: React.FC = ({ onSubmit, onCancel, onActiveTextInputChange, + width, + availableHeight, }) => { const [state, dispatch] = useReducer(askUserDialogReducerLogic, initialState); const { answers, isEditingCustomOption, submitted } = state; - const uiState = useContext(UIStateContext); - const terminalWidth = uiState?.terminalWidth ?? 80; - const availableWidth = terminalWidth; - const reviewTabIndex = questions.length; const tabCount = questions.length > 1 ? questions.length + 1 : questions.length; @@ -842,9 +859,12 @@ export const AskUserDialog: React.FC = ({ if (keyMatchers[Command.ESCAPE](key)) { onCancel(); return true; - } else if (keyMatchers[Command.QUIT](key) && !isEditingCustomOption) { - onCancel(); - return true; + } else if (keyMatchers[Command.QUIT](key)) { + if (!isEditingCustomOption) { + onCancel(); + } + // Return false to let ctrl-C bubble up to AppContainer for exit flow + return false; } return false; }, @@ -1021,7 +1041,8 @@ export const AskUserDialog: React.FC = ({ onAnswer={handleAnswer} onSelectionChange={handleSelectionChange} onEditingCustomOption={handleEditingCustomOption} - availableWidth={availableWidth} + availableWidth={width} + availableHeight={availableHeight} initialAnswer={answers[currentQuestionIndex]} progressHeader={progressHeader} keyboardHints={keyboardHints} @@ -1033,7 +1054,8 @@ export const AskUserDialog: React.FC = ({ onAnswer={handleAnswer} onSelectionChange={handleSelectionChange} onEditingCustomOption={handleEditingCustomOption} - availableWidth={availableWidth} + availableWidth={width} + availableHeight={availableHeight} initialAnswer={answers[currentQuestionIndex]} progressHeader={progressHeader} keyboardHints={keyboardHints} @@ -1043,7 +1065,7 @@ export const AskUserDialog: React.FC = ({ return ( {questionView} diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx new file mode 100644 index 0000000000..e5060af391 --- /dev/null +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx @@ -0,0 +1,459 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BackgroundShellDisplay } from './BackgroundShellDisplay.js'; +import { type BackgroundShell } from '../hooks/shellCommandProcessor.js'; +import { ShellExecutionService } from '@google/gemini-cli-core'; +import { act } from 'react'; +import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js'; +import { ScrollProvider } from '../contexts/ScrollProvider.js'; +import { Box } from 'ink'; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Mock dependencies +const mockDismissBackgroundShell = vi.fn(); +const mockSetActiveBackgroundShellPid = vi.fn(); +const mockSetIsBackgroundShellListOpen = vi.fn(); +const mockHandleWarning = vi.fn(); +const mockSetEmbeddedShellFocused = vi.fn(); + +vi.mock('../contexts/UIActionsContext.js', () => ({ + useUIActions: () => ({ + dismissBackgroundShell: mockDismissBackgroundShell, + setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid, + setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen, + handleWarning: mockHandleWarning, + setEmbeddedShellFocused: mockSetEmbeddedShellFocused, + }), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + ShellExecutionService: { + resizePty: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + }, + }; +}); + +// Mock AnsiOutputText since it's a complex component +vi.mock('./AnsiOutput.js', () => ({ + AnsiOutputText: ({ data }: { data: string | unknown }) => { + if (typeof data === 'string') return <>{data}; + // Simple serialization for object data + return <>{JSON.stringify(data)}; + }, +})); + +// Mock useKeypress +let keypressHandlers: Array<{ handler: KeypressHandler; isActive: boolean }> = + []; +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn((handler, { isActive }) => { + keypressHandlers.push({ handler, isActive }); + }), +})); + +const simulateKey = (key: Partial) => { + const fullKey: Key = createMockKey(key); + keypressHandlers.forEach(({ handler, isActive }) => { + if (isActive) { + handler(fullKey); + } + }); +}; + +vi.mock('../contexts/MouseContext.js', () => ({ + useMouseContext: vi.fn(() => ({ + subscribe: vi.fn(), + unsubscribe: vi.fn(), + })), + useMouse: vi.fn(), +})); + +// Mock ScrollableList +vi.mock('./shared/ScrollableList.js', () => ({ + SCROLL_TO_ITEM_END: 999999, + ScrollableList: vi.fn( + ({ + data, + renderItem, + }: { + data: BackgroundShell[]; + renderItem: (props: { + item: BackgroundShell; + index: number; + }) => React.ReactNode; + }) => ( + + {data.map((item: BackgroundShell, index: number) => ( + {renderItem({ item, index })} + ))} + + ), + ), +})); + +const createMockKey = (overrides: Partial): Key => ({ + name: '', + ctrl: false, + alt: false, + cmd: false, + shift: false, + insertable: false, + sequence: '', + ...overrides, +}); + +describe('', () => { + const mockShells = new Map(); + const shell1: BackgroundShell = { + pid: 1001, + command: 'npm start', + output: 'Starting server...', + isBinary: false, + binaryBytesReceived: 0, + status: 'running', + }; + const shell2: BackgroundShell = { + pid: 1002, + command: 'tail -f log.txt', + output: 'Log entry 1', + isBinary: false, + binaryBytesReceived: 0, + status: 'running', + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockShells.clear(); + mockShells.set(shell1.pid, shell1); + mockShells.set(shell2.pid, shell2); + keypressHandlers = []; + }); + + it('renders the output of the active shell', async () => { + const { lastFrame } = render( + + + , + ); + await act(async () => { + await delay(0); + }); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders tabs for multiple shells', async () => { + const { lastFrame } = render( + + + , + ); + await act(async () => { + await delay(0); + }); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('highlights the focused state', async () => { + const { lastFrame } = render( + + + , + ); + await act(async () => { + await delay(0); + }); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('resizes the PTY on mount and when dimensions change', async () => { + const { rerender } = render( + + + , + ); + await act(async () => { + await delay(0); + }); + + expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( + shell1.pid, + 76, + 21, + ); + + rerender( + + + , + ); + await act(async () => { + await delay(0); + }); + + expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( + shell1.pid, + 96, + 27, + ); + }); + + it('renders the process list when isListOpenProp is true', async () => { + const { lastFrame } = render( + + + , + ); + await act(async () => { + await delay(0); + }); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => { + render( + + + , + ); + await act(async () => { + await delay(0); + }); + + // Simulate down arrow to select the second process (handled by RadioButtonSelect) + act(() => { + simulateKey({ name: 'down' }); + }); + + // Simulate Ctrl+L (handled by BackgroundShellDisplay) + act(() => { + simulateKey({ name: 'l', ctrl: true }); + }); + + expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid); + expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false); + }); + + it('kills the highlighted process when Ctrl+K is pressed in list view', async () => { + render( + + + , + ); + await act(async () => { + await delay(0); + }); + + // Initial state: shell1 (active) is highlighted + + // Move to shell2 + act(() => { + simulateKey({ name: 'down' }); + }); + + // Press Ctrl+K + act(() => { + simulateKey({ name: 'k', ctrl: true }); + }); + + expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid); + }); + + it('kills the active process when Ctrl+K is pressed in output view', async () => { + render( + + + , + ); + await act(async () => { + await delay(0); + }); + + act(() => { + simulateKey({ name: 'k', ctrl: true }); + }); + + expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid); + }); + + it('scrolls to active shell when list opens', async () => { + // shell2 is active + const { lastFrame } = render( + + + , + ); + await act(async () => { + await delay(0); + }); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('keeps exit code status color even when selected', async () => { + const exitedShell: BackgroundShell = { + pid: 1003, + command: 'exit 0', + output: '', + isBinary: false, + binaryBytesReceived: 0, + status: 'exited', + exitCode: 0, + }; + mockShells.set(exitedShell.pid, exitedShell); + + const { lastFrame } = render( + + + , + ); + await act(async () => { + await delay(0); + }); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('unfocuses the shell when Shift+Tab is pressed', async () => { + render( + + + , + ); + await act(async () => { + await delay(0); + }); + + act(() => { + simulateKey({ name: 'tab', shift: true }); + }); + + expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false); + }); + + it('shows a warning when Tab is pressed', async () => { + render( + + + , + ); + await act(async () => { + await delay(0); + }); + + act(() => { + simulateKey({ name: 'tab' }); + }); + + expect(mockHandleWarning).toHaveBeenCalledWith( + 'Press Shift+Tab to focus out.', + ); + expect(mockSetEmbeddedShellFocused).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx new file mode 100644 index 0000000000..e0e63f636a --- /dev/null +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx @@ -0,0 +1,460 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { useEffect, useState, useRef } from 'react'; +import { useUIActions } from '../contexts/UIActionsContext.js'; +import { theme } from '../semantic-colors.js'; +import { + ShellExecutionService, + type AnsiOutput, + type AnsiLine, + type AnsiToken, +} from '@google/gemini-cli-core'; +import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js'; +import { type BackgroundShell } from '../hooks/shellCommandProcessor.js'; +import { Command, keyMatchers } from '../keyMatchers.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { commandDescriptions } from '../../config/keyBindings.js'; +import { + ScrollableList, + type ScrollableListRef, +} from './shared/ScrollableList.js'; + +import { SCROLL_TO_ITEM_END } from './shared/VirtualizedList.js'; + +import { + RadioButtonSelect, + type RadioSelectItem, +} from './shared/RadioButtonSelect.js'; + +interface BackgroundShellDisplayProps { + shells: Map; + activePid: number; + width: number; + height: number; + isFocused: boolean; + isListOpenProp: boolean; +} + +const CONTENT_PADDING_X = 1; +const BORDER_WIDTH = 2; // Left and Right border +const HEADER_HEIGHT = 3; // 2 for border, 1 for header +const TAB_DISPLAY_HORIZONTAL_PADDING = 4; + +const formatShellCommandForDisplay = (command: string, maxWidth: number) => { + const commandFirstLine = command.split('\n')[0]; + return cpLen(commandFirstLine) > maxWidth + ? `${cpSlice(commandFirstLine, 0, maxWidth - 3)}...` + : commandFirstLine; +}; + +export const BackgroundShellDisplay = ({ + shells, + activePid, + width, + height, + isFocused, + isListOpenProp, +}: BackgroundShellDisplayProps) => { + const { + dismissBackgroundShell, + setActiveBackgroundShellPid, + setIsBackgroundShellListOpen, + handleWarning, + setEmbeddedShellFocused, + } = useUIActions(); + const activeShell = shells.get(activePid); + const [output, setOutput] = useState( + activeShell?.output || '', + ); + const [highlightedPid, setHighlightedPid] = useState( + activePid, + ); + const outputRef = useRef>(null); + const subscribedRef = useRef(false); + + useEffect(() => { + if (!activePid) return; + + const ptyWidth = Math.max(1, width - BORDER_WIDTH - CONTENT_PADDING_X * 2); + const ptyHeight = Math.max(1, height - HEADER_HEIGHT); + ShellExecutionService.resizePty(activePid, ptyWidth, ptyHeight); + }, [activePid, width, height]); + + useEffect(() => { + if (!activePid) { + setOutput(''); + return; + } + + // Set initial output from the shell object + const shell = shells.get(activePid); + if (shell) { + setOutput(shell.output); + } + + subscribedRef.current = false; + + // Subscribe to live updates for the active shell + const unsubscribe = ShellExecutionService.subscribe(activePid, (event) => { + if (event.type === 'data') { + if (typeof event.chunk === 'string') { + if (!subscribedRef.current) { + // Initial synchronous update contains full history + setOutput(event.chunk); + } else { + // Subsequent updates are deltas for child_process + setOutput((prev) => + typeof prev === 'string' ? prev + event.chunk : event.chunk, + ); + } + } else { + // PTY always sends full AnsiOutput + setOutput(event.chunk); + } + } + }); + + subscribedRef.current = true; + + return () => { + unsubscribe(); + subscribedRef.current = false; + }; + }, [activePid, shells]); + + // Sync highlightedPid with activePid when list opens + useEffect(() => { + if (isListOpenProp) { + setHighlightedPid(activePid); + } + }, [isListOpenProp, activePid]); + + useKeypress( + (key) => { + if (!activeShell) return; + + // Handle Shift+Tab or Tab (in list) to focus out + if ( + keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) || + (isListOpenProp && + keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) + ) { + setEmbeddedShellFocused(false); + return true; + } + + // Handle Tab to warn but propagate + if ( + !isListOpenProp && + keyMatchers[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING](key) + ) { + handleWarning( + `Press ${commandDescriptions[Command.UNFOCUS_BACKGROUND_SHELL]} to focus out.`, + ); + // Fall through to allow Tab to be sent to the shell + } + + if (isListOpenProp) { + // Navigation (Up/Down/Enter) is handled by RadioButtonSelect + // We only handle special keys not consumed by RadioButtonSelect or overriding them if needed + // RadioButtonSelect handles Enter -> onSelect + + if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) { + setIsBackgroundShellListOpen(false); + return true; + } + + if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { + if (highlightedPid) { + dismissBackgroundShell(highlightedPid); + // If we killed the active one, the list might update via props + } + return true; + } + + if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { + if (highlightedPid) { + setActiveBackgroundShellPid(highlightedPid); + } + setIsBackgroundShellListOpen(false); + return true; + } + return false; + } + + if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { + return true; + } + + if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { + dismissBackgroundShell(activeShell.pid); + return true; + } + + if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { + setIsBackgroundShellListOpen(true); + return true; + } + + if (keyMatchers[Command.BACKGROUND_SHELL_SELECT](key)) { + ShellExecutionService.writeToPty(activeShell.pid, '\r'); + return true; + } else if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) { + ShellExecutionService.writeToPty(activeShell.pid, '\b'); + return true; + } else if (key.sequence) { + ShellExecutionService.writeToPty(activeShell.pid, key.sequence); + return true; + } + return false; + }, + { isActive: isFocused && !!activeShell }, + ); + + const helpText = `${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL]} Hide | ${commandDescriptions[Command.KILL_BACKGROUND_SHELL]} Kill | ${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]} List`; + + const renderTabs = () => { + const shellList = Array.from(shells.values()).filter( + (s) => s.status === 'running', + ); + + const pidInfoWidth = getCachedStringWidth( + ` (PID: ${activePid}) ${isFocused ? '(Focused)' : ''}`, + ); + + const availableWidth = + width - + TAB_DISPLAY_HORIZONTAL_PADDING - + getCachedStringWidth(helpText) - + pidInfoWidth; + + let currentWidth = 0; + const tabs = []; + + for (let i = 0; i < shellList.length; i++) { + const shell = shellList[i]; + // Account for " i: " (length 4 if i < 9) and spaces (length 2) + const labelOverhead = 4 + (i + 1).toString().length; + const maxTabLabelLength = Math.max( + 1, + Math.floor(availableWidth / shellList.length) - labelOverhead, + ); + const truncatedCommand = formatShellCommandForDisplay( + shell.command, + maxTabLabelLength, + ); + const label = ` ${i + 1}: ${truncatedCommand} `; + const labelWidth = getCachedStringWidth(label); + + // If this is the only shell, we MUST show it (truncated if necessary) + // even if it exceeds availableWidth, as there are no alternatives. + if (i > 0 && currentWidth + labelWidth > availableWidth) { + break; + } + + const isActive = shell.pid === activePid; + + tabs.push( + + {label} + , + ); + currentWidth += labelWidth; + } + + if (shellList.length > tabs.length && !isListOpenProp) { + const overflowLabel = ` ... (${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]}) `; + const overflowWidth = getCachedStringWidth(overflowLabel); + + // If we only have one tab, ensure we don't show the overflow if it's too cramped + // We want at least 10 chars for the overflow or we favor the first tab. + const shouldShowOverflow = + tabs.length > 1 || availableWidth - currentWidth >= overflowWidth; + + if (shouldShowOverflow) { + tabs.push( + + {overflowLabel} + , + ); + } + } + + return tabs; + }; + + const renderProcessList = () => { + const maxCommandLength = Math.max( + 0, + width - BORDER_WIDTH - CONTENT_PADDING_X * 2 - 10, + ); + + const items: Array> = Array.from( + shells.values(), + ).map((shell, index) => { + const truncatedCommand = formatShellCommandForDisplay( + shell.command, + maxCommandLength, + ); + + let label = `${index + 1}: ${truncatedCommand} (PID: ${shell.pid})`; + if (shell.status === 'exited') { + label += ` (Exit Code: ${shell.exitCode})`; + } + + return { + key: shell.pid.toString(), + value: shell.pid, + label, + }; + }); + + const initialIndex = items.findIndex((item) => item.value === activePid); + + return ( + + + + {`Select Process (${commandDescriptions[Command.BACKGROUND_SHELL_SELECT]} to select, ${commandDescriptions[Command.BACKGROUND_SHELL_ESCAPE]} to cancel):`} + + + + = 0 ? initialIndex : 0} + onSelect={(pid) => { + setActiveBackgroundShellPid(pid); + setIsBackgroundShellListOpen(false); + }} + onHighlight={(pid) => setHighlightedPid(pid)} + isFocused={isFocused} + maxItemsToShow={Math.max(1, height - HEADER_HEIGHT - 3)} // Adjust for header + renderItem={( + item, + { isSelected: _isSelected, titleColor: _titleColor }, + ) => { + // Custom render to handle exit code coloring if needed, + // or just use default. The default RadioButtonSelect renderer + // handles standard label. + // But we want to color exit code differently? + // The previous implementation colored exit code green/red. + // Let's reimplement that. + + // We need access to shell details here. + // We can put shell details in the item or lookup. + // Lookup from shells map. + const shell = shells.get(item.value); + if (!shell) return {item.label}; + + const truncatedCommand = formatShellCommandForDisplay( + shell.command, + maxCommandLength, + ); + + return ( + + {truncatedCommand} (PID: {shell.pid}) + {shell.status === 'exited' ? ( + + {' '} + (Exit Code: {shell.exitCode}) + + ) : null} + + ); + }} + /> + + + ); + }; + + const renderOutput = () => { + const lines = typeof output === 'string' ? output.split('\n') : output; + + return ( + { + if (typeof line === 'string') { + return {line}; + } + return ( + + {line.length > 0 + ? line.map((token: AnsiToken, tokenIndex: number) => ( + + {token.text} + + )) + : null} + + ); + }} + estimatedItemHeight={() => 1} + keyExtractor={(_, index) => index.toString()} + hasFocus={isFocused} + initialScrollIndex={SCROLL_TO_ITEM_END} + /> + ); + }; + + return ( + + + + {renderTabs()} + + {' '} + (PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''} + + + {helpText} + + + {isListOpenProp ? renderProcessList() : renderOutput()} + + + ); +}; diff --git a/packages/cli/src/ui/components/BubblingRegression.test.tsx b/packages/cli/src/ui/components/BubblingRegression.test.tsx index a7a0e31714..f91f6fe2dc 100644 --- a/packages/cli/src/ui/components/BubblingRegression.test.tsx +++ b/packages/cli/src/ui/components/BubblingRegression.test.tsx @@ -34,6 +34,8 @@ describe('Key Bubbling Regression', () => { questions={choiceQuestion} onSubmit={vi.fn()} onCancel={vi.fn()} + width={120} + availableHeight={20} />, { width: 120 }, ); diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index fcff3e305d..4e2ad6464f 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -133,6 +133,8 @@ const createMockUIState = (overrides: Partial = {}): UIState => nightly: false, isTrustedFolder: true, activeHooks: [], + isBackgroundShellVisible: false, + embeddedShellFocused: false, ...overrides, }) as UIState; @@ -310,6 +312,32 @@ describe('Composer', () => { expect(output).toContain('LoadingIndicator'); expect(output).not.toContain('Should not show during confirmation'); }); + + it('renders LoadingIndicator when embedded shell is focused but background shell is visible', () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + embeddedShellFocused: true, + isBackgroundShellVisible: true, + }); + + const { lastFrame } = renderComposer(uiState); + + const output = lastFrame(); + expect(output).toContain('LoadingIndicator'); + }); + + it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', () => { + const uiState = createMockUIState({ + streamingState: StreamingState.Responding, + embeddedShellFocused: true, + isBackgroundShellVisible: false, + }); + + const { lastFrame } = renderComposer(uiState); + + const output = lastFrame(); + expect(output).not.toContain('LoadingIndicator'); + }); }); describe('Message Queue Display', () => { diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 12a899b7b9..d366516a94 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -54,7 +54,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { flexGrow={0} flexShrink={0} > - {!uiState.embeddedShellFocused && ( + {(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && ( ; ideContext?: IdeContext; skillCount: number; + backgroundProcessCount?: number; } export const ContextSummaryDisplay: React.FC = ({ @@ -27,6 +28,7 @@ export const ContextSummaryDisplay: React.FC = ({ blockedMcpServers, ideContext, skillCount, + backgroundProcessCount = 0, }) => { const { columns: terminalWidth } = useTerminalSize(); const isNarrow = isNarrowWidth(terminalWidth); @@ -39,7 +41,8 @@ export const ContextSummaryDisplay: React.FC = ({ mcpServerCount === 0 && blockedMcpServerCount === 0 && openFileCount === 0 && - skillCount === 0 + skillCount === 0 && + backgroundProcessCount === 0 ) { return ; // Render an empty space to reserve height } @@ -93,9 +96,22 @@ export const ContextSummaryDisplay: React.FC = ({ return `${skillCount} skill${skillCount > 1 ? 's' : ''}`; })(); - const summaryParts = [openFilesText, geminiMdText, mcpText, skillText].filter( - Boolean, - ); + const backgroundText = (() => { + if (backgroundProcessCount === 0) { + return ''; + } + return `${backgroundProcessCount} Background process${ + backgroundProcessCount > 1 ? 'es' : '' + }`; + })(); + + const summaryParts = [ + openFilesText, + geminiMdText, + mcpText, + skillText, + backgroundText, + ].filter(Boolean); if (isNarrow) { return ( diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 527c2de983..6d4db7ca3b 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -32,8 +32,6 @@ import process from 'node:process'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; -import { AskUserDialog } from './AskUserDialog.js'; -import { useAskUserActions } from '../contexts/AskUserActionsContext.js'; import { NewAgentsNotification } from './NewAgentsNotification.js'; import { AgentConfigDialog } from './AgentConfigDialog.js'; @@ -59,22 +57,6 @@ export const DialogManager = ({ terminalWidth: uiTerminalWidth, } = uiState; - const { - request: askUserRequest, - submit: askUserSubmit, - cancel: askUserCancel, - } = useAskUserActions(); - - if (askUserRequest) { - return ( - - ); - } - if (uiState.adminSettingsChanged) { return ; } diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 549d51f881..52f37916bf 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -152,8 +152,14 @@ export const InputPrompt: React.FC = ({ const kittyProtocol = useKittyKeyboardProtocol(); const isShellFocused = useShellFocusState(); const { setEmbeddedShellFocused } = useUIActions(); - const { terminalWidth, activePtyId, history, terminalBackgroundColor } = - useUIState(); + const { + terminalWidth, + activePtyId, + history, + terminalBackgroundColor, + backgroundShells, + backgroundShellHeight, + } = useUIState(); const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); const escPressCount = useRef(0); const [showEscapePrompt, setShowEscapePrompt] = useState(false); @@ -915,7 +921,10 @@ export const InputPrompt: React.FC = ({ if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) { // If we got here, Autocomplete didn't handle the key (e.g. no suggestions). - if (activePtyId) { + if ( + activePtyId || + (backgroundShells.size > 0 && backgroundShellHeight > 0) + ) { setEmbeddedShellFocused(true); } return true; @@ -967,6 +976,8 @@ export const InputPrompt: React.FC = ({ onSubmit, activePtyId, setEmbeddedShellFocused, + backgroundShells.size, + backgroundShellHeight, history, ], ); diff --git a/packages/cli/src/ui/components/RewindViewer.test.tsx b/packages/cli/src/ui/components/RewindViewer.test.tsx index cdb0650408..5ad1f8b5e4 100644 --- a/packages/cli/src/ui/components/RewindViewer.test.tsx +++ b/packages/cli/src/ui/components/RewindViewer.test.tsx @@ -254,6 +254,7 @@ describe('RewindViewer', () => { { description: 'removes reference markers', prompt: `some command @file\n--- Content from referenced files ---\nContent from file:\nblah blah\n--- End of content ---`, + expected: 'some command @file', }, { description: 'strips expanded MCP resource content', @@ -263,10 +264,23 @@ describe('RewindViewer', () => { '\nContent from @server3:mcp://demo-resource:\n' + 'This is the content of the demo resource.\n' + `--- End of content ---`, + expected: 'read @server3:mcp://demo-resource hello', }, - ])('$description', async ({ prompt }) => { + { + description: 'uses displayContent if present and does not strip', + prompt: `raw content with markers\n--- Content from referenced files ---\nblah\n--- End of content ---`, + displayContent: 'clean display content', + expected: 'clean display content', + }, + ])('$description', async ({ prompt, displayContent, expected }) => { const conversation = createConversation([ - { type: 'user', content: prompt, id: '1', timestamp: '1' }, + { + type: 'user', + content: prompt, + displayContent, + id: '1', + timestamp: '1', + }, ]); const onRewind = vi.fn(); const { lastFrame, stdin } = renderWithProviders( @@ -289,6 +303,15 @@ describe('RewindViewer', () => { await waitFor(() => { expect(lastFrame()).toContain('Confirm Rewind'); }); + + // Confirm + act(() => { + stdin.write('\r'); + }); + + await waitFor(() => { + expect(onRewind).toHaveBeenCalledWith('1', expected, expect.anything()); + }); }); }); diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx index 2ab417888a..7a6143a6eb 100644 --- a/packages/cli/src/ui/components/RewindViewer.tsx +++ b/packages/cli/src/ui/components/RewindViewer.tsx @@ -35,6 +35,14 @@ interface RewindViewerProps { const MAX_LINES_PER_BOX = 2; +const getCleanedRewindText = (userPrompt: MessageRecord): string => { + const contentToUse = userPrompt.displayContent || userPrompt.content; + const originalUserText = contentToUse ? partToString(contentToUse) : ''; + return userPrompt.displayContent + ? originalUserText + : stripReferenceContent(originalUserText); +}; + export const RewindViewer: React.FC = ({ conversation, onExit, @@ -162,10 +170,7 @@ export const RewindViewer: React.FC = ({ (m) => m.id === selectedMessageId, ); if (userPrompt) { - const originalUserText = userPrompt.content - ? partToString(userPrompt.content) - : ''; - const cleanedText = stripReferenceContent(originalUserText); + const cleanedText = getCleanedRewindText(userPrompt); setIsRewinding(true); await onRewind(selectedMessageId, cleanedText, outcome); } @@ -224,7 +229,9 @@ export const RewindViewer: React.FC = ({ isSelected ? theme.status.success : theme.text.primary } > - {partToString(userPrompt.content)} + {partToString( + userPrompt.displayContent || userPrompt.content, + )} Cancel rewind and stay here @@ -235,10 +242,7 @@ export const RewindViewer: React.FC = ({ const stats = getStats(userPrompt); const firstFileName = stats?.details?.at(0)?.fileName; - const originalUserText = userPrompt.content - ? partToString(userPrompt.content) - : ''; - const cleanedText = stripReferenceContent(originalUserText); + const cleanedText = getCleanedRewindText(userPrompt); return ( diff --git a/packages/cli/src/ui/components/ShellInputPrompt.tsx b/packages/cli/src/ui/components/ShellInputPrompt.tsx index 5cdafff00b..4f956ae262 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.tsx @@ -9,6 +9,7 @@ import type React from 'react'; import { useKeypress } from '../hooks/useKeypress.js'; import { ShellExecutionService } from '@google/gemini-cli-core'; import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js'; +import { Command, keyMatchers } from '../keyMatchers.js'; export interface ShellInputPromptProps { activeShellPtyId: number | null; @@ -31,22 +32,31 @@ export const ShellInputPrompt: React.FC = ({ const handleInput = useCallback( (key: Key) => { if (!focus || !activeShellPtyId) { - return; + return false; } + + // Allow background shell toggle to bubble up + if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { + return false; + } + if (key.ctrl && key.shift && key.name === 'up') { ShellExecutionService.scrollPty(activeShellPtyId, -1); - return; + return true; } if (key.ctrl && key.shift && key.name === 'down') { ShellExecutionService.scrollPty(activeShellPtyId, 1); - return; + return true; } const ansiSequence = keyToAnsi(key); if (ansiSequence) { handleShellInputSubmit(ansiSequence); + return true; } + + return false; }, [focus, handleShellInputSubmit, activeShellPtyId], ); diff --git a/packages/cli/src/ui/components/StatusDisplay.test.tsx b/packages/cli/src/ui/components/StatusDisplay.test.tsx index 8861b3c62a..df4bcd4b0f 100644 --- a/packages/cli/src/ui/components/StatusDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.test.tsx @@ -15,8 +15,14 @@ import type { TextBuffer } from './shared/text-buffer.js'; // Mock child components to simplify testing vi.mock('./ContextSummaryDisplay.js', () => ({ - ContextSummaryDisplay: (props: { skillCount: number }) => ( - Mock Context Summary Display (Skills: {props.skillCount}) + ContextSummaryDisplay: (props: { + skillCount: number; + backgroundProcessCount: number; + }) => ( + + Mock Context Summary Display (Skills: {props.skillCount}, Shells:{' '} + {props.backgroundProcessCount}) + ), })); @@ -41,6 +47,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState => ideContextState: null, geminiMdFileCount: 0, contextFileNames: [], + backgroundShellCount: 0, buffer: { text: '' }, history: [{ id: 1, type: 'user', text: 'test' }], ...overrides, @@ -227,4 +234,15 @@ describe('StatusDisplay', () => { ); expect(lastFrame()).toBe(''); }); + + it('passes backgroundShellCount to ContextSummaryDisplay', () => { + const uiState = createMockUIState({ + backgroundShellCount: 3, + }); + const { lastFrame } = renderStatusDisplay( + { hideContextSummary: false }, + uiState, + ); + expect(lastFrame()).toContain('Shells: 3'); + }); }); diff --git a/packages/cli/src/ui/components/StatusDisplay.tsx b/packages/cli/src/ui/components/StatusDisplay.tsx index 45dcef10ba..52d22cd34d 100644 --- a/packages/cli/src/ui/components/StatusDisplay.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.tsx @@ -81,6 +81,7 @@ export const StatusDisplay: React.FC = ({ config.getMcpClientManager()?.getBlockedMcpServers() ?? [] } skillCount={config.getSkillManager().getDisplayableSkills().length} + backgroundProcessCount={uiState.backgroundShellCount} /> ); } diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx index 71f57b7a3a..0ee6fec05c 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx @@ -16,6 +16,21 @@ import { OverflowProvider } from '../contexts/OverflowContext.js'; import { ShowMoreLines } from './ShowMoreLines.js'; import { StickyHeader } from './StickyHeader.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; +import type { SerializableConfirmationDetails } from '@google/gemini-cli-core'; + +function getConfirmationHeader( + details: SerializableConfirmationDetails | undefined, +): string { + const headers: Partial< + Record + > = { + ask_user: 'Answer Questions', + }; + if (!details?.type) { + return 'Action Required'; + } + return headers[details.type] ?? 'Action Required'; +} interface ToolConfirmationQueueProps { confirmingTool: ConfirmingToolState; @@ -55,6 +70,7 @@ export const ToolConfirmationQueue: React.FC = ({ : undefined; const borderColor = theme.status.warning; + const hideToolIdentity = tool.confirmationDetails?.type === 'ask_user'; return ( @@ -67,25 +83,31 @@ export const ToolConfirmationQueue: React.FC = ({ > {/* Header */} - + - Action Required - - - {index} of {total} + {getConfirmationHeader(tool.confirmationDetails)} + {total > 1 && ( + + {index} of {total} + + )} - {/* Tool Identity (Context) */} - - - - + {!hideToolIdentity && ( + + + + + )} diff --git a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap index 54554740aa..fdb34f4adb 100644 --- a/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AskUserDialog.test.tsx.snap @@ -1,138 +1,131 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ What should we name this component? │ -│ │ -│ > e.g., UserProfileCard │ -│ │ -│ │ -│ Enter to submit · Esc to cancel │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +"What should we name this component? + +> e.g., UserProfileCard + + +Enter to submit · Esc to cancel" `; exports[`AskUserDialog > Text type questions > shows correct keyboard hints for text type 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Enter the variable name: │ -│ │ -│ > Enter your response │ -│ │ -│ │ -│ Enter to submit · Esc to cancel │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +"Enter the variable name: + +> Enter your response + + +Enter to submit · Esc to cancel" `; exports[`AskUserDialog > Text type questions > shows default placeholder when none provided 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Enter the database connection string: │ -│ │ -│ > Enter your response │ -│ │ -│ │ -│ Enter to submit · Esc to cancel │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +"Enter the database connection string: + +> Enter your response + + +Enter to submit · Esc to cancel" `; exports[`AskUserDialog > allows navigating to Review tab and back 1`] = ` -"╭─────────────────────────────────────────────────────────────────╮ -│ ← □ Tests │ □ Docs │ ≡ Review → │ -│ │ -│ Review your answers: │ -│ │ -│ ⚠ You have 2 unanswered questions │ -│ │ -│ Tests → (not answered) │ -│ Docs → (not answered) │ -│ │ -│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │ -╰─────────────────────────────────────────────────────────────────╯" +"← □ Tests │ □ Docs │ ≡ Review → + +Review your answers: + +⚠ You have 2 unanswered questions + +Tests → (not answered) +Docs → (not answered) + +Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel" `; exports[`AskUserDialog > hides progress header for single question 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Which authentication method should we use? │ -│ │ -│ ● 1. OAuth 2.0 │ -│ Industry standard, supports SSO │ -│ 2. JWT tokens │ -│ Stateless, good for APIs │ -│ 3. Enter a custom value │ -│ │ -│ Enter to select · ↑/↓ to navigate · Esc to cancel │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +"Which authentication method should we use? + +● 1. OAuth 2.0 + Industry standard, supports SSO + 2. JWT tokens + Stateless, good for APIs + 3. Enter a custom value + +Enter to select · ↑/↓ to navigate · Esc to cancel" `; exports[`AskUserDialog > renders question and options 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Which authentication method should we use? │ -│ │ -│ ● 1. OAuth 2.0 │ -│ Industry standard, supports SSO │ -│ 2. JWT tokens │ -│ Stateless, good for APIs │ -│ 3. Enter a custom value │ -│ │ -│ Enter to select · ↑/↓ to navigate · Esc to cancel │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +"Which authentication method should we use? + +● 1. OAuth 2.0 + Industry standard, supports SSO + 2. JWT tokens + Stateless, good for APIs + 3. Enter a custom value + +Enter to select · ↑/↓ to navigate · Esc to cancel" `; exports[`AskUserDialog > shows Review tab in progress header for multiple questions 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ ← □ Framework │ □ Styling │ ≡ Review → │ -│ │ -│ Which framework? │ -│ │ -│ ● 1. React │ -│ Component library │ -│ 2. Vue │ -│ Progressive framework │ -│ 3. Enter a custom value │ -│ │ -│ Enter to select · ←/→ to switch questions · Esc to cancel │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +"← □ Framework │ □ Styling │ ≡ Review → + +Which framework? + +● 1. React + Component library + 2. Vue + Progressive framework + 3. Enter a custom value + +Enter to select · ←/→ to switch questions · Esc to cancel" `; exports[`AskUserDialog > shows keyboard hints 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Which authentication method should we use? │ -│ │ -│ ● 1. OAuth 2.0 │ -│ Industry standard, supports SSO │ -│ 2. JWT tokens │ -│ Stateless, good for APIs │ -│ 3. Enter a custom value │ -│ │ -│ Enter to select · ↑/↓ to navigate · Esc to cancel │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +"Which authentication method should we use? + +● 1. OAuth 2.0 + Industry standard, supports SSO + 2. JWT tokens + Stateless, good for APIs + 3. Enter a custom value + +Enter to select · ↑/↓ to navigate · Esc to cancel" `; exports[`AskUserDialog > shows progress header for multiple questions 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ ← □ Database │ □ ORM │ ≡ Review → │ -│ │ -│ Which database should we use? │ -│ │ -│ ● 1. PostgreSQL │ -│ Relational database │ -│ 2. MongoDB │ -│ Document database │ -│ 3. Enter a custom value │ -│ │ -│ Enter to select · ←/→ to switch questions · Esc to cancel │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +"← □ Database │ □ ORM │ ≡ Review → + +Which database should we use? + +● 1. PostgreSQL + Relational database + 2. MongoDB + Document database + 3. Enter a custom value + +Enter to select · ←/→ to switch questions · Esc to cancel" +`; + +exports[`AskUserDialog > shows scroll arrows when options exceed available height 1`] = ` +"Choose an option + +▲ +● 1. Option 1 + Description 1 + 2. Option 2 + Description 2 +▼ + +Enter to select · ↑/↓ to navigate · Esc to cancel" `; exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = ` -"╭─────────────────────────────────────────────────────────────────╮ -│ ← □ License │ □ README │ ≡ Review → │ -│ │ -│ Review your answers: │ -│ │ -│ ⚠ You have 2 unanswered questions │ -│ │ -│ License → (not answered) │ -│ README → (not answered) │ -│ │ -│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │ -╰─────────────────────────────────────────────────────────────────╯" +"← □ License │ □ README │ ≡ Review → + +Review your answers: + +⚠ You have 2 unanswered questions + +License → (not answered) +README → (not answered) + +Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel" `; diff --git a/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap new file mode 100644 index 0000000000..84101e7f32 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap @@ -0,0 +1,56 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > highlights the focused state 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │ +│ Starting server... │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[` > keeps exit code status color even when selected 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 1: npm star... (PID: 1003) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │ +│ │ +│ Select Process (Enter to select, Esc to cancel): │ +│ │ +│ 1. npm start (PID: 1001) │ +│ 2. tail -f log.txt (PID: 1002) │ +│ ● 3. exit 0 (PID: 1003) (Exit Code: 0) │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[` > renders tabs for multiple shells 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 1: npm start 2: tail -f log.txt (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │ +│ Starting server... │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[` > renders the output of the active shell 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 1: npm ... 2: tail... (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │ +│ Starting server... │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[` > renders the process list when isListOpenProp is true 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │ +│ │ +│ Select Process (Enter to select, Esc to cancel): │ +│ │ +│ ● 1. npm start (PID: 1001) │ +│ 2. tail -f log.txt (PID: 1002) │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[` > scrolls to active shell when list opens 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ 1: npm star... (PID: 1002) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │ +│ │ +│ Select Process (Enter to select, Esc to cancel): │ +│ │ +│ 1. npm start (PID: 1001) │ +│ ● 2. tail -f log.txt (PID: 1002) │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; diff --git a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap index d5acee4a7b..9ae46a1e05 100644 --- a/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/RewindViewer.test.tsx.snap @@ -34,6 +34,23 @@ exports[`RewindViewer > Content Filtering > 'strips expanded MCP resource conten ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" `; +exports[`RewindViewer > Content Filtering > 'uses displayContent if present and do…' 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ > Rewind │ +│ │ +│ clean display content │ +│ No files have been changed │ +│ │ +│ ● Stay at current position │ +│ Cancel rewind and stay here │ +│ │ +│ │ +│ (Use Enter to select a message, Esc to close, Right/Left to expand/collapse) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + exports[`RewindViewer > Interaction Selection > 'cancels on Escape' > confirmation-dialog 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ diff --git a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap index 4f6c4f2231..f250079c49 100644 --- a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap @@ -1,12 +1,12 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `"Mock Context Summary Display (Skills: 2)"`; +exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `"Mock Context Summary Display (Skills: 2, Shells: 0)"`; exports[`StatusDisplay > prioritizes Ctrl+C prompt over everything else (except system md) 1`] = `"Press Ctrl+C again to exit."`; exports[`StatusDisplay > prioritizes warning over Ctrl+D 1`] = `"Warning"`; -exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2)"`; +exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2, Shells: 0)"`; exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`; diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap index 8f5abfa7a9..a4238e2028 100644 --- a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap @@ -2,7 +2,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on availableTerminalHeight from UI state 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ -│ Action Required 1 of 1 │ +│ Action Required │ │ │ │ ? replace edit file │ │ │ @@ -22,7 +22,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai exports[`ToolConfirmationQueue > does not render expansion hint when constrainHeight is false 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ -│ Action Required 1 of 1 │ +│ Action Required │ │ │ │ ? replace edit file │ │ │ @@ -44,7 +44,7 @@ exports[`ToolConfirmationQueue > does not render expansion hint when constrainHe exports[`ToolConfirmationQueue > renders expansion hint when content is long and constrained 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ -│ Action Required 1 of 1 │ +│ Action Required │ │ │ │ ? replace edit file │ │ │ diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index e200f0bb44..2272c1a4dd 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -13,6 +13,7 @@ import { type SerializableConfirmationDetails, type ToolCallConfirmationDetails, type Config, + type ToolConfirmationPayload, ToolConfirmationOutcome, hasRedirection, debugLogger, @@ -25,12 +26,14 @@ import { sanitizeForDisplay } from '../../utils/textUtils.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { theme } from '../../semantic-colors.js'; import { useSettings } from '../../contexts/SettingsContext.js'; +import { keyMatchers, Command } from '../../keyMatchers.js'; import { REDIRECTION_WARNING_NOTE_LABEL, REDIRECTION_WARNING_NOTE_TEXT, REDIRECTION_WARNING_TIP_LABEL, REDIRECTION_WARNING_TIP_TEXT, } from '../../textConstants.js'; +import { AskUserDialog } from '../AskUserDialog.js'; export interface ToolConfirmationMessageProps { callId: string; @@ -59,9 +62,12 @@ export const ToolConfirmationMessage: React.FC< const allowPermanentApproval = settings.merged.security.enablePermanentToolApproval; + const handlesOwnUI = confirmationDetails.type === 'ask_user'; + const isTrustedFolder = config.isTrustedFolder(); + const handleConfirm = useCallback( - (outcome: ToolConfirmationOutcome) => { - void confirm(callId, outcome).catch((error: unknown) => { + (outcome: ToolConfirmationOutcome, payload?: ToolConfirmationPayload) => { + void confirm(callId, outcome, payload).catch((error: unknown) => { debugLogger.error( `Failed to handle tool confirmation for ${callId}:`, error, @@ -71,15 +77,18 @@ export const ToolConfirmationMessage: React.FC< [confirm, callId], ); - const isTrustedFolder = config.isTrustedFolder(); - useKeypress( (key) => { if (!isFocused) return false; - if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { + if (keyMatchers[Command.ESCAPE](key)) { handleConfirm(ToolConfirmationOutcome.Cancel); return true; } + if (keyMatchers[Command.QUIT](key)) { + // Return false to let ctrl-C bubble up to AppContainer for exit flow. + // AppContainer will call cancelOngoingRequest which will cancel the tool. + return false; + } return false; }, { isActive: isFocused }, @@ -180,7 +189,7 @@ export const ToolConfirmationMessage: React.FC< value: ToolConfirmationOutcome.Cancel, key: 'No, suggest changes (esc)', }); - } else { + } else if (confirmationDetails.type === 'mcp') { // mcp tool confirmation options.push({ label: 'Allow once', @@ -251,6 +260,23 @@ export const ToolConfirmationMessage: React.FC< let question = ''; const options = getOptions(); + if (confirmationDetails.type === 'ask_user') { + bodyContent = ( + { + handleConfirm(ToolConfirmationOutcome.ProceedOnce, { answers }); + }} + onCancel={() => { + handleConfirm(ToolConfirmationOutcome.Cancel); + }} + width={terminalWidth} + availableHeight={availableBodyContentHeight() ?? 10} + /> + ); + return { question: '', bodyContent, options: [] }; + } + if (confirmationDetails.type === 'edit') { if (!confirmationDetails.isModifying) { question = `Apply this change?`; @@ -265,7 +291,7 @@ export const ToolConfirmationMessage: React.FC< } } else if (confirmationDetails.type === 'info') { question = `Do you want to proceed?`; - } else { + } else if (confirmationDetails.type === 'mcp') { // mcp tool confirmation const mcpProps = confirmationDetails; question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`; @@ -387,7 +413,7 @@ export const ToolConfirmationMessage: React.FC< )} ); - } else { + } else if (confirmationDetails.type === 'mcp') { // mcp tool confirmation const mcpProps = confirmationDetails; @@ -405,6 +431,7 @@ export const ToolConfirmationMessage: React.FC< getOptions, availableBodyContentHeight, terminalWidth, + handleConfirm, ]); if (confirmationDetails.type === 'edit') { @@ -429,32 +456,38 @@ export const ToolConfirmationMessage: React.FC< } return ( - - {/* Body Content (Diff Renderer or Command Info) */} - {/* No separate context display here anymore for edits */} - - - {bodyContent} - - + + {handlesOwnUI ? ( + bodyContent + ) : ( + <> + + + {bodyContent} + + - {/* Confirmation Question */} - - {question} - + + {question} + - {/* Select Input for Options */} - - - + + + + + )} ); }; diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx index 0d1eb43f6c..c671399baf 100644 --- a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx +++ b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx @@ -475,14 +475,7 @@ describe('BaseSelectionList', () => { ); await waitFor(() => { - const output = lastFrame(); - // At the top, should show first 3 items - expect(output).toContain('Item 1'); - expect(output).toContain('Item 3'); - expect(output).not.toContain('Item 4'); - // Both arrows should be visible - expect(output).toContain('▲'); - expect(output).toContain('▼'); + expect(lastFrame()).toMatchSnapshot(); }); }); @@ -493,15 +486,7 @@ describe('BaseSelectionList', () => { ); await waitFor(() => { - const output = lastFrame(); - // After scrolling to middle, should see items around index 5 - expect(output).toContain('Item 4'); - expect(output).toContain('Item 6'); - expect(output).not.toContain('Item 3'); - expect(output).not.toContain('Item 7'); - // Both scroll arrows should be visible - expect(output).toContain('▲'); - expect(output).toContain('▼'); + expect(lastFrame()).toMatchSnapshot(); }); }); @@ -512,32 +497,18 @@ describe('BaseSelectionList', () => { ); await waitFor(() => { - const output = lastFrame(); - // At the end, should show last 3 items - expect(output).toContain('Item 8'); - expect(output).toContain('Item 10'); - expect(output).not.toContain('Item 7'); - // Both arrows should be visible - expect(output).toContain('▲'); - expect(output).toContain('▼'); + expect(lastFrame()).toMatchSnapshot(); }); }); - it('should show both arrows dimmed when list fits entirely', () => { + it('should not show arrows when list fits entirely', () => { const { lastFrame } = renderComponent({ items, maxItemsToShow: 5, showScrollArrows: true, }); - const output = lastFrame(); - // Should show all items since maxItemsToShow > items.length - expect(output).toContain('Item A'); - expect(output).toContain('Item B'); - expect(output).toContain('Item C'); - // Both arrows should be visible but dimmed (this test doesn't need waitFor since no scrolling occurs) - expect(output).toContain('▲'); - expect(output).toContain('▼'); + expect(lastFrame()).toMatchSnapshot(); }); }); }); diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx index baec1bb8ca..db0d624a74 100644 --- a/packages/cli/src/ui/components/shared/BaseSelectionList.tsx +++ b/packages/cli/src/ui/components/shared/BaseSelectionList.tsx @@ -100,7 +100,7 @@ export function BaseSelectionList< return ( {/* Use conditional coloring instead of conditional rendering */} - {showScrollArrows && ( + {showScrollArrows && items.length > maxItemsToShow && ( 0 ? theme.text.primary : theme.text.secondary} > @@ -172,7 +172,7 @@ export function BaseSelectionList< ); })} - {showScrollArrows && ( + {showScrollArrows && items.length > maxItemsToShow && ( Scroll Arrows (showScrollArrows) > should not show arrows when list fits entirely 1`] = ` +"● 1. Item A + 2. Item B + 3. Item C" +`; + +exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the end 1`] = ` +"▲ + 8. Item 8 + 9. Item 9 +● 10. Item 10 +▼" +`; + +exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the middle 1`] = ` +"▲ + 4. Item 4 + 5. Item 5 +● 6. Item 6 +▼" +`; + +exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows with correct colors when enabled (at the top) 1`] = ` +"▲ +● 1. Item 1 + 2. Item 2 + 3. Item 3 +▼" +`; diff --git a/packages/cli/src/ui/components/shared/__snapshots__/DescriptiveRadioButtonSelect.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/DescriptiveRadioButtonSelect.test.tsx.snap index 822b88b0c8..da306c2823 100644 --- a/packages/cli/src/ui/components/shared/__snapshots__/DescriptiveRadioButtonSelect.test.tsx.snap +++ b/packages/cli/src/ui/components/shared/__snapshots__/DescriptiveRadioButtonSelect.test.tsx.snap @@ -1,14 +1,12 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`DescriptiveRadioButtonSelect > should render correctly with custom props 1`] = ` -"▲ - 1. Foo Title +" 1. Foo Title This is Foo. ● 2. Bar Title This is Bar. 3. Baz Title - This is Baz. -▼" + This is Baz." `; exports[`DescriptiveRadioButtonSelect > should render correctly with default props 1`] = ` diff --git a/packages/cli/src/ui/constants/tips.ts b/packages/cli/src/ui/constants/tips.ts index 1bd6f3233d..772966ad77 100644 --- a/packages/cli/src/ui/constants/tips.ts +++ b/packages/cli/src/ui/constants/tips.ts @@ -116,6 +116,8 @@ export const INFORMATIVE_TIPS = [ 'In menus, move up/down with k/j or the arrow keys…', 'In menus, select an item by typing its number…', "If you're using an IDE, see the context with Ctrl+G…", + 'Toggle background shells with Ctrl+B or /shells...', + 'Toggle the background shell process list with Ctrl+L...', // Keyboard shortcut tips end here // Command tips start here 'Show version info with /about…', diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 4eb8584ae3..3852dc887d 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -67,7 +67,11 @@ export interface UIActions { handleApiKeySubmit: (apiKey: string) => Promise; handleApiKeyCancel: () => void; setBannerVisible: (visible: boolean) => void; + handleWarning: (message: string) => void; setEmbeddedShellFocused: (value: boolean) => void; + dismissBackgroundShell: (pid: number) => void; + setActiveBackgroundShellPid: (pid: number) => void; + setIsBackgroundShellListOpen: (isOpen: boolean) => void; setAuthContext: (context: { requiresRestart?: boolean }) => void; handleRestart: () => void; handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index f613963e4b..5ba697c85d 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -50,6 +50,7 @@ export interface ValidationDialogRequest { import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js'; +import type { BackgroundShell } from '../hooks/shellCommandProcessor.js'; export interface UIState { history: HistoryItem[]; @@ -142,6 +143,8 @@ export interface UIState { isRestarting: boolean; extensionsUpdateState: Map; activePtyId: number | undefined; + backgroundShellCount: number; + isBackgroundShellVisible: boolean; embeddedShellFocused: boolean; showDebugProfiler: boolean; showFullTodos: boolean; @@ -155,6 +158,10 @@ export interface UIState { customDialog: React.ReactNode | null; terminalBackgroundColor: TerminalBackgroundColor; settingsNonce: number; + backgroundShells: Map; + activeBackgroundShellPid: number | null; + backgroundShellHeight: number; + isBackgroundShellListOpen: boolean; adminSettingsChanged: boolean; newAgents: AgentDefinition[] | null; } diff --git a/packages/cli/src/ui/hooks/__snapshots__/useToolScheduler.test.ts.snap b/packages/cli/src/ui/hooks/__snapshots__/useToolScheduler.test.ts.snap index 24ff4e1356..3195316980 100644 --- a/packages/cli/src/ui/hooks/__snapshots__/useToolScheduler.test.ts.snap +++ b/packages/cli/src/ui/hooks/__snapshots__/useToolScheduler.test.ts.snap @@ -4,6 +4,7 @@ exports[`useReactToolScheduler > should handle live output updates 1`] = ` { "callId": "liveCall", "contentLength": 12, + "data": undefined, "error": undefined, "errorType": undefined, "outputFile": undefined, @@ -26,6 +27,7 @@ exports[`useReactToolScheduler > should handle tool requiring confirmation - app { "callId": "callConfirm", "contentLength": 16, + "data": undefined, "error": undefined, "errorType": undefined, "outputFile": undefined, @@ -75,6 +77,7 @@ exports[`useReactToolScheduler > should schedule and execute a tool call success { "callId": "call1", "contentLength": 11, + "data": undefined, "error": undefined, "errorType": undefined, "outputFile": undefined, diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx index e99b594d0d..416b9d96f6 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx @@ -19,12 +19,34 @@ import { const mockIsBinary = vi.hoisted(() => vi.fn()); const mockShellExecutionService = vi.hoisted(() => vi.fn()); +const mockShellKill = vi.hoisted(() => vi.fn()); +const mockShellBackground = vi.hoisted(() => vi.fn()); +const mockShellSubscribe = vi.hoisted(() => + vi.fn< + (pid: number, listener: (event: ShellOutputEvent) => void) => () => void + >(() => vi.fn()), +); // Returns unsubscribe +const mockShellOnExit = vi.hoisted(() => + vi.fn< + ( + pid: number, + callback: (exitCode: number, signal?: number) => void, + ) => () => void + >(() => vi.fn()), +); + vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - ShellExecutionService: { execute: mockShellExecutionService }, + ShellExecutionService: { + execute: mockShellExecutionService, + kill: mockShellKill, + background: mockShellBackground, + subscribe: mockShellSubscribe, + onExit: mockShellOnExit, + }, isBinary: mockIsBinary, }; }); @@ -113,7 +135,13 @@ describe('useShellCommandProcessor', () => { const renderProcessorHook = () => { let hookResult: ReturnType; - function TestComponent() { + let renderCount = 0; + function TestComponent({ + isWaitingForConfirmation, + }: { + isWaitingForConfirmation?: boolean; + }) { + renderCount++; hookResult = useShellCommandProcessor( addItemToHistoryMock, setPendingHistoryItemMock, @@ -122,16 +150,25 @@ describe('useShellCommandProcessor', () => { mockConfig, mockGeminiClient, setShellInputFocusedMock, + undefined, + undefined, + undefined, + isWaitingForConfirmation, ); return null; } - render(); + const { rerender } = render(); return { result: { get current() { return hookResult; }, }, + getRenderCount: () => renderCount, + rerender: (isWaitingForConfirmation?: boolean) => + rerender( + , + ), }; }; @@ -723,4 +760,403 @@ describe('useShellCommandProcessor', () => { expect(result.current.activeShellPtyId).toBeNull(); }); }); + + describe('Background Shell Management', () => { + it('should register a background shell and update count', async () => { + const { result } = renderProcessorHook(); + + act(() => { + result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + }); + + expect(result.current.backgroundShellCount).toBe(1); + const shell = result.current.backgroundShells.get(1001); + expect(shell).toEqual( + expect.objectContaining({ + pid: 1001, + command: 'bg-cmd', + output: 'initial', + }), + ); + expect(mockShellOnExit).toHaveBeenCalledWith(1001, expect.any(Function)); + expect(mockShellSubscribe).toHaveBeenCalledWith( + 1001, + expect.any(Function), + ); + }); + + it('should toggle background shell visibility', async () => { + const { result } = renderProcessorHook(); + + act(() => { + result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + }); + + expect(result.current.isBackgroundShellVisible).toBe(false); + + act(() => { + result.current.toggleBackgroundShell(); + }); + + expect(result.current.isBackgroundShellVisible).toBe(true); + + act(() => { + result.current.toggleBackgroundShell(); + }); + + expect(result.current.isBackgroundShellVisible).toBe(false); + }); + + it('should show info message when toggling background shells if none are active', async () => { + const { result } = renderProcessorHook(); + + act(() => { + result.current.toggleBackgroundShell(); + }); + + expect(addItemToHistoryMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: 'No background shells are currently active.', + }), + expect.any(Number), + ); + expect(result.current.isBackgroundShellVisible).toBe(false); + }); + + it('should dismiss a background shell and remove it from state', async () => { + const { result } = renderProcessorHook(); + + act(() => { + result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + }); + + act(() => { + result.current.dismissBackgroundShell(1001); + }); + + expect(mockShellKill).toHaveBeenCalledWith(1001); + expect(result.current.backgroundShellCount).toBe(0); + expect(result.current.backgroundShells.has(1001)).toBe(false); + }); + + it('should handle backgrounding the current shell', async () => { + // Simulate an active shell + mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => { + mockShellOutputCallback = callback; + return Promise.resolve({ + pid: 555, + result: new Promise((resolve) => { + resolveExecutionPromise = resolve; + }), + }); + }); + + const { result } = renderProcessorHook(); + + await act(async () => { + result.current.handleShellCommand('top', new AbortController().signal); + }); + + expect(result.current.activeShellPtyId).toBe(555); + + act(() => { + result.current.backgroundCurrentShell(); + }); + + expect(mockShellBackground).toHaveBeenCalledWith(555); + // The actual state update happens when the promise resolves with backgrounded: true + // which is handled in handleShellCommand's .then block. + // We simulate that here: + + await act(async () => { + resolveExecutionPromise( + createMockServiceResult({ + backgrounded: true, + pid: 555, + output: 'running...', + }), + ); + }); + // Wait for promise resolution + await act(async () => await onExecMock.mock.calls[0][0]); + + expect(result.current.backgroundShellCount).toBe(1); + expect(result.current.activeShellPtyId).toBeNull(); + }); + + it('should persist background shell on successful exit and mark as exited', async () => { + const { result } = renderProcessorHook(); + + act(() => { + result.current.registerBackgroundShell(888, 'auto-exit', ''); + }); + + // Find the exit callback registered + const exitCallback = mockShellOnExit.mock.calls.find( + (call) => call[0] === 888, + )?.[1]; + expect(exitCallback).toBeDefined(); + + if (exitCallback) { + act(() => { + exitCallback(0); + }); + } + + // Should NOT be removed, but updated + expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0 + expect(result.current.backgroundShells.has(888)).toBe(true); // Map has it + const shell = result.current.backgroundShells.get(888); + expect(shell?.status).toBe('exited'); + expect(shell?.exitCode).toBe(0); + }); + + it('should persist background shell on failed exit', async () => { + const { result } = renderProcessorHook(); + + act(() => { + result.current.registerBackgroundShell(999, 'fail-exit', ''); + }); + + const exitCallback = mockShellOnExit.mock.calls.find( + (call) => call[0] === 999, + )?.[1]; + expect(exitCallback).toBeDefined(); + + if (exitCallback) { + act(() => { + exitCallback(1); + }); + } + + // Should NOT be removed, but updated + expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0 + const shell = result.current.backgroundShells.get(999); + expect(shell?.status).toBe('exited'); + expect(shell?.exitCode).toBe(1); + + // Now dismiss it + act(() => { + result.current.dismissBackgroundShell(999); + }); + expect(result.current.backgroundShellCount).toBe(0); + }); + + it('should NOT trigger re-render on background shell output when visible', async () => { + const { result, getRenderCount } = renderProcessorHook(); + + act(() => { + result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + }); + + // Show the background shells + act(() => { + result.current.toggleBackgroundShell(); + }); + + const initialRenderCount = getRenderCount(); + + const subscribeCallback = mockShellSubscribe.mock.calls.find( + (call) => call[0] === 1001, + )?.[1]; + expect(subscribeCallback).toBeDefined(); + + if (subscribeCallback) { + act(() => { + subscribeCallback({ type: 'data', chunk: ' + updated' }); + }); + } + + expect(getRenderCount()).toBeGreaterThan(initialRenderCount); + const shell = result.current.backgroundShells.get(1001); + expect(shell?.output).toBe('initial + updated'); + }); + + it('should NOT trigger re-render on background shell output when hidden', async () => { + const { result, getRenderCount } = renderProcessorHook(); + + act(() => { + result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + }); + + // Ensure background shells are hidden (default) + const initialRenderCount = getRenderCount(); + + const subscribeCallback = mockShellSubscribe.mock.calls.find( + (call) => call[0] === 1001, + )?.[1]; + expect(subscribeCallback).toBeDefined(); + + if (subscribeCallback) { + act(() => { + subscribeCallback({ type: 'data', chunk: ' + updated' }); + }); + } + + expect(getRenderCount()).toBeGreaterThan(initialRenderCount); + const shell = result.current.backgroundShells.get(1001); + expect(shell?.output).toBe('initial + updated'); + }); + + it('should trigger re-render on binary progress when visible', async () => { + const { result, getRenderCount } = renderProcessorHook(); + + act(() => { + result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + }); + + // Show the background shells + act(() => { + result.current.toggleBackgroundShell(); + }); + + const initialRenderCount = getRenderCount(); + + const subscribeCallback = mockShellSubscribe.mock.calls.find( + (call) => call[0] === 1001, + )?.[1]; + expect(subscribeCallback).toBeDefined(); + + if (subscribeCallback) { + act(() => { + subscribeCallback({ type: 'binary_progress', bytesReceived: 1024 }); + }); + } + + expect(getRenderCount()).toBeGreaterThan(initialRenderCount); + const shell = result.current.backgroundShells.get(1001); + expect(shell?.isBinary).toBe(true); + expect(shell?.binaryBytesReceived).toBe(1024); + }); + + it('should NOT hide background shell when model is responding without confirmation', async () => { + const { result, rerender } = renderProcessorHook(); + + // 1. Register and show background shell + act(() => { + result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + }); + act(() => { + result.current.toggleBackgroundShell(); + }); + expect(result.current.isBackgroundShellVisible).toBe(true); + + // 2. Simulate model responding (not waiting for confirmation) + act(() => { + rerender(false); // isWaitingForConfirmation = false + }); + + // Should stay visible + expect(result.current.isBackgroundShellVisible).toBe(true); + }); + + it('should hide background shell when waiting for confirmation and restore after delay', async () => { + const { result, rerender } = renderProcessorHook(); + + // 1. Register and show background shell + act(() => { + result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + }); + act(() => { + result.current.toggleBackgroundShell(); + }); + expect(result.current.isBackgroundShellVisible).toBe(true); + + // 2. Simulate tool confirmation showing up + act(() => { + rerender(true); // isWaitingForConfirmation = true + }); + + // Should be hidden + expect(result.current.isBackgroundShellVisible).toBe(false); + + // 3. Simulate confirmation accepted (waiting for PTY start) + act(() => { + rerender(false); + }); + + // Should STAY hidden during the 300ms gap + expect(result.current.isBackgroundShellVisible).toBe(false); + + // 4. Wait for restore delay + await waitFor(() => + expect(result.current.isBackgroundShellVisible).toBe(true), + ); + }); + + it('should auto-hide background shell when foreground shell starts and restore when it ends', async () => { + const { result } = renderProcessorHook(); + + // 1. Register and show background shell + act(() => { + result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + }); + act(() => { + result.current.toggleBackgroundShell(); + }); + expect(result.current.isBackgroundShellVisible).toBe(true); + + // 2. Start foreground shell + act(() => { + result.current.handleShellCommand('ls', new AbortController().signal); + }); + + // Wait for PID to be set + await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345)); + + // Should be hidden automatically + expect(result.current.isBackgroundShellVisible).toBe(false); + + // 3. Complete foreground shell + act(() => { + resolveExecutionPromise(createMockServiceResult()); + }); + + await waitFor(() => expect(result.current.activeShellPtyId).toBe(null)); + + // Should be restored automatically (after delay) + await waitFor(() => + expect(result.current.isBackgroundShellVisible).toBe(true), + ); + }); + + it('should NOT restore background shell if it was manually hidden during foreground execution', async () => { + const { result } = renderProcessorHook(); + + // 1. Register and show background shell + act(() => { + result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + }); + act(() => { + result.current.toggleBackgroundShell(); + }); + expect(result.current.isBackgroundShellVisible).toBe(true); + + // 2. Start foreground shell + act(() => { + result.current.handleShellCommand('ls', new AbortController().signal); + }); + await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345)); + expect(result.current.isBackgroundShellVisible).toBe(false); + + // 3. Manually toggle visibility (e.g. user wants to peek) + act(() => { + result.current.toggleBackgroundShell(); + }); + expect(result.current.isBackgroundShellVisible).toBe(true); + + // 4. Complete foreground shell + act(() => { + resolveExecutionPromise(createMockServiceResult()); + }); + await waitFor(() => expect(result.current.activeShellPtyId).toBe(null)); + + // It should NOT change visibility because manual toggle cleared the auto-restore flag + // After delay it should stay true (as it was manually toggled to true) + await waitFor(() => + expect(result.current.isBackgroundShellVisible).toBe(true), + ); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index f3a866a20b..860bece5d8 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -9,13 +9,8 @@ import type { IndividualToolCallDisplay, } from '../types.js'; import { ToolCallStatus } from '../types.js'; -import { useCallback, useState } from 'react'; -import type { - AnsiOutput, - Config, - GeminiClient, - ShellExecutionResult, -} from '@google/gemini-cli-core'; +import { useCallback, useReducer, useRef, useEffect } from 'react'; +import type { AnsiOutput, Config, GeminiClient } from '@google/gemini-cli-core'; import { isBinary, ShellExecutionService } from '@google/gemini-cli-core'; import { type PartListUnion } from '@google/genai'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; @@ -26,8 +21,15 @@ import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; import { themeManager } from '../../ui/themes/theme-manager.js'; +import { + shellReducer, + initialState, + type BackgroundShell, +} from './shellReducer.js'; +export { type BackgroundShell }; export const OUTPUT_UPDATE_INTERVAL_MS = 1000; +const RESTORE_VISIBILITY_DELAY_MS = 300; const MAX_OUTPUT_LENGTH = 10000; function addShellCommandToGeminiHistory( @@ -75,9 +77,190 @@ export const useShellCommandProcessor = ( setShellInputFocused: (value: boolean) => void, terminalWidth?: number, terminalHeight?: number, + activeToolPtyId?: number, + isWaitingForConfirmation?: boolean, ) => { - const [activeShellPtyId, setActiveShellPtyId] = useState(null); - const [lastShellOutputTime, setLastShellOutputTime] = useState(0); + const [state, dispatch] = useReducer(shellReducer, initialState); + + // Consolidate stable tracking into a single manager object + const manager = useRef<{ + wasVisibleBeforeForeground: boolean; + restoreTimeout: NodeJS.Timeout | null; + backgroundedPids: Set; + subscriptions: Map void>; + } | null>(null); + + if (!manager.current) { + manager.current = { + wasVisibleBeforeForeground: false, + restoreTimeout: null, + backgroundedPids: new Set(), + subscriptions: new Map(), + }; + } + const m = manager.current; + + const activePtyId = state.activeShellPtyId || activeToolPtyId; + + useEffect(() => { + const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation; + + if (isForegroundActive) { + if (m.restoreTimeout) { + clearTimeout(m.restoreTimeout); + m.restoreTimeout = null; + } + + if (state.isBackgroundShellVisible && !m.wasVisibleBeforeForeground) { + m.wasVisibleBeforeForeground = true; + dispatch({ type: 'SET_VISIBILITY', visible: false }); + } + } else if (m.wasVisibleBeforeForeground && !m.restoreTimeout) { + // Restore if it was automatically hidden, with a small delay to avoid + // flickering between model turn segments. + m.restoreTimeout = setTimeout(() => { + dispatch({ type: 'SET_VISIBILITY', visible: true }); + m.wasVisibleBeforeForeground = false; + m.restoreTimeout = null; + }, RESTORE_VISIBILITY_DELAY_MS); + } + + return () => { + if (m.restoreTimeout) { + clearTimeout(m.restoreTimeout); + } + }; + }, [ + activePtyId, + isWaitingForConfirmation, + state.isBackgroundShellVisible, + m, + dispatch, + ]); + + useEffect( + () => () => { + // Unsubscribe from all background shell events on unmount + for (const unsubscribe of m.subscriptions.values()) { + unsubscribe(); + } + m.subscriptions.clear(); + }, + [m], + ); + + const toggleBackgroundShell = useCallback(() => { + if (state.backgroundShells.size > 0) { + const willBeVisible = !state.isBackgroundShellVisible; + dispatch({ type: 'TOGGLE_VISIBILITY' }); + + const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation; + // If we are manually showing it during foreground, we set the restore flag + // so that useEffect doesn't immediately hide it again. + // If we are manually hiding it, we clear the restore flag so it stays hidden. + if (willBeVisible && isForegroundActive) { + m.wasVisibleBeforeForeground = true; + } else { + m.wasVisibleBeforeForeground = false; + } + + if (willBeVisible) { + dispatch({ type: 'SYNC_BACKGROUND_SHELLS' }); + } + } else { + dispatch({ type: 'SET_VISIBILITY', visible: false }); + addItemToHistory( + { + type: 'info', + text: 'No background shells are currently active.', + }, + Date.now(), + ); + } + }, [ + addItemToHistory, + state.backgroundShells.size, + state.isBackgroundShellVisible, + activePtyId, + isWaitingForConfirmation, + m, + dispatch, + ]); + + const backgroundCurrentShell = useCallback(() => { + const pidToBackground = state.activeShellPtyId || activeToolPtyId; + if (pidToBackground) { + ShellExecutionService.background(pidToBackground); + m.backgroundedPids.add(pidToBackground); + // Ensure backgrounding is silent and doesn't trigger restoration + m.wasVisibleBeforeForeground = false; + if (m.restoreTimeout) { + clearTimeout(m.restoreTimeout); + m.restoreTimeout = null; + } + } + }, [state.activeShellPtyId, activeToolPtyId, m]); + + const dismissBackgroundShell = useCallback( + (pid: number) => { + const shell = state.backgroundShells.get(pid); + if (shell) { + if (shell.status === 'running') { + ShellExecutionService.kill(pid); + } + dispatch({ type: 'DISMISS_SHELL', pid }); + m.backgroundedPids.delete(pid); + + // Unsubscribe from updates + const unsubscribe = m.subscriptions.get(pid); + if (unsubscribe) { + unsubscribe(); + m.subscriptions.delete(pid); + } + } + }, + [state.backgroundShells, dispatch, m], + ); + + const registerBackgroundShell = useCallback( + (pid: number, command: string, initialOutput: string | AnsiOutput) => { + dispatch({ type: 'REGISTER_SHELL', pid, command, initialOutput }); + + // Subscribe to process exit directly + const exitUnsubscribe = ShellExecutionService.onExit(pid, (code) => { + dispatch({ + type: 'UPDATE_SHELL', + pid, + update: { status: 'exited', exitCode: code }, + }); + m.backgroundedPids.delete(pid); + }); + + // Subscribe to future updates (data only) + const dataUnsubscribe = ShellExecutionService.subscribe(pid, (event) => { + if (event.type === 'data') { + dispatch({ type: 'APPEND_SHELL_OUTPUT', pid, chunk: event.chunk }); + } else if (event.type === 'binary_detected') { + dispatch({ type: 'UPDATE_SHELL', pid, update: { isBinary: true } }); + } else if (event.type === 'binary_progress') { + dispatch({ + type: 'UPDATE_SHELL', + pid, + update: { + isBinary: true, + binaryBytesReceived: event.bytesReceived, + }, + }); + } + }); + + m.subscriptions.set(pid, () => { + exitUnsubscribe(); + dataUnsubscribe(); + }); + }, + [dispatch, m], + ); const handleShellCommand = useCallback( (rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => { @@ -109,9 +292,7 @@ export const useShellCommandProcessor = ( commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`; } - const executeCommand = async ( - resolve: (value: void | PromiseLike) => void, - ) => { + const executeCommand = async () => { let cumulativeStdout: string | AnsiOutput = ''; let isBinaryStream = false; let binaryBytesReceived = 0; @@ -151,84 +332,90 @@ export const useShellCommandProcessor = ( defaultBg: activeTheme.colors.Background, }; - const { pid, result } = await ShellExecutionService.execute( - commandToExecute, - targetDir, - (event) => { - let shouldUpdate = false; - switch (event.type) { - case 'data': - // Do not process text data if we've already switched to binary mode. - if (isBinaryStream) break; - // PTY provides the full screen state, so we just replace. - // Child process provides chunks, so we append. - if (config.getEnableInteractiveShell()) { - cumulativeStdout = event.chunk; - shouldUpdate = true; - } else if ( - typeof event.chunk === 'string' && - typeof cumulativeStdout === 'string' - ) { - cumulativeStdout += event.chunk; - shouldUpdate = true; - } - break; - case 'binary_detected': - isBinaryStream = true; - // Force an immediate UI update to show the binary detection message. - shouldUpdate = true; - break; - case 'binary_progress': - isBinaryStream = true; - binaryBytesReceived = event.bytesReceived; - shouldUpdate = true; - break; - default: { - throw new Error('An unhandled ShellOutputEvent was found.'); - } - } + const { pid, result: resultPromise } = + await ShellExecutionService.execute( + commandToExecute, + targetDir, + (event) => { + let shouldUpdate = false; - // Compute the display string based on the *current* state. - let currentDisplayOutput: string | AnsiOutput; - if (isBinaryStream) { - if (binaryBytesReceived > 0) { - currentDisplayOutput = `[Receiving binary output... ${formatBytes( - binaryBytesReceived, - )} received]`; - } else { + switch (event.type) { + case 'data': + if (isBinaryStream) break; + if (typeof event.chunk === 'string') { + if (typeof cumulativeStdout === 'string') { + cumulativeStdout += event.chunk; + } else { + cumulativeStdout = event.chunk; + } + } else { + // AnsiOutput (PTY) is always the full state + cumulativeStdout = event.chunk; + } + shouldUpdate = true; + break; + case 'binary_detected': + isBinaryStream = true; + shouldUpdate = true; + break; + case 'binary_progress': + isBinaryStream = true; + binaryBytesReceived = event.bytesReceived; + shouldUpdate = true; + break; + case 'exit': + // No action needed for exit event during streaming + break; + default: + throw new Error('An unhandled ShellOutputEvent was found.'); + } + + if (executionPid && m.backgroundedPids.has(executionPid)) { + // If already backgrounded, let the background shell subscription handle it. + dispatch({ + type: 'APPEND_SHELL_OUTPUT', + pid: executionPid, + chunk: + event.type === 'data' ? event.chunk : cumulativeStdout, + }); + return; + } + + let currentDisplayOutput: string | AnsiOutput; + if (isBinaryStream) { currentDisplayOutput = - '[Binary output detected. Halting stream...]'; + binaryBytesReceived > 0 + ? `[Receiving binary output... ${formatBytes(binaryBytesReceived)} received]` + : '[Binary output detected. Halting stream...]'; + } else { + currentDisplayOutput = cumulativeStdout; } - } else { - currentDisplayOutput = cumulativeStdout; - } - // Throttle pending UI updates, but allow forced updates. - if (shouldUpdate) { - setLastShellOutputTime(Date.now()); - setPendingHistoryItem((prevItem) => { - if (prevItem?.type === 'tool_group') { - return { - ...prevItem, - tools: prevItem.tools.map((tool) => - tool.callId === callId - ? { ...tool, resultDisplay: currentDisplayOutput } - : tool, - ), - }; - } - return prevItem; - }); - } - }, - abortSignal, - config.getEnableInteractiveShell(), - shellExecutionConfig, - ); + if (shouldUpdate) { + dispatch({ type: 'SET_OUTPUT_TIME', time: Date.now() }); + setPendingHistoryItem((prevItem) => { + if (prevItem?.type === 'tool_group') { + return { + ...prevItem, + tools: prevItem.tools.map((tool) => + tool.callId === callId + ? { ...tool, resultDisplay: currentDisplayOutput } + : tool, + ), + }; + } + return prevItem; + }); + } + }, + abortSignal, + config.getEnableInteractiveShell(), + shellExecutionConfig, + ); executionPid = pid; if (pid) { - setActiveShellPtyId(pid); + dispatch({ type: 'SET_ACTIVE_PTY', pid }); setPendingHistoryItem((prevItem) => { if (prevItem?.type === 'tool_group') { return { @@ -242,94 +429,69 @@ export const useShellCommandProcessor = ( }); } - result - .then((result: ShellExecutionResult) => { - setPendingHistoryItem(null); + const result = await resultPromise; + setPendingHistoryItem(null); - let mainContent: string; + if (result.backgrounded && result.pid) { + registerBackgroundShell(result.pid, rawQuery, cumulativeStdout); + dispatch({ type: 'SET_ACTIVE_PTY', pid: null }); + } - if (isBinary(result.rawOutput)) { - mainContent = - '[Command produced binary output, which is not shown.]'; - } else { - mainContent = - result.output.trim() || '(Command produced no output)'; - } + let mainContent: string; + if (isBinary(result.rawOutput)) { + mainContent = + '[Command produced binary output, which is not shown.]'; + } else { + mainContent = + result.output.trim() || '(Command produced no output)'; + } - let finalOutput = mainContent; - let finalStatus = ToolCallStatus.Success; + let finalOutput = mainContent; + let finalStatus = ToolCallStatus.Success; - if (result.error) { - finalStatus = ToolCallStatus.Error; - finalOutput = `${result.error.message}\n${finalOutput}`; - } else if (result.aborted) { - finalStatus = ToolCallStatus.Canceled; - finalOutput = `Command was cancelled.\n${finalOutput}`; - } else if (result.signal) { - finalStatus = ToolCallStatus.Error; - finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`; - } else if (result.exitCode !== 0) { - finalStatus = ToolCallStatus.Error; - finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`; - } + if (result.error) { + finalStatus = ToolCallStatus.Error; + finalOutput = `${result.error.message}\n${finalOutput}`; + } else if (result.aborted) { + finalStatus = ToolCallStatus.Canceled; + finalOutput = `Command was cancelled.\n${finalOutput}`; + } else if (result.backgrounded) { + finalStatus = ToolCallStatus.Success; + finalOutput = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`; + } else if (result.signal) { + finalStatus = ToolCallStatus.Error; + finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`; + } else if (result.exitCode !== 0) { + finalStatus = ToolCallStatus.Error; + finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`; + } - if (pwdFilePath && fs.existsSync(pwdFilePath)) { - const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim(); - if (finalPwd && finalPwd !== targetDir) { - const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`; - finalOutput = `${warning}\n\n${finalOutput}`; - } - } + if (pwdFilePath && fs.existsSync(pwdFilePath)) { + const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim(); + if (finalPwd && finalPwd !== targetDir) { + const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`; + finalOutput = `${warning}\n\n${finalOutput}`; + } + } - const finalToolDisplay: IndividualToolCallDisplay = { - ...initialToolDisplay, - status: finalStatus, - resultDisplay: finalOutput, - }; + const finalToolDisplay: IndividualToolCallDisplay = { + ...initialToolDisplay, + status: finalStatus, + resultDisplay: finalOutput, + }; - // Add the complete, contextual result to the local UI history. - // We skip this for cancelled commands because useGeminiStream handles the - // immediate addition of the cancelled item to history to prevent flickering/duplicates. - if (finalStatus !== ToolCallStatus.Canceled) { - addItemToHistory( - { - type: 'tool_group', - tools: [finalToolDisplay], - } as HistoryItemWithoutId, - userMessageTimestamp, - ); - } + if (finalStatus !== ToolCallStatus.Canceled) { + addItemToHistory( + { + type: 'tool_group', + tools: [finalToolDisplay], + } as HistoryItemWithoutId, + userMessageTimestamp, + ); + } - // Add the same complete, contextual result to the LLM's history. - addShellCommandToGeminiHistory( - geminiClient, - rawQuery, - finalOutput, - ); - }) - .catch((err) => { - setPendingHistoryItem(null); - const errorMessage = - err instanceof Error ? err.message : String(err); - addItemToHistory( - { - type: 'error', - text: `An unexpected error occurred: ${errorMessage}`, - }, - userMessageTimestamp, - ); - }) - .finally(() => { - abortSignal.removeEventListener('abort', abortHandler); - if (pwdFilePath && fs.existsSync(pwdFilePath)) { - fs.unlinkSync(pwdFilePath); - } - setActiveShellPtyId(null); - setShellInputFocused(false); - resolve(); - }); + addShellCommandToGeminiHistory(geminiClient, rawQuery, finalOutput); } catch (err) { - // This block handles synchronous errors from `execute` setPendingHistoryItem(null); const errorMessage = err instanceof Error ? err.message : String(err); addItemToHistory( @@ -339,23 +501,18 @@ export const useShellCommandProcessor = ( }, userMessageTimestamp, ); - - // Perform cleanup here as well + } finally { + abortSignal.removeEventListener('abort', abortHandler); if (pwdFilePath && fs.existsSync(pwdFilePath)) { fs.unlinkSync(pwdFilePath); } - setActiveShellPtyId(null); + + dispatch({ type: 'SET_ACTIVE_PTY', pid: null }); setShellInputFocused(false); - resolve(); // Resolve the promise to unblock `onExec` } }; - const execPromise = new Promise((resolve) => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - executeCommand(resolve); - }); - - onExec(execPromise); + onExec(executeCommand()); return true; }, [ @@ -368,8 +525,26 @@ export const useShellCommandProcessor = ( setShellInputFocused, terminalHeight, terminalWidth, + registerBackgroundShell, + m, + dispatch, ], ); - return { handleShellCommand, activeShellPtyId, lastShellOutputTime }; + const backgroundShellCount = Array.from( + state.backgroundShells.values(), + ).filter((s: BackgroundShell) => s.status === 'running').length; + + return { + handleShellCommand, + activeShellPtyId: state.activeShellPtyId, + lastShellOutputTime: state.lastShellOutputTime, + backgroundShellCount, + isBackgroundShellVisible: state.isBackgroundShellVisible, + toggleBackgroundShell, + backgroundCurrentShell, + registerBackgroundShell, + dismissBackgroundShell, + backgroundShells: state.backgroundShells, + }; }; diff --git a/packages/cli/src/ui/hooks/shellReducer.test.ts b/packages/cli/src/ui/hooks/shellReducer.test.ts new file mode 100644 index 0000000000..a9d4bf6da5 --- /dev/null +++ b/packages/cli/src/ui/hooks/shellReducer.test.ts @@ -0,0 +1,193 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + shellReducer, + initialState, + type ShellState, + type ShellAction, +} from './shellReducer.js'; + +describe('shellReducer', () => { + it('should return the initial state', () => { + // @ts-expect-error - testing default case + expect(shellReducer(initialState, { type: 'UNKNOWN' })).toEqual( + initialState, + ); + }); + + it('should handle SET_ACTIVE_PTY', () => { + const action: ShellAction = { type: 'SET_ACTIVE_PTY', pid: 12345 }; + const state = shellReducer(initialState, action); + expect(state.activeShellPtyId).toBe(12345); + }); + + it('should handle SET_OUTPUT_TIME', () => { + const now = Date.now(); + const action: ShellAction = { type: 'SET_OUTPUT_TIME', time: now }; + const state = shellReducer(initialState, action); + expect(state.lastShellOutputTime).toBe(now); + }); + + it('should handle SET_VISIBILITY', () => { + const action: ShellAction = { type: 'SET_VISIBILITY', visible: true }; + const state = shellReducer(initialState, action); + expect(state.isBackgroundShellVisible).toBe(true); + }); + + it('should handle TOGGLE_VISIBILITY', () => { + const action: ShellAction = { type: 'TOGGLE_VISIBILITY' }; + let state = shellReducer(initialState, action); + expect(state.isBackgroundShellVisible).toBe(true); + state = shellReducer(state, action); + expect(state.isBackgroundShellVisible).toBe(false); + }); + + it('should handle REGISTER_SHELL', () => { + const action: ShellAction = { + type: 'REGISTER_SHELL', + pid: 1001, + command: 'ls', + initialOutput: 'init', + }; + const state = shellReducer(initialState, action); + expect(state.backgroundShells.has(1001)).toBe(true); + expect(state.backgroundShells.get(1001)).toEqual({ + pid: 1001, + command: 'ls', + output: 'init', + isBinary: false, + binaryBytesReceived: 0, + status: 'running', + }); + }); + + it('should not REGISTER_SHELL if PID already exists', () => { + const action: ShellAction = { + type: 'REGISTER_SHELL', + pid: 1001, + command: 'ls', + initialOutput: 'init', + }; + const state = shellReducer(initialState, action); + const state2 = shellReducer(state, { ...action, command: 'other' }); + expect(state2).toBe(state); + expect(state2.backgroundShells.get(1001)?.command).toBe('ls'); + }); + + it('should handle UPDATE_SHELL', () => { + const registeredState = shellReducer(initialState, { + type: 'REGISTER_SHELL', + pid: 1001, + command: 'ls', + initialOutput: 'init', + }); + + const action: ShellAction = { + type: 'UPDATE_SHELL', + pid: 1001, + update: { status: 'exited', exitCode: 0 }, + }; + const state = shellReducer(registeredState, action); + const shell = state.backgroundShells.get(1001); + expect(shell?.status).toBe('exited'); + expect(shell?.exitCode).toBe(0); + // Map should be new + expect(state.backgroundShells).not.toBe(registeredState.backgroundShells); + }); + + it('should handle APPEND_SHELL_OUTPUT when visible (triggers re-render)', () => { + const visibleState: ShellState = { + ...initialState, + isBackgroundShellVisible: true, + backgroundShells: new Map([ + [ + 1001, + { + pid: 1001, + command: 'ls', + output: 'init', + isBinary: false, + binaryBytesReceived: 0, + status: 'running', + }, + ], + ]), + }; + + const action: ShellAction = { + type: 'APPEND_SHELL_OUTPUT', + pid: 1001, + chunk: ' + more', + }; + const state = shellReducer(visibleState, action); + expect(state.backgroundShells.get(1001)?.output).toBe('init + more'); + // Drawer is visible, so we expect a NEW map object to trigger React re-render + expect(state.backgroundShells).not.toBe(visibleState.backgroundShells); + }); + + it('should handle APPEND_SHELL_OUTPUT when hidden (no re-render optimization)', () => { + const hiddenState: ShellState = { + ...initialState, + isBackgroundShellVisible: false, + backgroundShells: new Map([ + [ + 1001, + { + pid: 1001, + command: 'ls', + output: 'init', + isBinary: false, + binaryBytesReceived: 0, + status: 'running', + }, + ], + ]), + }; + + const action: ShellAction = { + type: 'APPEND_SHELL_OUTPUT', + pid: 1001, + chunk: ' + more', + }; + const state = shellReducer(hiddenState, action); + expect(state.backgroundShells.get(1001)?.output).toBe('init + more'); + // Drawer is hidden, so we expect the SAME map object (mutation optimization) + expect(state.backgroundShells).toBe(hiddenState.backgroundShells); + }); + + it('should handle SYNC_BACKGROUND_SHELLS', () => { + const action: ShellAction = { type: 'SYNC_BACKGROUND_SHELLS' }; + const state = shellReducer(initialState, action); + expect(state.backgroundShells).not.toBe(initialState.backgroundShells); + }); + + it('should handle DISMISS_SHELL', () => { + const registeredState: ShellState = { + ...initialState, + isBackgroundShellVisible: true, + backgroundShells: new Map([ + [ + 1001, + { + pid: 1001, + command: 'ls', + output: 'init', + isBinary: false, + binaryBytesReceived: 0, + status: 'running', + }, + ], + ]), + }; + + const action: ShellAction = { type: 'DISMISS_SHELL', pid: 1001 }; + const state = shellReducer(registeredState, action); + expect(state.backgroundShells.has(1001)).toBe(false); + expect(state.isBackgroundShellVisible).toBe(false); // Auto-hide if last shell + }); +}); diff --git a/packages/cli/src/ui/hooks/shellReducer.ts b/packages/cli/src/ui/hooks/shellReducer.ts new file mode 100644 index 0000000000..0e80994d4e --- /dev/null +++ b/packages/cli/src/ui/hooks/shellReducer.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { AnsiOutput } from '@google/gemini-cli-core'; + +export interface BackgroundShell { + pid: number; + command: string; + output: string | AnsiOutput; + isBinary: boolean; + binaryBytesReceived: number; + status: 'running' | 'exited'; + exitCode?: number; +} + +export interface ShellState { + activeShellPtyId: number | null; + lastShellOutputTime: number; + backgroundShells: Map; + isBackgroundShellVisible: boolean; +} + +export type ShellAction = + | { type: 'SET_ACTIVE_PTY'; pid: number | null } + | { type: 'SET_OUTPUT_TIME'; time: number } + | { type: 'SET_VISIBILITY'; visible: boolean } + | { type: 'TOGGLE_VISIBILITY' } + | { + type: 'REGISTER_SHELL'; + pid: number; + command: string; + initialOutput: string | AnsiOutput; + } + | { type: 'UPDATE_SHELL'; pid: number; update: Partial } + | { type: 'APPEND_SHELL_OUTPUT'; pid: number; chunk: string | AnsiOutput } + | { type: 'SYNC_BACKGROUND_SHELLS' } + | { type: 'DISMISS_SHELL'; pid: number }; + +export const initialState: ShellState = { + activeShellPtyId: null, + lastShellOutputTime: 0, + backgroundShells: new Map(), + isBackgroundShellVisible: false, +}; + +export function shellReducer( + state: ShellState, + action: ShellAction, +): ShellState { + switch (action.type) { + case 'SET_ACTIVE_PTY': + return { ...state, activeShellPtyId: action.pid }; + case 'SET_OUTPUT_TIME': + return { ...state, lastShellOutputTime: action.time }; + case 'SET_VISIBILITY': + return { ...state, isBackgroundShellVisible: action.visible }; + case 'TOGGLE_VISIBILITY': + return { + ...state, + isBackgroundShellVisible: !state.isBackgroundShellVisible, + }; + case 'REGISTER_SHELL': { + if (state.backgroundShells.has(action.pid)) return state; + const nextShells = new Map(state.backgroundShells); + nextShells.set(action.pid, { + pid: action.pid, + command: action.command, + output: action.initialOutput, + isBinary: false, + binaryBytesReceived: 0, + status: 'running', + }); + return { ...state, backgroundShells: nextShells }; + } + case 'UPDATE_SHELL': { + const shell = state.backgroundShells.get(action.pid); + if (!shell) return state; + const nextShells = new Map(state.backgroundShells); + const updatedShell = { ...shell, ...action.update }; + // Maintain insertion order, move to end if status changed to exited + if (action.update.status === 'exited') { + nextShells.delete(action.pid); + } + nextShells.set(action.pid, updatedShell); + return { ...state, backgroundShells: nextShells }; + } + case 'APPEND_SHELL_OUTPUT': { + const shell = state.backgroundShells.get(action.pid); + if (!shell) return state; + // Note: we mutate the shell object in the map for background updates + // to avoid re-rendering if the drawer is not visible. + // This is an intentional performance optimization for the CLI. + let newOutput = shell.output; + if (typeof action.chunk === 'string') { + newOutput = + typeof shell.output === 'string' + ? shell.output + action.chunk + : action.chunk; + } else { + newOutput = action.chunk; + } + shell.output = newOutput; + + if (state.isBackgroundShellVisible) { + return { ...state, backgroundShells: new Map(state.backgroundShells) }; + } + return state; + } + case 'SYNC_BACKGROUND_SHELLS': { + return { ...state, backgroundShells: new Map(state.backgroundShells) }; + } + case 'DISMISS_SHELL': { + const nextShells = new Map(state.backgroundShells); + nextShells.delete(action.pid); + return { + ...state, + backgroundShells: nextShells, + isBackgroundShellVisible: + nextShells.size === 0 ? false : state.isBackgroundShellVisible, + }; + } + default: + return state; + } +} diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 4a6a6a1c9b..9d963a9e63 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -213,6 +213,7 @@ describe('useSlashCommandProcessor', () => { toggleDebugProfiler: vi.fn(), dispatchExtensionStateUpdate: vi.fn(), addConfirmUpdateExtensionRequest: vi.fn(), + toggleBackgroundShell: vi.fn(), setText: vi.fn(), }, new Map(), // extensionsUpdateState diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index efd0762320..a8bb8ee2bf 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -82,6 +82,7 @@ interface SlashCommandProcessorActions { toggleDebugProfiler: () => void; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; + toggleBackgroundShell: () => void; setText: (text: string) => void; } @@ -237,6 +238,7 @@ export const useSlashCommandProcessor = ( addConfirmUpdateExtensionRequest: actions.addConfirmUpdateExtensionRequest, removeComponent: () => setCustomDialog(null), + toggleBackgroundShell: actions.toggleBackgroundShell, }, session: { stats: session.stats, diff --git a/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts b/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts index 17e4a108fb..4fec4edf18 100644 --- a/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useApprovalModeIndicator.test.ts @@ -30,6 +30,9 @@ vi.mock('@google/gemini-cli-core', async () => { return { ...actualServerModule, Config: vi.fn(), + getAdminErrorMessage: vi.fn( + (featureName: string) => `[Mock] ${featureName} is disabled`, + ), }; }); @@ -52,6 +55,9 @@ interface MockConfigInstanceShape { getUserMemory: Mock<() => string>; getGeminiMdFileCount: Mock<() => number>; getToolRegistry: Mock<() => { discoverTools: Mock<() => void> }>; + getRemoteAdminSettings: Mock< + () => { strictModeDisabled?: boolean; mcpEnabled?: boolean } | undefined + >; } type UseKeypressHandler = (key: Key) => void; @@ -109,6 +115,9 @@ describe('useApprovalModeIndicator', () => { .mockReturnValue({ discoverTools: vi.fn() }) as Mock< () => { discoverTools: Mock<() => void> } >, + getRemoteAdminSettings: vi.fn().mockReturnValue(undefined) as Mock< + () => { strictModeDisabled?: boolean } | undefined + >, }; instanceSetApprovalModeMock.mockImplementation((value: ApprovalMode) => { instanceGetApprovalModeMock.mockReturnValue(value); @@ -517,6 +526,9 @@ describe('useApprovalModeIndicator', () => { it('should not enable YOLO mode when Ctrl+Y is pressed and add an info message', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); + mockConfigInstance.getRemoteAdminSettings.mockReturnValue({ + strictModeDisabled: true, + }); const mockAddItem = vi.fn(); const { result } = renderHook(() => useApprovalModeIndicator({ @@ -544,6 +556,58 @@ describe('useApprovalModeIndicator', () => { // The mode should not change expect(result.current).toBe(ApprovalMode.DEFAULT); }); + + it('should show admin error message when YOLO mode is disabled by admin', () => { + mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); + mockConfigInstance.getRemoteAdminSettings.mockReturnValue({ + mcpEnabled: true, + }); + + const mockAddItem = vi.fn(); + renderHook(() => + useApprovalModeIndicator({ + config: mockConfigInstance as unknown as ActualConfigType, + addItem: mockAddItem, + }), + ); + + act(() => { + capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); + }); + + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.WARNING, + text: '[Mock] YOLO mode is disabled', + }, + expect.any(Number), + ); + }); + + it('should show default error message when admin settings are empty', () => { + mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); + mockConfigInstance.getRemoteAdminSettings.mockReturnValue({}); + + const mockAddItem = vi.fn(); + renderHook(() => + useApprovalModeIndicator({ + config: mockConfigInstance as unknown as ActualConfigType, + addItem: mockAddItem, + }), + ); + + act(() => { + capturedUseKeypressHandler({ name: 'y', ctrl: true } as Key); + }); + + expect(mockAddItem).toHaveBeenCalledWith( + { + type: MessageType.WARNING, + text: 'You cannot enter YOLO mode since it is disabled in your settings.', + }, + expect.any(Number), + ); + }); }); it('should call onApprovalModeChange when switching to YOLO mode', () => { diff --git a/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts index dfb1420303..3208b41603 100644 --- a/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts +++ b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts @@ -5,7 +5,11 @@ */ import { useState, useEffect } from 'react'; -import { ApprovalMode, type Config } from '@google/gemini-cli-core'; +import { + ApprovalMode, + type Config, + getAdminErrorMessage, +} from '@google/gemini-cli-core'; import { useKeypress } from './useKeypress.js'; import { keyMatchers, Command } from '../keyMatchers.js'; import type { HistoryItemWithoutId } from '../types.js'; @@ -41,10 +45,19 @@ export function useApprovalModeIndicator({ config.getApprovalMode() !== ApprovalMode.YOLO ) { if (addItem) { + let text = + 'You cannot enter YOLO mode since it is disabled in your settings.'; + const adminSettings = config.getRemoteAdminSettings(); + const hasSettings = + adminSettings && Object.keys(adminSettings).length > 0; + if (hasSettings && !adminSettings.strictModeDisabled) { + text = getAdminErrorMessage('YOLO mode', config); + } + addItem( { type: MessageType.WARNING, - text: 'You cannot enter YOLO mode since it is disabled in your settings.', + text, }, Date.now(), ); diff --git a/packages/cli/src/ui/hooks/useBackgroundShellManager.test.tsx b/packages/cli/src/ui/hooks/useBackgroundShellManager.test.tsx new file mode 100644 index 0000000000..0cf5fd995f --- /dev/null +++ b/packages/cli/src/ui/hooks/useBackgroundShellManager.test.tsx @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { + useBackgroundShellManager, + type BackgroundShellManagerProps, +} from './useBackgroundShellManager.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act } from 'react'; +import { type BackgroundShell } from './shellReducer.js'; + +describe('useBackgroundShellManager', () => { + const setEmbeddedShellFocused = vi.fn(); + const terminalHeight = 30; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderHook = (props: BackgroundShellManagerProps) => { + let hookResult: ReturnType; + function TestComponent({ p }: { p: BackgroundShellManagerProps }) { + hookResult = useBackgroundShellManager(p); + return null; + } + const { rerender } = render(); + return { + result: { + get current() { + return hookResult; + }, + }, + rerender: (newProps: BackgroundShellManagerProps) => + rerender(), + }; + }; + + it('should initialize with correct default values', () => { + const backgroundShells = new Map(); + const { result } = renderHook({ + backgroundShells, + backgroundShellCount: 0, + isBackgroundShellVisible: false, + activePtyId: null, + embeddedShellFocused: false, + setEmbeddedShellFocused, + terminalHeight, + }); + + expect(result.current.isBackgroundShellListOpen).toBe(false); + expect(result.current.activeBackgroundShellPid).toBe(null); + expect(result.current.backgroundShellHeight).toBe(0); + }); + + it('should auto-select the first background shell when added', () => { + const backgroundShells = new Map(); + const { result, rerender } = renderHook({ + backgroundShells, + backgroundShellCount: 0, + isBackgroundShellVisible: false, + activePtyId: null, + embeddedShellFocused: false, + setEmbeddedShellFocused, + terminalHeight, + }); + + const newShells = new Map([ + [123, {} as BackgroundShell], + ]); + rerender({ + backgroundShells: newShells, + backgroundShellCount: 1, + isBackgroundShellVisible: false, + activePtyId: null, + embeddedShellFocused: false, + setEmbeddedShellFocused, + terminalHeight, + }); + + expect(result.current.activeBackgroundShellPid).toBe(123); + }); + + it('should reset state when all shells are removed', () => { + const backgroundShells = new Map([ + [123, {} as BackgroundShell], + ]); + const { result, rerender } = renderHook({ + backgroundShells, + backgroundShellCount: 1, + isBackgroundShellVisible: true, + activePtyId: null, + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight, + }); + + act(() => { + result.current.setIsBackgroundShellListOpen(true); + }); + expect(result.current.isBackgroundShellListOpen).toBe(true); + + rerender({ + backgroundShells: new Map(), + backgroundShellCount: 0, + isBackgroundShellVisible: true, + activePtyId: null, + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight, + }); + + expect(result.current.activeBackgroundShellPid).toBe(null); + expect(result.current.isBackgroundShellListOpen).toBe(false); + }); + + it('should unfocus embedded shell when no shells are active', () => { + const backgroundShells = new Map([ + [123, {} as BackgroundShell], + ]); + renderHook({ + backgroundShells, + backgroundShellCount: 1, + isBackgroundShellVisible: false, // Background shell not visible + activePtyId: null, // No foreground shell + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight, + }); + + expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false); + }); + + it('should calculate backgroundShellHeight correctly when visible', () => { + const backgroundShells = new Map([ + [123, {} as BackgroundShell], + ]); + const { result } = renderHook({ + backgroundShells, + backgroundShellCount: 1, + isBackgroundShellVisible: true, + activePtyId: null, + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight: 100, + }); + + // 100 * 0.3 = 30 + expect(result.current.backgroundShellHeight).toBe(30); + }); + + it('should maintain current active shell if it still exists', () => { + const backgroundShells = new Map([ + [123, {} as BackgroundShell], + [456, {} as BackgroundShell], + ]); + const { result, rerender } = renderHook({ + backgroundShells, + backgroundShellCount: 2, + isBackgroundShellVisible: true, + activePtyId: null, + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight, + }); + + act(() => { + result.current.setActiveBackgroundShellPid(456); + }); + expect(result.current.activeBackgroundShellPid).toBe(456); + + // Remove the OTHER shell + const updatedShells = new Map([ + [456, {} as BackgroundShell], + ]); + rerender({ + backgroundShells: updatedShells, + backgroundShellCount: 1, + isBackgroundShellVisible: true, + activePtyId: null, + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight, + }); + + expect(result.current.activeBackgroundShellPid).toBe(456); + }); +}); diff --git a/packages/cli/src/ui/hooks/useBackgroundShellManager.ts b/packages/cli/src/ui/hooks/useBackgroundShellManager.ts new file mode 100644 index 0000000000..465e4b8e0d --- /dev/null +++ b/packages/cli/src/ui/hooks/useBackgroundShellManager.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useMemo } from 'react'; +import { type BackgroundShell } from './shellCommandProcessor.js'; + +export interface BackgroundShellManagerProps { + backgroundShells: Map; + backgroundShellCount: number; + isBackgroundShellVisible: boolean; + activePtyId: number | null | undefined; + embeddedShellFocused: boolean; + setEmbeddedShellFocused: (focused: boolean) => void; + terminalHeight: number; +} + +export function useBackgroundShellManager({ + backgroundShells, + backgroundShellCount, + isBackgroundShellVisible, + activePtyId, + embeddedShellFocused, + setEmbeddedShellFocused, + terminalHeight, +}: BackgroundShellManagerProps) { + const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] = + useState(false); + const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState< + number | null + >(null); + + useEffect(() => { + if (backgroundShells.size === 0) { + if (activeBackgroundShellPid !== null) { + setActiveBackgroundShellPid(null); + } + if (isBackgroundShellListOpen) { + setIsBackgroundShellListOpen(false); + } + } else if ( + activeBackgroundShellPid === null || + !backgroundShells.has(activeBackgroundShellPid) + ) { + // If active shell is closed or none selected, select the first one (last added usually, or just first in iteration) + setActiveBackgroundShellPid(backgroundShells.keys().next().value ?? null); + } + }, [ + backgroundShells, + activeBackgroundShellPid, + backgroundShellCount, + isBackgroundShellListOpen, + ]); + + useEffect(() => { + if (embeddedShellFocused) { + const hasActiveForegroundShell = !!activePtyId; + const hasVisibleBackgroundShell = + isBackgroundShellVisible && backgroundShells.size > 0; + + if (!hasActiveForegroundShell && !hasVisibleBackgroundShell) { + setEmbeddedShellFocused(false); + } + } + }, [ + isBackgroundShellVisible, + backgroundShells, + embeddedShellFocused, + backgroundShellCount, + activePtyId, + setEmbeddedShellFocused, + ]); + + const backgroundShellHeight = useMemo( + () => + isBackgroundShellVisible && backgroundShells.size > 0 + ? Math.max(Math.floor(terminalHeight * 0.3), 5) + : 0, + [isBackgroundShellVisible, backgroundShells.size, terminalHeight], + ); + + return { + isBackgroundShellListOpen, + setIsBackgroundShellListOpen, + activeBackgroundShellPid, + setActiveBackgroundShellPid, + backgroundShellHeight, + }; +} diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index d9763b96f5..1c4434a34a 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -68,6 +68,9 @@ const MockedGeminiClientClass = vi.hoisted(() => recordToolCalls: vi.fn(), getConversationFile: vi.fn(), }); + this.getCurrentSequenceModel = vi + .fn() + .mockReturnValue('gemini-2.0-flash-exp'); }), ); @@ -652,6 +655,9 @@ describe('useGeminiStream', () => { expectedMergedResponse, expect.any(AbortSignal), 'prompt-id-2', + undefined, + false, + expectedMergedResponse, ); }); @@ -1054,6 +1060,9 @@ describe('useGeminiStream', () => { toolCallResponseParts, expect.any(AbortSignal), 'prompt-id-4', + undefined, + false, + toolCallResponseParts, ); }); @@ -1495,6 +1504,9 @@ describe('useGeminiStream', () => { 'This is the actual prompt from the command file.', expect.any(AbortSignal), expect.any(String), + undefined, + false, + '/my-custom-command', ); expect(mockScheduleToolCalls).not.toHaveBeenCalled(); @@ -1521,6 +1533,9 @@ describe('useGeminiStream', () => { '', expect.any(AbortSignal), expect.any(String), + undefined, + false, + '/emptycmd', ); }); }); @@ -1539,6 +1554,9 @@ describe('useGeminiStream', () => { '// This is a line comment', expect.any(AbortSignal), expect.any(String), + undefined, + false, + '// This is a line comment', ); }); }); @@ -1557,6 +1575,9 @@ describe('useGeminiStream', () => { '/* This is a block comment */', expect.any(AbortSignal), expect.any(String), + undefined, + false, + '/* This is a block comment */', ); }); }); @@ -2389,6 +2410,9 @@ describe('useGeminiStream', () => { processedQueryParts, // Argument 1: The parts array directly expect.any(AbortSignal), // Argument 2: An AbortSignal expect.any(String), // Argument 3: The prompt_id string + undefined, + false, + rawQuery, ); }); @@ -2928,6 +2952,9 @@ describe('useGeminiStream', () => { 'test query', expect.any(AbortSignal), expect.any(String), + undefined, + false, + 'test query', ); }); }); @@ -3075,6 +3102,9 @@ describe('useGeminiStream', () => { 'second query', expect.any(AbortSignal), expect.any(String), + undefined, + false, + 'second query', ); }); }); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 0d5260b82f..eca933d982 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -43,6 +43,7 @@ import type { ServerGeminiStreamEvent as GeminiEvent, ThoughtSummary, ToolCallRequestInfo, + ToolCallResponseInfo, GeminiErrorEventValue, RetryAttemptPayload, ToolCallConfirmationDetails, @@ -72,6 +73,7 @@ import { type TrackedCompletedToolCall, type TrackedCancelledToolCall, type TrackedWaitingToolCall, + type TrackedExecutingToolCall, } from './useToolScheduler.js'; import { promises as fs } from 'node:fs'; import path from 'node:path'; @@ -79,12 +81,34 @@ import { useSessionStats } from '../contexts/SessionContext.js'; import { useKeypress } from './useKeypress.js'; import type { LoadedSettings } from '../../config/settings.js'; +type ToolResponseWithParts = ToolCallResponseInfo & { + llmContent?: PartListUnion; +}; + +interface ShellToolData { + pid?: number; + command?: string; + initialOutput?: string; +} + enum StreamProcessingStatus { Completed, UserCancelled, Error, } +function isShellToolData(data: unknown): data is ShellToolData { + if (typeof data !== 'object' || data === null) { + return false; + } + const d = data as Partial; + return ( + (d.pid === undefined || typeof d.pid === 'number') && + (d.command === undefined || typeof d.command === 'string') && + (d.initialOutput === undefined || typeof d.initialOutput === 'string') + ); +} + function showCitations(settings: LoadedSettings): boolean { const enabled = settings.merged.ui.showCitations; if (enabled !== undefined) { @@ -401,14 +425,11 @@ export const useGeminiStream = ( }, [toolCalls, pushedToolCallIds, config]); const activeToolPtyId = useMemo(() => { - const executingShellTool = toolCalls?.find( + const executingShellTool = toolCalls.find( (tc) => tc.status === 'executing' && tc.request.name === 'run_shell_command', ); - if (executingShellTool) { - return (executingShellTool as { pid?: number }).pid; - } - return undefined; + return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid; }, [toolCalls]); const lastQueryRef = useRef(null); @@ -426,18 +447,30 @@ export const useGeminiStream = ( await done; setIsResponding(false); }, []); - const { handleShellCommand, activeShellPtyId, lastShellOutputTime } = - useShellCommandProcessor( - addItem, - setPendingHistoryItem, - onExec, - onDebugMessage, - config, - geminiClient, - setShellInputFocused, - terminalWidth, - terminalHeight, - ); + + const { + handleShellCommand, + activeShellPtyId, + lastShellOutputTime, + backgroundShellCount, + isBackgroundShellVisible, + toggleBackgroundShell, + backgroundCurrentShell, + registerBackgroundShell, + dismissBackgroundShell, + backgroundShells, + } = useShellCommandProcessor( + addItem, + setPendingHistoryItem, + onExec, + onDebugMessage, + config, + geminiClient, + setShellInputFocused, + terminalWidth, + terminalHeight, + activeToolPtyId, + ); const activePtyId = activeShellPtyId || activeToolPtyId; @@ -1222,6 +1255,9 @@ export const useGeminiStream = ( queryToSend, abortSignal, prompt_id!, + undefined, + false, + query, ); const processingStatus = await processGeminiStreamEvents( stream, @@ -1404,6 +1440,25 @@ export const useGeminiStream = ( !processedMemoryToolsRef.current.has(t.request.callId), ); + // Handle backgrounded shell tools + completedAndReadyToSubmitTools.forEach((t) => { + const isShell = t.request.name === 'run_shell_command'; + // Access result from the tracked tool call response + const response = t.response as ToolResponseWithParts; + const rawData = response?.data; + const data = isShellToolData(rawData) ? rawData : undefined; + + // Use data.pid for shell commands moved to the background. + const pid = data?.pid; + + if (isShell && pid) { + const command = (data?.['command'] as string) ?? 'shell'; + const initialOutput = (data?.['initialOutput'] as string) ?? ''; + + registerBackgroundShell(pid, command, initialOutput); + } + }); + if (newSuccessfulMemorySaves.length > 0) { // Perform the refresh only if there are new ones. void performMemoryRefresh(); @@ -1510,6 +1565,7 @@ export const useGeminiStream = ( performMemoryRefresh, modelSwitchedFromQuotaError, addItem, + registerBackgroundShell, ], ); @@ -1599,6 +1655,12 @@ export const useGeminiStream = ( activePtyId, loopDetectionConfirmationRequest, lastOutputTime, + backgroundShellCount, + isBackgroundShellVisible, + toggleBackgroundShell, + backgroundCurrentShell, + backgroundShells, + dismissBackgroundShell, retryStatus, }; }; diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index 08952a5ac7..79b15fb293 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -40,7 +40,6 @@ export type TrackedWaitingToolCall = WaitingToolCall & { }; export type TrackedExecutingToolCall = ExecutingToolCall & { responseSubmittedToGemini?: boolean; - pid?: number; }; export type TrackedCompletedToolCall = CompletedToolCall & { responseSubmittedToGemini?: boolean; @@ -134,7 +133,15 @@ export function useReactToolScheduler( ...coreTc, responseSubmittedToGemini, liveOutput, - pid: coreTc.pid, + }; + } else if ( + coreTc.status === 'success' || + coreTc.status === 'error' || + coreTc.status === 'cancelled' + ) { + return { + ...coreTc, + responseSubmittedToGemini, }; } else { return { diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts index a376d525c9..9aaf13c8ef 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.test.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.test.ts @@ -178,6 +178,30 @@ describe('convertSessionToHistoryFormats', () => { }); }); + it('should prioritize displayContent for UI history but use content for client history', () => { + const messages: MessageRecord[] = [ + { + type: 'user', + content: [{ text: 'Expanded content' }], + displayContent: [{ text: 'User input' }], + } as MessageRecord, + ]; + + const result = convertSessionToHistoryFormats(messages); + + expect(result.uiHistory).toHaveLength(1); + expect(result.uiHistory[0]).toMatchObject({ + type: 'user', + text: 'User input', + }); + + expect(result.clientHistory).toHaveLength(1); + expect(result.clientHistory[0]).toEqual({ + role: 'user', + parts: [{ text: 'Expanded content' }], + }); + }); + it('should filter out slash commands from client history but keep in UI', () => { const messages: MessageRecord[] = [ { type: 'user', content: '/help' } as MessageRecord, diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index 3d9619d738..c214011c8b 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -15,6 +15,7 @@ import type { } from '@google/gemini-cli-core'; import type { Part } from '@google/genai'; import { partListUnionToString, coreEvents } from '@google/gemini-cli-core'; +import { checkExhaustive } from '../../utils/checks.js'; import type { SessionInfo } from '../../utils/sessionUtils.js'; import { MessageType, ToolCallStatus } from '../types.js'; @@ -125,8 +126,13 @@ export function convertSessionToHistoryFormats( for (const msg of messages) { // Add the message only if it has content + const displayContentString = msg.displayContent + ? partListUnionToString(msg.displayContent) + : undefined; const contentString = partListUnionToString(msg.content); - if (msg.content && contentString.trim()) { + const uiText = displayContentString || contentString; + + if (uiText.trim()) { let messageType: MessageType; switch (msg.type) { case 'user': @@ -141,14 +147,18 @@ export function convertSessionToHistoryFormats( case 'warning': messageType = MessageType.WARNING; break; + case 'gemini': + messageType = MessageType.GEMINI; + break; default: + checkExhaustive(msg); messageType = MessageType.GEMINI; break; } uiHistory.push({ type: messageType, - text: contentString, + text: uiText, }); } @@ -199,7 +209,9 @@ export function convertSessionToHistoryFormats( // Add regular user message clientHistory.push({ role: 'user', - parts: [{ text: contentString }], + parts: Array.isArray(msg.content) + ? (msg.content as Part[]) + : [{ text: contentString }], }); } else if (msg.type === 'gemini') { // Handle Gemini messages with potential tool calls diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index cf520b84e8..e65fd4077c 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -59,8 +59,12 @@ describe('keyMatchers', () => { }, { command: Command.MOVE_LEFT, - positive: [createKey('left'), createKey('b', { ctrl: true })], - negative: [createKey('left', { ctrl: true }), createKey('b')], + positive: [createKey('left')], + negative: [ + createKey('left', { ctrl: true }), + createKey('b'), + createKey('b', { ctrl: true }), + ], }, { command: Command.MOVE_RIGHT, @@ -285,7 +289,10 @@ describe('keyMatchers', () => { { command: Command.SHOW_ERROR_DETAILS, positive: [createKey('f12')], - negative: [createKey('o', { ctrl: true }), createKey('f11')], + negative: [ + createKey('o', { ctrl: true }), + createKey('b', { ctrl: true }), + ], }, { command: Command.SHOW_FULL_TODOS, @@ -357,6 +364,16 @@ describe('keyMatchers', () => { positive: [createKey('tab', { shift: true })], negative: [createKey('tab')], }, + { + command: Command.TOGGLE_BACKGROUND_SHELL, + positive: [createKey('b', { ctrl: true })], + negative: [createKey('f10'), createKey('b')], + }, + { + command: Command.TOGGLE_BACKGROUND_SHELL_LIST, + positive: [createKey('l', { ctrl: true })], + negative: [createKey('l')], + }, ]; describe('Data-driven key binding matches original logic', () => { diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx new file mode 100644 index 0000000000..11762ed19f --- /dev/null +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../test-utils/render.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DefaultAppLayout } from './DefaultAppLayout.js'; +import { StreamingState } from '../types.js'; +import { Text } from 'ink'; +import type { UIState } from '../contexts/UIStateContext.js'; +import type { BackgroundShell } from '../hooks/shellCommandProcessor.js'; + +// Mock dependencies +const mockUIState = { + rootUiRef: { current: null }, + terminalHeight: 24, + terminalWidth: 80, + mainAreaWidth: 80, + backgroundShells: new Map(), + activeBackgroundShellPid: null as number | null, + backgroundShellHeight: 10, + embeddedShellFocused: false, + dialogsVisible: false, + streamingState: StreamingState.Idle, + isBackgroundShellListOpen: false, + mainControlsRef: { current: null }, + customDialog: null, + historyManager: { addItem: vi.fn() }, + history: [], + pendingHistoryItems: [], + slashCommands: [], + constrainHeight: false, + availableTerminalHeight: 20, + activePtyId: null, + isBackgroundShellVisible: true, +} as unknown as UIState; + +vi.mock('../contexts/UIStateContext.js', () => ({ + useUIState: () => mockUIState, +})); + +vi.mock('../hooks/useFlickerDetector.js', () => ({ + useFlickerDetector: vi.fn(), +})); + +vi.mock('../hooks/useAlternateBuffer.js', () => ({ + useAlternateBuffer: vi.fn(() => false), +})); + +vi.mock('../contexts/ConfigContext.js', () => ({ + useConfig: () => ({ + getAccessibility: vi.fn(() => ({ + enableLoadingPhrases: true, + })), + }), +})); + +// Mock child components to simplify output +vi.mock('../components/LoadingIndicator.js', () => ({ + LoadingIndicator: () => LoadingIndicator, +})); +vi.mock('../components/MainContent.js', () => ({ + MainContent: () => MainContent, +})); +vi.mock('../components/Notifications.js', () => ({ + Notifications: () => Notifications, +})); +vi.mock('../components/DialogManager.js', () => ({ + DialogManager: () => DialogManager, +})); +vi.mock('../components/Composer.js', () => ({ + Composer: () => Composer, +})); +vi.mock('../components/ExitWarning.js', () => ({ + ExitWarning: () => ExitWarning, +})); +vi.mock('../components/CopyModeWarning.js', () => ({ + CopyModeWarning: () => CopyModeWarning, +})); +vi.mock('../components/BackgroundShellDisplay.js', () => ({ + BackgroundShellDisplay: () => BackgroundShellDisplay, +})); + +const createMockShell = (pid: number): BackgroundShell => ({ + pid, + command: 'test command', + output: 'test output', + isBinary: false, + binaryBytesReceived: 0, + status: 'running', +}); + +describe('', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Reset mock state defaults + mockUIState.backgroundShells = new Map(); + mockUIState.activeBackgroundShellPid = null; + mockUIState.streamingState = StreamingState.Idle; + }); + + it('renders BackgroundShellDisplay when shells exist and active', () => { + mockUIState.backgroundShells.set(123, createMockShell(123)); + mockUIState.activeBackgroundShellPid = 123; + mockUIState.backgroundShellHeight = 5; + + const { lastFrame } = render(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation', () => { + mockUIState.backgroundShells.set(123, createMockShell(123)); + mockUIState.activeBackgroundShellPid = 123; + mockUIState.backgroundShellHeight = 5; + mockUIState.streamingState = StreamingState.WaitingForConfirmation; + + const { lastFrame } = render(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation', () => { + mockUIState.backgroundShells.set(123, createMockShell(123)); + mockUIState.activeBackgroundShellPid = 123; + mockUIState.backgroundShellHeight = 5; + mockUIState.streamingState = StreamingState.Responding; + + const { lastFrame } = render(); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 7c22e46ac3..43b00095f3 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -15,6 +15,8 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useFlickerDetector } from '../hooks/useFlickerDetector.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { CopyModeWarning } from '../components/CopyModeWarning.js'; +import { BackgroundShellDisplay } from '../components/BackgroundShellDisplay.js'; +import { StreamingState } from '../types.js'; export const DefaultAppLayout: React.FC = () => { const uiState = useUIState(); @@ -37,6 +39,24 @@ export const DefaultAppLayout: React.FC = () => { > + {uiState.isBackgroundShellVisible && + uiState.backgroundShells.size > 0 && + uiState.activeBackgroundShellPid && + uiState.backgroundShellHeight > 0 && + uiState.streamingState !== StreamingState.WaitingForConfirmation && ( + + + + )} > hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation 1`] = ` +"MainContent +Notifications +CopyModeWarning +Composer +ExitWarning" +`; + +exports[` > renders BackgroundShellDisplay when shells exist and active 1`] = ` +"MainContent +BackgroundShellDisplay + + + + +Notifications +CopyModeWarning +Composer +ExitWarning" +`; + +exports[` > shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation 1`] = ` +"MainContent +BackgroundShellDisplay + + + + +Notifications +CopyModeWarning +Composer +ExitWarning" +`; diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index 6632583223..ae442c923f 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -29,5 +29,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] { dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {}, addConfirmUpdateExtensionRequest: (_request) => {}, removeComponent: () => {}, + toggleBackgroundShell: () => {}, }; } diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer.test.tsx.snap b/packages/cli/src/ui/utils/__snapshots__/TableRenderer.test.tsx.snap index 8f3380f51b..0f9e0b84d5 100644 --- a/packages/cli/src/ui/utils/__snapshots__/TableRenderer.test.tsx.snap +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer.test.tsx.snap @@ -1,35 +1,5 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`TableRenderer > handles empty rows 1`] = ` -" -┌──────┬──────┬────────┐ -│ Name │ Role │ Status │ -├──────┼──────┼────────┤ -└──────┴──────┴────────┘ -" -`; - -exports[`TableRenderer > handles markdown content in cells 1`] = ` -" -┌───────┬──────────┬────────┐ -│ Name │ Role │ Status │ -├───────┼──────────┼────────┤ -│ Alice │ Engineer │ Active │ -└───────┴──────────┴────────┘ -" -`; - -exports[`TableRenderer > handles rows with missing cells 1`] = ` -" -┌───────┬──────────┬────────┐ -│ Name │ Role │ Status │ -├───────┼──────────┼────────┤ -│ Alice │ Engineer │ -│ Bob │ -└───────┴──────────┴────────┘ -" -`; - exports[`TableRenderer > renders a 3x3 table correctly 1`] = ` " ┌──────────────┬──────────────┬──────────────┐ @@ -42,18 +12,6 @@ exports[`TableRenderer > renders a 3x3 table correctly 1`] = ` " `; -exports[`TableRenderer > renders a simple table correctly 1`] = ` -" -┌─────────┬──────────┬──────────┐ -│ Name │ Role │ Status │ -├─────────┼──────────┼──────────┤ -│ Alice │ Engineer │ Active │ -│ Bob │ Designer │ Inactive │ -│ Charlie │ Manager │ Active │ -└─────────┴──────────┴──────────┘ -" -`; - exports[`TableRenderer > renders a table with long headers and 4 columns correctly 1`] = ` " ┌──────────────────┬──────────────────┬───────────────────┬──────────────────┐ @@ -65,25 +23,3 @@ exports[`TableRenderer > renders a table with long headers and 4 columns correct └──────────────────┴──────────────────┴───────────────────┴──────────────────┘ " `; - -exports[`TableRenderer > truncates content when terminal width is small 1`] = ` -" -┌────────┬─────────┬─────────┐ -│ Name │ Role │ Status │ -├────────┼─────────┼─────────┤ -│ Alice │ Engi... │ Active │ -│ Bob │ Desi... │ Inac... │ -│ Cha... │ Manager │ Active │ -└────────┴─────────┴─────────┘ -" -`; - -exports[`TableRenderer > truncates long markdown content correctly 1`] = ` -" -┌───────────────────────────┬─────┬────┐ -│ Name │ Rol │ St │ -├───────────────────────────┼─────┼────┤ -│ Alice with a very long... │ Eng │ Ac │ -└───────────────────────────┴─────┴────┘ -" -`; diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index 1769cbafca..7273c0b961 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -983,6 +983,9 @@ function toPermissionOptions( }, ...basicPermissionOptions, ]; + case 'ask_user': + // askuser doesn't need "always allow" options since it's asking questions + return [...basicPermissionOptions]; default: { const unreachable: never = confirmation; throw new Error(`Unexpected: ${unreachable}`); diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts index 9eb43f357b..aa32d06bdd 100644 --- a/packages/core/src/agents/registry.test.ts +++ b/packages/core/src/agents/registry.test.ts @@ -26,6 +26,7 @@ import type { ConfigParameters } from '../config/config.js'; import type { ToolRegistry } from '../tools/tool-registry.js'; import { ThinkingLevel } from '@google/genai'; import type { AcknowledgedAgentsService } from './acknowledgedAgents.js'; +import { PolicyDecision } from '../policy/types.js'; vi.mock('./agentLoader.js', () => ({ loadAgentsFromDirectory: vi @@ -657,6 +658,114 @@ describe('AgentRegistry', () => { await Promise.all(promises); expect(registry.getAllDefinitions()).toHaveLength(100); }); + + it('should dynamically register an ALLOW policy for local agents', async () => { + const agent: AgentDefinition = { + ...MOCK_AGENT_V1, + name: 'PolicyTestAgent', + }; + const policyEngine = mockConfig.getPolicyEngine(); + const addRuleSpy = vi.spyOn(policyEngine, 'addRule'); + + await registry.testRegisterAgent(agent); + + expect(addRuleSpy).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: 'PolicyTestAgent', + decision: PolicyDecision.ALLOW, + priority: 1.05, + }), + ); + }); + + it('should dynamically register an ASK_USER policy for remote agents', async () => { + const remoteAgent: AgentDefinition = { + kind: 'remote', + name: 'RemotePolicyAgent', + description: 'A remote agent', + agentCardUrl: 'https://example.com/card', + inputConfig: { inputSchema: { type: 'object' } }, + }; + + vi.mocked(A2AClientManager.getInstance).mockReturnValue({ + loadAgent: vi.fn().mockResolvedValue({ name: 'RemotePolicyAgent' }), + } as unknown as A2AClientManager); + + const policyEngine = mockConfig.getPolicyEngine(); + const addRuleSpy = vi.spyOn(policyEngine, 'addRule'); + + await registry.testRegisterAgent(remoteAgent); + + expect(addRuleSpy).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: 'RemotePolicyAgent', + decision: PolicyDecision.ASK_USER, + priority: 1.05, + }), + ); + }); + + it('should not register a policy if a USER policy already exists', async () => { + const agent: AgentDefinition = { + ...MOCK_AGENT_V1, + name: 'ExistingUserPolicyAgent', + }; + const policyEngine = mockConfig.getPolicyEngine(); + // Mock hasRuleForTool to return true when ignoreDynamic=true (simulating a user policy) + vi.spyOn(policyEngine, 'hasRuleForTool').mockImplementation( + (toolName, ignoreDynamic) => + toolName === 'ExistingUserPolicyAgent' && ignoreDynamic === true, + ); + const addRuleSpy = vi.spyOn(policyEngine, 'addRule'); + + await registry.testRegisterAgent(agent); + + expect(addRuleSpy).not.toHaveBeenCalled(); + }); + + it('should replace an existing dynamic policy when an agent is overwritten', async () => { + const localAgent: AgentDefinition = { + ...MOCK_AGENT_V1, + name: 'OverwrittenAgent', + }; + const remoteAgent: AgentDefinition = { + kind: 'remote', + name: 'OverwrittenAgent', + description: 'A remote agent', + agentCardUrl: 'https://example.com/card', + inputConfig: { inputSchema: { type: 'object' } }, + }; + + vi.mocked(A2AClientManager.getInstance).mockReturnValue({ + loadAgent: vi.fn().mockResolvedValue({ name: 'OverwrittenAgent' }), + } as unknown as A2AClientManager); + + const policyEngine = mockConfig.getPolicyEngine(); + const removeRuleSpy = vi.spyOn(policyEngine, 'removeRulesForTool'); + const addRuleSpy = vi.spyOn(policyEngine, 'addRule'); + + // 1. Register local + await registry.testRegisterAgent(localAgent); + expect(addRuleSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ decision: PolicyDecision.ALLOW }), + ); + + // 2. Overwrite with remote + await registry.testRegisterAgent(remoteAgent); + + // Verify old dynamic rule was removed + expect(removeRuleSpy).toHaveBeenCalledWith( + 'OverwrittenAgent', + 'AgentRegistry (Dynamic)', + ); + // Verify new dynamic rule (remote -> ASK_USER) was added + expect(addRuleSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + toolName: 'OverwrittenAgent', + decision: PolicyDecision.ASK_USER, + }), + ); + }); }); describe('reload', () => { diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index cc91ffeeed..66a990f1db 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -21,6 +21,7 @@ import { type ModelConfig, ModelConfigService, } from '../services/modelConfigService.js'; +import { PolicyDecision } from '../policy/types.js'; /** * Returns the model config alias for a given agent definition. @@ -266,6 +267,39 @@ export class AgentRegistry { this.agents.set(mergedDefinition.name, mergedDefinition); this.registerModelConfigs(mergedDefinition); + this.addAgentPolicy(mergedDefinition); + } + + private addAgentPolicy(definition: AgentDefinition): void { + const policyEngine = this.config.getPolicyEngine(); + if (!policyEngine) { + return; + } + + // If the user has explicitly defined a policy for this tool, respect it. + // ignoreDynamic=true means we only check for rules NOT added by this registry. + if (policyEngine.hasRuleForTool(definition.name, true)) { + if (this.config.getDebugMode()) { + debugLogger.log( + `[AgentRegistry] User policy exists for '${definition.name}', skipping dynamic registration.`, + ); + } + return; + } + + // Clean up any old dynamic policy for this tool (e.g. if we are overwriting an agent) + policyEngine.removeRulesForTool(definition.name, 'AgentRegistry (Dynamic)'); + + // Add the new dynamic policy + policyEngine.addRule({ + toolName: definition.name, + decision: + definition.kind === 'local' + ? PolicyDecision.ALLOW + : PolicyDecision.ASK_USER, + priority: 1.05, + source: 'AgentRegistry (Dynamic)', + }); } private isAgentEnabled( @@ -342,6 +376,7 @@ export class AgentRegistry { ); } this.agents.set(definition.name, definition); + this.addAgentPolicy(definition); } catch (e) { debugLogger.warn( `[AgentRegistry] Error loading A2A agent "${definition.name}":`, diff --git a/packages/core/src/code_assist/admin/admin_controls.test.ts b/packages/core/src/code_assist/admin/admin_controls.test.ts index 8664530fb0..b36daa3c9b 100644 --- a/packages/core/src/code_assist/admin/admin_controls.test.ts +++ b/packages/core/src/code_assist/admin/admin_controls.test.ts @@ -17,8 +17,15 @@ import { fetchAdminControls, sanitizeAdminSettings, stopAdminControlsPolling, + getAdminErrorMessage, } from './admin_controls.js'; import type { CodeAssistServer } from '../server.js'; +import type { Config } from '../../config/config.js'; +import { getCodeAssistServer } from '../codeAssist.js'; + +vi.mock('../codeAssist.js', () => ({ + getCodeAssistServer: vi.fn(), +})); describe('Admin Controls', () => { let mockServer: CodeAssistServer; @@ -370,6 +377,57 @@ describe('Admin Controls', () => { // The poll should not have fired again expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1); + expect(mockServer.fetchAdminControls).toHaveBeenCalledTimes(1); + }); + }); + + describe('getAdminErrorMessage', () => { + let mockConfig: Config; + + beforeEach(() => { + mockConfig = {} as Config; + }); + + it('should include feature name and project ID when present', () => { + vi.mocked(getCodeAssistServer).mockReturnValue({ + projectId: 'test-project-123', + } as CodeAssistServer); + + const message = getAdminErrorMessage('Code Completion', mockConfig); + + expect(message).toBe( + 'Code Completion is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli?project=test-project-123', + ); + }); + + it('should include feature name but OMIT project ID when missing', () => { + vi.mocked(getCodeAssistServer).mockReturnValue({ + projectId: undefined, + } as CodeAssistServer); + + const message = getAdminErrorMessage('Chat', mockConfig); + + expect(message).toBe( + 'Chat is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); + }); + + it('should include feature name but OMIT project ID when server is undefined', () => { + vi.mocked(getCodeAssistServer).mockReturnValue(undefined); + + const message = getAdminErrorMessage('Chat', mockConfig); + + expect(message).toBe( + 'Chat is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); + }); + + it('should include feature name but OMIT project ID when config is undefined', () => { + const message = getAdminErrorMessage('Chat', undefined); + + expect(message).toBe( + 'Chat is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); }); }); }); diff --git a/packages/core/src/code_assist/admin/admin_controls.ts b/packages/core/src/code_assist/admin/admin_controls.ts index e2feaf9414..fce50b60f0 100644 --- a/packages/core/src/code_assist/admin/admin_controls.ts +++ b/packages/core/src/code_assist/admin/admin_controls.ts @@ -11,6 +11,8 @@ import { type FetchAdminControlsResponse, FetchAdminControlsResponseSchema, } from '../types.js'; +import { getCodeAssistServer } from '../codeAssist.js'; +import type { Config } from '../../config/config.js'; let pollingInterval: NodeJS.Timeout | undefined; let currentSettings: FetchAdminControlsResponse | undefined; @@ -132,3 +134,20 @@ export function stopAdminControlsPolling() { pollingInterval = undefined; } } + +/** + * Returns a standardized error message for features disabled by admin settings. + * + * @param featureName The name of the disabled feature + * @param config The application config + * @returns The formatted error message + */ +export function getAdminErrorMessage( + featureName: string, + config: Config | undefined, +): string { + const server = config ? getCodeAssistServer(config) : undefined; + const projectId = server?.projectId; + const projectParam = projectId ? `?project=${projectId}` : ''; + return `${featureName} is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli${projectParam}`; +} diff --git a/packages/core/src/code_assist/server.test.ts b/packages/core/src/code_assist/server.test.ts index 930e7dfdb2..35b91fd1c5 100644 --- a/packages/core/src/code_assist/server.test.ts +++ b/packages/core/src/code_assist/server.test.ts @@ -327,6 +327,22 @@ describe('CodeAssistServer', () => { const url = server.getMethodUrl('testMethod'); expect(url).toBe('https://custom-endpoint.com/v1internal:testMethod'); }); + + it('should use the CODE_ASSIST_API_VERSION environment variable if set', () => { + process.env['CODE_ASSIST_API_VERSION'] = 'v2beta'; + const server = new CodeAssistServer({} as never); + const url = server.getMethodUrl('testMethod'); + expect(url).toBe('https://cloudcode-pa.googleapis.com/v2beta:testMethod'); + }); + + it('should use default value if CODE_ASSIST_API_VERSION env var is empty', () => { + process.env['CODE_ASSIST_API_VERSION'] = ''; + const server = new CodeAssistServer({} as never); + const url = server.getMethodUrl('testMethod'); + expect(url).toBe( + 'https://cloudcode-pa.googleapis.com/v1internal:testMethod', + ); + }); }); it('should call the generateContentStream endpoint and parse SSE', async () => { diff --git a/packages/core/src/code_assist/server.ts b/packages/core/src/code_assist/server.ts index bf57bc55b7..fa34464444 100644 --- a/packages/core/src/code_assist/server.ts +++ b/packages/core/src/code_assist/server.ts @@ -374,7 +374,9 @@ export class CodeAssistServer implements ContentGenerator { private getBaseUrl(): string { const endpoint = process.env['CODE_ASSIST_ENDPOINT'] ?? CODE_ASSIST_ENDPOINT; - return `${endpoint}/${CODE_ASSIST_API_VERSION}`; + const version = + process.env['CODE_ASSIST_API_VERSION'] || CODE_ASSIST_API_VERSION; + return `${endpoint}/${version}`; } getMethodUrl(method: string): string { diff --git a/packages/core/src/confirmation-bus/types.ts b/packages/core/src/confirmation-bus/types.ts index 5a27b08d40..fcdd600f3c 100644 --- a/packages/core/src/confirmation-bus/types.ts +++ b/packages/core/src/confirmation-bus/types.ts @@ -65,7 +65,12 @@ export interface ToolConfirmationResponse { * Data-only versions of ToolCallConfirmationDetails for bus transmission. */ export type SerializableConfirmationDetails = - | { type: 'info'; title: string; prompt: string; urls?: string[] } + | { + type: 'info'; + title: string; + prompt: string; + urls?: string[]; + } | { type: 'edit'; title: string; @@ -90,6 +95,11 @@ export type SerializableConfirmationDetails = serverName: string; toolName: string; toolDisplayName: string; + } + | { + type: 'ask_user'; + title: string; + questions: Question[]; }; export interface UpdatePolicy { diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index ba30a63327..5d1edab256 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -890,6 +890,7 @@ ${JSON.stringify( { model: 'default-routed-model' }, initialRequest, expect.any(AbortSignal), + undefined, ); }); @@ -1707,6 +1708,7 @@ ${JSON.stringify( { model: 'routed-model' }, [{ text: 'Hi' }], expect.any(AbortSignal), + undefined, ); }); @@ -1724,6 +1726,7 @@ ${JSON.stringify( { model: 'routed-model' }, [{ text: 'Hi' }], expect.any(AbortSignal), + undefined, ); // Second turn @@ -1741,6 +1744,7 @@ ${JSON.stringify( { model: 'routed-model' }, [{ text: 'Continue' }], expect.any(AbortSignal), + undefined, ); }); @@ -1758,6 +1762,7 @@ ${JSON.stringify( { model: 'routed-model' }, [{ text: 'Hi' }], expect.any(AbortSignal), + undefined, ); // New prompt @@ -1779,6 +1784,7 @@ ${JSON.stringify( { model: 'new-routed-model' }, [{ text: 'A new topic' }], expect.any(AbortSignal), + undefined, ); }); @@ -1806,6 +1812,7 @@ ${JSON.stringify( { model: 'original-model' }, [{ text: 'Hi' }], expect.any(AbortSignal), + undefined, ); mockRouterService.route.mockResolvedValue({ @@ -1828,6 +1835,7 @@ ${JSON.stringify( { model: 'fallback-model' }, [{ text: 'Continue' }], expect.any(AbortSignal), + undefined, ); }); }); @@ -1912,6 +1920,7 @@ ${JSON.stringify( { model: 'default-routed-model' }, initialRequest, expect.any(AbortSignal), + undefined, ); // Second call with "Please continue." @@ -1920,6 +1929,7 @@ ${JSON.stringify( { model: 'default-routed-model' }, [{ text: 'System: Please continue.' }], expect.any(AbortSignal), + undefined, ); }); @@ -2332,6 +2342,7 @@ ${JSON.stringify( expect.objectContaining({ model: 'model-a' }), expect.anything(), expect.anything(), + undefined, ); }); @@ -3183,6 +3194,7 @@ ${JSON.stringify( expect.anything(), [{ text: 'Please explain' }], expect.anything(), + undefined, ); }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index d80b8b4002..d6c3bb8520 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -532,6 +532,7 @@ export class GeminiClient { prompt_id: string, boundedTurns: number, isInvalidStreamRetry: boolean, + displayContent?: PartListUnion, ): AsyncGenerator { // Re-initialize turn (it was empty before if in loop, or new instance) let turn = new Turn(this.getChat(), prompt_id); @@ -647,7 +648,12 @@ export class GeminiClient { yield { type: GeminiEventType.ModelInfo, value: modelToUse }; } this.currentSequenceModel = modelToUse; - const resultStream = turn.run(modelConfigKey, request, linkedSignal); + const resultStream = turn.run( + modelConfigKey, + request, + linkedSignal, + displayContent, + ); let isError = false; let isInvalidStream = false; @@ -708,6 +714,7 @@ export class GeminiClient { prompt_id, boundedTurns - 1, true, + displayContent, ); return turn; } @@ -739,7 +746,8 @@ export class GeminiClient { signal, prompt_id, boundedTurns - 1, - // isInvalidStreamRetry is false + false, // isInvalidStreamRetry is false + displayContent, ); return turn; } @@ -754,6 +762,7 @@ export class GeminiClient { prompt_id: string, turns: number = MAX_TURNS, isInvalidStreamRetry: boolean = false, + displayContent?: PartListUnion, ): AsyncGenerator { if (!isInvalidStreamRetry) { this.config.resetTurn(); @@ -809,6 +818,7 @@ export class GeminiClient { prompt_id, boundedTurns, isInvalidStreamRetry, + displayContent, ); // Fire AfterAgent hook if we have a turn and no pending tools @@ -860,6 +870,8 @@ export class GeminiClient { signal, prompt_id, boundedTurns - 1, + false, + displayContent, ); } } diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 154b975638..0df9fd58eb 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -1847,6 +1847,83 @@ describe('CoreToolScheduler Sequential Execution', () => { modifyWithEditorSpy.mockRestore(); }); + it('should handle inline modify with empty new content', async () => { + // Mock the modifiable check to return true for this test + const isModifiableSpy = vi + .spyOn(modifiableToolModule, 'isModifiableDeclarativeTool') + .mockReturnValue(true); + + const mockTool = new MockModifiableTool(); + const mockToolRegistry = { + getTool: () => mockTool, + getAllToolNames: () => [], + } as unknown as ToolRegistry; + + const mockConfig = createMockConfig({ + getToolRegistry: () => mockToolRegistry, + isInteractive: () => true, + }); + mockConfig.getHookSystem = vi.fn().mockReturnValue(undefined); + + const scheduler = new CoreToolScheduler({ + config: mockConfig, + getPreferredEditor: () => 'vscode', + }); + + // Manually inject a waiting tool call + const callId = 'call-1'; + const toolCall: WaitingToolCall = { + status: 'awaiting_approval', + request: { + callId, + name: 'mockModifiableTool', + args: {}, + isClientInitiated: false, + prompt_id: 'p1', + }, + tool: mockTool, + invocation: {} as unknown as ToolInvocation< + Record, + ToolResult + >, + confirmationDetails: { + type: 'edit', + title: 'Confirm', + fileName: 'test.txt', + filePath: 'test.txt', + fileDiff: 'diff', + originalContent: 'old', + newContent: 'new', + onConfirm: async () => {}, + }, + startTime: Date.now(), + }; + + const schedulerInternals = scheduler as unknown as { + toolCalls: ToolCall[]; + toolModifier: { applyInlineModify: Mock }; + }; + schedulerInternals.toolCalls = [toolCall]; + + const applyInlineModifySpy = vi + .spyOn(schedulerInternals.toolModifier, 'applyInlineModify') + .mockResolvedValue({ + updatedParams: { content: '' }, + updatedDiff: 'diff-empty', + }); + + await scheduler.handleConfirmationResponse( + callId, + async () => {}, + ToolConfirmationOutcome.ProceedOnce, + new AbortController().signal, + { newContent: '' } as ToolConfirmationPayload, + ); + + expect(applyInlineModifySpy).toHaveBeenCalled(); + isModifiableSpy.mockRestore(); + }); + it('should pass serverName to policy engine for DiscoveredMCPTool', async () => { const mockMcpTool = { tool: async () => ({ functionDeclarations: [] }), diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 124cef32b9..30093f289e 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -789,7 +789,7 @@ export class CoreToolScheduler { } else { // If the client provided new content, apply it and wait for // re-confirmation. - if (payload?.newContent && toolCall) { + if (payload && 'newContent' in payload && toolCall) { const result = await this.toolModifier.applyInlineModify( toolCall as WaitingToolCall, payload, diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index bd7182fd03..a9cf192418 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -268,6 +268,7 @@ export class GeminiChat { * @param message - The list of messages to send. * @param prompt_id - The ID of the prompt. * @param signal - An abort signal for this message. + * @param displayContent - An optional user-friendly version of the message to record. * @return The model's response. * * @example @@ -286,6 +287,7 @@ export class GeminiChat { message: PartListUnion, prompt_id: string, signal: AbortSignal, + displayContent?: PartListUnion, ): Promise> { await this.sendPromise; @@ -302,12 +304,25 @@ export class GeminiChat { // Record user input - capture complete message with all parts (text, files, images, etc.) // but skip recording function responses (tool call results) as they should be stored in tool call records if (!isFunctionResponse(userContent)) { - const userMessage = Array.isArray(message) ? message : [message]; - const userMessageContent = partListUnionToString(toParts(userMessage)); + const userMessageParts = userContent.parts || []; + const userMessageContent = partListUnionToString(userMessageParts); + + let finalDisplayContent: Part[] | undefined = undefined; + if (displayContent !== undefined) { + const displayParts = toParts( + Array.isArray(displayContent) ? displayContent : [displayContent], + ); + const displayContentString = partListUnionToString(displayParts); + if (displayContentString !== userMessageContent) { + finalDisplayContent = displayParts; + } + } + this.chatRecordingService.recordMessage({ model, type: 'user', - content: userMessageContent, + content: userMessageParts, + displayContent: finalDisplayContent, }); } diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index 43146e31ec..438ccdb55a 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -102,6 +102,7 @@ describe('Turn', () => { reqParts, 'prompt-id-1', expect.any(AbortSignal), + undefined, ); expect(events).toEqual([ diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 8e6974704d..aa46c5d080 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -248,6 +248,7 @@ export class Turn { modelConfigKey: ModelConfigKey, req: PartListUnion, signal: AbortSignal, + displayContent?: PartListUnion, ): AsyncGenerator { try { // Note: This assumes `sendMessageStream` yields events like @@ -257,6 +258,7 @@ export class Turn { req, this.prompt_id, signal, + displayContent, ); for await (const streamEvent of responseStream) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 85d5004c4c..219e8151ab 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -50,6 +50,7 @@ export * from './code_assist/server.js'; export * from './code_assist/setup.js'; export * from './code_assist/types.js'; export * from './code_assist/telemetry.js'; +export * from './code_assist/admin/admin_controls.js'; export * from './core/apiKeyCredentialStorage.js'; // Export utilities diff --git a/packages/core/src/policy/policies/agent.toml b/packages/core/src/policy/policies/agent.toml deleted file mode 100644 index 6c9711e8c4..0000000000 --- a/packages/core/src/policy/policies/agent.toml +++ /dev/null @@ -1,27 +0,0 @@ -# Priority system for policy rules: -# - Higher priority numbers win over lower priority numbers -# - When multiple rules match, the highest priority rule is applied -# - Rules are evaluated in order of priority (highest first) -# -# Priority bands (tiers): -# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) -# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) -# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) -# -# This ensures Admin > User > Default hierarchy is always preserved, -# while allowing user-specified priorities to work within each tier. -# -# Settings-based and dynamic rules (all in user tier 2.x): -# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI -# 2.9: MCP servers excluded list (security: persistent server blocks) -# 2.4: Command line flag --exclude-tools (explicit temporary blocks) -# 2.3: Command line flag --allowed-tools (explicit temporary allows) -# 2.2: MCP servers with trust=true (persistent trusted servers) -# 2.1: MCP servers allowed list (persistent general server allows) -# -# TOML policy priorities (before transformation): -# 10: Write tools default to ASK_USER (becomes 1.010 in default tier) -# 15: Auto-edit tool override (becomes 1.015 in default tier) -# 50: Read-only tools (becomes 1.050 in default tier) -# 999: YOLO mode allow-all (becomes 1.999 in default tier) - diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index 5e8f51f793..5b8b1d7882 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -66,7 +66,7 @@ modes = ["plan"] [[rule]] toolName = "ask_user" -decision = "allow" +decision = "ask_user" priority = 50 modes = ["plan"] diff --git a/packages/core/src/policy/policies/read-only.toml b/packages/core/src/policy/policies/read-only.toml index 5a669c28c7..5af7c9b1d4 100644 --- a/packages/core/src/policy/policies/read-only.toml +++ b/packages/core/src/policy/policies/read-only.toml @@ -49,8 +49,3 @@ priority = 50 toolName = "google_web_search" decision = "allow" priority = 50 - -[[rule]] -toolName = "SubagentInvocation" -decision = "allow" -priority = 50 diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index be5536a9df..656a17c109 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -439,9 +439,14 @@ export class PolicyEngine { /** * Remove rules for a specific tool. + * If source is provided, only rules matching that source are removed. */ - removeRulesForTool(toolName: string): void { - this.rules = this.rules.filter((rule) => rule.toolName !== toolName); + removeRulesForTool(toolName: string, source?: string): void { + this.rules = this.rules.filter( + (rule) => + rule.toolName !== toolName || + (source !== undefined && rule.source !== source), + ); } /** @@ -451,6 +456,18 @@ export class PolicyEngine { return this.rules; } + /** + * Check if a rule for a specific tool already exists. + * If ignoreDynamic is true, it only returns true if a rule exists that was NOT added by AgentRegistry. + */ + hasRuleForTool(toolName: string, ignoreDynamic = false): boolean { + return this.rules.some( + (rule) => + rule.toolName === toolName && + (!ignoreDynamic || rule.source !== 'AgentRegistry (Dynamic)'), + ); + } + getCheckers(): readonly SafetyCheckerRule[] { return this.checkers; } diff --git a/packages/core/src/scheduler/confirmation.ts b/packages/core/src/scheduler/confirmation.ts index 73958815d0..e5e94d5501 100644 --- a/packages/core/src/scheduler/confirmation.ts +++ b/packages/core/src/scheduler/confirmation.ts @@ -150,9 +150,13 @@ export async function resolveConfirmation( ); outcome = response.outcome; + if ('onConfirm' in details && typeof details.onConfirm === 'function') { + await details.onConfirm(outcome, response.payload); + } + if (outcome === ToolConfirmationOutcome.ModifyWithEditor) { await handleExternalModification(deps, toolCall, signal); - } else if (response.payload?.newContent) { + } else if (response.payload && 'newContent' in response.payload) { await handleInlineModification(deps, toolCall, response.payload, signal); outcome = ToolConfirmationOutcome.ProceedOnce; } diff --git a/packages/core/src/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts index 0c37ae2870..8b31c8166f 100644 --- a/packages/core/src/scheduler/tool-executor.ts +++ b/packages/core/src/scheduler/tool-executor.ts @@ -253,6 +253,7 @@ export class ToolExecutor { errorType: undefined, outputFile, contentLength: typeof content === 'string' ? content.length : undefined, + data: toolResult.data, }; const startTime = 'startTime' in call ? call.startTime : undefined; diff --git a/packages/core/src/scheduler/tool-modifier.test.ts b/packages/core/src/scheduler/tool-modifier.test.ts index 8107e4c901..0dc1a55a49 100644 --- a/packages/core/src/scheduler/tool-modifier.test.ts +++ b/packages/core/src/scheduler/tool-modifier.test.ts @@ -193,13 +193,46 @@ describe('ToolModificationHandler', () => { const result = await handler.applyInlineModify( mockWaitingToolCall, - { newContent: undefined } as unknown as ToolConfirmationPayload, + {} as ToolConfirmationPayload, // no newContent property new AbortController().signal, ); expect(result).toBeUndefined(); }); + it('should process empty string as valid new content', async () => { + vi.mocked( + modifiableToolModule.isModifiableDeclarativeTool, + ).mockReturnValue(true); + (Diff.createPatch as unknown as Mock).mockReturnValue('mock-diff-empty'); + + mockModifyContext.getCurrentContent.mockResolvedValue('old content'); + mockModifyContext.getFilePath.mockReturnValue('test.txt'); + mockModifyContext.createUpdatedParams.mockReturnValue({ + content: '', + }); + + const mockWaitingToolCall = createMockWaitingToolCall({ + tool: mockModifiableTool, + }); + + const result = await handler.applyInlineModify( + mockWaitingToolCall, + { newContent: '' }, + new AbortController().signal, + ); + + expect(mockModifyContext.createUpdatedParams).toHaveBeenCalledWith( + expect.any(String), + '', + expect.any(Object), + ); + expect(result).toEqual({ + updatedParams: { content: '' }, + updatedDiff: 'mock-diff-empty', + }); + }); + it('should calculate diff and return updated params', async () => { vi.mocked( modifiableToolModule.isModifiableDeclarativeTool, diff --git a/packages/core/src/scheduler/tool-modifier.ts b/packages/core/src/scheduler/tool-modifier.ts index c7d9c93c67..d964372bde 100644 --- a/packages/core/src/scheduler/tool-modifier.ts +++ b/packages/core/src/scheduler/tool-modifier.ts @@ -70,7 +70,7 @@ export class ToolModificationHandler { ): Promise { if ( toolCall.confirmationDetails.type !== 'edit' || - !payload.newContent || + !('newContent' in payload) || !isModifiableDeclarativeTool(toolCall.tool) ) { return undefined; diff --git a/packages/core/src/scheduler/types.ts b/packages/core/src/scheduler/types.ts index 7c0bbe07bd..c0b6cae3d7 100644 --- a/packages/core/src/scheduler/types.ts +++ b/packages/core/src/scheduler/types.ts @@ -38,6 +38,10 @@ export interface ToolCallResponseInfo { errorType: ToolErrorType | undefined; outputFile?: string | undefined; contentLength?: number; + /** + * Optional data payload for passing structured information back to the caller. + */ + data?: Record; } export type ValidatingToolCall = { diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index cba0a2e977..6dcfa79a77 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -130,6 +130,7 @@ describe('ChatRecordingService', () => { chatRecordingService.recordMessage({ type: 'user', content: 'Hello', + displayContent: 'User Hello', model: 'gemini-pro', }); expect(mkdirSyncSpy).toHaveBeenCalled(); @@ -139,6 +140,7 @@ describe('ChatRecordingService', () => { ) as ConversationRecord; expect(conversation.messages).toHaveLength(1); expect(conversation.messages[0].content).toBe('Hello'); + expect(conversation.messages[0].displayContent).toBe('User Hello'); expect(conversation.messages[0].type).toBe('user'); }); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index a0a8034ce8..e570923d54 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -47,6 +47,7 @@ export interface BaseMessageRecord { id: string; timestamp: string; content: PartListUnion; + displayContent?: PartListUnion; } /** @@ -207,12 +208,14 @@ export class ChatRecordingService { private newMessage( type: ConversationRecordExtra['type'], content: PartListUnion, + displayContent?: PartListUnion, ): MessageRecord { return { id: randomUUID(), timestamp: new Date().toISOString(), type, content, + displayContent, }; } @@ -223,12 +226,17 @@ export class ChatRecordingService { model: string | undefined; type: ConversationRecordExtra['type']; content: PartListUnion; + displayContent?: PartListUnion; }): void { if (!this.conversationFile) return; try { this.updateConversation((conversation) => { - const msg = this.newMessage(message.type, message.content); + const msg = this.newMessage( + message.type, + message.content, + message.displayContent, + ); if (msg.type === 'gemini') { // If it's a new Gemini message then incorporate any queued thoughts. conversation.messages.push({ diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index e5c977f103..61186c9eb2 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -76,7 +76,13 @@ vi.mock('../utils/getPty.js', () => ({ getPty: mockGetPty, })); vi.mock('../utils/terminalSerializer.js', () => ({ - serializeTerminalToObject: mockSerializeTerminalToObject, + // Avoid passing the heavy Terminal object to the spy to prevent OOM + serializeTerminalToObject: ( + _terminal: unknown, + ...args: [number | undefined, number | undefined] + ) => mockSerializeTerminalToObject(...args), + convertColorToHex: () => '#000000', + ColorMode: { DEFAULT: 0, PALETTE: 1, RGB: 2 }, })); vi.mock('../utils/systemEncoding.js', () => ({ getCachedEncodingForBuffer: vi.fn().mockReturnValue('utf-8'), @@ -318,6 +324,7 @@ describe('ShellExecutionService', () => { } pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); }, + { ...shellExecutionConfig, maxSerializedLines: 100 }, ); expect(result.exitCode).toBe(0); @@ -675,7 +682,7 @@ describe('ShellExecutionService', () => { expect(result.rawOutput).toEqual( Buffer.concat([binaryChunk1, binaryChunk2]), ); - expect(onOutputEventMock).toHaveBeenCalledTimes(3); + expect(onOutputEventMock).toHaveBeenCalledTimes(4); expect(onOutputEventMock.mock.calls[0][0]).toEqual({ type: 'binary_detected', }); @@ -687,6 +694,11 @@ describe('ShellExecutionService', () => { type: 'binary_progress', bytesReceived: 8, }); + expect(onOutputEventMock.mock.calls[3][0]).toEqual({ + type: 'exit', + exitCode: 0, + signal: null, + }); }); it('should not emit data events after binary is detected', async () => { @@ -705,6 +717,7 @@ describe('ShellExecutionService', () => { 'binary_detected', 'binary_progress', 'binary_progress', + 'exit', ]); }); }); @@ -763,9 +776,7 @@ describe('ShellExecutionService', () => { coloredShellExecutionConfig, ); - expect(mockSerializeTerminalToObject).toHaveBeenCalledWith( - expect.anything(), // The terminal object - ); + expect(mockSerializeTerminalToObject).toHaveBeenCalled(); expect(onOutputEventMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -932,11 +943,20 @@ describe('ShellExecutionService child_process fallback', () => { expect(result.error).toBeNull(); expect(result.aborted).toBe(false); expect(result.output).toBe('file1.txt\na warning'); - expect(handle.pid).toBe(undefined); + expect(handle.pid).toBe(12345); expect(onOutputEventMock).toHaveBeenCalledWith({ type: 'data', - chunk: 'file1.txt\na warning', + chunk: 'file1.txt\n', + }); + expect(onOutputEventMock).toHaveBeenCalledWith({ + type: 'data', + chunk: 'a warning', + }); + expect(onOutputEventMock).toHaveBeenCalledWith({ + type: 'exit', + exitCode: 0, + signal: null, }); }); @@ -948,12 +968,15 @@ describe('ShellExecutionService child_process fallback', () => { }); expect(result.output.trim()).toBe('aredword'); - expect(onOutputEventMock).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'data', - chunk: 'aredword', - }), - ); + expect(onOutputEventMock).toHaveBeenCalledWith({ + type: 'data', + chunk: 'a\u001b[31mred\u001b[0mword', + }); + expect(onOutputEventMock).toHaveBeenCalledWith({ + type: 'exit', + exitCode: 0, + signal: null, + }); }); it('should correctly decode multi-byte characters split across chunks', async () => { @@ -974,10 +997,14 @@ describe('ShellExecutionService child_process fallback', () => { }); expect(result.output.trim()).toBe(''); - expect(onOutputEventMock).not.toHaveBeenCalled(); + expect(onOutputEventMock).toHaveBeenCalledWith({ + type: 'exit', + exitCode: 0, + signal: null, + }); }); - it.skip('should truncate stdout using a sliding window and show a warning', async () => { + it('should truncate stdout using a sliding window and show a warning', async () => { const MAX_SIZE = 16 * 1024 * 1024; const chunk1 = 'a'.repeat(MAX_SIZE / 2 - 5); const chunk2 = 'b'.repeat(MAX_SIZE / 2 - 5); @@ -1173,26 +1200,44 @@ describe('ShellExecutionService child_process fallback', () => { expect(result.rawOutput).toEqual( Buffer.concat([binaryChunk1, binaryChunk2]), ); - expect(onOutputEventMock).toHaveBeenCalledTimes(1); + expect(onOutputEventMock).toHaveBeenCalledTimes(4); expect(onOutputEventMock.mock.calls[0][0]).toEqual({ type: 'binary_detected', }); + expect(onOutputEventMock.mock.calls[1][0]).toEqual({ + type: 'binary_progress', + bytesReceived: 4, + }); + expect(onOutputEventMock.mock.calls[2][0]).toEqual({ + type: 'binary_progress', + bytesReceived: 8, + }); + expect(onOutputEventMock.mock.calls[3][0]).toEqual({ + type: 'exit', + exitCode: 0, + signal: null, + }); }); it('should not emit data events after binary is detected', async () => { mockIsBinary.mockImplementation((buffer) => buffer.includes(0x00)); await simulateExecution('cat mixed_file', (cp) => { - cp.stdout?.emit('data', Buffer.from('some text')); cp.stdout?.emit('data', Buffer.from([0x00, 0x01, 0x02])); cp.stdout?.emit('data', Buffer.from('more text')); cp.emit('exit', 0, null); + cp.emit('close', 0, null); }); const eventTypes = onOutputEventMock.mock.calls.map( (call: [ShellOutputEvent]) => call[0].type, ); - expect(eventTypes).toEqual(['binary_detected']); + expect(eventTypes).toEqual([ + 'binary_detected', + 'binary_progress', + 'binary_progress', + 'exit', + ]); }); }); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 91c1df4853..2e94bb1858 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -7,7 +7,7 @@ import stripAnsi from 'strip-ansi'; import type { PtyImplementation } from '../utils/getPty.js'; import { getPty } from '../utils/getPty.js'; -import { spawn as cpSpawn } from 'node:child_process'; +import { spawn as cpSpawn, type ChildProcess } from 'node:child_process'; import { TextDecoder } from 'node:util'; import os from 'node:os'; import type { IPty } from '@lydell/node-pty'; @@ -27,9 +27,9 @@ import { sanitizeEnvironment, type EnvironmentSanitizationConfig, } from './environmentSanitization.js'; +import { killProcessGroup } from '../utils/process-utils.js'; const { Terminal } = pkg; -const SIGKILL_TIMEOUT_MS = 200; const MAX_CHILD_PROCESS_BUFFER_SIZE = 16 * 1024 * 1024; // 16MB // We want to allow shell outputs that are close to the context window in size. @@ -71,6 +71,8 @@ export interface ShellExecutionResult { pid: number | undefined; /** The method used to execute the shell command. */ executionMethod: 'lydell-node-pty' | 'node-pty' | 'child_process' | 'none'; + /** Whether the command was moved to the background. */ + backgrounded?: boolean; } /** A handle for an ongoing shell execution. */ @@ -92,6 +94,7 @@ export interface ShellExecutionConfig { // Used for testing disableDynamicLineTrimming?: boolean; scrollback?: number; + maxSerializedLines?: number; } /** @@ -113,11 +116,29 @@ export type ShellOutputEvent = type: 'binary_progress'; /** The total number of bytes received so far. */ bytesReceived: number; + } + | { + /** Signals that the process has exited. */ + type: 'exit'; + /** The exit code of the process, if any. */ + exitCode: number | null; + /** The signal that terminated the process, if any. */ + signal: number | null; }; interface ActivePty { ptyProcess: IPty; headlessTerminal: pkg.Terminal; + maxSerializedLines?: number; +} + +interface ActiveChildProcess { + process: ChildProcess; + state: { + output: string; + truncated: boolean; + outputChunks: Buffer[]; + }; } const getFullBufferText = (terminal: pkg.Terminal): string => { @@ -165,6 +186,19 @@ const getFullBufferText = (terminal: pkg.Terminal): string => { export class ShellExecutionService { private static activePtys = new Map(); + private static activeChildProcesses = new Map(); + private static exitedPtyInfo = new Map< + number, + { exitCode: number; signal?: number } + >(); + private static activeResolvers = new Map< + number, + (res: ShellExecutionResult) => void + >(); + private static activeListeners = new Map< + number, + Set<(event: ShellOutputEvent) => void> + >(); /** * Executes a shell command using `node-pty`, capturing all output and lifecycle events. * @@ -240,6 +274,13 @@ export class ShellExecutionService { return { newBuffer: truncatedBuffer + chunk, truncated: true }; } + private static emitEvent(pid: number, event: ShellOutputEvent): void { + const listeners = this.activeListeners.get(pid); + if (listeners) { + listeners.forEach((listener) => listener(event)); + } + } + private static childProcessFallback( commandToExecute: string, cwd: string, @@ -268,15 +309,26 @@ export class ShellExecutionService { }, }); + const state = { + output: '', + truncated: false, + outputChunks: [] as Buffer[], + }; + + if (child.pid) { + this.activeChildProcesses.set(child.pid, { + process: child, + state, + }); + } + const result = new Promise((resolve) => { + if (child.pid) { + this.activeResolvers.set(child.pid, resolve); + } + let stdoutDecoder: TextDecoder | null = null; let stderrDecoder: TextDecoder | null = null; - - let stdout = ''; - let stderr = ''; - let stdoutTruncated = false; - let stderrTruncated = false; - const outputChunks: Buffer[] = []; let error: Error | null = null; let exited = false; @@ -296,14 +348,17 @@ export class ShellExecutionService { } } - outputChunks.push(data); + state.outputChunks.push(data); if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) { - const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20)); + const sniffBuffer = Buffer.concat(state.outputChunks.slice(0, 20)); sniffedBytes = sniffBuffer.length; if (isBinary(sniffBuffer)) { isStreamingRawContent = false; + const event: ShellOutputEvent = { type: 'binary_detected' }; + onOutputEvent(event); + if (child.pid) ShellExecutionService.emitEvent(child.pid, event); } } @@ -311,27 +366,35 @@ export class ShellExecutionService { const decoder = stream === 'stdout' ? stdoutDecoder : stderrDecoder; const decodedChunk = decoder.decode(data, { stream: true }); - if (stream === 'stdout') { - const { newBuffer, truncated } = this.appendAndTruncate( - stdout, - decodedChunk, - MAX_CHILD_PROCESS_BUFFER_SIZE, - ); - stdout = newBuffer; - if (truncated) { - stdoutTruncated = true; - } - } else { - const { newBuffer, truncated } = this.appendAndTruncate( - stderr, - decodedChunk, - MAX_CHILD_PROCESS_BUFFER_SIZE, - ); - stderr = newBuffer; - if (truncated) { - stderrTruncated = true; - } + const { newBuffer, truncated } = this.appendAndTruncate( + state.output, + decodedChunk, + MAX_CHILD_PROCESS_BUFFER_SIZE, + ); + state.output = newBuffer; + if (truncated) { + state.truncated = true; } + + if (decodedChunk) { + const event: ShellOutputEvent = { + type: 'data', + chunk: decodedChunk, + }; + onOutputEvent(event); + if (child.pid) ShellExecutionService.emitEvent(child.pid, event); + } + } else { + const totalBytes = state.outputChunks.reduce( + (sum, chunk) => sum + chunk.length, + 0, + ); + const event: ShellOutputEvent = { + type: 'binary_progress', + bytesReceived: totalBytes, + }; + onOutputEvent(event); + if (child.pid) ShellExecutionService.emitEvent(child.pid, event); } }; @@ -340,12 +403,10 @@ export class ShellExecutionService { signal: NodeJS.Signals | null, ) => { const { finalBuffer } = cleanup(); - // Ensure we don't add an extra newline if stdout already ends with one. - const separator = stdout.endsWith('\n') ? '' : '\n'; - let combinedOutput = - stdout + (stderr ? (stdout ? separator : '') + stderr : ''); - if (stdoutTruncated || stderrTruncated) { + let combinedOutput = state.output; + + if (state.truncated) { const truncationMessage = `\n[GEMINI_CLI_WARNING: Output truncated. The buffer is limited to ${ MAX_CHILD_PROCESS_BUFFER_SIZE / (1024 * 1024) }MB.]`; @@ -353,23 +414,31 @@ export class ShellExecutionService { } const finalStrippedOutput = stripAnsi(combinedOutput).trim(); + const exitCode = code; + const exitSignal = signal ? os.constants.signals[signal] : null; - if (isStreamingRawContent) { - if (finalStrippedOutput) { - onOutputEvent({ type: 'data', chunk: finalStrippedOutput }); - } - } else { - onOutputEvent({ type: 'binary_detected' }); + if (child.pid) { + const event: ShellOutputEvent = { + type: 'exit', + exitCode, + signal: exitSignal, + }; + onOutputEvent(event); + ShellExecutionService.emitEvent(child.pid, event); + + this.activeChildProcesses.delete(child.pid); + this.activeResolvers.delete(child.pid); + this.activeListeners.delete(child.pid); } resolve({ rawOutput: finalBuffer, output: finalStrippedOutput, - exitCode: code, - signal: signal ? os.constants.signals[signal] : null, + exitCode, + signal: exitSignal, error, aborted: abortSignal.aborted, - pid: undefined, + pid: child.pid, executionMethod: 'child_process', }); }; @@ -383,28 +452,17 @@ export class ShellExecutionService { const abortHandler = async () => { if (child.pid && !exited) { - if (isWindows) { - cpSpawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t']); - } else { - try { - process.kill(-child.pid, 'SIGTERM'); - await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS)); - if (!exited) { - process.kill(-child.pid, 'SIGKILL'); - } - } catch (_e) { - if (!exited) child.kill('SIGKILL'); - } - } + await killProcessGroup({ + pid: child.pid, + escalate: true, + isExited: () => exited, + }); } }; abortSignal.addEventListener('abort', abortHandler, { once: true }); child.on('exit', (code, signal) => { - if (child.pid) { - this.activePtys.delete(child.pid); - } handleExit(code, signal); }); @@ -414,23 +472,43 @@ export class ShellExecutionService { if (stdoutDecoder) { const remaining = stdoutDecoder.decode(); if (remaining) { - stdout += remaining; + state.output += remaining; + // If there's remaining output, we should technically emit it too, + // but it's rare to have partial utf8 chars at the very end of stream. + if (isStreamingRawContent && remaining) { + const event: ShellOutputEvent = { + type: 'data', + chunk: remaining, + }; + onOutputEvent(event); + if (child.pid) + ShellExecutionService.emitEvent(child.pid, event); + } } } if (stderrDecoder) { const remaining = stderrDecoder.decode(); if (remaining) { - stderr += remaining; + state.output += remaining; + if (isStreamingRawContent && remaining) { + const event: ShellOutputEvent = { + type: 'data', + chunk: remaining, + }; + onOutputEvent(event); + if (child.pid) + ShellExecutionService.emitEvent(child.pid, event); + } } } - const finalBuffer = Buffer.concat(outputChunks); + const finalBuffer = Buffer.concat(state.outputChunks); - return { stdout, stderr, finalBuffer }; + return { finalBuffer }; } }); - return { pid: undefined, result }; + return { pid: child.pid, result }; } catch (e) { const error = e as Error; return { @@ -495,6 +573,8 @@ export class ShellExecutionService { }); const result = new Promise((resolve) => { + this.activeResolvers.set(ptyProcess.pid, resolve); + const headlessTerminal = new Terminal({ allowProposedApi: true, cols, @@ -503,7 +583,11 @@ export class ShellExecutionService { }); headlessTerminal.scrollToTop(); - this.activePtys.set(ptyProcess.pid, { ptyProcess, headlessTerminal }); + this.activePtys.set(ptyProcess.pid, { + ptyProcess, + headlessTerminal, + maxSerializedLines: shellExecutionConfig.maxSerializedLines, + }); let processingChain = Promise.resolve(); let decoder: TextDecoder | null = null; @@ -537,17 +621,29 @@ export class ShellExecutionService { } const buffer = headlessTerminal.buffer.active; + const endLine = buffer.length; + const startLine = Math.max( + 0, + endLine - (shellExecutionConfig.maxSerializedLines ?? 2000), + ); + let newOutput: AnsiOutput; if (shellExecutionConfig.showColor) { - newOutput = serializeTerminalToObject(headlessTerminal); + newOutput = serializeTerminalToObject( + headlessTerminal, + startLine, + endLine, + ); } else { - newOutput = (serializeTerminalToObject(headlessTerminal) || []).map( - (line) => - line.map((token) => { - token.fg = ''; - token.bg = ''; - return token; - }), + newOutput = ( + serializeTerminalToObject(headlessTerminal, startLine, endLine) || + [] + ).map((line) => + line.map((token) => { + token.fg = ''; + token.bg = ''; + return token; + }), ); } @@ -565,8 +661,11 @@ export class ShellExecutionService { } } - if (buffer.cursorY > lastNonEmptyLine) { - lastNonEmptyLine = buffer.cursorY; + const absoluteCursorY = buffer.baseY + buffer.cursorY; + const cursorRelativeIndex = absoluteCursorY - startLine; + + if (cursorRelativeIndex > lastNonEmptyLine) { + lastNonEmptyLine = cursorRelativeIndex; } const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1); @@ -575,13 +674,14 @@ export class ShellExecutionService { ? newOutput : trimmedOutput; - // Using stringify for a quick deep comparison. - if (JSON.stringify(output) !== JSON.stringify(finalOutput)) { + if (output !== finalOutput) { output = finalOutput; - onOutputEvent({ + const event: ShellOutputEvent = { type: 'data', chunk: finalOutput, - }); + }; + onOutputEvent(event); + ShellExecutionService.emitEvent(ptyProcess.pid, event); } }; @@ -631,7 +731,9 @@ export class ShellExecutionService { if (isBinary(sniffBuffer)) { isStreamingRawContent = false; - onOutputEvent({ type: 'binary_detected' }); + const event: ShellOutputEvent = { type: 'binary_detected' }; + onOutputEvent(event); + ShellExecutionService.emitEvent(ptyProcess.pid, event); } } @@ -652,10 +754,12 @@ export class ShellExecutionService { (sum, chunk) => sum + chunk.length, 0, ); - onOutputEvent({ + const event: ShellOutputEvent = { type: 'binary_progress', bytesReceived: totalBytes, - }); + }; + onOutputEvent(event); + ShellExecutionService.emitEvent(ptyProcess.pid, event); resolve(); } }), @@ -681,6 +785,28 @@ export class ShellExecutionService { const finalize = () => { render(true); + + // Store exit info for late subscribers (e.g. backgrounding race condition) + this.exitedPtyInfo.set(ptyProcess.pid, { exitCode, signal }); + setTimeout( + () => { + this.exitedPtyInfo.delete(ptyProcess.pid); + }, + 5 * 60 * 1000, + ).unref(); + + this.activePtys.delete(ptyProcess.pid); + this.activeResolvers.delete(ptyProcess.pid); + + const event: ShellOutputEvent = { + type: 'exit', + exitCode, + signal: signal ?? null, + }; + onOutputEvent(event); + ShellExecutionService.emitEvent(ptyProcess.pid, event); + this.activeListeners.delete(ptyProcess.pid); + const finalBuffer = Buffer.concat(outputChunks); resolve({ @@ -720,25 +846,12 @@ export class ShellExecutionService { const abortHandler = async () => { if (ptyProcess.pid && !exited) { - if (os.platform() === 'win32') { - ptyProcess.kill(); - } else { - try { - // Kill the entire process group - process.kill(-ptyProcess.pid, 'SIGTERM'); - await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS)); - if (!exited) { - process.kill(-ptyProcess.pid, 'SIGKILL'); - } - } catch (_e) { - // Fallback to killing just the process if the group kill fails - ptyProcess.kill('SIGTERM'); - await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS)); - if (!exited) { - ptyProcess.kill('SIGKILL'); - } - } - } + await killProcessGroup({ + pid: ptyProcess.pid, + escalate: true, + isExited: () => exited, + pty: ptyProcess, + }); } }; @@ -780,6 +893,14 @@ export class ShellExecutionService { * @param input The string to write to the terminal. */ static writeToPty(pid: number, input: string): void { + if (this.activeChildProcesses.has(pid)) { + const activeChild = this.activeChildProcesses.get(pid); + if (activeChild) { + activeChild.process.stdin?.write(input); + } + return; + } + if (!this.isPtyActive(pid)) { return; } @@ -791,6 +912,14 @@ export class ShellExecutionService { } static isPtyActive(pid: number): boolean { + if (this.activeChildProcesses.has(pid)) { + try { + return process.kill(pid, 0); + } catch { + return false; + } + } + try { // process.kill with signal 0 is a way to check for the existence of a process. // It doesn't actually send a signal. @@ -800,6 +929,162 @@ export class ShellExecutionService { } } + /** + * Registers a callback to be invoked when the process with the given PID exits. + * This attaches directly to the PTY's exit event. + * + * @param pid The process ID to watch. + * @param callback The function to call on exit. + * @returns An unsubscribe function. + */ + static onExit( + pid: number, + callback: (exitCode: number, signal?: number) => void, + ): () => void { + const activePty = this.activePtys.get(pid); + if (activePty) { + const disposable = activePty.ptyProcess.onExit( + ({ exitCode, signal }: { exitCode: number; signal?: number }) => { + callback(exitCode, signal); + disposable.dispose(); + }, + ); + return () => disposable.dispose(); + } else if (this.activeChildProcesses.has(pid)) { + const activeChild = this.activeChildProcesses.get(pid); + const listener = (code: number | null, signal: NodeJS.Signals | null) => { + let signalNumber: number | undefined; + if (signal) { + signalNumber = os.constants.signals[signal]; + } + callback(code ?? 0, signalNumber); + }; + activeChild?.process.on('exit', listener); + return () => { + activeChild?.process.removeListener('exit', listener); + }; + } else { + // Check if it already exited recently + const exitedInfo = this.exitedPtyInfo.get(pid); + if (exitedInfo) { + callback(exitedInfo.exitCode, exitedInfo.signal); + } + return () => {}; + } + } + + /** + * Kills a process by its PID. + * + * @param pid The process ID to kill. + */ + static kill(pid: number): void { + const activePty = this.activePtys.get(pid); + const activeChild = this.activeChildProcesses.get(pid); + + if (activeChild) { + killProcessGroup({ pid }).catch(() => {}); + this.activeChildProcesses.delete(pid); + } else if (activePty) { + killProcessGroup({ pid, pty: activePty.ptyProcess }).catch(() => {}); + this.activePtys.delete(pid); + } + + this.activeResolvers.delete(pid); + this.activeListeners.delete(pid); + } + + /** + * Moves a running shell command to the background. + * This resolves the execution promise but keeps the PTY active. + * + * @param pid The process ID of the target PTY. + */ + static background(pid: number): void { + const resolve = this.activeResolvers.get(pid); + if (resolve) { + let output = ''; + const rawOutput = Buffer.from(''); + + const activePty = this.activePtys.get(pid); + const activeChild = this.activeChildProcesses.get(pid); + + if (activePty) { + output = getFullBufferText(activePty.headlessTerminal); + resolve({ + rawOutput, + output, + exitCode: null, + signal: null, + error: null, + aborted: false, + pid, + executionMethod: 'node-pty', + backgrounded: true, + }); + } else if (activeChild) { + output = activeChild.state.output; + + resolve({ + rawOutput, + output, + exitCode: null, + signal: null, + error: null, + aborted: false, + pid, + executionMethod: 'child_process', + backgrounded: true, + }); + } + + this.activeResolvers.delete(pid); + } + } + + static subscribe( + pid: number, + listener: (event: ShellOutputEvent) => void, + ): () => void { + if (!this.activeListeners.has(pid)) { + this.activeListeners.set(pid, new Set()); + } + this.activeListeners.get(pid)?.add(listener); + + // Send current buffer content immediately + const activePty = this.activePtys.get(pid); + const activeChild = this.activeChildProcesses.get(pid); + + if (activePty) { + // Use serializeTerminalToObject to preserve colors and structure + const endLine = activePty.headlessTerminal.buffer.active.length; + const startLine = Math.max( + 0, + endLine - (activePty.maxSerializedLines ?? 2000), + ); + const bufferData = serializeTerminalToObject( + activePty.headlessTerminal, + startLine, + endLine, + ); + if (bufferData && bufferData.length > 0) { + listener({ type: 'data', chunk: bufferData }); + } + } else if (activeChild) { + const output = activeChild.state.output; + if (output) { + listener({ type: 'data', chunk: output }); + } + } + + return () => { + this.activeListeners.get(pid)?.delete(listener); + if (this.activeListeners.get(pid)?.size === 0) { + this.activeListeners.delete(pid); + } + }; + } + /** * Resizes the pseudo-terminal (PTY) of a running process. * @@ -835,6 +1120,25 @@ export class ShellExecutionService { } } } + + // Force emit the new state after resize + if (activePty) { + const endLine = activePty.headlessTerminal.buffer.active.length; + const startLine = Math.max( + 0, + endLine - (activePty.maxSerializedLines ?? 2000), + ); + const bufferData = serializeTerminalToObject( + activePty.headlessTerminal, + startLine, + endLine, + ); + const event: ShellOutputEvent = { type: 'data', chunk: bufferData }; + const listeners = ShellExecutionService.activeListeners.get(pid); + if (listeners) { + listeners.forEach((listener) => listener(event)); + } + } } /** diff --git a/packages/core/src/tools/ask-user.test.ts b/packages/core/src/tools/ask-user.test.ts index 01dfefb2ee..da41ff45f2 100644 --- a/packages/core/src/tools/ask-user.test.ts +++ b/packages/core/src/tools/ask-user.test.ts @@ -6,12 +6,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { AskUserTool } from './ask-user.js'; -import { - MessageBusType, - QuestionType, - type Question, -} from '../confirmation-bus/types.js'; +import { QuestionType, type Question } from '../confirmation-bus/types.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { ToolConfirmationOutcome } from './tools.js'; describe('AskUserTool', () => { let mockMessageBus: MessageBus; @@ -213,142 +210,183 @@ describe('AskUserTool', () => { }); }); - it('should publish ASK_USER_REQUEST and wait for response', async () => { - const questions = [ - { - question: 'How should we proceed with this task?', - header: 'Approach', - options: [ - { - label: 'Quick fix (Recommended)', - description: - 'Apply the most direct solution to resolve the immediate issue.', - }, - { - label: 'Comprehensive refactor', - description: - 'Restructure the affected code for better long-term maintainability.', - }, - ], - multiSelect: false, - }, - ]; - - const invocation = tool.build({ questions }); - const executePromise = invocation.execute(new AbortController().signal); - - // Verify publish called with normalized questions (type defaults to CHOICE) - expect(mockMessageBus.publish).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageBusType.ASK_USER_REQUEST, - questions: questions.map((q) => ({ - ...q, - type: QuestionType.CHOICE, - })), - }), - ); - - // Get the correlation ID from the published message - const publishCall = vi.mocked(mockMessageBus.publish).mock.calls[0][0] as { - correlationId: string; - }; - const correlationId = publishCall.correlationId; - expect(correlationId).toBeDefined(); - - // Verify subscribe called - expect(mockMessageBus.subscribe).toHaveBeenCalledWith( - MessageBusType.ASK_USER_RESPONSE, - expect.any(Function), - ); - - // Simulate response - const subscribeCall = vi - .mocked(mockMessageBus.subscribe) - .mock.calls.find((call) => call[0] === MessageBusType.ASK_USER_RESPONSE); - const handler = subscribeCall![1]; - - const answers = { '0': 'Quick fix (Recommended)' }; - handler({ - type: MessageBusType.ASK_USER_RESPONSE, - correlationId, - answers, - }); - - const result = await executePromise; - expect(result.returnDisplay).toContain('User answered:'); - expect(result.returnDisplay).toContain( - ' Approach → Quick fix (Recommended)', - ); - expect(JSON.parse(result.llmContent as string)).toEqual({ answers }); - }); - - it('should display message when user submits without answering', async () => { - const questions = [ - { - question: 'Which approach?', - header: 'Approach', - options: [ - { label: 'Option A', description: 'First option' }, - { label: 'Option B', description: 'Second option' }, - ], - }, - ]; - - const invocation = tool.build({ questions }); - const executePromise = invocation.execute(new AbortController().signal); - - // Get the correlation ID from the published message - const publishCall = vi.mocked(mockMessageBus.publish).mock.calls[0][0] as { - correlationId: string; - }; - const correlationId = publishCall.correlationId; - - // Simulate response with empty answers - const subscribeCall = vi - .mocked(mockMessageBus.subscribe) - .mock.calls.find((call) => call[0] === MessageBusType.ASK_USER_RESPONSE); - const handler = subscribeCall![1]; - - handler({ - type: MessageBusType.ASK_USER_RESPONSE, - correlationId, - answers: {}, - }); - - const result = await executePromise; - expect(result.returnDisplay).toBe( - 'User submitted without answering questions.', - ); - expect(JSON.parse(result.llmContent as string)).toEqual({ answers: {} }); - }); - - it('should handle cancellation', async () => { - const invocation = tool.build({ - questions: [ + describe('shouldConfirmExecute', () => { + it('should return confirmation details with normalized questions', async () => { + const questions = [ { - question: 'Which sections of the documentation should be updated?', - header: 'Docs', + question: 'How should we proceed with this task?', + header: 'Approach', options: [ { - label: 'User Guide', - description: 'Update the main user-facing documentation.', + label: 'Quick fix (Recommended)', + description: + 'Apply the most direct solution to resolve the immediate issue.', }, { - label: 'API Reference', - description: 'Update the detailed API documentation.', + label: 'Comprehensive refactor', + description: + 'Restructure the affected code for better long-term maintainability.', }, ], - multiSelect: true, + multiSelect: false, }, - ], + ]; + + const invocation = tool.build({ questions }); + const details = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + expect(details).not.toBe(false); + if (details && details.type === 'ask_user') { + expect(details.title).toBe('Ask User'); + expect(details.questions).toEqual( + questions.map((q) => ({ + ...q, + type: QuestionType.CHOICE, + })), + ); + expect(typeof details.onConfirm).toBe('function'); + } else { + // Type guard for TypeScript + expect(details).toBeTruthy(); + } }); - const controller = new AbortController(); - const executePromise = invocation.execute(controller.signal); + it('should normalize question type to CHOICE when omitted', async () => { + const questions = [ + { + question: 'Which approach?', + header: 'Approach', + options: [ + { label: 'Option A', description: 'First option' }, + { label: 'Option B', description: 'Second option' }, + ], + }, + ]; - controller.abort(); + const invocation = tool.build({ questions }); + const details = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); - const result = await executePromise; - expect(result.error?.message).toBe('Cancelled'); + if (details && details.type === 'ask_user') { + expect(details.questions[0].type).toBe(QuestionType.CHOICE); + } + }); + }); + + describe('execute', () => { + it('should return user answers after confirmation', async () => { + const questions = [ + { + question: 'How should we proceed with this task?', + header: 'Approach', + options: [ + { + label: 'Quick fix (Recommended)', + description: + 'Apply the most direct solution to resolve the immediate issue.', + }, + { + label: 'Comprehensive refactor', + description: + 'Restructure the affected code for better long-term maintainability.', + }, + ], + multiSelect: false, + }, + ]; + + const invocation = tool.build({ questions }); + const details = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + // Simulate confirmation with answers + if (details && 'onConfirm' in details) { + const answers = { '0': 'Quick fix (Recommended)' }; + await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + answers, + }); + } + + const result = await invocation.execute(new AbortController().signal); + expect(result.returnDisplay).toContain('User answered:'); + expect(result.returnDisplay).toContain( + ' Approach → Quick fix (Recommended)', + ); + expect(JSON.parse(result.llmContent as string)).toEqual({ + answers: { '0': 'Quick fix (Recommended)' }, + }); + }); + + it('should display message when user submits without answering', async () => { + const questions = [ + { + question: 'Which approach?', + header: 'Approach', + options: [ + { label: 'Option A', description: 'First option' }, + { label: 'Option B', description: 'Second option' }, + ], + }, + ]; + + const invocation = tool.build({ questions }); + const details = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + // Simulate confirmation with empty answers + if (details && 'onConfirm' in details) { + await details.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + answers: {}, + }); + } + + const result = await invocation.execute(new AbortController().signal); + expect(result.returnDisplay).toBe( + 'User submitted without answering questions.', + ); + expect(JSON.parse(result.llmContent as string)).toEqual({ answers: {} }); + }); + + it('should handle cancellation', async () => { + const invocation = tool.build({ + questions: [ + { + question: 'Which sections of the documentation should be updated?', + header: 'Docs', + options: [ + { + label: 'User Guide', + description: 'Update the main user-facing documentation.', + }, + { + label: 'API Reference', + description: 'Update the detailed API documentation.', + }, + ], + multiSelect: true, + }, + ], + }); + + const details = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + // Simulate cancellation + if (details && 'onConfirm' in details) { + await details.onConfirm(ToolConfirmationOutcome.Cancel); + } + + const result = await invocation.execute(new AbortController().signal); + expect(result.returnDisplay).toBe('User dismissed dialog'); + expect(result.llmContent).toBe( + 'User dismissed ask_user dialog without answering.', + ); + }); }); }); diff --git a/packages/core/src/tools/ask-user.ts b/packages/core/src/tools/ask-user.ts index 81d62d021c..c155dec4e9 100644 --- a/packages/core/src/tools/ask-user.ts +++ b/packages/core/src/tools/ask-user.ts @@ -9,17 +9,12 @@ import { BaseToolInvocation, type ToolResult, Kind, - type ToolCallConfirmationDetails, + type ToolAskUserConfirmationDetails, + type ToolConfirmationPayload, + ToolConfirmationOutcome, } from './tools.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; -import { - MessageBusType, - QuestionType, - type Question, - type AskUserRequest, - type AskUserResponse, -} from '../confirmation-bus/types.js'; -import { randomUUID } from 'node:crypto'; +import { QuestionType, type Question } from '../confirmation-bus/types.js'; import { ASK_USER_TOOL_NAME, ASK_USER_DISPLAY_NAME } from './tool-names.js'; export interface AskUserParams { @@ -165,100 +160,61 @@ export class AskUserInvocation extends BaseToolInvocation< AskUserParams, ToolResult > { + private confirmationOutcome: ToolConfirmationOutcome | null = null; + private userAnswers: { [questionIndex: string]: string } = {}; + override async shouldConfirmExecute( _abortSignal: AbortSignal, - ): Promise { - return false; + ): Promise { + const normalizedQuestions = this.params.questions.map((q) => ({ + ...q, + type: q.type ?? QuestionType.CHOICE, + })); + + return { + type: 'ask_user', + title: 'Ask User', + questions: normalizedQuestions, + onConfirm: async ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => { + this.confirmationOutcome = outcome; + if (payload && 'answers' in payload) { + this.userAnswers = payload.answers; + } + }, + }; } getDescription(): string { return `Asking user: ${this.params.questions.map((q) => q.question).join(', ')}`; } - async execute(signal: AbortSignal): Promise { - const correlationId = randomUUID(); + async execute(_signal: AbortSignal): Promise { + if (this.confirmationOutcome === ToolConfirmationOutcome.Cancel) { + return { + llmContent: 'User dismissed ask_user dialog without answering.', + returnDisplay: 'User dismissed dialog', + }; + } - const request: AskUserRequest = { - type: MessageBusType.ASK_USER_REQUEST, - questions: this.params.questions.map((q) => ({ - ...q, - type: q.type ?? QuestionType.CHOICE, - })), - correlationId, + const answerEntries = Object.entries(this.userAnswers); + const hasAnswers = answerEntries.length > 0; + + const returnDisplay = hasAnswers + ? `**User answered:**\n${answerEntries + .map(([index, answer]) => { + const question = this.params.questions[parseInt(index, 10)]; + const category = question?.header ?? `Q${index}`; + return ` ${category} → ${answer}`; + }) + .join('\n')}` + : 'User submitted without answering questions.'; + + return { + llmContent: JSON.stringify({ answers: this.userAnswers }), + returnDisplay, }; - - return new Promise((resolve, reject) => { - const responseHandler = (response: AskUserResponse): void => { - if (response.correlationId === correlationId) { - cleanup(); - - // Handle user cancellation - if (response.cancelled) { - resolve({ - llmContent: 'User dismissed ask user dialog without answering.', - returnDisplay: 'User dismissed dialog', - }); - return; - } - - // Build formatted key-value display - const answerEntries = Object.entries(response.answers); - const hasAnswers = answerEntries.length > 0; - - const returnDisplay = hasAnswers - ? `**User answered:**\n${answerEntries - .map(([index, answer]) => { - const question = this.params.questions[parseInt(index, 10)]; - const category = question?.header ?? `Q${index}`; - return ` ${category} → ${answer}`; - }) - .join('\n')}` - : 'User submitted without answering questions.'; - - resolve({ - llmContent: JSON.stringify({ answers: response.answers }), - returnDisplay, - }); - } - }; - - const cleanup = () => { - if (responseHandler) { - this.messageBus.unsubscribe( - MessageBusType.ASK_USER_RESPONSE, - responseHandler, - ); - } - signal.removeEventListener('abort', abortHandler); - }; - - const abortHandler = () => { - cleanup(); - resolve({ - llmContent: 'Tool execution cancelled by user.', - returnDisplay: 'Cancelled', - error: { - message: 'Cancelled', - }, - }); - }; - - if (signal.aborted) { - abortHandler(); - return; - } - - signal.addEventListener('abort', abortHandler); - this.messageBus.subscribe( - MessageBusType.ASK_USER_RESPONSE, - responseHandler, - ); - - // Publish request - this.messageBus.publish(request).catch((err) => { - cleanup(); - reject(err); - }); - }); } } diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 7575dcc616..b851ee99d4 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -18,8 +18,13 @@ import { const mockPlatform = vi.hoisted(() => vi.fn()); const mockShellExecutionService = vi.hoisted(() => vi.fn()); +const mockShellBackground = vi.hoisted(() => vi.fn()); + vi.mock('../services/shellExecutionService.js', () => ({ - ShellExecutionService: { execute: mockShellExecutionService }, + ShellExecutionService: { + execute: mockShellExecutionService, + background: mockShellBackground, + }, })); vi.mock('node:os', async (importOriginal) => { @@ -38,6 +43,7 @@ vi.mock('../utils/summarizer.js'); import { initializeShellParsers } from '../utils/shell-utils.js'; import { ShellTool } from './shell.js'; +import { debugLogger } from '../index.js'; import { type Config } from '../config/config.js'; import { type ShellExecutionResult, @@ -168,6 +174,20 @@ describe('ShellTool', () => { }), }; }); + + mockShellBackground.mockImplementation(() => { + resolveExecutionPromise({ + output: '', + rawOutput: Buffer.from(''), + exitCode: null, + signal: null, + error: null, + aborted: false, + pid: 12345, + executionMethod: 'child_process', + backgrounded: true, + }); + }); }); afterEach(() => { @@ -305,6 +325,25 @@ describe('ShellTool', () => { ); }); + it('should handle is_background parameter by calling ShellExecutionService.background', async () => { + vi.useFakeTimers(); + const invocation = shellTool.build({ + command: 'sleep 10', + is_background: true, + }); + const promise = invocation.execute(mockAbortSignal); + + // We need to provide a PID for the background logic to trigger + resolveShellExecution({ pid: 12345 }); + + // Advance time to trigger the background timeout + await vi.advanceTimersByTimeAsync(250); + + expect(mockShellBackground).toHaveBeenCalledWith(12345); + + await promise; + }); + itWindowsOnly( 'should not wrap command on windows', async () => { @@ -430,8 +469,6 @@ describe('ShellTool', () => { // We can also verify that setTimeout was NOT called for the inactivity timeout. // However, since we don't have direct access to the internal `resetTimeout`, // we can infer success by the fact it didn't abort. - - vi.useRealTimers(); }); it('should clean up the temp file on synchronous execution error', async () => { @@ -450,10 +487,28 @@ describe('ShellTool', () => { expect(fs.existsSync(tmpFile)).toBe(false); }); + it('should not log "missing pgrep output" when process is backgrounded', async () => { + vi.useFakeTimers(); + const debugErrorSpy = vi.spyOn(debugLogger, 'error'); + + const invocation = shellTool.build({ + command: 'sleep 10', + is_background: true, + }); + const promise = invocation.execute(mockAbortSignal); + + // Advance time to trigger backgrounding + await vi.advanceTimersByTimeAsync(200); + + await promise; + + expect(debugErrorSpy).not.toHaveBeenCalledWith('missing pgrep output'); + }); + describe('Streaming to `updateOutput`', () => { let updateOutputMock: Mock; beforeEach(() => { - vi.useFakeTimers({ toFake: ['Date'] }); + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); updateOutputMock = vi.fn(); }); afterEach(() => { @@ -503,6 +558,27 @@ describe('ShellTool', () => { }); await promise; }); + + it('should NOT call updateOutput if the command is backgrounded', async () => { + const invocation = shellTool.build({ + command: 'sleep 10', + is_background: true, + }); + const promise = invocation.execute(mockAbortSignal, updateOutputMock); + + mockShellOutputCallback({ type: 'data', chunk: 'some output' }); + expect(updateOutputMock).not.toHaveBeenCalled(); + + // We need to provide a PID for the background logic to trigger + resolveShellExecution({ pid: 12345 }); + + // Advance time to trigger the background timeout + await vi.advanceTimersByTimeAsync(250); + + expect(mockShellBackground).toHaveBeenCalledWith(12345); + + await promise; + }); }); }); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 55575511f0..e29419913e 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -46,10 +46,14 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js'; export const OUTPUT_UPDATE_INTERVAL_MS = 1000; +// Delay so user does not see the output of the process before the process is moved to the background. +const BACKGROUND_DELAY_MS = 200; + export interface ShellToolParams { command: string; description?: string; dir_path?: string; + is_background?: boolean; } export class ShellToolInvocation extends BaseToolInvocation< @@ -79,6 +83,9 @@ export class ShellToolInvocation extends BaseToolInvocation< if (this.params.description) { description += ` (${this.params.description.replace(/\n/g, ' ')})`; } + if (this.params.is_background) { + description += ' [background]'; + } return description; } @@ -249,12 +256,14 @@ export class ShellToolInvocation extends BaseToolInvocation< shouldUpdate = true; } break; + case 'exit': + break; default: { throw new Error('An unhandled ShellOutputEvent was found.'); } } - if (shouldUpdate) { + if (shouldUpdate && !this.params.is_background) { updateOutput(cumulativeOutput); lastUpdateTime = Date.now(); } @@ -270,8 +279,17 @@ export class ShellToolInvocation extends BaseToolInvocation< }, ); - if (pid && setPidCallback) { - setPidCallback(pid); + if (pid) { + if (setPidCallback) { + setPidCallback(pid); + } + + // If the model requested to run in the background, do so after a short delay. + if (this.params.is_background) { + setTimeout(() => { + ShellExecutionService.background(pid); + }, BACKGROUND_DELAY_MS); + } } const result = await resultPromise; @@ -299,12 +317,14 @@ export class ShellToolInvocation extends BaseToolInvocation< } } } else { - if (!signal.aborted) { + if (!signal.aborted && !result.backgrounded) { debugLogger.error('missing pgrep output'); } } } + let data: Record | undefined; + let llmContent = ''; let timeoutMessage = ''; if (result.aborted) { @@ -322,6 +342,13 @@ export class ShellToolInvocation extends BaseToolInvocation< } else { llmContent += ' There was no output before it was cancelled.'; } + } else if (this.params.is_background || result.backgrounded) { + llmContent = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`; + data = { + pid: result.pid, + command: this.params.command, + initialOutput: result.output, + }; } else { // Create a formatted error string for display, replacing the wrapper command // with the user-facing command. @@ -356,7 +383,9 @@ export class ShellToolInvocation extends BaseToolInvocation< if (this.config.getDebugMode()) { returnDisplayMessage = llmContent; } else { - if (result.output.trim()) { + if (this.params.is_background || result.backgrounded) { + returnDisplayMessage = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`; + } else if (result.output.trim()) { returnDisplayMessage = result.output; } else { if (result.aborted) { @@ -406,6 +435,7 @@ export class ShellToolInvocation extends BaseToolInvocation< return { llmContent, returnDisplay: returnDisplayMessage, + data, ...executionError, }; } finally { @@ -421,7 +451,7 @@ export class ShellToolInvocation extends BaseToolInvocation< } } -function getShellToolDescription(): string { +function getShellToolDescription(enableInteractiveShell: boolean): string { const returnedInfo = ` The following information is returned: @@ -434,9 +464,15 @@ function getShellToolDescription(): string { Process Group PGID: Only included if available.`; if (os.platform() === 'win32') { - return `This tool executes a given shell command as \`powershell.exe -NoProfile -Command \`. Command can start background processes using PowerShell constructs such as \`Start-Process -NoNewWindow\` or \`Start-Job\`.${returnedInfo}`; + const backgroundInstructions = enableInteractiveShell + ? 'To run a command in the background, set the `is_background` parameter to true. Do NOT use PowerShell background constructs.' + : 'Command can start background processes using PowerShell constructs such as `Start-Process -NoNewWindow` or `Start-Job`.'; + return `This tool executes a given shell command as \`powershell.exe -NoProfile -Command \`. ${backgroundInstructions}${returnedInfo}`; } else { - return `This tool executes a given shell command as \`bash -c \`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${returnedInfo}`; + const backgroundInstructions = enableInteractiveShell + ? 'To run a command in the background, set the `is_background` parameter to true. Do NOT use `&` to background commands.' + : 'Command can start background processes using `&`.'; + return `This tool executes a given shell command as \`bash -c \`. ${backgroundInstructions} Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${returnedInfo}`; } } @@ -464,7 +500,7 @@ export class ShellTool extends BaseDeclarativeTool< super( ShellTool.Name, 'Shell', - getShellToolDescription(), + getShellToolDescription(config.getEnableInteractiveShell()), Kind.Execute, { type: 'object', @@ -483,6 +519,11 @@ export class ShellTool extends BaseDeclarativeTool< description: '(OPTIONAL) The path of the directory to run the command in. If not provided, the project root directory is used. Must be a directory within the workspace and must already exist.', }, + is_background: { + type: 'boolean', + description: + 'Set to true if this command should be run in the background (e.g. for long-running servers or watchers). The command will be started, allowed to run for a brief moment to check for immediate errors, and then moved to the background.', + }, }, required: ['command'], }, diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 32a5e72972..9c308ecba6 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -16,6 +16,7 @@ import { MessageBusType, type ToolConfirmationRequest, type ToolConfirmationResponse, + type Question, } from '../confirmation-bus/types.js'; /** @@ -550,6 +551,11 @@ export interface ToolResult { message: string; // raw error message type?: ToolErrorType; // An optional machine-readable error type (e.g., 'FILE_NOT_FOUND'). }; + + /** + * Optional data payload for passing structured information back to the caller. + */ + data?: Record; } /** @@ -687,12 +693,18 @@ export interface ToolEditConfirmationDetails { ideConfirmation?: Promise; } -export interface ToolConfirmationPayload { - // used to override `modifiedProposedContent` for modifiable tools in the - // inline modify flow +export interface ToolEditConfirmationPayload { newContent: string; } +export interface ToolAskUserConfirmationPayload { + answers: { [questionIndex: string]: string }; +} + +export type ToolConfirmationPayload = + | ToolEditConfirmationPayload + | ToolAskUserConfirmationPayload; + export interface ToolExecuteConfirmationDetails { type: 'exec'; title: string; @@ -720,11 +732,22 @@ export interface ToolInfoConfirmationDetails { urls?: string[]; } +export interface ToolAskUserConfirmationDetails { + type: 'ask_user'; + title: string; + questions: Question[]; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; +} + export type ToolCallConfirmationDetails = | ToolEditConfirmationDetails | ToolExecuteConfirmationDetails | ToolMcpConfirmationDetails - | ToolInfoConfirmationDetails; + | ToolInfoConfirmationDetails + | ToolAskUserConfirmationDetails; export enum ToolConfirmationOutcome { ProceedOnce = 'proceed_once', diff --git a/packages/core/src/utils/process-utils.test.ts b/packages/core/src/utils/process-utils.test.ts new file mode 100644 index 0000000000..9da6048a15 --- /dev/null +++ b/packages/core/src/utils/process-utils.test.ts @@ -0,0 +1,134 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import os from 'node:os'; +import { spawn as cpSpawn } from 'node:child_process'; +import { killProcessGroup, SIGKILL_TIMEOUT_MS } from './process-utils.js'; + +vi.mock('node:os'); +vi.mock('node:child_process'); + +describe('process-utils', () => { + const mockProcessKill = vi + .spyOn(process, 'kill') + .mockImplementation(() => true); + const mockSpawn = vi.mocked(cpSpawn); + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('killProcessGroup', () => { + it('should use taskkill on Windows', async () => { + vi.mocked(os.platform).mockReturnValue('win32'); + + await killProcessGroup({ pid: 1234 }); + + expect(mockSpawn).toHaveBeenCalledWith('taskkill', [ + '/pid', + '1234', + '/f', + '/t', + ]); + expect(mockProcessKill).not.toHaveBeenCalled(); + }); + + it('should use pty.kill() on Windows if pty is provided', async () => { + vi.mocked(os.platform).mockReturnValue('win32'); + const mockPty = { kill: vi.fn() }; + + await killProcessGroup({ pid: 1234, pty: mockPty }); + + expect(mockPty.kill).toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('should kill the process group on Unix with SIGKILL by default', async () => { + vi.mocked(os.platform).mockReturnValue('linux'); + + await killProcessGroup({ pid: 1234 }); + + expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGKILL'); + }); + + it('should use escalation on Unix if requested', async () => { + vi.mocked(os.platform).mockReturnValue('linux'); + const exited = false; + const isExited = () => exited; + + const killPromise = killProcessGroup({ + pid: 1234, + escalate: true, + isExited, + }); + + // First call should be SIGTERM + expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGTERM'); + + // Advance time + await vi.advanceTimersByTimeAsync(SIGKILL_TIMEOUT_MS); + + // Second call should be SIGKILL + expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGKILL'); + + await killPromise; + }); + + it('should skip SIGKILL if isExited returns true after SIGTERM', async () => { + vi.mocked(os.platform).mockReturnValue('linux'); + let exited = false; + const isExited = vi.fn().mockImplementation(() => exited); + + const killPromise = killProcessGroup({ + pid: 1234, + escalate: true, + isExited, + }); + + expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGTERM'); + + // Simulate process exiting + exited = true; + + await vi.advanceTimersByTimeAsync(SIGKILL_TIMEOUT_MS); + + expect(mockProcessKill).not.toHaveBeenCalledWith(-1234, 'SIGKILL'); + await killPromise; + }); + + it('should fallback to specific process kill if group kill fails', async () => { + vi.mocked(os.platform).mockReturnValue('linux'); + mockProcessKill.mockImplementationOnce(() => { + throw new Error('ESRCH'); + }); + + await killProcessGroup({ pid: 1234 }); + + // Failed group kill + expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGKILL'); + // Fallback individual kill + expect(mockProcessKill).toHaveBeenCalledWith(1234, 'SIGKILL'); + }); + + it('should use pty fallback on Unix if group kill fails', async () => { + vi.mocked(os.platform).mockReturnValue('linux'); + mockProcessKill.mockImplementationOnce(() => { + throw new Error('ESRCH'); + }); + const mockPty = { kill: vi.fn() }; + + await killProcessGroup({ pid: 1234, pty: mockPty }); + + expect(mockPty.kill).toHaveBeenCalledWith('SIGKILL'); + }); + }); +}); diff --git a/packages/core/src/utils/process-utils.ts b/packages/core/src/utils/process-utils.ts new file mode 100644 index 0000000000..74f802718f --- /dev/null +++ b/packages/core/src/utils/process-utils.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import os from 'node:os'; +import { spawn as cpSpawn } from 'node:child_process'; + +/** Default timeout for SIGKILL escalation on Unix systems. */ +export const SIGKILL_TIMEOUT_MS = 200; + +/** Configuration for process termination. */ +export interface KillOptions { + /** The process ID to terminate. */ + pid: number; + /** Whether to attempt SIGTERM before SIGKILL on Unix systems. */ + escalate?: boolean; + /** Initial signal to use (defaults to SIGTERM if escalate is true, else SIGKILL). */ + signal?: NodeJS.Signals | number; + /** Callback to check if the process has already exited. */ + isExited?: () => boolean; + /** Optional PTY object for PTY-specific kill methods. */ + pty?: { kill: (signal?: string) => void }; +} + +/** + * Robustly terminates a process or process group across platforms. + * + * On Windows, it uses `taskkill /f /t` to ensure the entire tree is terminated, + * or the PTY's built-in kill method. + * + * On Unix, it attempts to kill the process group (using -pid) with escalation + * from SIGTERM to SIGKILL if requested. + */ +export async function killProcessGroup(options: KillOptions): Promise { + const { pid, escalate = false, isExited = () => false, pty } = options; + const isWindows = os.platform() === 'win32'; + + if (isWindows) { + if (pty) { + try { + pty.kill(); + } catch { + // Ignore errors for dead processes + } + } else { + cpSpawn('taskkill', ['/pid', pid.toString(), '/f', '/t']); + } + return; + } + + // Unix logic + try { + const initialSignal = options.signal || (escalate ? 'SIGTERM' : 'SIGKILL'); + + // Try killing the process group first (-pid) + process.kill(-pid, initialSignal); + + if (escalate && !isExited()) { + await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS)); + if (!isExited()) { + try { + process.kill(-pid, 'SIGKILL'); + } catch { + // Ignore + } + } + } + } catch (_e) { + // Fallback to specific process kill if group kill fails or on error + if (!isExited()) { + if (pty) { + if (escalate) { + try { + pty.kill('SIGTERM'); + await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS)); + if (!isExited()) pty.kill('SIGKILL'); + } catch { + // Ignore + } + } else { + try { + pty.kill('SIGKILL'); + } catch { + // Ignore + } + } + } else { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Ignore + } + } + } + } +} diff --git a/packages/core/src/utils/terminalSerializer.ts b/packages/core/src/utils/terminalSerializer.ts index 7bcd2a4ce6..b52c6ef6d7 100644 --- a/packages/core/src/utils/terminalSerializer.ts +++ b/packages/core/src/utils/terminalSerializer.ts @@ -34,12 +34,12 @@ export const enum ColorMode { } class Cell { - private readonly cell: IBufferCell | null; - private readonly x: number; - private readonly y: number; - private readonly cursorX: number; - private readonly cursorY: number; - private readonly attributes: number = 0; + private cell: IBufferCell | null = null; + private x = 0; + private y = 0; + private cursorX = 0; + private cursorY = 0; + private attributes: number = 0; fg = 0; bg = 0; fgColorMode: ColorMode = ColorMode.DEFAULT; @@ -51,12 +51,23 @@ class Cell { y: number, cursorX: number, cursorY: number, + ) { + this.update(cell, x, y, cursorX, cursorY); + } + + update( + cell: IBufferCell | null, + x: number, + y: number, + cursorX: number, + cursorY: number, ) { this.cell = cell; this.x = x; this.y = y; this.cursorX = cursorX; this.cursorY = cursorY; + this.attributes = 0; if (!cell) { return; @@ -131,7 +142,11 @@ class Cell { } } -export function serializeTerminalToObject(terminal: Terminal): AnsiOutput { +export function serializeTerminalToObject( + terminal: Terminal, + startLine?: number, + endLine?: number, +): AnsiOutput { const buffer = terminal.buffer.active; const cursorX = buffer.cursorX; const cursorY = buffer.cursorY; @@ -140,22 +155,30 @@ export function serializeTerminalToObject(terminal: Terminal): AnsiOutput { const result: AnsiOutput = []; - for (let y = 0; y < terminal.rows; y++) { - const line = buffer.getLine(buffer.viewportY + y); + // Reuse cell instances + const lastCell = new Cell(null, -1, -1, cursorX, cursorY); + const currentCell = new Cell(null, -1, -1, cursorX, cursorY); + + const effectiveStart = startLine ?? buffer.viewportY; + const effectiveEnd = endLine ?? buffer.viewportY + terminal.rows; + + for (let y = effectiveStart; y < effectiveEnd; y++) { + const line = buffer.getLine(y); const currentLine: AnsiLine = []; if (!line) { result.push(currentLine); continue; } - let lastCell = new Cell(null, -1, -1, cursorX, cursorY); + // Reset lastCell for new line + lastCell.update(null, -1, -1, cursorX, cursorY); let currentText = ''; for (let x = 0; x < terminal.cols; x++) { const cellData = line.getCell(x); - const cell = new Cell(cellData || null, x, y, cursorX, cursorY); + currentCell.update(cellData || null, x, y, cursorX, cursorY); - if (x > 0 && !cell.equals(lastCell)) { + if (x > 0 && !currentCell.equals(lastCell)) { if (currentText) { const token: AnsiToken = { text: currentText, @@ -172,8 +195,10 @@ export function serializeTerminalToObject(terminal: Terminal): AnsiOutput { } currentText = ''; } - currentText += cell.getChars(); - lastCell = cell; + currentText += currentCell.getChars(); + // Copy state from currentCell to lastCell. Since we can't easily deep copy + // without allocating, we just update lastCell with the same data. + lastCell.update(cellData || null, x, y, cursorX, cursorY); } if (currentText) { diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 3254af9d33..d33c75bf63 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1273,6 +1273,16 @@ "default": false, "type": "boolean" }, + "allowedExtensions": { + "title": "Extension Source Regex Allowlist", + "description": "List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting.", + "markdownDescription": "List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting.\n\n- Category: `Security`\n- Requires restart: `yes`\n- Default: `[]`", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "folderTrust": { "title": "Folder Trust", "description": "Settings for folder trust.",