From 3eebb75b7a63b77fb9436882049246528b7d83db Mon Sep 17 00:00:00 2001 From: Adam Weidman <65992621+adamfweidman@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:27:51 -0400 Subject: [PATCH] feat(core): agnostic background task UI with CompletionBehavior (#22740) Co-authored-by: mkorwel --- docs/reference/configuration.md | 8 + packages/cli/src/config/config.ts | 2 + packages/cli/src/config/settingsSchema.ts | 15 + .../cli/src/services/BuiltinCommandLoader.ts | 4 +- packages/cli/src/test-utils/render.tsx | 10 +- packages/cli/src/ui/App.test.tsx | 2 +- packages/cli/src/ui/AppContainer.test.tsx | 48 +-- packages/cli/src/ui/AppContainer.tsx | 138 ++++---- .../cli/src/ui/commands/shellsCommand.test.ts | 35 --- .../cli/src/ui/commands/tasksCommand.test.ts | 36 +++ .../{shellsCommand.ts => tasksCommand.ts} | 10 +- packages/cli/src/ui/commands/types.ts | 2 +- ...est.tsx => BackgroundTaskDisplay.test.tsx} | 64 ++-- ...lDisplay.tsx => BackgroundTaskDisplay.tsx} | 32 +- .../cli/src/ui/components/Composer.test.tsx | 6 +- .../cli/src/ui/components/InputPrompt.tsx | 10 +- .../src/ui/components/MainContent.test.tsx | 20 +- .../src/ui/components/StatusDisplay.test.tsx | 6 +- .../cli/src/ui/components/StatusDisplay.tsx | 2 +- ...ap => BackgroundTaskDisplay.test.tsx.snap} | 12 +- .../components/messages/ToolGroupMessage.tsx | 6 +- .../cli/src/ui/contexts/UIActionsContext.tsx | 6 +- .../cli/src/ui/contexts/UIStateContext.tsx | 14 +- .../cli/src/ui/hooks/shellReducer.test.ts | 72 ++--- packages/cli/src/ui/hooks/shellReducer.ts | 91 +++--- .../ui/hooks/slashCommandProcessor.test.tsx | 2 +- .../cli/src/ui/hooks/slashCommandProcessor.ts | 4 +- .../hooks/useBackgroundShellManager.test.tsx | 191 ----------- .../src/ui/hooks/useBackgroundShellManager.ts | 91 ------ .../hooks/useBackgroundTaskManager.test.tsx | 191 +++++++++++ .../src/ui/hooks/useBackgroundTaskManager.ts | 91 ++++++ .../cli/src/ui/hooks/useComposerStatus.ts | 2 +- ...est.tsx => useExecutionLifecycle.test.tsx} | 181 ++++++----- ...dProcessor.ts => useExecutionLifecycle.ts} | 198 ++++++++---- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 11 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 42 +-- .../src/ui/layouts/DefaultAppLayout.test.tsx | 46 +-- .../cli/src/ui/layouts/DefaultAppLayout.tsx | 22 +- .../DefaultAppLayout.test.tsx.snap | 10 +- .../src/ui/noninteractive/nonInteractiveUi.ts | 2 +- packages/cli/src/ui/utils/borderStyles.ts | 6 +- packages/core/src/config/config.ts | 21 +- packages/core/src/config/topicState.ts | 48 +++ packages/core/src/index.ts | 6 - .../core/src/prompts/promptProvider.test.ts | 2 +- .../executionLifecycleService.test.ts | 297 +++++++++++++++++- .../src/services/executionLifecycleService.ts | 114 ++++++- .../src/services/shellExecutionService.ts | 38 ++- packages/core/src/tools/shell.test.ts | 1 + packages/core/src/tools/shell.ts | 2 + packages/core/src/tools/topicTool.test.ts | 3 +- packages/core/src/tools/topicTool.ts | 43 --- packages/core/src/utils/textUtils.ts | 18 ++ schemas/settings.schema.json | 8 + 54 files changed, 1467 insertions(+), 875 deletions(-) delete mode 100644 packages/cli/src/ui/commands/shellsCommand.test.ts create mode 100644 packages/cli/src/ui/commands/tasksCommand.test.ts rename packages/cli/src/ui/commands/{shellsCommand.ts => tasksCommand.ts} (56%) rename packages/cli/src/ui/components/{BackgroundShellDisplay.test.tsx => BackgroundTaskDisplay.test.tsx} (85%) rename packages/cli/src/ui/components/{BackgroundShellDisplay.tsx => BackgroundTaskDisplay.tsx} (95%) rename packages/cli/src/ui/components/__snapshots__/{BackgroundShellDisplay.test.tsx.snap => BackgroundTaskDisplay.test.tsx.snap} (92%) delete mode 100644 packages/cli/src/ui/hooks/useBackgroundShellManager.test.tsx delete mode 100644 packages/cli/src/ui/hooks/useBackgroundShellManager.ts create mode 100644 packages/cli/src/ui/hooks/useBackgroundTaskManager.test.tsx create mode 100644 packages/cli/src/ui/hooks/useBackgroundTaskManager.ts rename packages/cli/src/ui/hooks/{shellCommandProcessor.test.tsx => useExecutionLifecycle.test.tsx} (86%) rename packages/cli/src/ui/hooks/{shellCommandProcessor.ts => useExecutionLifecycle.ts} (75%) create mode 100644 packages/core/src/config/topicState.ts diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index eef73a700c..acfb272754 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1366,6 +1366,14 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes +- **`tools.shell.backgroundCompletionBehavior`** (enum): + - **Description:** Controls what happens when a background shell command + finishes. 'silent' (default): quietly exits in background. 'inject': + automatically returns output to agent. 'notify': shows brief message in + chat. + - **Default:** `"silent"` + - **Values:** `"silent"`, `"inject"`, `"notify"` + - **`tools.shell.pager`** (string): - **Description:** The pager command to use for shell output. Defaults to `cat`. diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index b89dde6bc3..25419a2d6c 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1000,6 +1000,8 @@ export async function loadCliConfig( useAlternateBuffer: settings.ui?.useAlternateBuffer, useRipgrep: settings.tools?.useRipgrep, enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell, + shellBackgroundCompletionBehavior: settings.tools?.shell + ?.backgroundCompletionBehavior as string | undefined, shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout, enableShellOutputEfficiency: settings.tools?.shell?.enableShellOutputEfficiency ?? true, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index e205b15edd..c40e87db18 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1458,6 +1458,21 @@ const SETTINGS_SCHEMA = { `, showInDialog: true, }, + backgroundCompletionBehavior: { + type: 'enum', + label: 'Background Completion Behavior', + category: 'Tools', + requiresRestart: false, + default: 'silent', + description: + "Controls what happens when a background shell command finishes. 'silent' (default): quietly exits in background. 'inject': automatically returns output to agent. 'notify': shows brief message in chat.", + showInDialog: false, + options: [ + { label: 'Silent', value: 'silent' }, + { label: 'Inject', value: 'inject' }, + { label: 'Notify', value: 'notify' }, + ], + }, pager: { type: 'string', label: 'Pager', diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 66806f5ef1..c1cbd5621e 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -56,7 +56,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 { tasksCommand } from '../ui/commands/tasksCommand.js'; import { vimCommand } from '../ui/commands/vimCommand.js'; import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js'; import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js'; @@ -221,7 +221,7 @@ export class BuiltinCommandLoader implements ICommandLoader { : [skillsCommand] : []), settingsCommand, - shellsCommand, + tasksCommand, vimCommand, setupGithubCommand, terminalSetupCommand, diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index f4822c7158..6ca30dd8b9 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -506,8 +506,8 @@ const baseMockUiState = { cleanUiDetailsVisible: false, allowPlanMode: true, activePtyId: undefined, - backgroundShells: new Map(), - backgroundShellHeight: 0, + backgroundTasks: new Map(), + backgroundTaskHeight: 0, quota: { userTier: undefined, stats: undefined, @@ -579,9 +579,9 @@ const mockUIActions: UIActions = { revealCleanUiDetailsTemporarily: vi.fn(), handleWarning: vi.fn(), setEmbeddedShellFocused: vi.fn(), - dismissBackgroundShell: vi.fn(), - setActiveBackgroundShellPid: vi.fn(), - setIsBackgroundShellListOpen: vi.fn(), + dismissBackgroundTask: vi.fn(), + setActiveBackgroundTaskPid: vi.fn(), + setIsBackgroundTaskListOpen: vi.fn(), setAuthContext: vi.fn(), onHintInput: vi.fn(), onHintBackspace: vi.fn(), diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index b836202eb7..3505e63452 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -88,7 +88,7 @@ describe('App', () => { defaultText: 'Mock Banner Text', warningText: '', }, - backgroundShells: new Map(), + backgroundTasks: new Map(), }; it('should render main content and composer when not quitting', async () => { diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 3324505778..0e436cc645 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -328,13 +328,13 @@ describe('AppContainer State Management', () => { 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(), + backgroundTaskCount: 0, + isBackgroundTaskVisible: false, + toggleBackgroundTasks: vi.fn(), + backgroundCurrentExecution: vi.fn(), + backgroundTasks: new Map(), + registerBackgroundTask: vi.fn(), + dismissBackgroundTask: vi.fn(), }; beforeEach(() => { @@ -2257,13 +2257,13 @@ describe('AppContainer State Management', () => { }); it('should focus background shell on Tab when already visible (not toggle it off)', async () => { - const mockToggleBackgroundShell = vi.fn(); + const mockToggleBackgroundTask = vi.fn(); mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, activePtyId: null, - isBackgroundShellVisible: true, - backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]), - toggleBackgroundShell: mockToggleBackgroundShell, + isBackgroundTaskVisible: true, + backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]), + toggleBackgroundTasks: mockToggleBackgroundTask, }); await setupKeypressTest(); @@ -2277,7 +2277,7 @@ describe('AppContainer State Management', () => { // Should be focused expect(capturedUIState.embeddedShellFocused).toBe(true); // Should NOT have toggled (closed) the shell - expect(mockToggleBackgroundShell).not.toHaveBeenCalled(); + expect(mockToggleBackgroundTask).not.toHaveBeenCalled(); unmount(); }); @@ -2285,13 +2285,13 @@ describe('AppContainer State Management', () => { describe('Background Shell Toggling (CTRL+B)', () => { it('should toggle background shell on Ctrl+B even if visible but not focused', async () => { - const mockToggleBackgroundShell = vi.fn(); + const mockToggleBackgroundTask = vi.fn(); mockedUseGeminiStream.mockReturnValue({ ...DEFAULT_GEMINI_STREAM_MOCK, activePtyId: null, - isBackgroundShellVisible: true, - backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]), - toggleBackgroundShell: mockToggleBackgroundShell, + isBackgroundTaskVisible: true, + backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]), + toggleBackgroundTasks: mockToggleBackgroundTask, }); await setupKeypressTest(); @@ -2303,7 +2303,7 @@ describe('AppContainer State Management', () => { pressKey('\x02'); // Should have toggled (closed) the shell - expect(mockToggleBackgroundShell).toHaveBeenCalled(); + expect(mockToggleBackgroundTask).toHaveBeenCalled(); // Should be unfocused expect(capturedUIState.embeddedShellFocused).toBe(false); @@ -2311,28 +2311,28 @@ describe('AppContainer State Management', () => { }); it('should show and focus background shell on Ctrl+B if hidden', async () => { - const mockToggleBackgroundShell = vi.fn(); + const mockToggleBackgroundTask = vi.fn(); const geminiStreamMock = { ...DEFAULT_GEMINI_STREAM_MOCK, activePtyId: null, - isBackgroundShellVisible: false, - backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]), - toggleBackgroundShell: mockToggleBackgroundShell, + isBackgroundTaskVisible: false, + backgroundTasks: new Map([[123, { pid: 123, status: 'running' }]]), + toggleBackgroundTasks: mockToggleBackgroundTask, }; mockedUseGeminiStream.mockReturnValue(geminiStreamMock); await setupKeypressTest(); // Update the mock state when toggled to simulate real behavior - mockToggleBackgroundShell.mockImplementation(() => { - geminiStreamMock.isBackgroundShellVisible = true; + mockToggleBackgroundTask.mockImplementation(() => { + geminiStreamMock.isBackgroundTaskVisible = true; }); // Press Ctrl+B pressKey('\x02'); // Should have toggled (shown) the shell - expect(mockToggleBackgroundShell).toHaveBeenCalled(); + expect(mockToggleBackgroundTask).toHaveBeenCalled(); // Should be focused expect(capturedUIState.embeddedShellFocused).toBe(true); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 3cde63a6e8..9942e24e48 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -110,7 +110,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 { type BackgroundTask } from './hooks/useExecutionLifecycle.js'; import { useVim } from './hooks/vim.js'; import { type LoadableSettingScope, SettingScope } from '../config/settings.js'; import { type InitializationResult } from '../core/initializer.js'; @@ -151,7 +151,7 @@ import { useInputHistoryStore } from './hooks/useInputHistoryStore.js'; import { useBanner } from './hooks/useBanner.js'; import { useTerminalSetupPrompt } from './utils/terminalSetup.js'; import { useHookDisplayState } from './hooks/useHookDisplayState.js'; -import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js'; +import { useBackgroundTaskManager } from './hooks/useBackgroundTaskManager.js'; import { WARNING_PROMPT_DURATION_MS, QUEUE_ERROR_DISPLAY_DURATION_MS, @@ -232,9 +232,9 @@ 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 toggleBackgroundTasksRef = useRef<() => void>(() => {}); + const isBackgroundTaskVisibleRef = useRef(false); + const backgroundTasksRef = useRef>(new Map()); const [adminSettingsChanged, setAdminSettingsChanged] = useState(false); @@ -454,7 +454,7 @@ export const AppContainer = (props: AppContainerProps) => { // Kill all background shells await Promise.all( - Array.from(backgroundShellsRef.current.keys()).map((pid) => + Array.from(backgroundTasksRef.current.keys()).map((pid) => ShellExecutionService.kill(pid), ), ); @@ -865,7 +865,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const { toggleVimEnabled } = useVimMode(); - const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>( + const setIsBackgroundTaskListOpenRef = useRef<(open: boolean) => void>( () => {}, ); const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false); @@ -900,14 +900,14 @@ Logging in with Google... Restarting Gemini CLI to continue. toggleDebugProfiler, dispatchExtensionStateUpdate, addConfirmUpdateExtensionRequest, - toggleBackgroundShell: () => { - toggleBackgroundShellRef.current(); - if (!isBackgroundShellVisibleRef.current) { + toggleBackgroundTasks: () => { + toggleBackgroundTasksRef.current(); + if (!isBackgroundTaskVisibleRef.current) { setEmbeddedShellFocused(true); - if (backgroundShellsRef.current.size > 1) { - setIsBackgroundShellListOpenRef.current(true); + if (backgroundTasksRef.current.size > 1) { + setIsBackgroundTaskListOpenRef.current(true); } else { - setIsBackgroundShellListOpenRef.current(false); + setIsBackgroundTaskListOpenRef.current(false); } } }, @@ -1079,7 +1079,7 @@ Logging in with Google... Restarting Gemini CLI to continue. useEffect(() => { const hintListener = (text: string, source: InjectionSource) => { - if (source !== 'user_steering') { + if (source !== 'user_steering' && source !== 'background_completion') { return; } pendingHintsRef.current.push(text); @@ -1103,12 +1103,12 @@ Logging in with Google... Restarting Gemini CLI to continue. activePtyId, loopDetectionConfirmationRequest, lastOutputTime, - backgroundShellCount, - isBackgroundShellVisible, - toggleBackgroundShell, - backgroundCurrentShell, - backgroundShells, - dismissBackgroundShell, + backgroundTaskCount, + isBackgroundTaskVisible, + toggleBackgroundTasks, + backgroundCurrentExecution, + backgroundTasks, + dismissBackgroundTask, retryStatus, } = useGeminiStream( config.getGeminiClient(), @@ -1142,27 +1142,27 @@ Logging in with Google... Restarting Gemini CLI to continue. [pendingHistoryItems], ); - toggleBackgroundShellRef.current = toggleBackgroundShell; - isBackgroundShellVisibleRef.current = isBackgroundShellVisible; - backgroundShellsRef.current = backgroundShells; + toggleBackgroundTasksRef.current = toggleBackgroundTasks; + isBackgroundTaskVisibleRef.current = isBackgroundTaskVisible; + backgroundTasksRef.current = backgroundTasks; const { - activeBackgroundShellPid, - setIsBackgroundShellListOpen, - isBackgroundShellListOpen, - setActiveBackgroundShellPid, - backgroundShellHeight, - } = useBackgroundShellManager({ - backgroundShells, - backgroundShellCount, - isBackgroundShellVisible, + activeBackgroundTaskPid, + setIsBackgroundTaskListOpen, + isBackgroundTaskListOpen, + setActiveBackgroundTaskPid, + backgroundTaskHeight, + } = useBackgroundTaskManager({ + backgroundTasks, + backgroundTaskCount, + isBackgroundTaskVisible, activePtyId, embeddedShellFocused, setEmbeddedShellFocused, terminalHeight, }); - setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen; + setIsBackgroundTaskListOpenRef.current = setIsBackgroundTaskListOpen; const lastOutputTimeRef = useRef(0); @@ -1434,7 +1434,7 @@ Logging in with Google... Restarting Gemini CLI to continue. // Compute available terminal height based on stable controls measurement const availableTerminalHeight = Math.max( 0, - terminalHeight - stableControlsHeight - backgroundShellHeight - 1, + terminalHeight - stableControlsHeight - backgroundTaskHeight - 1, ); config.setShellExecutionConfig({ @@ -1790,7 +1790,7 @@ Logging in with Google... Restarting Gemini CLI to continue. } else if ( (keyMatchers[Command.FOCUS_SHELL_INPUT](key) || keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) && - (activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0)) + (activePtyId || (isBackgroundTaskVisible && backgroundTasks.size > 0)) ) { if (embeddedShellFocused) { const capturedTime = lastOutputTimeRef.current; @@ -1811,12 +1811,12 @@ Logging in with Google... Restarting Gemini CLI to continue. const isIdle = Date.now() - lastOutputTimeRef.current >= 100; - if (isIdle && !activePtyId && !isBackgroundShellVisible) { + if (isIdle && !activePtyId && !isBackgroundTaskVisible) { if (tabFocusTimeoutRef.current) clearTimeout(tabFocusTimeoutRef.current); - toggleBackgroundShell(); + toggleBackgroundTasks(); setEmbeddedShellFocused(true); - if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true); + if (backgroundTasks.size > 1) setIsBackgroundTaskListOpen(true); return true; } @@ -1833,15 +1833,15 @@ Logging in with Google... Restarting Gemini CLI to continue. return false; } else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { if (activePtyId) { - backgroundCurrentShell(); + backgroundCurrentExecution(); // After backgrounding, we explicitly do NOT show or focus the background UI. } else { - toggleBackgroundShell(); + toggleBackgroundTasks(); // Toggle focus based on intent: if we were hiding, unfocus; if showing, focus. - if (!isBackgroundShellVisible && backgroundShells.size > 0) { + if (!isBackgroundTaskVisible && backgroundTasks.size > 0) { setEmbeddedShellFocused(true); - if (backgroundShells.size > 1) { - setIsBackgroundShellListOpen(true); + if (backgroundTasks.size > 1) { + setIsBackgroundTaskListOpen(true); } } else { setEmbeddedShellFocused(false); @@ -1849,11 +1849,11 @@ Logging in with Google... Restarting Gemini CLI to continue. } return true; } else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { - if (backgroundShells.size > 0 && isBackgroundShellVisible) { + if (backgroundTasks.size > 0 && isBackgroundTaskVisible) { if (!embeddedShellFocused) { setEmbeddedShellFocused(true); } - setIsBackgroundShellListOpen(true); + setIsBackgroundTaskListOpen(true); } return true; } @@ -1878,11 +1878,11 @@ Logging in with Google... Restarting Gemini CLI to continue. tabFocusTimeoutRef, isAlternateBuffer, shortcutsHelpVisible, - backgroundCurrentShell, - toggleBackgroundShell, - backgroundShells, - isBackgroundShellVisible, - setIsBackgroundShellListOpen, + backgroundCurrentExecution, + toggleBackgroundTasks, + backgroundTasks, + isBackgroundTaskVisible, + setIsBackgroundTaskListOpen, lastOutputTimeRef, showTransientMessage, settings.merged.general.devtools, @@ -2055,7 +2055,7 @@ Logging in with Google... Restarting Gemini CLI to continue. const showStatusWit = loadingPhrases === 'witty' || loadingPhrases === 'all'; const showLoadingIndicator = - (!embeddedShellFocused || isBackgroundShellVisible) && + (!embeddedShellFocused || isBackgroundTaskVisible) && streamingState === StreamingState.Responding && !hasPendingActionRequired; @@ -2313,8 +2313,8 @@ Logging in with Google... Restarting Gemini CLI to continue. isRestarting, extensionsUpdateState, activePtyId, - backgroundShellCount, - isBackgroundShellVisible, + backgroundTaskCount, + isBackgroundTaskVisible, embeddedShellFocused, showDebugProfiler, customDialog, @@ -2324,10 +2324,10 @@ Logging in with Google... Restarting Gemini CLI to continue. bannerVisible, terminalBackgroundColor: config.getTerminalBackground(), settingsNonce, - backgroundShells, - activeBackgroundShellPid, - backgroundShellHeight, - isBackgroundShellListOpen, + backgroundTasks, + activeBackgroundTaskPid, + backgroundTaskHeight, + isBackgroundTaskListOpen, adminSettingsChanged, newAgents, showIsExpandableHint, @@ -2436,8 +2436,8 @@ Logging in with Google... Restarting Gemini CLI to continue. currentModel, extensionsUpdateState, activePtyId, - backgroundShellCount, - isBackgroundShellVisible, + backgroundTaskCount, + isBackgroundTaskVisible, historyManager, embeddedShellFocused, showDebugProfiler, @@ -2450,10 +2450,10 @@ Logging in with Google... Restarting Gemini CLI to continue. bannerVisible, config, settingsNonce, - backgroundShellHeight, - isBackgroundShellListOpen, - activeBackgroundShellPid, - backgroundShells, + backgroundTaskHeight, + isBackgroundTaskListOpen, + activeBackgroundTaskPid, + backgroundTasks, adminSettingsChanged, newAgents, showIsExpandableHint, @@ -2513,9 +2513,9 @@ Logging in with Google... Restarting Gemini CLI to continue. revealCleanUiDetailsTemporarily, handleWarning, setEmbeddedShellFocused, - dismissBackgroundShell, - setActiveBackgroundShellPid, - setIsBackgroundShellListOpen, + dismissBackgroundTask, + setActiveBackgroundTaskPid, + setIsBackgroundTaskListOpen, setAuthContext, onHintInput: () => {}, onHintBackspace: () => {}, @@ -2605,9 +2605,9 @@ Logging in with Google... Restarting Gemini CLI to continue. revealCleanUiDetailsTemporarily, handleWarning, setEmbeddedShellFocused, - dismissBackgroundShell, - setActiveBackgroundShellPid, - setIsBackgroundShellListOpen, + dismissBackgroundTask, + setActiveBackgroundTaskPid, + setIsBackgroundTaskListOpen, setAuthContext, setAccountSuspensionInfo, newAgents, diff --git a/packages/cli/src/ui/commands/shellsCommand.test.ts b/packages/cli/src/ui/commands/shellsCommand.test.ts deleted file mode 100644 index 794d162d6e..0000000000 --- a/packages/cli/src/ui/commands/shellsCommand.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * @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/tasksCommand.test.ts b/packages/cli/src/ui/commands/tasksCommand.test.ts new file mode 100644 index 0000000000..b60f3f8ab3 --- /dev/null +++ b/packages/cli/src/ui/commands/tasksCommand.test.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { tasksCommand } from './tasksCommand.js'; +import type { CommandContext } from './types.js'; + +describe('tasksCommand', () => { + it('should call toggleBackgroundTasks', async () => { + const toggleBackgroundTasks = vi.fn(); + const context = { + ui: { + toggleBackgroundTasks, + }, + } as unknown as CommandContext; + + if (tasksCommand.action) { + await tasksCommand.action(context, ''); + } + + expect(toggleBackgroundTasks).toHaveBeenCalled(); + }); + + it('should have correct name and altNames', () => { + expect(tasksCommand.name).toBe('tasks'); + expect(tasksCommand.altNames).toContain('bg'); + expect(tasksCommand.altNames).toContain('background'); + }); + + it('should auto-execute', () => { + expect(tasksCommand.autoExecute).toBe(true); + }); +}); diff --git a/packages/cli/src/ui/commands/shellsCommand.ts b/packages/cli/src/ui/commands/tasksCommand.ts similarity index 56% rename from packages/cli/src/ui/commands/shellsCommand.ts rename to packages/cli/src/ui/commands/tasksCommand.ts index 80645bbf8e..0980744e44 100644 --- a/packages/cli/src/ui/commands/shellsCommand.ts +++ b/packages/cli/src/ui/commands/tasksCommand.ts @@ -6,13 +6,13 @@ import { CommandKind, type SlashCommand } from './types.js'; -export const shellsCommand: SlashCommand = { - name: 'shells', - altNames: ['bashes'], +export const tasksCommand: SlashCommand = { + name: 'tasks', + altNames: ['bg', 'background'], kind: CommandKind.BUILT_IN, - description: 'Toggle background shells view', + description: 'Toggle background tasks view', autoExecute: true, action: async (context) => { - context.ui.toggleBackgroundShell(); + context.ui.toggleBackgroundTasks(); }, }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 4065e075bf..7b48439381 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -90,7 +90,7 @@ export interface CommandContext { */ setConfirmationRequest: (value: ConfirmationRequest) => void; removeComponent: () => void; - toggleBackgroundShell: () => void; + toggleBackgroundTasks: () => void; toggleShortcutsHelp: () => void; }; // Session-specific data diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx b/packages/cli/src/ui/components/BackgroundTaskDisplay.test.tsx similarity index 85% rename from packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx rename to packages/cli/src/ui/components/BackgroundTaskDisplay.test.tsx index c097028a0d..6083a0e569 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.test.tsx +++ b/packages/cli/src/ui/components/BackgroundTaskDisplay.test.tsx @@ -6,8 +6,8 @@ import { render } from '../../test-utils/render.js'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { BackgroundShellDisplay } from './BackgroundShellDisplay.js'; -import { type BackgroundShell } from '../hooks/shellCommandProcessor.js'; +import { BackgroundTaskDisplay } from './BackgroundTaskDisplay.js'; +import { type BackgroundTask } from '../hooks/useExecutionLifecycle.js'; import { ShellExecutionService } from '@google/gemini-cli-core'; import { act } from 'react'; import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js'; @@ -15,15 +15,15 @@ import { ScrollProvider } from '../contexts/ScrollProvider.js'; import { Box } from 'ink'; // Mock dependencies -const mockDismissBackgroundShell = vi.fn(); -const mockSetActiveBackgroundShellPid = vi.fn(); -const mockSetIsBackgroundShellListOpen = vi.fn(); +const mockDismissBackgroundTask = vi.fn(); +const mockSetActiveBackgroundTaskPid = vi.fn(); +const mockSetIsBackgroundTaskListOpen = vi.fn(); vi.mock('../contexts/UIActionsContext.js', () => ({ useUIActions: () => ({ - dismissBackgroundShell: mockDismissBackgroundShell, - setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid, - setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen, + dismissBackgroundTask: mockDismissBackgroundTask, + setActiveBackgroundTaskPid: mockSetActiveBackgroundTaskPid, + setIsBackgroundTaskListOpen: mockSetIsBackgroundTaskListOpen, }), })); @@ -86,14 +86,14 @@ vi.mock('./shared/ScrollableList.js', () => ({ data, renderItem, }: { - data: BackgroundShell[]; + data: BackgroundTask[]; renderItem: (props: { - item: BackgroundShell; + item: BackgroundTask; index: number; }) => React.ReactNode; }) => ( - {data.map((item: BackgroundShell, index: number) => ( + {data.map((item: BackgroundTask, index: number) => ( {renderItem({ item, index })} ))} @@ -116,9 +116,9 @@ const createMockKey = (overrides: Partial): Key => ({ ...overrides, }); -describe('', () => { - const mockShells = new Map(); - const shell1: BackgroundShell = { +describe('', () => { + const mockShells = new Map(); + const shell1: BackgroundTask = { pid: 1001, command: 'npm start', output: 'Starting server...', @@ -126,7 +126,7 @@ describe('', () => { binaryBytesReceived: 0, status: 'running', }; - const shell2: BackgroundShell = { + const shell2: BackgroundTask = { pid: 1002, command: 'tail -f log.txt', output: 'Log entry 1', @@ -147,7 +147,7 @@ describe('', () => { const width = 80; const { lastFrame, unmount } = await render( - ', () => { const width = 100; const { lastFrame, unmount } = await render( - ', () => { const width = 80; const { lastFrame, unmount } = await render( - ', () => { const width = 80; const { rerender, unmount } = await render( - ', () => { rerender( - ', () => { const width = 80; const { lastFrame, unmount } = await render( - ', () => { const width = 80; const { unmount } = await render( - ', () => { simulateKey({ name: 'down' }); }); - // Simulate Ctrl+L (handled by BackgroundShellDisplay) + // Simulate Ctrl+L (handled by BackgroundTaskDisplay) await act(async () => { simulateKey({ name: 'l', ctrl: true }); }); - expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid); - expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false); + expect(mockSetActiveBackgroundTaskPid).toHaveBeenCalledWith(shell2.pid); + expect(mockSetIsBackgroundTaskListOpen).toHaveBeenCalledWith(false); unmount(); }); @@ -301,7 +301,7 @@ describe('', () => { const width = 80; const { unmount } = await render( - ', () => { simulateKey({ name: 'k', ctrl: true }); }); - expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid); + expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell2.pid); unmount(); }); @@ -333,7 +333,7 @@ describe('', () => { const width = 80; const { unmount } = await render( - ', () => { simulateKey({ name: 'k', ctrl: true }); }); - expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid); + expect(mockDismissBackgroundTask).toHaveBeenCalledWith(shell1.pid); unmount(); }); @@ -358,7 +358,7 @@ describe('', () => { const width = 80; const { lastFrame, unmount } = await render( - ', () => { }); it('keeps exit code status color even when selected', async () => { - const exitedShell: BackgroundShell = { + const exitedShell: BackgroundTask = { pid: 1003, command: 'exit 0', output: '', @@ -389,7 +389,7 @@ describe('', () => { const width = 80; const { lastFrame, unmount } = await render( - ; +interface BackgroundTaskDisplayProps { + shells: Map; activePid: number; width: number; height: number; @@ -61,19 +61,19 @@ const formatShellCommandForDisplay = (command: string, maxWidth: number) => { : commandFirstLine; }; -export const BackgroundShellDisplay = ({ +export const BackgroundTaskDisplay = ({ shells, activePid, width, height, isFocused, isListOpenProp, -}: BackgroundShellDisplayProps) => { +}: BackgroundTaskDisplayProps) => { const keyMatchers = useKeyMatchers(); const { - dismissBackgroundShell, - setActiveBackgroundShellPid, - setIsBackgroundShellListOpen, + dismissBackgroundTask, + setActiveBackgroundTaskPid, + setIsBackgroundTaskListOpen, } = useUIActions(); const activeShell = shells.get(activePid); const [output, setOutput] = useState( @@ -152,13 +152,13 @@ export const BackgroundShellDisplay = ({ // RadioButtonSelect handles Enter -> onSelect if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) { - setIsBackgroundShellListOpen(false); + setIsBackgroundTaskListOpen(false); return true; } if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { if (highlightedPid) { - void dismissBackgroundShell(highlightedPid); + void dismissBackgroundTask(highlightedPid); // If we killed the active one, the list might update via props } return true; @@ -166,9 +166,9 @@ export const BackgroundShellDisplay = ({ if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { if (highlightedPid) { - setActiveBackgroundShellPid(highlightedPid); + setActiveBackgroundTaskPid(highlightedPid); } - setIsBackgroundShellListOpen(false); + setIsBackgroundTaskListOpen(false); return true; } return false; @@ -179,12 +179,12 @@ export const BackgroundShellDisplay = ({ } if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { - void dismissBackgroundShell(activeShell.pid); + void dismissBackgroundTask(activeShell.pid); return true; } if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) { - setIsBackgroundShellListOpen(true); + setIsBackgroundTaskListOpen(true); return true; } @@ -339,8 +339,8 @@ export const BackgroundShellDisplay = ({ items={items} initialIndex={initialIndex >= 0 ? initialIndex : 0} onSelect={(pid) => { - setActiveBackgroundShellPid(pid); - setIsBackgroundShellListOpen(false); + setActiveBackgroundTaskPid(pid); + setIsBackgroundTaskListOpen(false); }} onHighlight={(pid) => setHighlightedPid(pid)} isFocused={isFocused} diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 1cbb29a06c..1750536dbe 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -198,7 +198,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => nightly: false, isTrustedFolder: true, activeHooks: [], - isBackgroundShellVisible: false, + isBackgroundTaskVisible: false, embeddedShellFocused: false, showIsExpandableHint: false, quota: { @@ -464,7 +464,7 @@ describe('Composer', () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, embeddedShellFocused: true, - isBackgroundShellVisible: true, + isBackgroundTaskVisible: true, }); const { lastFrame } = await renderComposer(uiState); @@ -494,7 +494,7 @@ describe('Composer', () => { const uiState = createMockUIState({ streamingState: StreamingState.Responding, embeddedShellFocused: true, - isBackgroundShellVisible: false, + isBackgroundTaskVisible: false, }); const { lastFrame } = await renderComposer(uiState); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index b8dfaf3c0e..f078dbc7d6 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -232,8 +232,8 @@ export const InputPrompt: React.FC = ({ terminalWidth, activePtyId, history, - backgroundShells, - backgroundShellHeight, + backgroundTasks, + backgroundTaskHeight, shortcutsHelpVisible, } = useUIState(); const [suppressCompletion, setSuppressCompletion] = useState(false); @@ -1262,7 +1262,7 @@ export const InputPrompt: React.FC = ({ if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) { if ( activePtyId || - (backgroundShells.size > 0 && backgroundShellHeight > 0) + (backgroundTasks.size > 0 && backgroundTaskHeight > 0) ) { setEmbeddedShellFocused(true); return true; @@ -1325,8 +1325,8 @@ export const InputPrompt: React.FC = ({ setBannerVisible, activePtyId, setEmbeddedShellFocused, - backgroundShells.size, - backgroundShellHeight, + backgroundTasks.size, + backgroundTaskHeight, streamingState, handleEscPress, registerPlainTabPress, diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index b6bc0795eb..93d77e0dfe 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -86,10 +86,10 @@ vi.mock('./shared/ScrollableList.js', () => ({ })); import { theme } from '../semantic-colors.js'; -import { type BackgroundShell } from '../hooks/shellReducer.js'; +import { type BackgroundTask } from '../hooks/shellReducer.js'; describe('getToolGroupBorderAppearance', () => { - const mockBackgroundShells = new Map(); + const mockBackgroundTasks = new Map(); const activeShellPtyId = 123; it('returns default empty values for non-tool_group items', () => { @@ -99,7 +99,7 @@ describe('getToolGroupBorderAppearance', () => { null, false, [], - mockBackgroundShells, + mockBackgroundTasks, ); expect(result).toEqual({ borderColor: '', borderDimColor: false }); }); @@ -144,7 +144,7 @@ describe('getToolGroupBorderAppearance', () => { null, false, pendingItems, - mockBackgroundShells, + mockBackgroundTasks, ); expect(result).toEqual({ borderColor: theme.border.default, @@ -173,7 +173,7 @@ describe('getToolGroupBorderAppearance', () => { null, false, [], - mockBackgroundShells, + mockBackgroundTasks, ); expect(result).toEqual({ borderColor: theme.border.default, @@ -202,7 +202,7 @@ describe('getToolGroupBorderAppearance', () => { null, false, [], - mockBackgroundShells, + mockBackgroundTasks, ); expect(result).toEqual({ borderColor: theme.status.warning, @@ -232,7 +232,7 @@ describe('getToolGroupBorderAppearance', () => { activeShellPtyId, false, [], - mockBackgroundShells, + mockBackgroundTasks, ); expect(result).toEqual({ borderColor: theme.ui.active, @@ -262,7 +262,7 @@ describe('getToolGroupBorderAppearance', () => { activeShellPtyId, true, [], - mockBackgroundShells, + mockBackgroundTasks, ); expect(result).toEqual({ borderColor: theme.ui.focus, @@ -291,7 +291,7 @@ describe('getToolGroupBorderAppearance', () => { activeShellPtyId, false, [], - mockBackgroundShells, + mockBackgroundTasks, ); expect(result).toEqual({ borderColor: theme.ui.active, @@ -308,7 +308,7 @@ describe('getToolGroupBorderAppearance', () => { activeShellPtyId, true, [], - mockBackgroundShells, + mockBackgroundTasks, ); // Since there are no tools to inspect, it falls back to empty pending, but isCurrentlyInShellTurn=true // so it counts as pending shell. diff --git a/packages/cli/src/ui/components/StatusDisplay.test.tsx b/packages/cli/src/ui/components/StatusDisplay.test.tsx index 82b439e65f..a8a369b301 100644 --- a/packages/cli/src/ui/components/StatusDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.test.tsx @@ -51,7 +51,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState => ideContextState: null, geminiMdFileCount: 0, contextFileNames: [], - backgroundShellCount: 0, + backgroundTaskCount: 0, buffer: { text: '' }, history: [{ id: 1, type: 'user', text: 'test' }], ...overrides, @@ -159,9 +159,9 @@ describe('StatusDisplay', () => { unmount(); }); - it('passes backgroundShellCount to ContextSummaryDisplay', async () => { + it('passes backgroundTaskCount to ContextSummaryDisplay', async () => { const uiState = createMockUIState({ - backgroundShellCount: 3, + backgroundTaskCount: 3, }); const { lastFrame, unmount } = await renderStatusDisplay( { hideContextSummary: false }, diff --git a/packages/cli/src/ui/components/StatusDisplay.tsx b/packages/cli/src/ui/components/StatusDisplay.tsx index 472e900b3b..7cd0656e60 100644 --- a/packages/cli/src/ui/components/StatusDisplay.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.tsx @@ -38,7 +38,7 @@ export const StatusDisplay: React.FC = ({ config.getMcpClientManager()?.getBlockedMcpServers() ?? [] } skillCount={config.getSkillManager().getDisplayableSkills().length} - backgroundProcessCount={uiState.backgroundShellCount} + backgroundProcessCount={uiState.backgroundTaskCount} /> ); } diff --git a/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/BackgroundTaskDisplay.test.tsx.snap similarity index 92% rename from packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap rename to packages/cli/src/ui/components/__snapshots__/BackgroundTaskDisplay.test.tsx.snap index 0cc1f4b9f0..b9e20b490d 100644 --- a/packages/cli/src/ui/components/__snapshots__/BackgroundShellDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/BackgroundTaskDisplay.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` > highlights the focused state 1`] = ` +exports[` > highlights the focused state 1`] = ` "┌──────────────────────────────────────────────────────────────────────────────┐ │ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │ │ (Focused) (Ctrl+L) │ @@ -10,7 +10,7 @@ exports[` > highlights the focused state 1`] = ` " `; -exports[` > keeps exit code status color even when selected 1`] = ` +exports[` > keeps exit code status color even when selected 1`] = ` "┌──────────────────────────────────────────────────────────────────────────────┐ │ 1: npm sta.. (PID: 1003) Close (Ctrl+B) | Kill (Ctrl+K) | List │ │ (Focused) (Ctrl+L) │ @@ -25,7 +25,7 @@ exports[` > keeps exit code status color even when sel " `; -exports[` > renders tabs for multiple shells 1`] = ` +exports[` > renders tabs for multiple shells 1`] = ` "┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ │ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │ │ Starting server... │ @@ -34,7 +34,7 @@ exports[` > renders tabs for multiple shells 1`] = ` " `; -exports[` > renders the output of the active shell 1`] = ` +exports[` > renders the output of the active shell 1`] = ` "┌──────────────────────────────────────────────────────────────────────────────┐ │ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │ │ Starting server... │ @@ -43,7 +43,7 @@ exports[` > renders the output of the active shell 1`] " `; -exports[` > renders the process list when isListOpenProp is true 1`] = ` +exports[` > renders the process list when isListOpenProp is true 1`] = ` "┌──────────────────────────────────────────────────────────────────────────────┐ │ 1: npm sta.. (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List │ │ (Focused) (Ctrl+L) │ @@ -57,7 +57,7 @@ exports[` > renders the process list when isListOpenPr " `; -exports[` > scrolls to active shell when list opens 1`] = ` +exports[` > scrolls to active shell when list opens 1`] = ` "┌──────────────────────────────────────────────────────────────────────────────┐ │ 1: npm sta.. (PID: 1002) Close (Ctrl+B) | Kill (Ctrl+K) | List │ │ (Focused) (Ctrl+L) │ diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 637e8afa40..6bad49b1b6 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -81,7 +81,7 @@ export const ToolGroupMessage: React.FC = ({ const { activePtyId, embeddedShellFocused, - backgroundShells, + backgroundTasks, pendingHistoryItems, } = useUIState(); @@ -92,14 +92,14 @@ export const ToolGroupMessage: React.FC = ({ activePtyId, embeddedShellFocused, pendingHistoryItems, - backgroundShells, + backgroundTasks, ), [ item, activePtyId, embeddedShellFocused, pendingHistoryItems, - backgroundShells, + backgroundTasks, ], ); diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 9d83070e94..f1959c0173 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -81,9 +81,9 @@ export interface UIActions { revealCleanUiDetailsTemporarily: (durationMs?: number) => void; handleWarning: (message: string) => void; setEmbeddedShellFocused: (value: boolean) => void; - dismissBackgroundShell: (pid: number) => Promise; - setActiveBackgroundShellPid: (pid: number) => void; - setIsBackgroundShellListOpen: (isOpen: boolean) => void; + dismissBackgroundTask: (pid: number) => Promise; + setActiveBackgroundTaskPid: (pid: number) => void; + setIsBackgroundTaskListOpen: (isOpen: boolean) => void; setAuthContext: (context: { requiresRestart?: boolean }) => void; onHintInput: (char: string) => void; onHintBackspace: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 8447247e53..a5d10820b2 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -84,7 +84,7 @@ export interface EmptyWalletDialogRequest { 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'; +import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js'; export interface QuotaState { userTier: UserTierId | undefined; @@ -201,8 +201,8 @@ export interface UIState { isRestarting: boolean; extensionsUpdateState: Map; activePtyId: number | undefined; - backgroundShellCount: number; - isBackgroundShellVisible: boolean; + backgroundTaskCount: number; + isBackgroundTaskVisible: boolean; embeddedShellFocused: boolean; showDebugProfiler: boolean; showFullTodos: boolean; @@ -215,10 +215,10 @@ export interface UIState { customDialog: React.ReactNode | null; terminalBackgroundColor: TerminalBackgroundColor; settingsNonce: number; - backgroundShells: Map; - activeBackgroundShellPid: number | null; - backgroundShellHeight: number; - isBackgroundShellListOpen: boolean; + backgroundTasks: Map; + activeBackgroundTaskPid: number | null; + backgroundTaskHeight: number; + isBackgroundTaskListOpen: boolean; adminSettingsChanged: boolean; newAgents: AgentDefinition[] | null; showIsExpandableHint: boolean; diff --git a/packages/cli/src/ui/hooks/shellReducer.test.ts b/packages/cli/src/ui/hooks/shellReducer.test.ts index a9d4bf6da5..a6df9e61e6 100644 --- a/packages/cli/src/ui/hooks/shellReducer.test.ts +++ b/packages/cli/src/ui/hooks/shellReducer.test.ts @@ -36,27 +36,27 @@ describe('shellReducer', () => { it('should handle SET_VISIBILITY', () => { const action: ShellAction = { type: 'SET_VISIBILITY', visible: true }; const state = shellReducer(initialState, action); - expect(state.isBackgroundShellVisible).toBe(true); + expect(state.isBackgroundTaskVisible).toBe(true); }); it('should handle TOGGLE_VISIBILITY', () => { const action: ShellAction = { type: 'TOGGLE_VISIBILITY' }; let state = shellReducer(initialState, action); - expect(state.isBackgroundShellVisible).toBe(true); + expect(state.isBackgroundTaskVisible).toBe(true); state = shellReducer(state, action); - expect(state.isBackgroundShellVisible).toBe(false); + expect(state.isBackgroundTaskVisible).toBe(false); }); - it('should handle REGISTER_SHELL', () => { + it('should handle REGISTER_TASK', () => { const action: ShellAction = { - type: 'REGISTER_SHELL', + type: 'REGISTER_TASK', pid: 1001, command: 'ls', initialOutput: 'init', }; const state = shellReducer(initialState, action); - expect(state.backgroundShells.has(1001)).toBe(true); - expect(state.backgroundShells.get(1001)).toEqual({ + expect(state.backgroundTasks.has(1001)).toBe(true); + expect(state.backgroundTasks.get(1001)).toEqual({ pid: 1001, command: 'ls', output: 'init', @@ -66,9 +66,9 @@ describe('shellReducer', () => { }); }); - it('should not REGISTER_SHELL if PID already exists', () => { + it('should not REGISTER_TASK if PID already exists', () => { const action: ShellAction = { - type: 'REGISTER_SHELL', + type: 'REGISTER_TASK', pid: 1001, command: 'ls', initialOutput: 'init', @@ -76,35 +76,35 @@ describe('shellReducer', () => { const state = shellReducer(initialState, action); const state2 = shellReducer(state, { ...action, command: 'other' }); expect(state2).toBe(state); - expect(state2.backgroundShells.get(1001)?.command).toBe('ls'); + expect(state2.backgroundTasks.get(1001)?.command).toBe('ls'); }); - it('should handle UPDATE_SHELL', () => { + it('should handle UPDATE_TASK', () => { const registeredState = shellReducer(initialState, { - type: 'REGISTER_SHELL', + type: 'REGISTER_TASK', pid: 1001, command: 'ls', initialOutput: 'init', }); const action: ShellAction = { - type: 'UPDATE_SHELL', + type: 'UPDATE_TASK', pid: 1001, update: { status: 'exited', exitCode: 0 }, }; const state = shellReducer(registeredState, action); - const shell = state.backgroundShells.get(1001); + const shell = state.backgroundTasks.get(1001); expect(shell?.status).toBe('exited'); expect(shell?.exitCode).toBe(0); // Map should be new - expect(state.backgroundShells).not.toBe(registeredState.backgroundShells); + expect(state.backgroundTasks).not.toBe(registeredState.backgroundTasks); }); - it('should handle APPEND_SHELL_OUTPUT when visible (triggers re-render)', () => { + it('should handle APPEND_TASK_OUTPUT when visible (triggers re-render)', () => { const visibleState: ShellState = { ...initialState, - isBackgroundShellVisible: true, - backgroundShells: new Map([ + isBackgroundTaskVisible: true, + backgroundTasks: new Map([ [ 1001, { @@ -120,21 +120,21 @@ describe('shellReducer', () => { }; const action: ShellAction = { - type: 'APPEND_SHELL_OUTPUT', + type: 'APPEND_TASK_OUTPUT', pid: 1001, chunk: ' + more', }; const state = shellReducer(visibleState, action); - expect(state.backgroundShells.get(1001)?.output).toBe('init + more'); + expect(state.backgroundTasks.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); + expect(state.backgroundTasks).not.toBe(visibleState.backgroundTasks); }); - it('should handle APPEND_SHELL_OUTPUT when hidden (no re-render optimization)', () => { + it('should handle APPEND_TASK_OUTPUT when hidden (no re-render optimization)', () => { const hiddenState: ShellState = { ...initialState, - isBackgroundShellVisible: false, - backgroundShells: new Map([ + isBackgroundTaskVisible: false, + backgroundTasks: new Map([ [ 1001, { @@ -150,27 +150,27 @@ describe('shellReducer', () => { }; const action: ShellAction = { - type: 'APPEND_SHELL_OUTPUT', + type: 'APPEND_TASK_OUTPUT', pid: 1001, chunk: ' + more', }; const state = shellReducer(hiddenState, action); - expect(state.backgroundShells.get(1001)?.output).toBe('init + more'); + expect(state.backgroundTasks.get(1001)?.output).toBe('init + more'); // Drawer is hidden, so we expect the SAME map object (mutation optimization) - expect(state.backgroundShells).toBe(hiddenState.backgroundShells); + expect(state.backgroundTasks).toBe(hiddenState.backgroundTasks); }); - it('should handle SYNC_BACKGROUND_SHELLS', () => { - const action: ShellAction = { type: 'SYNC_BACKGROUND_SHELLS' }; + it('should handle SYNC_BACKGROUND_TASKS', () => { + const action: ShellAction = { type: 'SYNC_BACKGROUND_TASKS' }; const state = shellReducer(initialState, action); - expect(state.backgroundShells).not.toBe(initialState.backgroundShells); + expect(state.backgroundTasks).not.toBe(initialState.backgroundTasks); }); - it('should handle DISMISS_SHELL', () => { + it('should handle DISMISS_TASK', () => { const registeredState: ShellState = { ...initialState, - isBackgroundShellVisible: true, - backgroundShells: new Map([ + isBackgroundTaskVisible: true, + backgroundTasks: new Map([ [ 1001, { @@ -185,9 +185,9 @@ describe('shellReducer', () => { ]), }; - const action: ShellAction = { type: 'DISMISS_SHELL', pid: 1001 }; + const action: ShellAction = { type: 'DISMISS_TASK', pid: 1001 }; const state = shellReducer(registeredState, action); - expect(state.backgroundShells.has(1001)).toBe(false); - expect(state.isBackgroundShellVisible).toBe(false); // Auto-hide if last shell + expect(state.backgroundTasks.has(1001)).toBe(false); + expect(state.isBackgroundTaskVisible).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 index 7d3917c681..43f40d546c 100644 --- a/packages/cli/src/ui/hooks/shellReducer.ts +++ b/packages/cli/src/ui/hooks/shellReducer.ts @@ -4,9 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { AnsiOutput } from '@google/gemini-cli-core'; +import type { AnsiOutput, CompletionBehavior } from '@google/gemini-cli-core'; -export interface BackgroundShell { +export interface BackgroundTask { pid: number; command: string; output: string | AnsiOutput; @@ -14,13 +14,14 @@ export interface BackgroundShell { binaryBytesReceived: number; status: 'running' | 'exited'; exitCode?: number; + completionBehavior?: CompletionBehavior; } export interface ShellState { activeShellPtyId: number | null; lastShellOutputTime: number; - backgroundShells: Map; - isBackgroundShellVisible: boolean; + backgroundTasks: Map; + isBackgroundTaskVisible: boolean; } export type ShellAction = @@ -29,21 +30,22 @@ export type ShellAction = | { type: 'SET_VISIBILITY'; visible: boolean } | { type: 'TOGGLE_VISIBILITY' } | { - type: 'REGISTER_SHELL'; + type: 'REGISTER_TASK'; pid: number; command: string; initialOutput: string | AnsiOutput; + completionBehavior?: CompletionBehavior; } - | { 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 }; + | { type: 'UPDATE_TASK'; pid: number; update: Partial } + | { type: 'APPEND_TASK_OUTPUT'; pid: number; chunk: string | AnsiOutput } + | { type: 'SYNC_BACKGROUND_TASKS' } + | { type: 'DISMISS_TASK'; pid: number }; export const initialState: ShellState = { activeShellPtyId: null, lastShellOutputTime: 0, - backgroundShells: new Map(), - isBackgroundShellVisible: false, + backgroundTasks: new Map(), + isBackgroundTaskVisible: false, }; export function shellReducer( @@ -56,75 +58,76 @@ export function shellReducer( case 'SET_OUTPUT_TIME': return { ...state, lastShellOutputTime: action.time }; case 'SET_VISIBILITY': - return { ...state, isBackgroundShellVisible: action.visible }; + return { ...state, isBackgroundTaskVisible: action.visible }; case 'TOGGLE_VISIBILITY': return { ...state, - isBackgroundShellVisible: !state.isBackgroundShellVisible, + isBackgroundTaskVisible: !state.isBackgroundTaskVisible, }; - case 'REGISTER_SHELL': { - if (state.backgroundShells.has(action.pid)) return state; - const nextShells = new Map(state.backgroundShells); - nextShells.set(action.pid, { + case 'REGISTER_TASK': { + if (state.backgroundTasks.has(action.pid)) return state; + const nextTasks = new Map(state.backgroundTasks); + nextTasks.set(action.pid, { pid: action.pid, command: action.command, output: action.initialOutput, isBinary: false, binaryBytesReceived: 0, status: 'running', + completionBehavior: action.completionBehavior, }); - return { ...state, backgroundShells: nextShells }; + return { ...state, backgroundTasks: nextTasks }; } - 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 }; + case 'UPDATE_TASK': { + const task = state.backgroundTasks.get(action.pid); + if (!task) return state; + const nextTasks = new Map(state.backgroundTasks); + const updatedTask = { ...task, ...action.update }; // Maintain insertion order, move to end if status changed to exited if (action.update.status === 'exited') { - nextShells.delete(action.pid); + nextTasks.delete(action.pid); } - nextShells.set(action.pid, updatedShell); - return { ...state, backgroundShells: nextShells }; + nextTasks.set(action.pid, updatedTask); + return { ...state, backgroundTasks: nextTasks }; } - 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 + case 'APPEND_TASK_OUTPUT': { + const task = state.backgroundTasks.get(action.pid); + if (!task) return state; + // Note: we mutate the task 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; + let newOutput = task.output; if (typeof action.chunk === 'string') { newOutput = - typeof shell.output === 'string' - ? shell.output + action.chunk + typeof task.output === 'string' + ? task.output + action.chunk : action.chunk; } else { newOutput = action.chunk; } - shell.output = newOutput; + task.output = newOutput; const nextState = { ...state, lastShellOutputTime: Date.now() }; - if (state.isBackgroundShellVisible) { + if (state.isBackgroundTaskVisible) { return { ...nextState, - backgroundShells: new Map(state.backgroundShells), + backgroundTasks: new Map(state.backgroundTasks), }; } return nextState; } - case 'SYNC_BACKGROUND_SHELLS': { - return { ...state, backgroundShells: new Map(state.backgroundShells) }; + case 'SYNC_BACKGROUND_TASKS': { + return { ...state, backgroundTasks: new Map(state.backgroundTasks) }; } - case 'DISMISS_SHELL': { - const nextShells = new Map(state.backgroundShells); - nextShells.delete(action.pid); + case 'DISMISS_TASK': { + const nextTasks = new Map(state.backgroundTasks); + nextTasks.delete(action.pid); return { ...state, - backgroundShells: nextShells, - isBackgroundShellVisible: - nextShells.size === 0 ? false : state.isBackgroundShellVisible, + backgroundTasks: nextTasks, + isBackgroundTaskVisible: + nextTasks.size === 0 ? false : state.isBackgroundTaskVisible, }; } default: diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx index 33df14dcce..ec4aa00677 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.tsx @@ -213,7 +213,7 @@ describe('useSlashCommandProcessor', () => { toggleDebugProfiler: vi.fn(), dispatchExtensionStateUpdate: vi.fn(), addConfirmUpdateExtensionRequest: vi.fn(), - toggleBackgroundShell: vi.fn(), + toggleBackgroundTasks: vi.fn(), toggleShortcutsHelp: vi.fn(), setText: vi.fn(), }, diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 1839670df7..f55503ad25 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -84,7 +84,7 @@ interface SlashCommandProcessorActions { toggleDebugProfiler: () => void; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; - toggleBackgroundShell: () => void; + toggleBackgroundTasks: () => void; toggleShortcutsHelp: () => void; setText: (text: string) => void; } @@ -242,7 +242,7 @@ export const useSlashCommandProcessor = ( actions.addConfirmUpdateExtensionRequest, setConfirmationRequest, removeComponent: () => setCustomDialog(null), - toggleBackgroundShell: actions.toggleBackgroundShell, + toggleBackgroundTasks: actions.toggleBackgroundTasks, toggleShortcutsHelp: actions.toggleShortcutsHelp, }, session: { diff --git a/packages/cli/src/ui/hooks/useBackgroundShellManager.test.tsx b/packages/cli/src/ui/hooks/useBackgroundShellManager.test.tsx deleted file mode 100644 index c6a5e9ef4e..0000000000 --- a/packages/cli/src/ui/hooks/useBackgroundShellManager.test.tsx +++ /dev/null @@ -1,191 +0,0 @@ -/** - * @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 = async (props: BackgroundShellManagerProps) => { - let hookResult: ReturnType; - function TestComponent({ p }: { p: BackgroundShellManagerProps }) { - hookResult = useBackgroundShellManager(p); - return null; - } - const { rerender } = await render(); - return { - result: { - get current() { - return hookResult; - }, - }, - rerender: (newProps: BackgroundShellManagerProps) => - rerender(), - }; - }; - - it('should initialize with correct default values', async () => { - const backgroundShells = new Map(); - const { result } = await 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', async () => { - const backgroundShells = new Map(); - const { result, rerender } = await 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', async () => { - const backgroundShells = new Map([ - [123, {} as BackgroundShell], - ]); - const { result, rerender } = await 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', async () => { - const backgroundShells = new Map([ - [123, {} as BackgroundShell], - ]); - await 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', async () => { - const backgroundShells = new Map([ - [123, {} as BackgroundShell], - ]); - const { result } = await 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', async () => { - const backgroundShells = new Map([ - [123, {} as BackgroundShell], - [456, {} as BackgroundShell], - ]); - const { result, rerender } = await 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 deleted file mode 100644 index 465e4b8e0d..0000000000 --- a/packages/cli/src/ui/hooks/useBackgroundShellManager.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @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/useBackgroundTaskManager.test.tsx b/packages/cli/src/ui/hooks/useBackgroundTaskManager.test.tsx new file mode 100644 index 0000000000..d1c25e7de4 --- /dev/null +++ b/packages/cli/src/ui/hooks/useBackgroundTaskManager.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 { + useBackgroundTaskManager, + type BackgroundTaskManagerProps, +} from './useBackgroundTaskManager.js'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { act } from 'react'; +import { type BackgroundTask } from './shellReducer.js'; + +describe('useBackgroundTaskManager', () => { + const setEmbeddedShellFocused = vi.fn(); + const terminalHeight = 30; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderHook = async (props: BackgroundTaskManagerProps) => { + let hookResult: ReturnType; + function TestComponent({ p }: { p: BackgroundTaskManagerProps }) { + hookResult = useBackgroundTaskManager(p); + return null; + } + const { rerender } = await render(); + return { + result: { + get current() { + return hookResult; + }, + }, + rerender: (newProps: BackgroundTaskManagerProps) => + rerender(), + }; + }; + + it('should initialize with correct default values', async () => { + const backgroundTasks = new Map(); + const { result } = await renderHook({ + backgroundTasks, + backgroundTaskCount: 0, + isBackgroundTaskVisible: false, + activePtyId: null, + embeddedShellFocused: false, + setEmbeddedShellFocused, + terminalHeight, + }); + + expect(result.current.isBackgroundTaskListOpen).toBe(false); + expect(result.current.activeBackgroundTaskPid).toBe(null); + expect(result.current.backgroundTaskHeight).toBe(0); + }); + + it('should auto-select the first background shell when added', async () => { + const backgroundTasks = new Map(); + const { result, rerender } = await renderHook({ + backgroundTasks, + backgroundTaskCount: 0, + isBackgroundTaskVisible: false, + activePtyId: null, + embeddedShellFocused: false, + setEmbeddedShellFocused, + terminalHeight, + }); + + const newShells = new Map([ + [123, {} as BackgroundTask], + ]); + rerender({ + backgroundTasks: newShells, + backgroundTaskCount: 1, + isBackgroundTaskVisible: false, + activePtyId: null, + embeddedShellFocused: false, + setEmbeddedShellFocused, + terminalHeight, + }); + + expect(result.current.activeBackgroundTaskPid).toBe(123); + }); + + it('should reset state when all shells are removed', async () => { + const backgroundTasks = new Map([ + [123, {} as BackgroundTask], + ]); + const { result, rerender } = await renderHook({ + backgroundTasks, + backgroundTaskCount: 1, + isBackgroundTaskVisible: true, + activePtyId: null, + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight, + }); + + act(() => { + result.current.setIsBackgroundTaskListOpen(true); + }); + expect(result.current.isBackgroundTaskListOpen).toBe(true); + + rerender({ + backgroundTasks: new Map(), + backgroundTaskCount: 0, + isBackgroundTaskVisible: true, + activePtyId: null, + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight, + }); + + expect(result.current.activeBackgroundTaskPid).toBe(null); + expect(result.current.isBackgroundTaskListOpen).toBe(false); + }); + + it('should unfocus embedded shell when no shells are active', async () => { + const backgroundTasks = new Map([ + [123, {} as BackgroundTask], + ]); + await renderHook({ + backgroundTasks, + backgroundTaskCount: 1, + isBackgroundTaskVisible: false, // Background shell not visible + activePtyId: null, // No foreground shell + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight, + }); + + expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false); + }); + + it('should calculate backgroundTaskHeight correctly when visible', async () => { + const backgroundTasks = new Map([ + [123, {} as BackgroundTask], + ]); + const { result } = await renderHook({ + backgroundTasks, + backgroundTaskCount: 1, + isBackgroundTaskVisible: true, + activePtyId: null, + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight: 100, + }); + + // 100 * 0.3 = 30 + expect(result.current.backgroundTaskHeight).toBe(30); + }); + + it('should maintain current active shell if it still exists', async () => { + const backgroundTasks = new Map([ + [123, {} as BackgroundTask], + [456, {} as BackgroundTask], + ]); + const { result, rerender } = await renderHook({ + backgroundTasks, + backgroundTaskCount: 2, + isBackgroundTaskVisible: true, + activePtyId: null, + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight, + }); + + act(() => { + result.current.setActiveBackgroundTaskPid(456); + }); + expect(result.current.activeBackgroundTaskPid).toBe(456); + + // Remove the OTHER shell + const updatedShells = new Map([ + [456, {} as BackgroundTask], + ]); + rerender({ + backgroundTasks: updatedShells, + backgroundTaskCount: 1, + isBackgroundTaskVisible: true, + activePtyId: null, + embeddedShellFocused: true, + setEmbeddedShellFocused, + terminalHeight, + }); + + expect(result.current.activeBackgroundTaskPid).toBe(456); + }); +}); diff --git a/packages/cli/src/ui/hooks/useBackgroundTaskManager.ts b/packages/cli/src/ui/hooks/useBackgroundTaskManager.ts new file mode 100644 index 0000000000..54b8c553fe --- /dev/null +++ b/packages/cli/src/ui/hooks/useBackgroundTaskManager.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useMemo } from 'react'; +import { type BackgroundTask } from './useExecutionLifecycle.js'; + +export interface BackgroundTaskManagerProps { + backgroundTasks: Map; + backgroundTaskCount: number; + isBackgroundTaskVisible: boolean; + activePtyId: number | null | undefined; + embeddedShellFocused: boolean; + setEmbeddedShellFocused: (focused: boolean) => void; + terminalHeight: number; +} + +export function useBackgroundTaskManager({ + backgroundTasks, + backgroundTaskCount, + isBackgroundTaskVisible, + activePtyId, + embeddedShellFocused, + setEmbeddedShellFocused, + terminalHeight, +}: BackgroundTaskManagerProps) { + const [isBackgroundTaskListOpen, setIsBackgroundTaskListOpen] = + useState(false); + const [activeBackgroundTaskPid, setActiveBackgroundTaskPid] = useState< + number | null + >(null); + + useEffect(() => { + if (backgroundTasks.size === 0) { + if (activeBackgroundTaskPid !== null) { + setActiveBackgroundTaskPid(null); + } + if (isBackgroundTaskListOpen) { + setIsBackgroundTaskListOpen(false); + } + } else if ( + activeBackgroundTaskPid === null || + !backgroundTasks.has(activeBackgroundTaskPid) + ) { + // If active shell is closed or none selected, select the first one (last added usually, or just first in iteration) + setActiveBackgroundTaskPid(backgroundTasks.keys().next().value ?? null); + } + }, [ + backgroundTasks, + activeBackgroundTaskPid, + backgroundTaskCount, + isBackgroundTaskListOpen, + ]); + + useEffect(() => { + if (embeddedShellFocused) { + const hasActiveForegroundShell = !!activePtyId; + const hasVisibleBackgroundTask = + isBackgroundTaskVisible && backgroundTasks.size > 0; + + if (!hasActiveForegroundShell && !hasVisibleBackgroundTask) { + setEmbeddedShellFocused(false); + } + } + }, [ + isBackgroundTaskVisible, + backgroundTasks, + embeddedShellFocused, + backgroundTaskCount, + activePtyId, + setEmbeddedShellFocused, + ]); + + const backgroundTaskHeight = useMemo( + () => + isBackgroundTaskVisible && backgroundTasks.size > 0 + ? Math.max(Math.floor(terminalHeight * 0.3), 5) + : 0, + [isBackgroundTaskVisible, backgroundTasks.size, terminalHeight], + ); + + return { + isBackgroundTaskListOpen, + setIsBackgroundTaskListOpen, + activeBackgroundTaskPid, + setActiveBackgroundTaskPid, + backgroundTaskHeight, + }; +} diff --git a/packages/cli/src/ui/hooks/useComposerStatus.ts b/packages/cli/src/ui/hooks/useComposerStatus.ts index 0f82e650aa..3b9c5f0eec 100644 --- a/packages/cli/src/ui/hooks/useComposerStatus.ts +++ b/packages/cli/src/ui/hooks/useComposerStatus.ts @@ -49,7 +49,7 @@ export const useComposerStatus = () => { ); const showLoadingIndicator = - (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && + (!uiState.embeddedShellFocused || uiState.isBackgroundTaskVisible) && uiState.streamingState === StreamingState.Responding && !hasPendingActionRequired; diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx similarity index 86% rename from packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx rename to packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx index f9416d379f..743bf90c04 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx @@ -35,6 +35,23 @@ const mockShellOnExit = vi.hoisted(() => ) => () => void >(() => vi.fn()), ); +const mockLifecycleSubscribe = vi.hoisted(() => + vi.fn< + (pid: number, listener: (event: ShellOutputEvent) => void) => () => void + >(() => vi.fn()), +); +const mockLifecycleOnExit = vi.hoisted(() => + vi.fn< + ( + pid: number, + callback: (exitCode: number, signal?: number) => void, + ) => () => void + >(() => vi.fn()), +); +const mockLifecycleKill = vi.hoisted(() => vi.fn()); +const mockLifecycleBackground = vi.hoisted(() => vi.fn()); +const mockLifecycleOnBackground = vi.hoisted(() => vi.fn()); +const mockLifecycleOffBackground = vi.hoisted(() => vi.fn()); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = @@ -48,6 +65,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { subscribe: mockShellSubscribe, onExit: mockShellOnExit, }, + ExecutionLifecycleService: { + subscribe: mockLifecycleSubscribe, + onExit: mockLifecycleOnExit, + kill: mockLifecycleKill, + background: mockLifecycleBackground, + onBackground: mockLifecycleOnBackground, + offBackground: mockLifecycleOffBackground, + }, isBinary: mockIsBinary, }; }); @@ -68,9 +93,9 @@ vi.mock('node:os', async (importOriginal) => { vi.mock('node:crypto'); import { - useShellCommandProcessor, + useExecutionLifecycle, OUTPUT_UPDATE_INTERVAL_MS, -} from './shellCommandProcessor.js'; +} from './useExecutionLifecycle.js'; import { type Config, type GeminiClient, @@ -83,7 +108,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import * as crypto from 'node:crypto'; -describe('useShellCommandProcessor', () => { +describe('useExecutionLifecycle', () => { let addItemToHistoryMock: Mock; let setPendingHistoryItemMock: Mock; let onExecMock: Mock; @@ -140,7 +165,7 @@ describe('useShellCommandProcessor', () => { }); const renderProcessorHook = async () => { - let hookResult: ReturnType; + let hookResult: ReturnType; let renderCount = 0; function TestComponent({ isWaitingForConfirmation, @@ -148,7 +173,7 @@ describe('useShellCommandProcessor', () => { isWaitingForConfirmation?: boolean; }) { renderCount++; - hookResult = useShellCommandProcessor( + hookResult = useExecutionLifecycle( addItemToHistoryMock, setPendingHistoryItemMock, onExecMock, @@ -772,11 +797,11 @@ describe('useShellCommandProcessor', () => { const { result } = await renderProcessorHook(); act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); - expect(result.current.backgroundShellCount).toBe(1); - const shell = result.current.backgroundShells.get(1001); + expect(result.current.backgroundTaskCount).toBe(1); + const shell = result.current.backgroundTasks.get(1001); expect(shell).toEqual( expect.objectContaining({ pid: 1001, @@ -784,8 +809,11 @@ describe('useShellCommandProcessor', () => { output: 'initial', }), ); - expect(mockShellOnExit).toHaveBeenCalledWith(1001, expect.any(Function)); - expect(mockShellSubscribe).toHaveBeenCalledWith( + expect(mockLifecycleOnExit).toHaveBeenCalledWith( + 1001, + expect.any(Function), + ); + expect(mockLifecycleSubscribe).toHaveBeenCalledWith( 1001, expect.any(Function), ); @@ -795,55 +823,55 @@ describe('useShellCommandProcessor', () => { const { result } = await renderProcessorHook(); act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); - expect(result.current.isBackgroundShellVisible).toBe(false); + expect(result.current.isBackgroundTaskVisible).toBe(false); act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); - expect(result.current.isBackgroundShellVisible).toBe(true); + expect(result.current.isBackgroundTaskVisible).toBe(true); act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); - expect(result.current.isBackgroundShellVisible).toBe(false); + expect(result.current.isBackgroundTaskVisible).toBe(false); }); it('should show info message when toggling background shells if none are active', async () => { const { result } = await renderProcessorHook(); act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); expect(addItemToHistoryMock).toHaveBeenCalledWith( expect.objectContaining({ type: 'info', - text: 'No background shells are currently active.', + text: 'No background tasks are currently active.', }), expect.any(Number), ); - expect(result.current.isBackgroundShellVisible).toBe(false); + expect(result.current.isBackgroundTaskVisible).toBe(false); }); it('should dismiss a background shell and remove it from state', async () => { const { result } = await renderProcessorHook(); act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); await act(async () => { - await result.current.dismissBackgroundShell(1001); + await result.current.dismissBackgroundTask(1001); }); - expect(mockShellKill).toHaveBeenCalledWith(1001); - expect(result.current.backgroundShellCount).toBe(0); - expect(result.current.backgroundShells.has(1001)).toBe(false); + expect(mockLifecycleKill).toHaveBeenCalledWith(1001); + expect(result.current.backgroundTaskCount).toBe(0); + expect(result.current.backgroundTasks.has(1001)).toBe(false); }); it('should handle backgrounding the current shell', async () => { @@ -867,7 +895,7 @@ describe('useShellCommandProcessor', () => { expect(result.current.activeShellPtyId).toBe(555); act(() => { - result.current.backgroundCurrentShell(); + result.current.backgroundCurrentExecution(); }); expect(mockShellBackground).toHaveBeenCalledWith(555); @@ -887,19 +915,19 @@ describe('useShellCommandProcessor', () => { // Wait for promise resolution await act(async () => await onExecMock.mock.calls[0][0]); - expect(result.current.backgroundShellCount).toBe(1); + expect(result.current.backgroundTaskCount).toBe(1); expect(result.current.activeShellPtyId).toBeNull(); }); - it('should persist background shell on successful exit and mark as exited', async () => { + it('should auto-dismiss background task on successful exit', async () => { const { result } = await renderProcessorHook(); act(() => { - result.current.registerBackgroundShell(888, 'auto-exit', ''); + result.current.registerBackgroundTask(888, 'auto-exit', ''); }); // Find the exit callback registered - const exitCallback = mockShellOnExit.mock.calls.find( + const exitCallback = mockLifecycleOnExit.mock.calls.find( (call) => call[0] === 888, )?.[1]; expect(exitCallback).toBeDefined(); @@ -910,22 +938,19 @@ describe('useShellCommandProcessor', () => { }); } - // 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); + // Should be auto-dismissed from the panel + expect(result.current.backgroundTaskCount).toBe(0); + expect(result.current.backgroundTasks.has(888)).toBe(false); }); - it('should persist background shell on failed exit', async () => { + it('should auto-dismiss background task on failed exit', async () => { const { result } = await renderProcessorHook(); act(() => { - result.current.registerBackgroundShell(999, 'fail-exit', ''); + result.current.registerBackgroundTask(999, 'fail-exit', ''); }); - const exitCallback = mockShellOnExit.mock.calls.find( + const exitCallback = mockLifecycleOnExit.mock.calls.find( (call) => call[0] === 999, )?.[1]; expect(exitCallback).toBeDefined(); @@ -936,34 +961,26 @@ describe('useShellCommandProcessor', () => { }); } - // 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 - await act(async () => { - await result.current.dismissBackgroundShell(999); - }); - expect(result.current.backgroundShellCount).toBe(0); + // Should be auto-dismissed from the panel + expect(result.current.backgroundTaskCount).toBe(0); + expect(result.current.backgroundTasks.has(999)).toBe(false); }); it('should NOT trigger re-render on background shell output when visible', async () => { const { result, getRenderCount } = await renderProcessorHook(); act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); // Show the background shells act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); const initialRenderCount = getRenderCount(); - const subscribeCallback = mockShellSubscribe.mock.calls.find( + const subscribeCallback = mockLifecycleSubscribe.mock.calls.find( (call) => call[0] === 1001, )?.[1]; expect(subscribeCallback).toBeDefined(); @@ -975,7 +992,7 @@ describe('useShellCommandProcessor', () => { } expect(getRenderCount()).toBeGreaterThan(initialRenderCount); - const shell = result.current.backgroundShells.get(1001); + const shell = result.current.backgroundTasks.get(1001); expect(shell?.output).toBe('initial + updated'); }); @@ -983,13 +1000,13 @@ describe('useShellCommandProcessor', () => { const { result, getRenderCount } = await renderProcessorHook(); act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); // Ensure background shells are hidden (default) const initialRenderCount = getRenderCount(); - const subscribeCallback = mockShellSubscribe.mock.calls.find( + const subscribeCallback = mockLifecycleSubscribe.mock.calls.find( (call) => call[0] === 1001, )?.[1]; expect(subscribeCallback).toBeDefined(); @@ -1001,7 +1018,7 @@ describe('useShellCommandProcessor', () => { } expect(getRenderCount()).toBeGreaterThan(initialRenderCount); - const shell = result.current.backgroundShells.get(1001); + const shell = result.current.backgroundTasks.get(1001); expect(shell?.output).toBe('initial + updated'); }); @@ -1009,17 +1026,17 @@ describe('useShellCommandProcessor', () => { const { result, getRenderCount } = await renderProcessorHook(); act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); // Show the background shells act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); const initialRenderCount = getRenderCount(); - const subscribeCallback = mockShellSubscribe.mock.calls.find( + const subscribeCallback = mockLifecycleSubscribe.mock.calls.find( (call) => call[0] === 1001, )?.[1]; expect(subscribeCallback).toBeDefined(); @@ -1031,7 +1048,7 @@ describe('useShellCommandProcessor', () => { } expect(getRenderCount()).toBeGreaterThan(initialRenderCount); - const shell = result.current.backgroundShells.get(1001); + const shell = result.current.backgroundTasks.get(1001); expect(shell?.isBinary).toBe(true); expect(shell?.binaryBytesReceived).toBe(1024); }); @@ -1041,12 +1058,12 @@ describe('useShellCommandProcessor', () => { // 1. Register and show background shell act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); - expect(result.current.isBackgroundShellVisible).toBe(true); + expect(result.current.isBackgroundTaskVisible).toBe(true); // 2. Simulate model responding (not waiting for confirmation) act(() => { @@ -1054,7 +1071,7 @@ describe('useShellCommandProcessor', () => { }); // Should stay visible - expect(result.current.isBackgroundShellVisible).toBe(true); + expect(result.current.isBackgroundTaskVisible).toBe(true); }); it('should hide background shell when waiting for confirmation and restore after delay', async () => { @@ -1062,12 +1079,12 @@ describe('useShellCommandProcessor', () => { // 1. Register and show background shell act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); - expect(result.current.isBackgroundShellVisible).toBe(true); + expect(result.current.isBackgroundTaskVisible).toBe(true); // 2. Simulate tool confirmation showing up act(() => { @@ -1075,7 +1092,7 @@ describe('useShellCommandProcessor', () => { }); // Should be hidden - expect(result.current.isBackgroundShellVisible).toBe(false); + expect(result.current.isBackgroundTaskVisible).toBe(false); // 3. Simulate confirmation accepted (waiting for PTY start) act(() => { @@ -1083,11 +1100,11 @@ describe('useShellCommandProcessor', () => { }); // Should STAY hidden during the 300ms gap - expect(result.current.isBackgroundShellVisible).toBe(false); + expect(result.current.isBackgroundTaskVisible).toBe(false); // 4. Wait for restore delay await waitFor(() => - expect(result.current.isBackgroundShellVisible).toBe(true), + expect(result.current.isBackgroundTaskVisible).toBe(true), ); }); @@ -1096,12 +1113,12 @@ describe('useShellCommandProcessor', () => { // 1. Register and show background shell act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); - expect(result.current.isBackgroundShellVisible).toBe(true); + expect(result.current.isBackgroundTaskVisible).toBe(true); // 2. Start foreground shell act(() => { @@ -1112,7 +1129,7 @@ describe('useShellCommandProcessor', () => { await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345)); // Should be hidden automatically - expect(result.current.isBackgroundShellVisible).toBe(false); + expect(result.current.isBackgroundTaskVisible).toBe(false); // 3. Complete foreground shell act(() => { @@ -1123,7 +1140,7 @@ describe('useShellCommandProcessor', () => { // Should be restored automatically (after delay) await waitFor(() => - expect(result.current.isBackgroundShellVisible).toBe(true), + expect(result.current.isBackgroundTaskVisible).toBe(true), ); }); @@ -1132,25 +1149,25 @@ describe('useShellCommandProcessor', () => { // 1. Register and show background shell act(() => { - result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial'); + result.current.registerBackgroundTask(1001, 'bg-cmd', 'initial'); }); act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); - expect(result.current.isBackgroundShellVisible).toBe(true); + expect(result.current.isBackgroundTaskVisible).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); + expect(result.current.isBackgroundTaskVisible).toBe(false); // 3. Manually toggle visibility (e.g. user wants to peek) act(() => { - result.current.toggleBackgroundShell(); + result.current.toggleBackgroundTasks(); }); - expect(result.current.isBackgroundShellVisible).toBe(true); + expect(result.current.isBackgroundTaskVisible).toBe(true); // 4. Complete foreground shell act(() => { @@ -1161,7 +1178,7 @@ describe('useShellCommandProcessor', () => { // 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), + expect(result.current.isBackgroundTaskVisible).toBe(true), ); }); }); diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/useExecutionLifecycle.ts similarity index 75% rename from packages/cli/src/ui/hooks/shellCommandProcessor.ts rename to packages/cli/src/ui/hooks/useExecutionLifecycle.ts index 3e67ad84b7..e0b5c3ffaa 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/useExecutionLifecycle.ts @@ -9,10 +9,16 @@ import type { IndividualToolCallDisplay, } from '../types.js'; import { useCallback, useReducer, useRef, useEffect } from 'react'; -import type { AnsiOutput, Config, GeminiClient } from '@google/gemini-cli-core'; +import type { + AnsiOutput, + Config, + GeminiClient, + CompletionBehavior, +} from '@google/gemini-cli-core'; import { isBinary, ShellExecutionService, + ExecutionLifecycleService, CoreToolCallStatus, } from '@google/gemini-cli-core'; import { type PartListUnion } from '@google/genai'; @@ -27,9 +33,9 @@ import { themeManager } from '../../ui/themes/theme-manager.js'; import { shellReducer, initialState, - type BackgroundShell, + type BackgroundTask, } from './shellReducer.js'; -export { type BackgroundShell }; +export { type BackgroundTask }; export const OUTPUT_UPDATE_INTERVAL_MS = 1000; const RESTORE_VISIBILITY_DELAY_MS = 300; @@ -66,7 +72,7 @@ function addShellCommandToGeminiHistory( * Hook to process shell commands. * Orchestrates command execution and updates history and agent context. */ -export const useShellCommandProcessor = ( +export const useExecutionLifecycle = ( addItemToHistory: UseHistoryManagerReturn['addItem'], setPendingHistoryItem: React.Dispatch< React.SetStateAction @@ -113,7 +119,7 @@ export const useShellCommandProcessor = ( m.restoreTimeout = null; } - if (state.isBackgroundShellVisible && !m.wasVisibleBeforeForeground) { + if (state.isBackgroundTaskVisible && !m.wasVisibleBeforeForeground) { m.wasVisibleBeforeForeground = true; dispatch({ type: 'SET_VISIBILITY', visible: false }); } @@ -135,14 +141,14 @@ export const useShellCommandProcessor = ( }, [ activePtyId, isWaitingForConfirmation, - state.isBackgroundShellVisible, + state.isBackgroundTaskVisible, m, dispatch, ]); useEffect( () => () => { - // Unsubscribe from all background shell events on unmount + // Unsubscribe from all background task events on unmount for (const unsubscribe of m.subscriptions.values()) { unsubscribe(); } @@ -151,9 +157,9 @@ export const useShellCommandProcessor = ( [m], ); - const toggleBackgroundShell = useCallback(() => { - if (state.backgroundShells.size > 0) { - const willBeVisible = !state.isBackgroundShellVisible; + const toggleBackgroundTasks = useCallback(() => { + if (state.backgroundTasks.size > 0) { + const willBeVisible = !state.isBackgroundTaskVisible; dispatch({ type: 'TOGGLE_VISIBILITY' }); const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation; @@ -167,34 +173,44 @@ export const useShellCommandProcessor = ( } if (willBeVisible) { - dispatch({ type: 'SYNC_BACKGROUND_SHELLS' }); + dispatch({ type: 'SYNC_BACKGROUND_TASKS' }); } } else { dispatch({ type: 'SET_VISIBILITY', visible: false }); addItemToHistory( { type: 'info', - text: 'No background shells are currently active.', + text: 'No background tasks are currently active.', }, Date.now(), ); } }, [ addItemToHistory, - state.backgroundShells.size, - state.isBackgroundShellVisible, + state.backgroundTasks.size, + state.isBackgroundTaskVisible, activePtyId, isWaitingForConfirmation, m, dispatch, ]); - const backgroundCurrentShell = useCallback(() => { + const backgroundCurrentExecution = useCallback(() => { const pidToBackground = state.activeShellPtyId ?? activeBackgroundExecutionId; if (pidToBackground) { - ShellExecutionService.background(pidToBackground); + // TRACK THE PID BEFORE TRIGGERING THE BACKGROUND ACTION + // This prevents the onBackground listener from double-registering. m.backgroundedPids.add(pidToBackground); + + // Use ShellExecutionService for shell PTYs (handles log files, etc.), + // fall back to ExecutionLifecycleService for non-shell executions + // (e.g. remote agents, MCP tools, local agents). + if (state.activeShellPtyId) { + ShellExecutionService.background(pidToBackground); + } else { + ExecutionLifecycleService.background(pidToBackground); + } // Ensure backgrounding is silent and doesn't trigger restoration m.wasVisibleBeforeForeground = false; if (m.restoreTimeout) { @@ -204,14 +220,16 @@ export const useShellCommandProcessor = ( } }, [state.activeShellPtyId, activeBackgroundExecutionId, m]); - const dismissBackgroundShell = useCallback( + const dismissBackgroundTask = useCallback( async (pid: number) => { - const shell = state.backgroundShells.get(pid); + const shell = state.backgroundTasks.get(pid); if (shell) { if (shell.status === 'running') { - await ShellExecutionService.kill(pid); + // ExecutionLifecycleService.kill handles both shell and non-shell + // executions. For shells, ShellExecutionService.kill delegates to it. + ExecutionLifecycleService.kill(pid); } - dispatch({ type: 'DISMISS_SHELL', pid }); + dispatch({ type: 'DISMISS_TASK', pid }); m.backgroundedPids.delete(pid); // Unsubscribe from updates @@ -222,40 +240,73 @@ export const useShellCommandProcessor = ( } } }, - [state.backgroundShells, dispatch, m], + [state.backgroundTasks, dispatch, m], ); - const registerBackgroundShell = useCallback( - (pid: number, command: string, initialOutput: string | AnsiOutput) => { - dispatch({ type: 'REGISTER_SHELL', pid, command, initialOutput }); + const registerBackgroundTask = useCallback( + ( + pid: number, + command: string, + initialOutput: string | AnsiOutput, + completionBehavior?: CompletionBehavior, + ) => { + m.backgroundedPids.add(pid); + dispatch({ + type: 'REGISTER_TASK', + pid, + command, + initialOutput, + completionBehavior, + }); - // Subscribe to process exit directly - const exitUnsubscribe = ShellExecutionService.onExit(pid, (code) => { + // Subscribe to exit via ExecutionLifecycleService (works for all execution types) + const exitUnsubscribe = ExecutionLifecycleService.onExit(pid, (code) => { dispatch({ - type: 'UPDATE_SHELL', + type: 'UPDATE_TASK', pid, update: { status: 'exited', exitCode: code }, }); + // Auto-dismiss for inject/notify (output was delivered to conversation). + // Silent tasks stay in the UI until manually dismissed. + if (completionBehavior !== 'silent') { + dispatch({ type: 'DISMISS_TASK', pid }); + } + const unsub = m.subscriptions.get(pid); + if (unsub) { + unsub(); + m.subscriptions.delete(pid); + } 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, - }, - }); - } - }); + // Subscribe to output via ExecutionLifecycleService (works for all execution types) + const dataUnsubscribe = ExecutionLifecycleService.subscribe( + pid, + (event) => { + if (event.type === 'data') { + dispatch({ + type: 'APPEND_TASK_OUTPUT', + pid, + chunk: event.chunk, + }); + } else if (event.type === 'binary_detected') { + dispatch({ + type: 'UPDATE_TASK', + pid, + update: { isBinary: true }, + }); + } else if (event.type === 'binary_progress') { + dispatch({ + type: 'UPDATE_TASK', + pid, + update: { + isBinary: true, + binaryBytesReceived: event.bytesReceived, + }, + }); + } + }, + ); m.subscriptions.set(pid, () => { exitUnsubscribe(); @@ -265,6 +316,34 @@ export const useShellCommandProcessor = ( [dispatch, m], ); + // Auto-register any execution that gets backgrounded, regardless of type. + // This is the agnostic hook: any tool that calls + // ExecutionLifecycleService.createExecution() or attachExecution() + // automatically gets Ctrl+B support — no UI changes needed per tool. + useEffect(() => { + const listener = (info: { + executionId: number; + label: string; + output: string; + completionBehavior: CompletionBehavior; + }) => { + // Skip if already registered (e.g. shells register via their own flow) + if (m.backgroundedPids.has(info.executionId)) { + return; + } + registerBackgroundTask( + info.executionId, + info.label, + info.output, + info.completionBehavior, + ); + }; + ExecutionLifecycleService.onBackground(listener); + return () => { + ExecutionLifecycleService.offBackground(listener); + }; + }, [registerBackgroundTask, m]); + const handleShellCommand = useCallback( (rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => { if (typeof rawQuery !== 'string' || rawQuery.trim() === '') { @@ -377,7 +456,7 @@ export const useShellCommandProcessor = ( if (executionPid && m.backgroundedPids.has(executionPid)) { // If already backgrounded, let the background shell subscription handle it. dispatch({ - type: 'APPEND_SHELL_OUTPUT', + type: 'APPEND_TASK_OUTPUT', pid: executionPid, chunk: event.type === 'data' ? event.chunk : cumulativeStdout, @@ -437,7 +516,12 @@ export const useShellCommandProcessor = ( setPendingHistoryItem(null); if (result.backgrounded && result.pid) { - registerBackgroundShell(result.pid, rawQuery, cumulativeStdout); + registerBackgroundTask( + result.pid, + rawQuery, + cumulativeStdout, + 'notify', + ); dispatch({ type: 'SET_ACTIVE_PTY', pid: null }); } @@ -529,26 +613,26 @@ export const useShellCommandProcessor = ( setShellInputFocused, terminalHeight, terminalWidth, - registerBackgroundShell, + registerBackgroundTask, m, dispatch, ], ); - const backgroundShellCount = Array.from( - state.backgroundShells.values(), - ).filter((s: BackgroundShell) => s.status === 'running').length; + const backgroundTaskCount = Array.from(state.backgroundTasks.values()).filter( + (s: BackgroundTask) => s.status === 'running', + ).length; return { handleShellCommand, activeShellPtyId: state.activeShellPtyId, lastShellOutputTime: state.lastShellOutputTime, - backgroundShellCount, - isBackgroundShellVisible: state.isBackgroundShellVisible, - toggleBackgroundShell, - backgroundCurrentShell, - registerBackgroundShell, - dismissBackgroundShell, - backgroundShells: state.backgroundShells, + backgroundTaskCount, + isBackgroundTaskVisible: state.isBackgroundTaskVisible, + toggleBackgroundTasks, + backgroundCurrentExecution, + registerBackgroundTask, + dismissBackgroundTask, + backgroundTasks: state.backgroundTasks, }; }; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 7858ad6ede..e7d9949124 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -179,11 +179,18 @@ vi.mock('./useKeypress.js', () => ({ useKeypress: vi.fn(), })); -vi.mock('./shellCommandProcessor.js', () => ({ - useShellCommandProcessor: vi.fn().mockReturnValue({ +vi.mock('./useExecutionLifecycle.js', () => ({ + useExecutionLifecycle: vi.fn().mockReturnValue({ handleShellCommand: vi.fn(), activeShellPtyId: null, lastShellOutputTime: 0, + backgroundTaskCount: 0, + isBackgroundTaskVisible: false, + toggleBackgroundTasks: vi.fn(), + backgroundCurrentExecution: vi.fn(), + backgroundTasks: new Map(), + dismissBackgroundTask: vi.fn(), + registerBackgroundTask: vi.fn(), }), })); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 757c24f2c3..d571ae445e 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -73,7 +73,7 @@ import { ToolCallStatus, } from '../types.js'; import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js'; -import { useShellCommandProcessor } from './shellCommandProcessor.js'; +import { useExecutionLifecycle } from './useExecutionLifecycle.js'; import { handleAtCommand } from './atCommandProcessor.js'; import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js'; import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js'; @@ -364,14 +364,14 @@ export const useGeminiStream = ( handleShellCommand, activeShellPtyId, lastShellOutputTime, - backgroundShellCount, - isBackgroundShellVisible, - toggleBackgroundShell, - backgroundCurrentShell, - registerBackgroundShell, - dismissBackgroundShell, - backgroundShells, - } = useShellCommandProcessor( + backgroundTaskCount, + isBackgroundTaskVisible, + toggleBackgroundTasks, + backgroundCurrentExecution, + registerBackgroundTask, + dismissBackgroundTask, + backgroundTasks, + } = useExecutionLifecycle( addItem, setPendingHistoryItem, onExec, @@ -483,7 +483,7 @@ export const useGeminiStream = ( activeShellPtyId, !!isShellFocused, [], - backgroundShells, + backgroundTasks, ), }); addItem(historyItem); @@ -500,7 +500,7 @@ export const useGeminiStream = ( addItem, activeShellPtyId, isShellFocused, - backgroundShells, + backgroundTasks, ]); const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => { @@ -515,7 +515,7 @@ export const useGeminiStream = ( activeShellPtyId, !!isShellFocused, [], - backgroundShells, + backgroundTasks, ); if (remainingTools.length > 0) { @@ -604,7 +604,7 @@ export const useGeminiStream = ( pushedToolCallIds, activeShellPtyId, isShellFocused, - backgroundShells, + backgroundTasks, ]); const lastQueryRef = useRef(null); @@ -1794,7 +1794,7 @@ export const useGeminiStream = ( for (const toolCall of completedAndReadyToSubmitTools) { const backgroundedTool = getBackgroundedToolInfo(toolCall); if (backgroundedTool) { - registerBackgroundShell( + registerBackgroundTask( backgroundedTool.pid, backgroundedTool.command, backgroundedTool.initialOutput, @@ -1928,7 +1928,7 @@ export const useGeminiStream = ( performMemoryRefresh, modelSwitchedFromQuotaError, addItem, - registerBackgroundShell, + registerBackgroundTask, consumeUserHint, isLowErrorVerbosity, maybeAddSuppressedToolErrorNote, @@ -2023,12 +2023,12 @@ export const useGeminiStream = ( activePtyId, loopDetectionConfirmationRequest, lastOutputTime, - backgroundShellCount, - isBackgroundShellVisible, - toggleBackgroundShell, - backgroundCurrentShell, - backgroundShells, - dismissBackgroundShell, + backgroundTaskCount, + isBackgroundTaskVisible, + toggleBackgroundTasks, + backgroundCurrentExecution, + backgroundTasks, + dismissBackgroundTask, retryStatus, }; }; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx index 7bf51b7d84..402ff501ad 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx @@ -10,7 +10,7 @@ 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'; +import type { BackgroundTask } from '../hooks/useExecutionLifecycle.js'; // Mock dependencies const mockUIState = { @@ -18,13 +18,13 @@ const mockUIState = { terminalHeight: 24, terminalWidth: 80, mainAreaWidth: 80, - backgroundShells: new Map(), - activeBackgroundShellPid: null as number | null, - backgroundShellHeight: 10, + backgroundTasks: new Map(), + activeBackgroundTaskPid: null as number | null, + backgroundTaskHeight: 10, embeddedShellFocused: false, dialogsVisible: false, streamingState: StreamingState.Idle, - isBackgroundShellListOpen: false, + isBackgroundTaskListOpen: false, mainControlsRef: vi.fn(), customDialog: null, historyManager: { addItem: vi.fn() }, @@ -34,7 +34,7 @@ const mockUIState = { constrainHeight: false, availableTerminalHeight: 20, activePtyId: null, - isBackgroundShellVisible: true, + isBackgroundTaskVisible: true, } as unknown as UIState; vi.mock('../contexts/UIStateContext.js', () => ({ @@ -79,11 +79,11 @@ vi.mock('../components/ExitWarning.js', () => ({ vi.mock('../components/CopyModeWarning.js', () => ({ CopyModeWarning: () => CopyModeWarning, })); -vi.mock('../components/BackgroundShellDisplay.js', () => ({ - BackgroundShellDisplay: () => BackgroundShellDisplay, +vi.mock('../components/BackgroundTaskDisplay.js', () => ({ + BackgroundTaskDisplay: () => BackgroundTaskDisplay, })); -const createMockShell = (pid: number): BackgroundShell => ({ +const createMockShell = (pid: number): BackgroundTask => ({ pid, command: 'test command', output: 'test output', @@ -96,25 +96,25 @@ describe('', () => { beforeEach(() => { vi.clearAllMocks(); // Reset mock state defaults - mockUIState.backgroundShells = new Map(); - mockUIState.activeBackgroundShellPid = null; + mockUIState.backgroundTasks = new Map(); + mockUIState.activeBackgroundTaskPid = null; mockUIState.streamingState = StreamingState.Idle; }); - it('renders BackgroundShellDisplay when shells exist and active', async () => { - mockUIState.backgroundShells.set(123, createMockShell(123)); - mockUIState.activeBackgroundShellPid = 123; - mockUIState.backgroundShellHeight = 5; + it('renders BackgroundTaskDisplay when shells exist and active', async () => { + mockUIState.backgroundTasks.set(123, createMockShell(123)); + mockUIState.activeBackgroundTaskPid = 123; + mockUIState.backgroundTaskHeight = 5; const { lastFrame, unmount } = await render(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); - it('hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation', async () => { - mockUIState.backgroundShells.set(123, createMockShell(123)); - mockUIState.activeBackgroundShellPid = 123; - mockUIState.backgroundShellHeight = 5; + it('hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation', async () => { + mockUIState.backgroundTasks.set(123, createMockShell(123)); + mockUIState.activeBackgroundTaskPid = 123; + mockUIState.backgroundTaskHeight = 5; mockUIState.streamingState = StreamingState.WaitingForConfirmation; const { lastFrame, unmount } = await render(); @@ -122,10 +122,10 @@ describe('', () => { unmount(); }); - it('shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation', async () => { - mockUIState.backgroundShells.set(123, createMockShell(123)); - mockUIState.activeBackgroundShellPid = 123; - mockUIState.backgroundShellHeight = 5; + it('shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation', async () => { + mockUIState.backgroundTasks.set(123, createMockShell(123)); + mockUIState.activeBackgroundTaskPid = 123; + mockUIState.backgroundTaskHeight = 5; mockUIState.streamingState = StreamingState.Responding; const { lastFrame, unmount } = await render(); diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 8370b78085..aaa9e04632 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -15,7 +15,7 @@ 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 { BackgroundTaskDisplay } from '../components/BackgroundTaskDisplay.js'; import { StreamingState } from '../types.js'; export const DefaultAppLayout: React.FC = () => { @@ -39,21 +39,21 @@ export const DefaultAppLayout: React.FC = () => { > - {uiState.isBackgroundShellVisible && - uiState.backgroundShells.size > 0 && - uiState.activeBackgroundShellPid && - uiState.backgroundShellHeight > 0 && + {uiState.isBackgroundTaskVisible && + uiState.backgroundTasks.size > 0 && + uiState.activeBackgroundTaskPid && + uiState.backgroundTaskHeight > 0 && uiState.streamingState !== StreamingState.WaitingForConfirmation && ( - - + )} diff --git a/packages/cli/src/ui/layouts/__snapshots__/DefaultAppLayout.test.tsx.snap b/packages/cli/src/ui/layouts/__snapshots__/DefaultAppLayout.test.tsx.snap index 30515981af..48cb662534 100644 --- a/packages/cli/src/ui/layouts/__snapshots__/DefaultAppLayout.test.tsx.snap +++ b/packages/cli/src/ui/layouts/__snapshots__/DefaultAppLayout.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[` > hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation 1`] = ` +exports[` > hides BackgroundTaskDisplay when StreamingState is WaitingForConfirmation 1`] = ` "MainContent Notifications CopyModeWarning @@ -9,9 +9,9 @@ ExitWarning " `; -exports[` > renders BackgroundShellDisplay when shells exist and active 1`] = ` +exports[` > renders BackgroundTaskDisplay when shells exist and active 1`] = ` "MainContent -BackgroundShellDisplay +BackgroundTaskDisplay @@ -23,9 +23,9 @@ ExitWarning " `; -exports[` > shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation 1`] = ` +exports[` > shows BackgroundTaskDisplay when StreamingState is NOT WaitingForConfirmation 1`] = ` "MainContent -BackgroundShellDisplay +BackgroundTaskDisplay diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index 00efd3f7fc..3aff41d2de 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -41,7 +41,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] { addConfirmUpdateExtensionRequest: (_request) => {}, setConfirmationRequest: (_request) => {}, removeComponent: () => {}, - toggleBackgroundShell: () => {}, + toggleBackgroundTasks: () => {}, toggleShortcutsHelp: () => {}, }; } diff --git a/packages/cli/src/ui/utils/borderStyles.ts b/packages/cli/src/ui/utils/borderStyles.ts index 7b7b767734..7b7dba5fc5 100644 --- a/packages/cli/src/ui/utils/borderStyles.ts +++ b/packages/cli/src/ui/utils/borderStyles.ts @@ -13,7 +13,7 @@ import type { HistoryItemToolGroup, IndividualToolCallDisplay, } from '../types.js'; -import type { BackgroundShell } from '../hooks/shellReducer.js'; +import type { BackgroundTask } from '../hooks/shellReducer.js'; import type { TrackedToolCall } from '../hooks/useToolScheduler.js'; function isTrackedToolCall( @@ -33,7 +33,7 @@ export function getToolGroupBorderAppearance( activeShellPtyId: number | null | undefined, embeddedShellFocused: boolean | undefined, allPendingItems: HistoryItemWithoutId[] = [], - backgroundShells: Map = new Map(), + backgroundTasks: Map = new Map(), ): { borderColor: string; borderDimColor: boolean } { if (item.type !== 'tool_group') { return { borderColor: '', borderDimColor: false }; @@ -100,7 +100,7 @@ export function getToolGroupBorderAppearance( // If we have an active PTY that isn't a background shell, then the current // pending batch is definitely a shell batch. const isCurrentlyInShellTurn = - !!activeShellPtyId && !backgroundShells.has(activeShellPtyId); + !!activeShellPtyId && !backgroundTasks.has(activeShellPtyId); const isShell = isShellCommand || (item.tools.length === 0 && isCurrentlyInShellTurn); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index efa2080743..075c5439ad 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -36,7 +36,8 @@ import { WebFetchTool } from '../tools/web-fetch.js'; import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js'; import { WebSearchTool } from '../tools/web-search.js'; import { AskUserTool } from '../tools/ask-user.js'; -import { UpdateTopicTool, TopicState } from '../tools/topicTool.js'; +import { UpdateTopicTool } from '../tools/topicTool.js'; +import { TopicState } from './topicState.js'; import { ExitPlanModeTool } from '../tools/exit-plan-mode.js'; import { EnterPlanModeTool } from '../tools/enter-plan-mode.js'; import { GeminiClient } from '../core/client.js'; @@ -641,6 +642,7 @@ export interface ConfigParameters { useAlternateBuffer?: boolean; useRipgrep?: boolean; enableInteractiveShell?: boolean; + shellBackgroundCompletionBehavior?: string; skipNextSpeakerCheck?: boolean; shellExecutionConfig?: ShellExecutionConfig; extensionManagement?: boolean; @@ -845,6 +847,10 @@ export class Config implements McpContext, AgentLoopContext { private readonly directWebFetch: boolean; private readonly useRipgrep: boolean; private readonly enableInteractiveShell: boolean; + private readonly shellBackgroundCompletionBehavior: + | 'inject' + | 'notify' + | 'silent'; private readonly skipNextSpeakerCheck: boolean; private readonly useBackgroundColor: boolean; private readonly useAlternateBuffer: boolean; @@ -1183,6 +1189,14 @@ export class Config implements McpContext, AgentLoopContext { this.useBackgroundColor = params.useBackgroundColor ?? true; this.useAlternateBuffer = params.useAlternateBuffer ?? false; this.enableInteractiveShell = params.enableInteractiveShell ?? false; + + const requestedBehavior = params.shellBackgroundCompletionBehavior; + if (requestedBehavior === 'inject' || requestedBehavior === 'notify') { + this.shellBackgroundCompletionBehavior = requestedBehavior; + } else { + this.shellBackgroundCompletionBehavior = 'silent'; + } + this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? true; this.shellExecutionConfig = { terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80, @@ -1192,6 +1206,7 @@ export class Config implements McpContext, AgentLoopContext { sanitizationConfig: this.sanitizationConfig, sandboxManager: this._sandboxManager, sandboxConfig: this.sandbox, + backgroundCompletionBehavior: this.shellBackgroundCompletionBehavior, }; this.truncateToolOutputThreshold = params.truncateToolOutputThreshold ?? @@ -3166,6 +3181,10 @@ export class Config implements McpContext, AgentLoopContext { return this.enableInteractiveShell; } + getShellBackgroundCompletionBehavior(): 'inject' | 'notify' | 'silent' { + return this.shellBackgroundCompletionBehavior; + } + getSkipNextSpeakerCheck(): boolean { return this.skipNextSpeakerCheck; } diff --git a/packages/core/src/config/topicState.ts b/packages/core/src/config/topicState.ts new file mode 100644 index 0000000000..ee9a50af4f --- /dev/null +++ b/packages/core/src/config/topicState.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Manages the current active topic title and tactical intent for a session. + * Hosted within the Config instance for session-scoping. + */ +export class TopicState { + private activeTopicTitle?: string; + private activeIntent?: string; + + /** + * Sanitizes and sets the topic title and/or intent. + * @returns true if the input was valid and set, false otherwise. + */ + setTopic(title?: string, intent?: string): boolean { + const sanitizedTitle = title?.trim().replace(/[\r\n]+/g, ' '); + const sanitizedIntent = intent?.trim().replace(/[\r\n]+/g, ' '); + + if (!sanitizedTitle && !sanitizedIntent) return false; + + if (sanitizedTitle) { + this.activeTopicTitle = sanitizedTitle; + } + + if (sanitizedIntent) { + this.activeIntent = sanitizedIntent; + } + + return true; + } + + getTopic(): string | undefined { + return this.activeTopicTitle; + } + + getIntent(): string | undefined { + return this.activeIntent; + } + + reset(): void { + this.activeTopicTitle = undefined; + this.activeIntent = undefined; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0edb8b3462..4633b5f4c3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -164,12 +164,6 @@ export * from './services/executionLifecycleService.js'; // Export Injection Service export * from './config/injectionService.js'; -// Export Execution Lifecycle Service -export * from './services/executionLifecycleService.js'; - -// Export Injection Service -export * from './config/injectionService.js'; - // Export base tool definitions export * from './tools/tools.js'; export * from './tools/tool-error.js'; diff --git a/packages/core/src/prompts/promptProvider.test.ts b/packages/core/src/prompts/promptProvider.test.ts index a611cc7435..74cc83ae3a 100644 --- a/packages/core/src/prompts/promptProvider.test.ts +++ b/packages/core/src/prompts/promptProvider.test.ts @@ -16,7 +16,7 @@ import { ApprovalMode } from '../policy/types.js'; import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; import { MockTool } from '../test-utils/mock-tool.js'; import { UPDATE_TOPIC_TOOL_NAME } from '../tools/tool-names.js'; -import { TopicState } from '../tools/topicTool.js'; +import { TopicState } from '../config/topicState.js'; import type { CallableTool } from '@google/genai'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import type { ToolRegistry } from '../tools/tool-registry.js'; diff --git a/packages/core/src/services/executionLifecycleService.test.ts b/packages/core/src/services/executionLifecycleService.test.ts index 0d800c6e55..ed8dd58a3c 100644 --- a/packages/core/src/services/executionLifecycleService.test.ts +++ b/packages/core/src/services/executionLifecycleService.test.ts @@ -10,6 +10,7 @@ import { type ExecutionHandle, type ExecutionResult, } from './executionLifecycleService.js'; +import { InjectionService } from '../config/injectionService.js'; function createResult( overrides: Partial = {}, @@ -296,6 +297,81 @@ describe('ExecutionLifecycleService', () => { }).toThrow('Execution 4324 is already attached.'); }); + describe('Background Start Listeners', () => { + it('fires onBackground when an execution is backgrounded', async () => { + const listener = vi.fn(); + ExecutionLifecycleService.onBackground(listener); + + const handle = ExecutionLifecycleService.createExecution( + '', + undefined, + 'remote_agent', + undefined, + 'My Remote Agent', + ); + const executionId = handle.pid!; + + ExecutionLifecycleService.appendOutput(executionId, 'some output'); + ExecutionLifecycleService.background(executionId); + await handle.result; + + expect(listener).toHaveBeenCalledTimes(1); + const info = listener.mock.calls[0][0]; + expect(info.executionId).toBe(executionId); + expect(info.executionMethod).toBe('remote_agent'); + expect(info.label).toBe('My Remote Agent'); + expect(info.output).toBe('some output'); + + ExecutionLifecycleService.offBackground(listener); + }); + + it('uses fallback label when none is provided', async () => { + const listener = vi.fn(); + ExecutionLifecycleService.onBackground(listener); + + const handle = ExecutionLifecycleService.createExecution( + '', + undefined, + 'none', + ); + const executionId = handle.pid!; + + ExecutionLifecycleService.background(executionId); + await handle.result; + + const info = listener.mock.calls[0][0]; + expect(info.label).toContain('none'); + expect(info.label).toContain(String(executionId)); + + ExecutionLifecycleService.offBackground(listener); + }); + + it('does not fire onBackground for non-backgrounded completions', async () => { + const listener = vi.fn(); + ExecutionLifecycleService.onBackground(listener); + + const handle = ExecutionLifecycleService.createExecution(); + ExecutionLifecycleService.completeExecution(handle.pid!); + await handle.result; + + expect(listener).not.toHaveBeenCalled(); + + ExecutionLifecycleService.offBackground(listener); + }); + + it('offBackground removes the listener', async () => { + const listener = vi.fn(); + ExecutionLifecycleService.onBackground(listener); + ExecutionLifecycleService.offBackground(listener); + + const handle = ExecutionLifecycleService.createExecution(); + ExecutionLifecycleService.background(handle.pid!); + await handle.result; + + expect(listener).not.toHaveBeenCalled(); + }); + }); + describe('Background Completion Listeners', () => { it('fires onBackgroundComplete with formatInjection text when backgrounded execution settles', async () => { const listener = vi.fn(); @@ -326,7 +402,10 @@ describe('ExecutionLifecycleService', () => { expect(info.executionMethod).toBe('remote_agent'); expect(info.output).toBe('agent output'); expect(info.error).toBeNull(); - expect(info.injectionText).toBe('[Agent completed]\nagent output'); + expect(info.injectionText).toBe( + '\n[Agent completed]\nagent output\n', + ); + expect(info.completionBehavior).toBe('inject'); ExecutionLifecycleService.offBackgroundComplete(listener); }); @@ -353,12 +432,14 @@ describe('ExecutionLifecycleService', () => { expect(listener).toHaveBeenCalledTimes(1); const info = listener.mock.calls[0][0]; expect(info.error?.message).toBe('something broke'); - expect(info.injectionText).toBe('Error: something broke'); + expect(info.injectionText).toBe( + '\nError: something broke\n', + ); ExecutionLifecycleService.offBackgroundComplete(listener); }); - it('sets injectionText to null when no formatInjection callback is provided', async () => { + it('sets injectionText to null and completionBehavior to silent when no formatInjection is provided', async () => { const listener = vi.fn(); ExecutionLifecycleService.onBackgroundComplete(listener); @@ -377,6 +458,7 @@ describe('ExecutionLifecycleService', () => { expect(listener).toHaveBeenCalledTimes(1); expect(listener.mock.calls[0][0].injectionText).toBeNull(); + expect(listener.mock.calls[0][0].completionBehavior).toBe('silent'); ExecutionLifecycleService.offBackgroundComplete(listener); }); @@ -443,5 +525,214 @@ describe('ExecutionLifecycleService', () => { expect(listener).not.toHaveBeenCalled(); }); + + it('explicit notify behavior includes injectionText and auto-dismiss signal', async () => { + const listener = vi.fn(); + ExecutionLifecycleService.onBackgroundComplete(listener); + + const handle = ExecutionLifecycleService.createExecution( + '', + undefined, + 'child_process', + () => '[Command completed. Output saved to /tmp/bg.log]', + undefined, + 'notify', + ); + const executionId = handle.pid!; + + ExecutionLifecycleService.background(executionId); + await handle.result; + + ExecutionLifecycleService.completeExecution(executionId); + + expect(listener).toHaveBeenCalledTimes(1); + const info = listener.mock.calls[0][0]; + expect(info.completionBehavior).toBe('notify'); + expect(info.injectionText).toBe( + '\n[Command completed. Output saved to /tmp/bg.log]\n', + ); + + ExecutionLifecycleService.offBackgroundComplete(listener); + }); + + it('explicit silent behavior skips injection even when formatInjection is provided', async () => { + const formatFn = vi.fn().mockReturnValue('should not appear'); + const listener = vi.fn(); + ExecutionLifecycleService.onBackgroundComplete(listener); + + const handle = ExecutionLifecycleService.createExecution( + '', + undefined, + 'none', + formatFn, + undefined, + 'silent', + ); + const executionId = handle.pid!; + + ExecutionLifecycleService.background(executionId); + await handle.result; + + ExecutionLifecycleService.completeExecution(executionId); + + expect(listener).toHaveBeenCalledTimes(1); + const info = listener.mock.calls[0][0]; + expect(info.completionBehavior).toBe('silent'); + expect(info.injectionText).toBeNull(); + expect(formatFn).not.toHaveBeenCalled(); + + ExecutionLifecycleService.offBackgroundComplete(listener); + }); + + it('includes completionBehavior in BackgroundStartInfo', async () => { + const bgStartListener = vi.fn(); + ExecutionLifecycleService.onBackground(bgStartListener); + + const handle = ExecutionLifecycleService.createExecution( + '', + undefined, + 'remote_agent', + () => 'text', + 'test-label', + 'inject', + ); + + ExecutionLifecycleService.background(handle.pid!); + await handle.result; + + expect(bgStartListener).toHaveBeenCalledTimes(1); + expect(bgStartListener.mock.calls[0][0].completionBehavior).toBe( + 'inject', + ); + + ExecutionLifecycleService.offBackground(bgStartListener); + }); + + it('completionBehavior flows through attachExecution', async () => { + const listener = vi.fn(); + ExecutionLifecycleService.onBackgroundComplete(listener); + + const handle = ExecutionLifecycleService.attachExecution(9999, { + executionMethod: 'child_process', + formatInjection: () => '[notify message]', + completionBehavior: 'notify', + }); + + ExecutionLifecycleService.background(9999); + await handle.result; + + ExecutionLifecycleService.completeWithResult( + 9999, + createResult({ pid: 9999, executionMethod: 'child_process' }), + ); + + expect(listener).toHaveBeenCalledTimes(1); + const info = listener.mock.calls[0][0]; + expect(info.completionBehavior).toBe('notify'); + expect(info.injectionText).toBe('\n[notify message]\n'); + + ExecutionLifecycleService.offBackgroundComplete(listener); + }); + + it('injects directly into InjectionService when wired via setInjectionService', async () => { + const injectionService = new InjectionService(() => true); + ExecutionLifecycleService.setInjectionService(injectionService); + + const injectionListener = vi.fn(); + injectionService.onInjection(injectionListener); + + const handle = ExecutionLifecycleService.createExecution( + '', + undefined, + 'remote_agent', + (output) => `[Completed] ${output}`, + undefined, + 'inject', + ); + const executionId = handle.pid!; + + ExecutionLifecycleService.appendOutput(executionId, 'agent output'); + ExecutionLifecycleService.background(executionId); + await handle.result; + + ExecutionLifecycleService.completeExecution(executionId); + + expect(injectionListener).toHaveBeenCalledWith( + '\n[Completed] agent output\n', + 'background_completion', + ); + }); + + it('sanitizes injectionText for inject behavior but NOT for notify behavior', async () => { + const injectionService = new InjectionService(() => true); + ExecutionLifecycleService.setInjectionService(injectionService); + + const injectionListener = vi.fn(); + injectionService.onInjection(injectionListener); + + // 1. Test 'inject' sanitization + const handleInject = ExecutionLifecycleService.createExecution( + '', + undefined, + 'remote_agent', + (output) => `Dangerous ${output}`, + undefined, + 'inject', + ); + ExecutionLifecycleService.appendOutput(handleInject.pid!, 'more'); + ExecutionLifecycleService.background(handleInject.pid!); + await handleInject.result; + ExecutionLifecycleService.completeExecution(handleInject.pid!); + + expect(injectionListener).toHaveBeenCalledWith( + '\nDangerous </output> more\n', + 'background_completion', + ); + + // 2. Test 'notify' (should also be wrapped in tag) + injectionListener.mockClear(); + const handleNotify = ExecutionLifecycleService.createExecution( + '', + undefined, + 'remote_agent', + (output) => `Pointer to ${output}`, + undefined, + 'notify', + ); + ExecutionLifecycleService.appendOutput(handleNotify.pid!, 'logs'); + ExecutionLifecycleService.background(handleNotify.pid!); + await handleNotify.result; + ExecutionLifecycleService.completeExecution(handleNotify.pid!); + + expect(injectionListener).toHaveBeenCalledWith( + '\nPointer to logs\n', + 'background_completion', + ); + }); + + it('does not inject into InjectionService for silent behavior', async () => { + const injectionService = new InjectionService(() => true); + ExecutionLifecycleService.setInjectionService(injectionService); + + const injectionListener = vi.fn(); + injectionService.onInjection(injectionListener); + + const handle = ExecutionLifecycleService.createExecution( + '', + undefined, + 'none', + () => 'should not inject', + undefined, + 'silent', + ); + const executionId = handle.pid!; + + ExecutionLifecycleService.background(executionId); + await handle.result; + + ExecutionLifecycleService.completeExecution(executionId); + + expect(injectionListener).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/core/src/services/executionLifecycleService.ts b/packages/core/src/services/executionLifecycleService.ts index 5efe26c375..a559fea82c 100644 --- a/packages/core/src/services/executionLifecycleService.ts +++ b/packages/core/src/services/executionLifecycleService.ts @@ -7,6 +7,7 @@ import type { InjectionService } from '../config/injectionService.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { sanitizeOutput } from '../utils/textUtils.js'; export type ExecutionMethod = | 'lydell-node-pty' @@ -59,12 +60,16 @@ export interface ExecutionCompletionOptions { export interface ExternalExecutionRegistration { executionMethod: ExecutionMethod; + /** Human-readable label for the background task UI (e.g. the command string). */ + label?: string; initialOutput?: string; getBackgroundOutput?: () => string; getSubscriptionSnapshot?: () => string | AnsiOutput | undefined; writeInput?: (input: string) => void; kill?: () => void; isActive?: () => boolean; + formatInjection?: FormatInjectionFn; + completionBehavior?: CompletionBehavior; } /** @@ -77,15 +82,41 @@ export type FormatInjectionFn = ( error: Error | null, ) => string | null; +/** + * Controls what happens when a backgrounded execution completes: + * - `'inject'` — full formatted output is injected into the conversation; task auto-dismisses from UI. + * - `'notify'` — a short pointer (e.g. "output saved to /tmp/...") is injected; task auto-dismisses from UI. + * - `'silent'` — nothing is injected; task stays in the UI until manually dismissed. + * + * The distinction between `inject` and `notify` is semantic for now (both inject + dismiss), + * but enables the system to treat them differently in the future (e.g. LLM-decided injection). + */ +export type CompletionBehavior = 'inject' | 'notify' | 'silent'; + interface ManagedExecutionBase { executionMethod: ExecutionMethod; + label?: string; output: string; backgrounded?: boolean; formatInjection?: FormatInjectionFn; + completionBehavior?: CompletionBehavior; getBackgroundOutput?: () => string; getSubscriptionSnapshot?: () => string | AnsiOutput | undefined; } +/** + * Payload emitted when an execution is moved to the background. + */ +export interface BackgroundStartInfo { + executionId: number; + executionMethod: ExecutionMethod; + label: string; + output: string; + completionBehavior: CompletionBehavior; +} + +export type BackgroundStartListener = (info: BackgroundStartInfo) => void; + /** * Payload emitted when a previously-backgrounded execution settles. */ @@ -96,6 +127,7 @@ export interface BackgroundCompletionInfo { error: Error | null; /** Pre-formatted injection text from the execution creator, or `null` if skipped. */ injectionText: string | null; + completionBehavior: CompletionBehavior; } export type BackgroundCompletionListener = ( @@ -124,6 +156,16 @@ const NON_PROCESS_EXECUTION_ID_START = 2_000_000_000; export class ExecutionLifecycleService { private static readonly EXIT_INFO_TTL_MS = 5 * 60 * 1000; private static nextExecutionId = NON_PROCESS_EXECUTION_ID_START; + private static injectionService: InjectionService | null = null; + + /** + * Connects the lifecycle service to the injection service so that + * backgrounded executions are reinjected into the model conversation + * directly from the backend — no UI hop needed. + */ + static setInjectionService(service: InjectionService): void { + this.injectionService = service; + } private static activeExecutions = new Map(); private static activeResolvers = new Map< @@ -140,14 +182,22 @@ export class ExecutionLifecycleService { >(); private static backgroundCompletionListeners = new Set(); - private static injectionService: InjectionService | null = null; + + private static backgroundStartListeners = new Set(); /** - * Wires a singleton InjectionService so that backgrounded executions - * can inject their output directly without routing through the UI layer. + * Registers a listener that fires when any execution is moved to the background. + * This is the hook for the UI to automatically discover backgrounded executions. */ - static setInjectionService(service: InjectionService): void { - this.injectionService = service; + static onBackground(listener: BackgroundStartListener): void { + this.backgroundStartListeners.add(listener); + } + + /** + * Unregisters a background start listener. + */ + static offBackground(listener: BackgroundStartListener): void { + this.backgroundStartListeners.delete(listener); } /** @@ -222,6 +272,7 @@ export class ExecutionLifecycleService { this.exitedExecutionInfo.clear(); this.backgroundCompletionListeners.clear(); this.injectionService = null; + this.backgroundStartListeners.clear(); this.nextExecutionId = NON_PROCESS_EXECUTION_ID_START; } @@ -239,6 +290,7 @@ export class ExecutionLifecycleService { this.activeExecutions.set(executionId, { executionMethod: registration.executionMethod, + label: registration.label, output: registration.initialOutput ?? '', kind: 'external', getBackgroundOutput: registration.getBackgroundOutput, @@ -246,6 +298,8 @@ export class ExecutionLifecycleService { writeInput: registration.writeInput, kill: registration.kill, isActive: registration.isActive, + formatInjection: registration.formatInjection, + completionBehavior: registration.completionBehavior, }); return { @@ -259,15 +313,19 @@ export class ExecutionLifecycleService { onKill?: () => void, executionMethod: ExecutionMethod = 'none', formatInjection?: FormatInjectionFn, + label?: string, + completionBehavior?: CompletionBehavior, ): ExecutionHandle { const executionId = this.allocateExecutionId(); this.activeExecutions.set(executionId, { executionMethod, + label, output: initialOutput, kind: 'virtual', onKill, formatInjection, + completionBehavior, getBackgroundOutput: () => { const state = this.activeExecutions.get(executionId); return state?.output ?? initialOutput; @@ -325,19 +383,17 @@ export class ExecutionLifecycleService { // Fire background completion listeners if this was a backgrounded execution. if (execution.backgrounded && !result.aborted) { - const injectionText = execution.formatInjection - ? execution.formatInjection(result.output, result.error) - : null; - const info: BackgroundCompletionInfo = { - executionId, - executionMethod: execution.executionMethod, - output: result.output, - error: result.error, - injectionText, - }; + const behavior = + execution.completionBehavior ?? + (execution.formatInjection ? 'inject' : 'silent'); + const rawInjection = + behavior !== 'silent' && execution.formatInjection + ? execution.formatInjection(result.output, result.error) + : null; - // Inject directly into the model conversation if injection text is - // available and the injection service has been wired up. + const injectionText = rawInjection ? sanitizeOutput(rawInjection) : null; + + // Inject directly into the model conversation from the backend. if (injectionText && this.injectionService) { this.injectionService.addInjection( injectionText, @@ -345,6 +401,15 @@ export class ExecutionLifecycleService { ); } + const info: BackgroundCompletionInfo = { + executionId, + executionMethod: execution.executionMethod, + output: result.output, + error: result.error, + injectionText, + completionBehavior: behavior, + }; + for (const listener of this.backgroundCompletionListeners) { try { listener(info); @@ -434,6 +499,21 @@ export class ExecutionLifecycleService { this.activeResolvers.delete(executionId); execution.backgrounded = true; + + // Notify listeners that an execution was moved to the background. + const info: BackgroundStartInfo = { + executionId, + executionMethod: execution.executionMethod, + label: + execution.label ?? `${execution.executionMethod} (ID: ${executionId})`, + output, + completionBehavior: + execution.completionBehavior ?? + (execution.formatInjection ? 'inject' : 'silent'), + }; + for (const listener of this.backgroundStartListeners) { + listener(info); + } } static subscribe( diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 6184354a2a..b757bdd793 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -19,7 +19,7 @@ import { resolveExecutable, type ShellType, } from '../utils/shell-utils.js'; -import { isBinary } from '../utils/textUtils.js'; +import { isBinary, truncateString } from '../utils/textUtils.js'; import pkg from '@xterm/headless'; import { debugLogger } from '../utils/debugLogger.js'; import { Storage } from '../config/storage.js'; @@ -102,6 +102,7 @@ export interface ShellExecutionConfig { scrollback?: number; maxSerializedLines?: number; sandboxConfig?: SandboxConfig; + backgroundCompletionBehavior?: 'inject' | 'notify' | 'silent'; } /** @@ -239,6 +240,23 @@ export class ShellExecutionService { return path.join(Storage.getGlobalTempDir(), 'background-processes'); } + private static formatShellBackgroundCompletion( + pid: number, + behavior: string, + output: string, + error?: Error, + ): string { + const logPath = ShellExecutionService.getLogFilePath(pid); + const status = error ? `with error: ${error.message}` : 'successfully'; + + if (behavior === 'inject') { + const truncated = truncateString(output, 5000); + return `[Background command completed ${status}. Output saved to ${logPath}]\n\n${truncated}`; + } + + return `[Background command completed ${status}. Output saved to ${logPath}]`; + } + static getLogFilePath(pid: number): string { return path.join(this.getLogDir(), `background-${pid}.log`); } @@ -532,6 +550,15 @@ export class ShellExecutionService { return false; } }, + formatInjection: (output, error) => + ShellExecutionService.formatShellBackgroundCompletion( + child.pid!, + shellExecutionConfig.backgroundCompletionBehavior || 'silent', + output, + error ?? undefined, + ), + completionBehavior: + shellExecutionConfig.backgroundCompletionBehavior || 'silent', }) : undefined; @@ -862,6 +889,15 @@ export class ShellExecutionService { ); return bufferData.length > 0 ? bufferData : undefined; }, + formatInjection: (output, error) => + ShellExecutionService.formatShellBackgroundCompletion( + ptyPid, + shellExecutionConfig.backgroundCompletionBehavior || 'silent', + output, + error ?? undefined, + ), + completionBehavior: + shellExecutionConfig.backgroundCompletionBehavior || 'silent', }).result; let processingChain = Promise.resolve(); diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 78b54fe297..a19520f0e1 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -136,6 +136,7 @@ describe('ShellTool', () => { getGeminiClient: vi.fn().mockReturnValue({}), getShellToolInactivityTimeout: vi.fn().mockReturnValue(1000), getEnableInteractiveShell: vi.fn().mockReturnValue(false), + getShellBackgroundCompletionBehavior: vi.fn().mockReturnValue('silent'), getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true), getSandboxEnabled: vi.fn().mockReturnValue(false), sanitizationConfig: {}, diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 3a70de3ea4..63a9b1dc83 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -357,6 +357,8 @@ export class ShellToolInvocation extends BaseToolInvocation< this.context.config.sanitizationConfig, sandboxManager: this.context.config.sandboxManager, additionalPermissions: this.params[PARAM_ADDITIONAL_PERMISSIONS], + backgroundCompletionBehavior: + this.context.config.getShellBackgroundCompletionBehavior(), }, ); diff --git a/packages/core/src/tools/topicTool.test.ts b/packages/core/src/tools/topicTool.test.ts index 7c6497a2de..25d2730e8c 100644 --- a/packages/core/src/tools/topicTool.test.ts +++ b/packages/core/src/tools/topicTool.test.ts @@ -5,7 +5,8 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { TopicState, UpdateTopicTool } from './topicTool.js'; +import { UpdateTopicTool } from './topicTool.js'; +import { TopicState } from '../config/topicState.js'; import { MessageBus } from '../confirmation-bus/message-bus.js'; import type { PolicyEngine } from '../policy/policy-engine.js'; import { diff --git a/packages/core/src/tools/topicTool.ts b/packages/core/src/tools/topicTool.ts index 51c7999fba..abc0e63972 100644 --- a/packages/core/src/tools/topicTool.ts +++ b/packages/core/src/tools/topicTool.ts @@ -21,49 +21,6 @@ import { debugLogger } from '../utils/debugLogger.js'; import { getUpdateTopicDeclaration } from './definitions/dynamic-declaration-helpers.js'; import type { Config } from '../config/config.js'; -/** - * Manages the current active topic title and tactical intent for a session. - * Hosted within the Config instance for session-scoping. - */ -export class TopicState { - private activeTopicTitle?: string; - private activeIntent?: string; - - /** - * Sanitizes and sets the topic title and/or intent. - * @returns true if the input was valid and set, false otherwise. - */ - setTopic(title?: string, intent?: string): boolean { - const sanitizedTitle = title?.trim().replace(/[\r\n]+/g, ' '); - const sanitizedIntent = intent?.trim().replace(/[\r\n]+/g, ' '); - - if (!sanitizedTitle && !sanitizedIntent) return false; - - if (sanitizedTitle) { - this.activeTopicTitle = sanitizedTitle; - } - - if (sanitizedIntent) { - this.activeIntent = sanitizedIntent; - } - - return true; - } - - getTopic(): string | undefined { - return this.activeTopicTitle; - } - - getIntent(): string | undefined { - return this.activeIntent; - } - - reset(): void { - this.activeTopicTitle = undefined; - this.activeIntent = undefined; - } -} - interface UpdateTopicParams { [TOPIC_PARAM_TITLE]?: string; [TOPIC_PARAM_SUMMARY]?: string; diff --git a/packages/core/src/utils/textUtils.ts b/packages/core/src/utils/textUtils.ts index 8d4cbfa6d5..c5d62074a0 100644 --- a/packages/core/src/utils/textUtils.ts +++ b/packages/core/src/utils/textUtils.ts @@ -133,3 +133,21 @@ export function safeTemplateReplace( : match, ); } + +/** + * Sanitizes output for injection into the model conversation. + * Wraps output in a secure tag and handles potential injection vectors + * (like closing tags or template patterns) within the data. + * @param output The raw output to sanitize. + * @returns The sanitized string ready for injection. + */ +export function sanitizeOutput(output: string): string { + const trimmed = output.trim(); + if (trimmed.length === 0) { + return ''; + } + + // Prevent direct closing tag injection. + const escaped = trimmed.replaceAll('', '</output>'); + return `\n${escaped}\n`; +} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 0a501fad6e..52a6f1e183 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -2394,6 +2394,14 @@ "default": true, "type": "boolean" }, + "backgroundCompletionBehavior": { + "title": "Background Completion Behavior", + "description": "Controls what happens when a background shell command finishes. 'silent' (default): quietly exits in background. 'inject': automatically returns output to agent. 'notify': shows brief message in chat.", + "markdownDescription": "Controls what happens when a background shell command finishes. 'silent' (default): quietly exits in background. 'inject': automatically returns output to agent. 'notify': shows brief message in chat.\n\n- Category: `Tools`\n- Requires restart: `no`\n- Default: `silent`", + "default": "silent", + "type": "string", + "enum": ["silent", "inject", "notify"] + }, "pager": { "title": "Pager", "description": "The pager command to use for shell output. Defaults to `cat`.",