mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 21:51:11 -07:00
ccfd2a48ad
Non-shell executions rely on the onBackground listener to register in the UI panel. Adding their PID to backgroundedPids before firing the listener caused them to be skipped and disappear from the UI.
637 lines
20 KiB
TypeScript
637 lines
20 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type {
|
|
HistoryItemWithoutId,
|
|
IndividualToolCallDisplay,
|
|
} from '../types.js';
|
|
import { useCallback, useReducer, useRef, useEffect } from 'react';
|
|
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';
|
|
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
|
import { SHELL_COMMAND_NAME } from '../constants.js';
|
|
import { formatBytes } from '../utils/formatters.js';
|
|
import crypto from 'node:crypto';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import fs from 'node:fs';
|
|
import { themeManager } from '../../ui/themes/theme-manager.js';
|
|
import {
|
|
shellReducer,
|
|
initialState,
|
|
type BackgroundShell,
|
|
} from './shellReducer.js';
|
|
export { type BackgroundShell };
|
|
|
|
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
|
|
const RESTORE_VISIBILITY_DELAY_MS = 300;
|
|
const MAX_OUTPUT_LENGTH = 10000;
|
|
|
|
function addShellCommandToGeminiHistory(
|
|
geminiClient: GeminiClient,
|
|
rawQuery: string,
|
|
resultText: string,
|
|
) {
|
|
const modelContent =
|
|
resultText.length > MAX_OUTPUT_LENGTH
|
|
? resultText.substring(0, MAX_OUTPUT_LENGTH) + '\n... (truncated)'
|
|
: resultText;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
geminiClient.addHistory({
|
|
role: 'user',
|
|
parts: [
|
|
{
|
|
text: `I ran the following shell command:
|
|
\`\`\`sh
|
|
${rawQuery}
|
|
\`\`\`
|
|
|
|
This produced the following result:
|
|
\`\`\`
|
|
${modelContent}
|
|
\`\`\``,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Hook to process shell commands.
|
|
* Orchestrates command execution and updates history and agent context.
|
|
*/
|
|
export const useShellCommandProcessor = (
|
|
addItemToHistory: UseHistoryManagerReturn['addItem'],
|
|
setPendingHistoryItem: React.Dispatch<
|
|
React.SetStateAction<HistoryItemWithoutId | null>
|
|
>,
|
|
onExec: (command: Promise<void>) => void,
|
|
onDebugMessage: (message: string) => void,
|
|
config: Config,
|
|
geminiClient: GeminiClient,
|
|
setShellInputFocused: (value: boolean) => void,
|
|
terminalWidth?: number,
|
|
terminalHeight?: number,
|
|
activeBackgroundExecutionId?: number,
|
|
isWaitingForConfirmation?: boolean,
|
|
) => {
|
|
const [state, dispatch] = useReducer(shellReducer, initialState);
|
|
|
|
// Consolidate stable tracking into a single manager object
|
|
const manager = useRef<{
|
|
wasVisibleBeforeForeground: boolean;
|
|
restoreTimeout: NodeJS.Timeout | null;
|
|
backgroundedPids: Set<number>;
|
|
subscriptions: Map<number, () => void>;
|
|
} | null>(null);
|
|
|
|
if (!manager.current) {
|
|
manager.current = {
|
|
wasVisibleBeforeForeground: false,
|
|
restoreTimeout: null,
|
|
backgroundedPids: new Set(),
|
|
subscriptions: new Map(),
|
|
};
|
|
}
|
|
const m = manager.current;
|
|
|
|
const activePtyId =
|
|
state.activeShellPtyId ?? activeBackgroundExecutionId ?? undefined;
|
|
|
|
useEffect(() => {
|
|
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
|
|
|
|
if (isForegroundActive) {
|
|
if (m.restoreTimeout) {
|
|
clearTimeout(m.restoreTimeout);
|
|
m.restoreTimeout = null;
|
|
}
|
|
|
|
if (state.isBackgroundShellVisible && !m.wasVisibleBeforeForeground) {
|
|
m.wasVisibleBeforeForeground = true;
|
|
dispatch({ type: 'SET_VISIBILITY', visible: false });
|
|
}
|
|
} else if (m.wasVisibleBeforeForeground && !m.restoreTimeout) {
|
|
// Restore if it was automatically hidden, with a small delay to avoid
|
|
// flickering between model turn segments.
|
|
m.restoreTimeout = setTimeout(() => {
|
|
dispatch({ type: 'SET_VISIBILITY', visible: true });
|
|
m.wasVisibleBeforeForeground = false;
|
|
m.restoreTimeout = null;
|
|
}, RESTORE_VISIBILITY_DELAY_MS);
|
|
}
|
|
|
|
return () => {
|
|
if (m.restoreTimeout) {
|
|
clearTimeout(m.restoreTimeout);
|
|
}
|
|
};
|
|
}, [
|
|
activePtyId,
|
|
isWaitingForConfirmation,
|
|
state.isBackgroundShellVisible,
|
|
m,
|
|
dispatch,
|
|
]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
// Unsubscribe from all background task events on unmount
|
|
for (const unsubscribe of m.subscriptions.values()) {
|
|
unsubscribe();
|
|
}
|
|
m.subscriptions.clear();
|
|
},
|
|
[m],
|
|
);
|
|
|
|
const toggleBackgroundShell = useCallback(() => {
|
|
if (state.backgroundShells.size > 0) {
|
|
const willBeVisible = !state.isBackgroundShellVisible;
|
|
dispatch({ type: 'TOGGLE_VISIBILITY' });
|
|
|
|
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
|
|
// If we are manually showing it during foreground, we set the restore flag
|
|
// so that useEffect doesn't immediately hide it again.
|
|
// If we are manually hiding it, we clear the restore flag so it stays hidden.
|
|
if (willBeVisible && isForegroundActive) {
|
|
m.wasVisibleBeforeForeground = true;
|
|
} else {
|
|
m.wasVisibleBeforeForeground = false;
|
|
}
|
|
|
|
if (willBeVisible) {
|
|
dispatch({ type: 'SYNC_BACKGROUND_SHELLS' });
|
|
}
|
|
} else {
|
|
dispatch({ type: 'SET_VISIBILITY', visible: false });
|
|
addItemToHistory(
|
|
{
|
|
type: 'info',
|
|
text: 'No background tasks are currently active.',
|
|
},
|
|
Date.now(),
|
|
);
|
|
}
|
|
}, [
|
|
addItemToHistory,
|
|
state.backgroundShells.size,
|
|
state.isBackgroundShellVisible,
|
|
activePtyId,
|
|
isWaitingForConfirmation,
|
|
m,
|
|
dispatch,
|
|
]);
|
|
|
|
const backgroundCurrentExecution = useCallback(() => {
|
|
const pidToBackground =
|
|
state.activeShellPtyId ?? activeBackgroundExecutionId;
|
|
if (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) {
|
|
m.backgroundedPids.add(pidToBackground);
|
|
ShellExecutionService.background(pidToBackground);
|
|
} else {
|
|
ExecutionLifecycleService.background(pidToBackground);
|
|
}
|
|
// Ensure backgrounding is silent and doesn't trigger restoration
|
|
m.wasVisibleBeforeForeground = false;
|
|
if (m.restoreTimeout) {
|
|
clearTimeout(m.restoreTimeout);
|
|
m.restoreTimeout = null;
|
|
}
|
|
}
|
|
}, [state.activeShellPtyId, activeBackgroundExecutionId, m]);
|
|
|
|
const dismissBackgroundTask = useCallback(
|
|
async (pid: number) => {
|
|
const shell = state.backgroundShells.get(pid);
|
|
if (shell) {
|
|
if (shell.status === 'running') {
|
|
// ExecutionLifecycleService.kill handles both shell and non-shell
|
|
// executions. For shells, ShellExecutionService.kill delegates to it.
|
|
ExecutionLifecycleService.kill(pid);
|
|
}
|
|
dispatch({ type: 'DISMISS_SHELL', pid });
|
|
m.backgroundedPids.delete(pid);
|
|
|
|
// Unsubscribe from updates
|
|
const unsubscribe = m.subscriptions.get(pid);
|
|
if (unsubscribe) {
|
|
unsubscribe();
|
|
m.subscriptions.delete(pid);
|
|
}
|
|
}
|
|
},
|
|
[state.backgroundShells, dispatch, m],
|
|
);
|
|
|
|
const registerBackgroundTask = useCallback(
|
|
(
|
|
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) => {
|
|
dispatch({
|
|
type: 'UPDATE_SHELL',
|
|
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_SHELL', pid });
|
|
}
|
|
const unsub = m.subscriptions.get(pid);
|
|
if (unsub) {
|
|
unsub();
|
|
m.subscriptions.delete(pid);
|
|
}
|
|
m.backgroundedPids.delete(pid);
|
|
});
|
|
|
|
// Subscribe to output via ExecutionLifecycleService (works for all execution types)
|
|
const dataUnsubscribe = ExecutionLifecycleService.subscribe(
|
|
pid,
|
|
(event) => {
|
|
if (event.type === 'data') {
|
|
dispatch({
|
|
type: 'APPEND_SHELL_OUTPUT',
|
|
pid,
|
|
chunk: event.chunk,
|
|
});
|
|
} else if (event.type === 'binary_detected') {
|
|
dispatch({
|
|
type: 'UPDATE_SHELL',
|
|
pid,
|
|
update: { isBinary: true },
|
|
});
|
|
} else if (event.type === 'binary_progress') {
|
|
dispatch({
|
|
type: 'UPDATE_SHELL',
|
|
pid,
|
|
update: {
|
|
isBinary: true,
|
|
binaryBytesReceived: event.bytesReceived,
|
|
},
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
m.subscriptions.set(pid, () => {
|
|
exitUnsubscribe();
|
|
dataUnsubscribe();
|
|
});
|
|
},
|
|
[dispatch, m],
|
|
);
|
|
|
|
// 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() === '') {
|
|
return false;
|
|
}
|
|
|
|
const userMessageTimestamp = Date.now();
|
|
const callId = `shell-${userMessageTimestamp}`;
|
|
addItemToHistory(
|
|
{ type: 'user_shell', text: rawQuery },
|
|
userMessageTimestamp,
|
|
);
|
|
|
|
const isWindows = os.platform() === 'win32';
|
|
const targetDir = config.getTargetDir();
|
|
let commandToExecute = rawQuery;
|
|
let pwdFilePath: string | undefined;
|
|
|
|
// On non-windows, wrap the command to capture the final working directory.
|
|
if (!isWindows) {
|
|
let command = rawQuery.trim();
|
|
const pwdFileName = `shell_pwd_${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
pwdFilePath = path.join(os.tmpdir(), pwdFileName);
|
|
// Ensure command ends with a separator before adding our own.
|
|
if (!command.endsWith(';') && !command.endsWith('&')) {
|
|
command += ';';
|
|
}
|
|
commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`;
|
|
}
|
|
|
|
const executeCommand = async () => {
|
|
let cumulativeStdout: string | AnsiOutput = '';
|
|
let isBinaryStream = false;
|
|
let binaryBytesReceived = 0;
|
|
|
|
const initialToolDisplay: IndividualToolCallDisplay = {
|
|
callId,
|
|
name: SHELL_COMMAND_NAME,
|
|
description: rawQuery,
|
|
status: CoreToolCallStatus.Executing,
|
|
isClientInitiated: true,
|
|
resultDisplay: '',
|
|
confirmationDetails: undefined,
|
|
};
|
|
|
|
setPendingHistoryItem({
|
|
type: 'tool_group',
|
|
tools: [initialToolDisplay],
|
|
});
|
|
|
|
let executionPid: number | undefined;
|
|
|
|
const abortHandler = () => {
|
|
onDebugMessage(
|
|
`Aborting shell command (PID: ${executionPid ?? 'unknown'})`,
|
|
);
|
|
};
|
|
abortSignal.addEventListener('abort', abortHandler, { once: true });
|
|
|
|
onDebugMessage(`Executing in ${targetDir}: ${commandToExecute}`);
|
|
|
|
try {
|
|
const activeTheme = themeManager.getActiveTheme();
|
|
const shellExecutionConfig = {
|
|
...config.getShellExecutionConfig(),
|
|
terminalWidth,
|
|
terminalHeight,
|
|
defaultFg: activeTheme.colors.Foreground,
|
|
defaultBg: activeTheme.colors.Background,
|
|
};
|
|
|
|
const { pid, result: resultPromise } =
|
|
await ShellExecutionService.execute(
|
|
commandToExecute,
|
|
targetDir,
|
|
(event) => {
|
|
let shouldUpdate = false;
|
|
|
|
switch (event.type) {
|
|
case 'data':
|
|
if (isBinaryStream) break;
|
|
if (typeof event.chunk === 'string') {
|
|
if (typeof cumulativeStdout === 'string') {
|
|
cumulativeStdout += event.chunk;
|
|
} else {
|
|
cumulativeStdout = event.chunk;
|
|
}
|
|
} else {
|
|
// AnsiOutput (PTY) is always the full state
|
|
cumulativeStdout = event.chunk;
|
|
}
|
|
shouldUpdate = true;
|
|
break;
|
|
case 'binary_detected':
|
|
isBinaryStream = true;
|
|
shouldUpdate = true;
|
|
break;
|
|
case 'binary_progress':
|
|
isBinaryStream = true;
|
|
binaryBytesReceived = event.bytesReceived;
|
|
shouldUpdate = true;
|
|
break;
|
|
case 'exit':
|
|
// No action needed for exit event during streaming
|
|
break;
|
|
default:
|
|
throw new Error('An unhandled ShellOutputEvent was found.');
|
|
}
|
|
|
|
if (executionPid && m.backgroundedPids.has(executionPid)) {
|
|
// If already backgrounded, let the background shell subscription handle it.
|
|
dispatch({
|
|
type: 'APPEND_SHELL_OUTPUT',
|
|
pid: executionPid,
|
|
chunk:
|
|
event.type === 'data' ? event.chunk : cumulativeStdout,
|
|
});
|
|
return;
|
|
}
|
|
|
|
let currentDisplayOutput: string | AnsiOutput;
|
|
if (isBinaryStream) {
|
|
currentDisplayOutput =
|
|
binaryBytesReceived > 0
|
|
? `[Receiving binary output... ${formatBytes(binaryBytesReceived)} received]`
|
|
: '[Binary output detected. Halting stream...]';
|
|
} else {
|
|
currentDisplayOutput = cumulativeStdout;
|
|
}
|
|
|
|
if (shouldUpdate) {
|
|
dispatch({ type: 'SET_OUTPUT_TIME', time: Date.now() });
|
|
setPendingHistoryItem((prevItem) => {
|
|
if (prevItem?.type === 'tool_group') {
|
|
return {
|
|
...prevItem,
|
|
tools: prevItem.tools.map((tool) =>
|
|
tool.callId === callId
|
|
? { ...tool, resultDisplay: currentDisplayOutput }
|
|
: tool,
|
|
),
|
|
};
|
|
}
|
|
return prevItem;
|
|
});
|
|
}
|
|
},
|
|
abortSignal,
|
|
config.getEnableInteractiveShell(),
|
|
shellExecutionConfig,
|
|
);
|
|
|
|
executionPid = pid;
|
|
if (pid) {
|
|
dispatch({ type: 'SET_ACTIVE_PTY', pid });
|
|
setPendingHistoryItem((prevItem) => {
|
|
if (prevItem?.type === 'tool_group') {
|
|
return {
|
|
...prevItem,
|
|
tools: prevItem.tools.map((tool) =>
|
|
tool.callId === callId ? { ...tool, ptyId: pid } : tool,
|
|
),
|
|
};
|
|
}
|
|
return prevItem;
|
|
});
|
|
}
|
|
|
|
const result = await resultPromise;
|
|
setPendingHistoryItem(null);
|
|
|
|
if (result.backgrounded && result.pid) {
|
|
registerBackgroundTask(
|
|
result.pid,
|
|
rawQuery,
|
|
cumulativeStdout,
|
|
'notify',
|
|
);
|
|
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
|
|
}
|
|
|
|
let mainContent: string;
|
|
if (isBinary(result.rawOutput)) {
|
|
mainContent =
|
|
'[Command produced binary output, which is not shown.]';
|
|
} else {
|
|
mainContent =
|
|
result.output.trim() || '(Command produced no output)';
|
|
}
|
|
|
|
let finalOutput = mainContent;
|
|
let finalStatus = CoreToolCallStatus.Success;
|
|
|
|
if (result.error) {
|
|
finalStatus = CoreToolCallStatus.Error;
|
|
finalOutput = `${result.error.message}\n${finalOutput}`;
|
|
} else if (result.aborted) {
|
|
finalStatus = CoreToolCallStatus.Cancelled;
|
|
finalOutput = `Command was cancelled.\n${finalOutput}`;
|
|
} else if (result.backgrounded) {
|
|
finalStatus = CoreToolCallStatus.Success;
|
|
finalOutput = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
|
|
} else if (result.signal) {
|
|
finalStatus = CoreToolCallStatus.Error;
|
|
finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`;
|
|
} else if (result.exitCode !== 0) {
|
|
finalStatus = CoreToolCallStatus.Error;
|
|
finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`;
|
|
}
|
|
|
|
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
|
|
const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
|
|
if (finalPwd && finalPwd !== targetDir) {
|
|
const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
|
|
finalOutput = `${warning}\n\n${finalOutput}`;
|
|
}
|
|
}
|
|
|
|
const finalToolDisplay: IndividualToolCallDisplay = {
|
|
...initialToolDisplay,
|
|
status: finalStatus,
|
|
resultDisplay: finalOutput,
|
|
};
|
|
|
|
if (finalStatus !== CoreToolCallStatus.Cancelled) {
|
|
addItemToHistory(
|
|
{
|
|
type: 'tool_group',
|
|
tools: [finalToolDisplay],
|
|
} as HistoryItemWithoutId,
|
|
userMessageTimestamp,
|
|
);
|
|
}
|
|
|
|
addShellCommandToGeminiHistory(geminiClient, rawQuery, finalOutput);
|
|
} catch (err) {
|
|
setPendingHistoryItem(null);
|
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
addItemToHistory(
|
|
{
|
|
type: 'error',
|
|
text: `An unexpected error occurred: ${errorMessage}`,
|
|
},
|
|
userMessageTimestamp,
|
|
);
|
|
} finally {
|
|
abortSignal.removeEventListener('abort', abortHandler);
|
|
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
|
|
fs.unlinkSync(pwdFilePath);
|
|
}
|
|
|
|
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
|
|
setShellInputFocused(false);
|
|
}
|
|
};
|
|
|
|
onExec(executeCommand());
|
|
return true;
|
|
},
|
|
[
|
|
config,
|
|
onDebugMessage,
|
|
addItemToHistory,
|
|
setPendingHistoryItem,
|
|
onExec,
|
|
geminiClient,
|
|
setShellInputFocused,
|
|
terminalHeight,
|
|
terminalWidth,
|
|
registerBackgroundTask,
|
|
m,
|
|
dispatch,
|
|
],
|
|
);
|
|
|
|
const backgroundShellCount = Array.from(
|
|
state.backgroundShells.values(),
|
|
).filter((s: BackgroundShell) => s.status === 'running').length;
|
|
|
|
return {
|
|
handleShellCommand,
|
|
activeShellPtyId: state.activeShellPtyId,
|
|
lastShellOutputTime: state.lastShellOutputTime,
|
|
backgroundShellCount,
|
|
isBackgroundShellVisible: state.isBackgroundShellVisible,
|
|
toggleBackgroundShell,
|
|
backgroundCurrentShell: backgroundCurrentExecution,
|
|
registerBackgroundShell: registerBackgroundTask,
|
|
dismissBackgroundShell: dismissBackgroundTask,
|
|
backgroundShells: state.backgroundShells,
|
|
};
|
|
};
|