diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index 2fb6910b0a..a8ebca2535 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -9,7 +9,12 @@ 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, @@ -238,8 +243,19 @@ export const useShellCommandProcessor = ( ); const registerBackgroundTask = useCallback( - (pid: number, command: string, initialOutput: string | AnsiOutput) => { - dispatch({ type: 'REGISTER_SHELL', pid, command, initialOutput }); + ( + pid: number, + command: string, + initialOutput: string | AnsiOutput, + completionBehavior?: CompletionBehavior, + ) => { + dispatch({ + type: 'REGISTER_SHELL', + pid, + command, + initialOutput, + completionBehavior, + }); // Subscribe to exit via ExecutionLifecycleService (works for all execution types) const exitUnsubscribe = ExecutionLifecycleService.onExit(pid, (code) => { @@ -248,8 +264,11 @@ export const useShellCommandProcessor = ( pid, update: { status: 'exited', exitCode: code }, }); - // Auto-dismiss completed tasks from the background panel. - dispatch({ type: 'DISMISS_SHELL', pid }); + // 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_SHELL', pid }); + } const unsub = m.subscriptions.get(pid); if (unsub) { unsub(); @@ -304,12 +323,18 @@ export const useShellCommandProcessor = ( 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); + registerBackgroundTask( + info.executionId, + info.label, + info.output, + info.completionBehavior, + ); }; ExecutionLifecycleService.onBackground(listener); return () => { @@ -489,7 +514,12 @@ export const useShellCommandProcessor = ( setPendingHistoryItem(null); if (result.backgrounded && result.pid) { - registerBackgroundTask(result.pid, rawQuery, cumulativeStdout); + registerBackgroundTask( + result.pid, + rawQuery, + cumulativeStdout, + 'notify', + ); dispatch({ type: 'SET_ACTIVE_PTY', pid: null }); } diff --git a/packages/cli/src/ui/hooks/shellReducer.ts b/packages/cli/src/ui/hooks/shellReducer.ts index 7d3917c681..8fee906228 100644 --- a/packages/cli/src/ui/hooks/shellReducer.ts +++ b/packages/cli/src/ui/hooks/shellReducer.ts @@ -4,7 +4,7 @@ * 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 { pid: number; @@ -14,6 +14,7 @@ export interface BackgroundShell { binaryBytesReceived: number; status: 'running' | 'exited'; exitCode?: number; + completionBehavior?: CompletionBehavior; } export interface ShellState { @@ -33,6 +34,7 @@ export type ShellAction = 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 } @@ -72,6 +74,7 @@ export function shellReducer( isBinary: false, binaryBytesReceived: 0, status: 'running', + completionBehavior: action.completionBehavior, }); return { ...state, backgroundShells: nextShells }; } diff --git a/packages/core/src/services/executionLifecycleService.test.ts b/packages/core/src/services/executionLifecycleService.test.ts index b5b78a8e86..137fa09ec8 100644 --- a/packages/core/src/services/executionLifecycleService.test.ts +++ b/packages/core/src/services/executionLifecycleService.test.ts @@ -402,6 +402,7 @@ describe('ExecutionLifecycleService', () => { expect(info.output).toBe('agent output'); expect(info.error).toBeNull(); expect(info.injectionText).toBe('[Agent completed]\nagent output'); + expect(info.completionBehavior).toBe('inject'); ExecutionLifecycleService.offBackgroundComplete(listener); }); @@ -433,7 +434,7 @@ describe('ExecutionLifecycleService', () => { 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); @@ -452,6 +453,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); }); @@ -518,5 +520,113 @@ 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( + '[Command completed. Output saved to /tmp/bg.log]', + ); + + 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('[notify message]'); + + ExecutionLifecycleService.offBackgroundComplete(listener); + }); }); }); diff --git a/packages/core/src/services/executionLifecycleService.ts b/packages/core/src/services/executionLifecycleService.ts index a0212bbed5..a66c4bd000 100644 --- a/packages/core/src/services/executionLifecycleService.ts +++ b/packages/core/src/services/executionLifecycleService.ts @@ -67,6 +67,8 @@ export interface ExternalExecutionRegistration { writeInput?: (input: string) => void; kill?: () => void; isActive?: () => boolean; + formatInjection?: FormatInjectionFn; + completionBehavior?: CompletionBehavior; } /** @@ -79,12 +81,24 @@ 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; } @@ -97,6 +111,7 @@ export interface BackgroundStartInfo { executionMethod: ExecutionMethod; label: string; output: string; + completionBehavior: CompletionBehavior; } export type BackgroundStartListener = (info: BackgroundStartInfo) => void; @@ -111,6 +126,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 = ( @@ -280,6 +296,8 @@ export class ExecutionLifecycleService { writeInput: registration.writeInput, kill: registration.kill, isActive: registration.isActive, + formatInjection: registration.formatInjection, + completionBehavior: registration.completionBehavior, }); return { @@ -294,6 +312,7 @@ export class ExecutionLifecycleService { executionMethod: ExecutionMethod = 'none', formatInjection?: FormatInjectionFn, label?: string, + completionBehavior?: CompletionBehavior, ): ExecutionHandle { const executionId = this.allocateExecutionId(); @@ -304,6 +323,7 @@ export class ExecutionLifecycleService { kind: 'virtual', onKill, formatInjection, + completionBehavior, getBackgroundOutput: () => { const state = this.activeExecutions.get(executionId); return state?.output ?? initialOutput; @@ -361,15 +381,20 @@ 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 behavior = + execution.completionBehavior ?? + (execution.formatInjection ? 'inject' : 'silent'); + const injectionText = + behavior !== 'silent' && execution.formatInjection + ? execution.formatInjection(result.output, result.error) + : null; const info: BackgroundCompletionInfo = { executionId, executionMethod: execution.executionMethod, output: result.output, error: result.error, injectionText, + completionBehavior: behavior, }; // Inject directly into the model conversation if injection text is @@ -478,6 +503,9 @@ export class ExecutionLifecycleService { label: execution.label ?? `${execution.executionMethod} (ID: ${executionId})`, output, + completionBehavior: + execution.completionBehavior ?? + (execution.formatInjection ? 'inject' : 'silent'), }; for (const listener of this.backgroundStartListeners) { listener(info); diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index f8d2e728d2..07ea0011b1 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -448,6 +448,14 @@ export class ShellExecutionService { return false; } }, + formatInjection: (_output, error) => { + const logPath = ShellExecutionService.getLogFilePath(child.pid!); + const status = error + ? `with error: ${error.message}` + : 'successfully'; + return `[Background command completed ${status}. Output saved to ${logPath}]`; + }, + completionBehavior: 'notify', }) : undefined; @@ -782,6 +790,14 @@ export class ShellExecutionService { ); return bufferData.length > 0 ? bufferData : undefined; }, + formatInjection: (_output, error) => { + const logPath = ShellExecutionService.getLogFilePath(ptyPid); + const status = error + ? `with error: ${error.message}` + : 'successfully'; + return `[Background command completed ${status}. Output saved to ${logPath}]`; + }, + completionBehavior: 'notify', }).result; let processingChain = Promise.resolve();