From b611f9a519b1c9bc452629a6c361fcfb6a5127a8 Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Fri, 30 Jan 2026 09:53:09 -0800 Subject: [PATCH] feat: Implement background shell commands (#14849) --- docs/cli/commands.md | 3 + docs/cli/keyboard-shortcuts.md | 10 +- packages/cli/src/config/keyBindings.ts | 36 +- .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/test-utils/render.tsx | 7 + packages/cli/src/ui/App.test.tsx | 1 + packages/cli/src/ui/AppContainer.test.tsx | 129 ++--- packages/cli/src/ui/AppContainer.tsx | 166 +++++- .../cli/src/ui/commands/shellsCommand.test.ts | 35 ++ packages/cli/src/ui/commands/shellsCommand.ts | 18 + packages/cli/src/ui/commands/types.ts | 1 + .../BackgroundShellDisplay.test.tsx | 459 +++++++++++++++ .../ui/components/BackgroundShellDisplay.tsx | 460 ++++++++++++++++ .../cli/src/ui/components/Composer.test.tsx | 28 + packages/cli/src/ui/components/Composer.tsx | 2 +- .../ui/components/ContextSummaryDisplay.tsx | 24 +- .../cli/src/ui/components/InputPrompt.tsx | 17 +- .../src/ui/components/ShellInputPrompt.tsx | 16 +- .../src/ui/components/StatusDisplay.test.tsx | 22 +- .../cli/src/ui/components/StatusDisplay.tsx | 1 + .../BackgroundShellDisplay.test.tsx.snap | 56 ++ .../__snapshots__/StatusDisplay.test.tsx.snap | 4 +- packages/cli/src/ui/constants/tips.ts | 2 + .../cli/src/ui/contexts/UIActionsContext.tsx | 4 + .../cli/src/ui/contexts/UIStateContext.tsx | 7 + .../useToolScheduler.test.ts.snap | 3 + .../ui/hooks/shellCommandProcessor.test.tsx | 442 ++++++++++++++- .../cli/src/ui/hooks/shellCommandProcessor.ts | 521 ++++++++++++------ .../cli/src/ui/hooks/shellReducer.test.ts | 193 +++++++ packages/cli/src/ui/hooks/shellReducer.ts | 128 +++++ .../ui/hooks/slashCommandProcessor.test.tsx | 1 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 2 + .../hooks/useBackgroundShellManager.test.tsx | 191 +++++++ .../src/ui/hooks/useBackgroundShellManager.ts | 91 +++ .../cli/src/ui/hooks/useGeminiStream.test.tsx | 3 + packages/cli/src/ui/hooks/useGeminiStream.ts | 93 +++- .../cli/src/ui/hooks/useReactToolScheduler.ts | 11 +- packages/cli/src/ui/keyMatchers.test.ts | 23 +- .../src/ui/layouts/DefaultAppLayout.test.tsx | 132 +++++ .../cli/src/ui/layouts/DefaultAppLayout.tsx | 20 + .../DefaultAppLayout.test.tsx.snap | 35 ++ .../src/ui/noninteractive/nonInteractiveUi.ts | 1 + packages/core/src/scheduler/tool-executor.ts | 1 + packages/core/src/scheduler/types.ts | 4 + .../services/shellExecutionService.test.ts | 81 ++- .../src/services/shellExecutionService.ts | 508 +++++++++++++---- packages/core/src/tools/shell.test.ts | 84 ++- packages/core/src/tools/shell.ts | 59 +- packages/core/src/tools/tools.ts | 5 + packages/core/src/utils/process-utils.test.ts | 134 +++++ packages/core/src/utils/process-utils.ts | 98 ++++ packages/core/src/utils/terminalSerializer.ts | 53 +- 52 files changed, 3957 insertions(+), 470 deletions(-) create mode 100644 packages/cli/src/ui/commands/shellsCommand.test.ts create mode 100644 packages/cli/src/ui/commands/shellsCommand.ts create mode 100644 packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx create mode 100644 packages/cli/src/ui/components/BackgroundShellDisplay.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap create mode 100644 packages/cli/src/ui/hooks/shellReducer.test.ts create mode 100644 packages/cli/src/ui/hooks/shellReducer.ts create mode 100644 packages/cli/src/ui/hooks/useBackgroundShellManager.test.tsx create mode 100644 packages/cli/src/ui/hooks/useBackgroundShellManager.ts create mode 100644 packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx create mode 100644 packages/cli/src/ui/layouts/__snapshots__/DefaultAppLayout.test.tsx.snap create mode 100644 packages/core/src/utils/process-utils.test.ts create mode 100644 packages/core/src/utils/process-utils.ts 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/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/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 3c32549ef2..b72a239328 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -47,6 +47,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'; @@ -164,6 +165,7 @@ export class BuiltinCommandLoader implements ICommandLoader { : [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..bff645b6f7 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', () => { diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 5170b60f62..d897bc91b4 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -269,6 +269,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(); @@ -334,14 +353,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 +1205,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 +1243,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 +1312,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 +1350,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 +1387,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 +1441,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 +1500,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 +1512,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 +1570,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 +1621,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 +1644,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 +1706,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 +1747,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 +1787,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 +1890,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 +1962,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 +1983,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 +2524,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 }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c792094969..aeeff89289 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -99,6 +99,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 +139,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 +261,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); @@ -489,6 +495,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 +849,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 +876,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 +1038,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 +1066,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 +1238,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({ @@ -1542,9 +1602,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 +1613,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; @@ -1595,11 +1702,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 @@ -1878,6 +1992,8 @@ Logging in with Google... Restarting Gemini CLI to continue. isRestarting, extensionsUpdateState, activePtyId, + backgroundShellCount, + isBackgroundShellVisible, embeddedShellFocused, showDebugProfiler, customDialog, @@ -1887,6 +2003,10 @@ Logging in with Google... Restarting Gemini CLI to continue. bannerVisible, terminalBackgroundColor: config.getTerminalBackground(), settingsNonce, + backgroundShells, + activeBackgroundShellPid, + backgroundShellHeight, + isBackgroundShellListOpen, adminSettingsChanged, newAgents, }), @@ -1977,6 +2097,8 @@ Logging in with Google... Restarting Gemini CLI to continue. currentModel, extensionsUpdateState, activePtyId, + backgroundShellCount, + isBackgroundShellVisible, historyManager, embeddedShellFocused, showDebugProfiler, @@ -1989,6 +2111,10 @@ Logging in with Google... Restarting Gemini CLI to continue. bannerVisible, config, settingsNonce, + backgroundShellHeight, + isBackgroundShellListOpen, + activeBackgroundShellPid, + backgroundShells, adminSettingsChanged, newAgents, ], @@ -2036,7 +2162,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 +2238,11 @@ Logging in with Google... Restarting Gemini CLI to continue. handleApiKeySubmit, handleApiKeyCancel, setBannerVisible, + handleWarning, setEmbeddedShellFocused, + dismissBackgroundShell, + setActiveBackgroundShellPid, + setIsBackgroundShellListOpen, setAuthContext, newAgents, config, 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/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/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/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/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/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/__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__/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/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/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..b4971323cc 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'); }), ); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 0d5260b82f..100d069323 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; @@ -1404,6 +1437,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 +1562,7 @@ export const useGeminiStream = ( performMemoryRefresh, modelSwitchedFromQuotaError, addItem, + registerBackgroundShell, ], ); @@ -1599,6 +1652,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/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/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/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/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/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..7407ce36da 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -550,6 +550,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; } /** 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) {