feat: Implement background shell commands (#14849)

This commit is contained in:
Gal Zahavi
2026-01-30 09:53:09 -08:00
committed by GitHub
parent d3bca5d97a
commit b611f9a519
52 changed files with 3957 additions and 470 deletions
+3
View File
@@ -264,6 +264,9 @@ Slash commands provide meta-level control over the CLI itself.
modify them as desired. Changes to some settings are applied immediately,
while others require a restart.
- **`/shells`** (or **`/bashes`**)
- **Description:** Toggle the background shells view. This allows you to view
and manage long-running processes that you've sent to the background.
- **`/setup-github`**
- **Description:** Set up GitHub Actions to triage issues and review PRs with
Gemini.
+9 -1
View File
@@ -23,7 +23,7 @@ available combinations.
| Move the cursor to the end of the line. | `Ctrl + E`<br />`End (no Shift, Ctrl)` |
| Move the cursor up one line. | `Up Arrow (no Shift, Alt, Ctrl, Cmd)` |
| Move the cursor down one line. | `Down Arrow (no Shift, Alt, Ctrl, Cmd)` |
| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)`<br />`Ctrl + B` |
| Move the cursor one character to the left. | `Left Arrow (no Shift, Alt, Ctrl, Cmd)` |
| Move the cursor one character to the right. | `Right Arrow (no Shift, Alt, Ctrl, Cmd)`<br />`Ctrl + F` |
| Move the cursor one word to the left. | `Ctrl + Left Arrow`<br />`Alt + Left Arrow`<br />`Alt + B` |
| Move the cursor one word to the right. | `Ctrl + Right Arrow`<br />`Alt + Right Arrow`<br />`Alt + F` |
@@ -106,6 +106,14 @@ available combinations.
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
| Ctrl+B | `Ctrl + B` |
| Ctrl+L | `Ctrl + L` |
| Ctrl+K | `Ctrl + K` |
| Enter | `Enter` |
| Esc | `Esc` |
| Shift+Tab | `Shift + Tab` |
| Tab | `Tab (no Shift)` |
| Tab | `Tab (no Shift)` |
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
| Focus the Gemini input from the shell input. | `Tab` |
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
+35 -1
View File
@@ -72,6 +72,15 @@ export enum Command {
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
PASTE_CLIPBOARD = 'input.paste',
BACKGROUND_SHELL_ESCAPE = 'backgroundShellEscape',
BACKGROUND_SHELL_SELECT = 'backgroundShellSelect',
TOGGLE_BACKGROUND_SHELL = 'toggleBackgroundShell',
TOGGLE_BACKGROUND_SHELL_LIST = 'toggleBackgroundShellList',
KILL_BACKGROUND_SHELL = 'backgroundShell.kill',
UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus',
UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus',
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning',
// App Controls
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
SHOW_FULL_TODOS = 'app.showFullTodos',
@@ -139,7 +148,6 @@ export const defaultKeyBindings: KeyBindingConfig = {
],
[Command.MOVE_LEFT]: [
{ key: 'left', shift: false, alt: false, ctrl: false, cmd: false },
{ key: 'b', ctrl: true },
],
[Command.MOVE_RIGHT]: [
{ key: 'right', shift: false, alt: false, ctrl: false, cmd: false },
@@ -265,6 +273,16 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }],
[Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }],
[Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: true }],
[Command.TOGGLE_BACKGROUND_SHELL]: [{ key: 'b', ctrl: true }],
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: [{ key: 'l', ctrl: true }],
[Command.KILL_BACKGROUND_SHELL]: [{ key: 'k', ctrl: true }],
[Command.UNFOCUS_BACKGROUND_SHELL]: [{ key: 'tab', shift: true }],
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [{ key: 'tab', shift: false }],
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [
{ key: 'tab', shift: false },
],
[Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
[Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
[Command.SHOW_MORE_LINES]: [
{ key: 'o', ctrl: true },
{ key: 's', ctrl: true },
@@ -379,6 +397,14 @@ export const commandCategories: readonly CommandCategory[] = [
Command.TOGGLE_YOLO,
Command.CYCLE_APPROVAL_MODE,
Command.SHOW_MORE_LINES,
Command.TOGGLE_BACKGROUND_SHELL,
Command.TOGGLE_BACKGROUND_SHELL_LIST,
Command.KILL_BACKGROUND_SHELL,
Command.BACKGROUND_SHELL_SELECT,
Command.BACKGROUND_SHELL_ESCAPE,
Command.UNFOCUS_BACKGROUND_SHELL,
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
Command.FOCUS_SHELL_INPUT,
Command.UNFOCUS_SHELL_INPUT,
Command.CLEAR_SCREEN,
@@ -470,6 +496,14 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
[Command.SHOW_MORE_LINES]:
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
[Command.BACKGROUND_SHELL_SELECT]: 'Enter',
[Command.BACKGROUND_SHELL_ESCAPE]: 'Esc',
[Command.TOGGLE_BACKGROUND_SHELL]: 'Ctrl+B',
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Ctrl+L',
[Command.KILL_BACKGROUND_SHELL]: 'Ctrl+K',
[Command.UNFOCUS_BACKGROUND_SHELL]: 'Shift+Tab',
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: 'Tab',
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Tab',
[Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
@@ -47,6 +47,7 @@ import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { skillsCommand } from '../ui/commands/skillsCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { shellsCommand } from '../ui/commands/shellsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
@@ -164,6 +165,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
: [skillsCommand]
: []),
settingsCommand,
shellsCommand,
vimCommand,
setupGithubCommand,
terminalSetupCommand,
+7
View File
@@ -157,6 +157,9 @@ const baseMockUiState = {
terminalHeight: 40,
currentModel: 'gemini-pro',
terminalBackgroundColor: undefined,
activePtyId: undefined,
backgroundShells: new Map(),
backgroundShellHeight: 0,
};
export const mockAppState: AppState = {
@@ -201,7 +204,11 @@ const mockUIActions: UIActions = {
handleApiKeyCancel: vi.fn(),
setBannerVisible: vi.fn(),
setEmbeddedShellFocused: vi.fn(),
dismissBackgroundShell: vi.fn(),
setActiveBackgroundShellPid: vi.fn(),
setIsBackgroundShellListOpen: vi.fn(),
setAuthContext: vi.fn(),
handleWarning: vi.fn(),
handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(),
};
+1
View File
@@ -88,6 +88,7 @@ describe('App', () => {
defaultText: 'Mock Banner Text',
warningText: '',
},
backgroundShells: new Map(),
};
it('should render main content and composer when not quitting', () => {
+37 -92
View File
@@ -269,6 +269,25 @@ describe('AppContainer State Management', () => {
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
const mockedUseHookDisplayState = useHookDisplayState as Mock;
const DEFAULT_GEMINI_STREAM_MOCK = {
streamingState: 'idle',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
handleApprovalModeChange: vi.fn(),
activePtyId: null,
loopDetectionConfirmationRequest: null,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
toggleBackgroundShell: vi.fn(),
backgroundCurrentShell: vi.fn(),
backgroundShells: new Map(),
registerBackgroundShell: vi.fn(),
dismissBackgroundShell: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
@@ -334,14 +353,7 @@ describe('AppContainer State Management', () => {
handleNewMessage: vi.fn(),
clearConsoleMessages: vi.fn(),
});
mockedUseGeminiStream.mockReturnValue({
streamingState: 'idle',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
mockedUseVim.mockReturnValue({ handleInput: vi.fn() });
mockedUseFolderTrust.mockReturnValue({
isFolderTrustDialogOpen: false,
@@ -1193,12 +1205,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state as Active
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Some thought' },
cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1234,12 +1243,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Some thought' },
cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1306,12 +1312,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought
const thoughtSubject = 'Processing request';
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: thoughtSubject },
cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1347,14 +1350,7 @@ describe('AppContainer State Management', () => {
} as unknown as LoadedSettings;
// Mock the streaming state as Idle with no thought
mockedUseGeminiStream.mockReturnValue({
streamingState: 'idle',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
// Act: Render the container
const { unmount } = renderAppContainer({
@@ -1391,12 +1387,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought
const thoughtSubject = 'Confirm tool execution';
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'waiting_for_confirmation',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: thoughtSubject },
cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1448,16 +1441,11 @@ describe('AppContainer State Management', () => {
// Mock an active shell pty but not focused
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
cancelOngoingRequest: vi.fn(),
pendingToolCalls: [],
handleApprovalModeChange: vi.fn(),
activePtyId: 'pty-1',
loopDetectionConfirmationRequest: null,
lastOutputTime: startTime + 100, // Trigger aggressive delay
retryStatus: null,
});
@@ -1512,12 +1500,9 @@ describe('AppContainer State Management', () => {
// Mock an active shell pty with redirection active
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
cancelOngoingRequest: vi.fn(),
pendingToolCalls: [
{
request: {
@@ -1527,9 +1512,7 @@ describe('AppContainer State Management', () => {
status: 'executing',
} as unknown as TrackedToolCall,
],
handleApprovalModeChange: vi.fn(),
activePtyId: 'pty-1',
loopDetectionConfirmationRequest: null,
lastOutputTime: startTime,
retryStatus: null,
});
@@ -1587,16 +1570,11 @@ describe('AppContainer State Management', () => {
// Mock an active shell pty with NO output since operation started (silent)
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
cancelOngoingRequest: vi.fn(),
pendingToolCalls: [],
handleApprovalModeChange: vi.fn(),
activePtyId: 'pty-1',
loopDetectionConfirmationRequest: null,
lastOutputTime: startTime, // lastOutputTime <= operationStartTime
retryStatus: null,
});
@@ -1643,12 +1621,9 @@ describe('AppContainer State Management', () => {
// Mock an active shell pty but not focused
let lastOutputTime = startTime + 1000;
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
cancelOngoingRequest: vi.fn(),
activePtyId: 'pty-1',
lastOutputTime,
}));
@@ -1669,12 +1644,9 @@ describe('AppContainer State Management', () => {
// Update lastOutputTime to simulate new output
lastOutputTime = startTime + 21000;
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: 'Executing shell command' },
cancelOngoingRequest: vi.fn(),
activePtyId: 'pty-1',
lastOutputTime,
}));
@@ -1734,12 +1706,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought with a short subject
const shortTitle = 'Short';
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: shortTitle },
cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1778,12 +1747,9 @@ describe('AppContainer State Management', () => {
// Mock the streaming state and thought
const title = 'Test Title';
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: { subject: title },
cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1821,12 +1787,8 @@ describe('AppContainer State Management', () => {
// Mock the streaming state
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
});
// Act: Render the container
@@ -1928,12 +1890,7 @@ describe('AppContainer State Management', () => {
mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen
mockedUseGeminiStream.mockReturnValue({
streamingState: 'idle',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: 'some-id',
});
@@ -2005,11 +1962,7 @@ describe('AppContainer State Management', () => {
// Mock request cancellation
mockCancelOngoingRequest = vi.fn();
mockedUseGeminiStream.mockReturnValue({
streamingState: 'idle',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
...DEFAULT_GEMINI_STREAM_MOCK,
cancelOngoingRequest: mockCancelOngoingRequest,
});
@@ -2030,11 +1983,8 @@ describe('AppContainer State Management', () => {
describe('CTRL+C', () => {
it('should cancel ongoing request on first press', async () => {
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: 'responding',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: mockCancelOngoingRequest,
});
await setupKeypressTest();
@@ -2574,12 +2524,7 @@ describe('AppContainer State Management', () => {
});
mockedUseGeminiStream.mockReturnValue({
streamingState: 'idle',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
...DEFAULT_GEMINI_STREAM_MOCK,
activePtyId: 'some-pty-id', // Make sure activePtyId is set
});
+150 -16
View File
@@ -99,6 +99,7 @@ import { computeTerminalTitle } from '../utils/windowTitle.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
import { useLogger } from './hooks/useLogger.js';
import { useGeminiStream } from './hooks/useGeminiStream.js';
import { type BackgroundShell } from './hooks/shellCommandProcessor.js';
import { useVim } from './hooks/vim.js';
import { type LoadableSettingScope, SettingScope } from '../config/settings.js';
import { type InitializationResult } from '../core/initializer.js';
@@ -138,6 +139,7 @@ import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js'
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useBanner } from './hooks/useBanner.js';
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
import {
WARNING_PROMPT_DURATION_MS,
QUEUE_ERROR_DISPLAY_DURATION_MS,
@@ -259,6 +261,10 @@ export const AppContainer = (props: AppContainerProps) => {
);
const [copyModeEnabled, setCopyModeEnabled] = useState(false);
const [pendingRestorePrompt, setPendingRestorePrompt] = useState(false);
const toggleBackgroundShellRef = useRef<() => void>(() => {});
const isBackgroundShellVisibleRef = useRef<boolean>(false);
const backgroundShellsRef = useRef<Map<number, BackgroundShell>>(new Map());
const [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
const [shellModeActive, setShellModeActive] = useState(false);
@@ -489,6 +495,12 @@ export const AppContainer = (props: AppContainerProps) => {
registerCleanup(async () => {
// Turn off mouse scroll.
disableMouseEvents();
// Kill all background shells
for (const pid of backgroundShellsRef.current.keys()) {
ShellExecutionService.kill(pid);
}
const ideClient = await IdeClient.getInstance();
await ideClient.disconnect();
@@ -837,6 +849,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { toggleVimEnabled } = useVimMode();
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
() => {},
);
const slashCommandActions = useMemo(
() => ({
openAuthDialog: () => setAuthState(AuthState.Updating),
@@ -860,6 +876,17 @@ Logging in with Google... Restarting Gemini CLI to continue.
toggleDebugProfiler,
dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest,
toggleBackgroundShell: () => {
toggleBackgroundShellRef.current();
if (!isBackgroundShellVisibleRef.current) {
setEmbeddedShellFocused(true);
if (backgroundShellsRef.current.size > 1) {
setIsBackgroundShellListOpenRef.current(true);
} else {
setIsBackgroundShellListOpenRef.current(false);
}
}
},
setText: (text: string) => buffer.setText(text),
}),
[
@@ -1011,6 +1038,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
dismissBackgroundShell,
retryStatus,
} = useGeminiStream(
config.getGeminiClient(),
@@ -1033,7 +1066,30 @@ Logging in with Google... Restarting Gemini CLI to continue.
embeddedShellFocused,
);
toggleBackgroundShellRef.current = toggleBackgroundShell;
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
backgroundShellsRef.current = backgroundShells;
const {
activeBackgroundShellPid,
setIsBackgroundShellListOpen,
isBackgroundShellListOpen,
setActiveBackgroundShellPid,
backgroundShellHeight,
} = useBackgroundShellManager({
backgroundShells,
backgroundShellCount,
isBackgroundShellVisible,
activePtyId,
embeddedShellFocused,
setEmbeddedShellFocused,
terminalHeight,
});
setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen;
const lastOutputTimeRef = useRef(0);
useEffect(() => {
lastOutputTimeRef.current = lastOutputTime;
}, [lastOutputTime]);
@@ -1182,7 +1238,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
// Compute available terminal height based on controls measurement
const availableTerminalHeight = Math.max(
0,
terminalHeight - controlsHeight - staticExtraHeight - 2,
terminalHeight -
controlsHeight -
staticExtraHeight -
2 -
backgroundShellHeight,
);
config.setShellExecutionConfig({
@@ -1542,9 +1602,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
setConstrainHeight(false);
return true;
} else if (
keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) &&
activePtyId &&
embeddedShellFocused
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
(activePtyId ||
(isBackgroundShellVisible && backgroundShells.size > 0)) &&
buffer.text.length === 0
) {
if (key.name === 'tab' && key.shift) {
// Always change focus
@@ -1552,26 +1613,72 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
}
if (embeddedShellFocused) {
handleWarning('Press Shift+Tab to focus out.');
return true;
}
const now = Date.now();
// If the shell hasn't produced output in the last 100ms, it's considered idle.
const isIdle = now - lastOutputTimeRef.current >= 100;
if (isIdle) {
if (isIdle && !activePtyId) {
if (tabFocusTimeoutRef.current) {
clearTimeout(tabFocusTimeoutRef.current);
}
tabFocusTimeoutRef.current = setTimeout(() => {
tabFocusTimeoutRef.current = null;
// If the shell produced output since the tab press, we assume it handled the tab
// (e.g. autocomplete) so we should not toggle focus.
if (lastOutputTimeRef.current > now) {
handleWarning('Press Shift+Tab to focus out.');
return;
toggleBackgroundShell();
if (!isBackgroundShellVisible) {
// We are about to show it, so focus it
setEmbeddedShellFocused(true);
if (backgroundShells.size > 1) {
setIsBackgroundShellListOpen(true);
}
setEmbeddedShellFocused(false);
}, 100);
} else {
// We are about to hide it
tabFocusTimeoutRef.current = setTimeout(() => {
tabFocusTimeoutRef.current = null;
// If the shell produced output since the tab press, we assume it handled the tab
// (e.g. autocomplete) so we should not toggle focus.
if (lastOutputTimeRef.current > now) {
handleWarning('Press Shift+Tab to focus out.');
return;
}
setEmbeddedShellFocused(false);
}, 100);
}
return true;
}
handleWarning('Press Shift+Tab to focus out.');
// Not idle, just focus it
setEmbeddedShellFocused(true);
return true;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
if (activePtyId) {
backgroundCurrentShell();
// After backgrounding, we explicitly do NOT show or focus the background UI.
} else {
if (isBackgroundShellVisible && !embeddedShellFocused) {
setEmbeddedShellFocused(true);
} else {
toggleBackgroundShell();
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
setEmbeddedShellFocused(true);
if (backgroundShells.size > 1) {
setIsBackgroundShellListOpen(true);
}
} else {
setEmbeddedShellFocused(false);
}
}
}
return true;
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
if (backgroundShells.size > 0 && isBackgroundShellVisible) {
if (!embeddedShellFocused) {
setEmbeddedShellFocused(true);
}
setIsBackgroundShellListOpen(true);
}
return true;
}
return false;
@@ -1595,11 +1702,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
setCopyModeEnabled,
copyModeEnabled,
isAlternateBuffer,
backgroundCurrentShell,
toggleBackgroundShell,
backgroundShells,
isBackgroundShellVisible,
setIsBackgroundShellListOpen,
lastOutputTimeRef,
tabFocusTimeoutRef,
handleWarning,
],
);
useKeypress(handleGlobalKeypress, { isActive: true });
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
useEffect(() => {
// Respect hideWindowTitle settings
@@ -1878,6 +1992,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isRestarting,
extensionsUpdateState,
activePtyId,
backgroundShellCount,
isBackgroundShellVisible,
embeddedShellFocused,
showDebugProfiler,
customDialog,
@@ -1887,6 +2003,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerVisible,
terminalBackgroundColor: config.getTerminalBackground(),
settingsNonce,
backgroundShells,
activeBackgroundShellPid,
backgroundShellHeight,
isBackgroundShellListOpen,
adminSettingsChanged,
newAgents,
}),
@@ -1977,6 +2097,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
currentModel,
extensionsUpdateState,
activePtyId,
backgroundShellCount,
isBackgroundShellVisible,
historyManager,
embeddedShellFocused,
showDebugProfiler,
@@ -1989,6 +2111,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
bannerVisible,
config,
settingsNonce,
backgroundShellHeight,
isBackgroundShellListOpen,
activeBackgroundShellPid,
backgroundShells,
adminSettingsChanged,
newAgents,
],
@@ -2036,7 +2162,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeySubmit,
handleApiKeyCancel,
setBannerVisible,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
setAuthContext,
handleRestart: async () => {
if (process.send) {
@@ -2108,7 +2238,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeySubmit,
handleApiKeyCancel,
setBannerVisible,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
setAuthContext,
newAgents,
config,
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { shellsCommand } from './shellsCommand.js';
import type { CommandContext } from './types.js';
describe('shellsCommand', () => {
it('should call toggleBackgroundShell', async () => {
const toggleBackgroundShell = vi.fn();
const context = {
ui: {
toggleBackgroundShell,
},
} as unknown as CommandContext;
if (shellsCommand.action) {
await shellsCommand.action(context, '');
}
expect(toggleBackgroundShell).toHaveBeenCalled();
});
it('should have correct name and altNames', () => {
expect(shellsCommand.name).toBe('shells');
expect(shellsCommand.altNames).toContain('bashes');
});
it('should auto-execute', () => {
expect(shellsCommand.autoExecute).toBe(true);
});
});
@@ -0,0 +1,18 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { CommandKind, type SlashCommand } from './types.js';
export const shellsCommand: SlashCommand = {
name: 'shells',
altNames: ['bashes'],
kind: CommandKind.BUILT_IN,
description: 'Toggle background shells view',
autoExecute: true,
action: async (context) => {
context.ui.toggleBackgroundShell();
},
};
+1
View File
@@ -84,6 +84,7 @@ export interface CommandContext {
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
removeComponent: () => void;
toggleBackgroundShell: () => void;
};
// Session-specific data
session: {
@@ -0,0 +1,459 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { act } from 'react';
import { type Key, type KeypressHandler } from '../contexts/KeypressContext.js';
import { ScrollProvider } from '../contexts/ScrollProvider.js';
import { Box } from 'ink';
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Mock dependencies
const mockDismissBackgroundShell = vi.fn();
const mockSetActiveBackgroundShellPid = vi.fn();
const mockSetIsBackgroundShellListOpen = vi.fn();
const mockHandleWarning = vi.fn();
const mockSetEmbeddedShellFocused = vi.fn();
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: () => ({
dismissBackgroundShell: mockDismissBackgroundShell,
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
handleWarning: mockHandleWarning,
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
}),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
ShellExecutionService: {
resizePty: vi.fn(),
subscribe: vi.fn(() => vi.fn()),
},
};
});
// Mock AnsiOutputText since it's a complex component
vi.mock('./AnsiOutput.js', () => ({
AnsiOutputText: ({ data }: { data: string | unknown }) => {
if (typeof data === 'string') return <>{data}</>;
// Simple serialization for object data
return <>{JSON.stringify(data)}</>;
},
}));
// Mock useKeypress
let keypressHandlers: Array<{ handler: KeypressHandler; isActive: boolean }> =
[];
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn((handler, { isActive }) => {
keypressHandlers.push({ handler, isActive });
}),
}));
const simulateKey = (key: Partial<Key>) => {
const fullKey: Key = createMockKey(key);
keypressHandlers.forEach(({ handler, isActive }) => {
if (isActive) {
handler(fullKey);
}
});
};
vi.mock('../contexts/MouseContext.js', () => ({
useMouseContext: vi.fn(() => ({
subscribe: vi.fn(),
unsubscribe: vi.fn(),
})),
useMouse: vi.fn(),
}));
// Mock ScrollableList
vi.mock('./shared/ScrollableList.js', () => ({
SCROLL_TO_ITEM_END: 999999,
ScrollableList: vi.fn(
({
data,
renderItem,
}: {
data: BackgroundShell[];
renderItem: (props: {
item: BackgroundShell;
index: number;
}) => React.ReactNode;
}) => (
<Box flexDirection="column">
{data.map((item: BackgroundShell, index: number) => (
<Box key={index}>{renderItem({ item, index })}</Box>
))}
</Box>
),
),
}));
const createMockKey = (overrides: Partial<Key>): Key => ({
name: '',
ctrl: false,
alt: false,
cmd: false,
shift: false,
insertable: false,
sequence: '',
...overrides,
});
describe('<BackgroundShellDisplay />', () => {
const mockShells = new Map<number, BackgroundShell>();
const shell1: BackgroundShell = {
pid: 1001,
command: 'npm start',
output: 'Starting server...',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
};
const shell2: BackgroundShell = {
pid: 1002,
command: 'tail -f log.txt',
output: 'Log entry 1',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
};
beforeEach(() => {
vi.clearAllMocks();
mockShells.clear();
mockShells.set(shell1.pid, shell1);
mockShells.set(shell2.pid, shell2);
keypressHandlers = [];
});
it('renders the output of the active shell', async () => {
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders tabs for multiple shells', async () => {
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={100}
height={24}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('highlights the focused state', async () => {
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true} // Focused
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('resizes the PTY on mount and when dimensions change', async () => {
const { rerender } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
76,
21,
);
rerender(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={100}
height={30}
isFocused={false}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(ShellExecutionService.resizePty).toHaveBeenCalledWith(
shell1.pid,
96,
27,
);
});
it('renders the process list when isListOpenProp is true', async () => {
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
// Simulate down arrow to select the second process (handled by RadioButtonSelect)
act(() => {
simulateKey({ name: 'down' });
});
// Simulate Ctrl+L (handled by BackgroundShellDisplay)
act(() => {
simulateKey({ name: 'l', ctrl: true });
});
expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid);
expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false);
});
it('kills the highlighted process when Ctrl+K is pressed in list view', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
// Initial state: shell1 (active) is highlighted
// Move to shell2
act(() => {
simulateKey({ name: 'down' });
});
// Press Ctrl+K
act(() => {
simulateKey({ name: 'k', ctrl: true });
});
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid);
});
it('kills the active process when Ctrl+K is pressed in output view', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
act(() => {
simulateKey({ name: 'k', ctrl: true });
});
expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid);
});
it('scrolls to active shell when list opens', async () => {
// shell2 is active
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell2.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('keeps exit code status color even when selected', async () => {
const exitedShell: BackgroundShell = {
pid: 1003,
command: 'exit 0',
output: '',
isBinary: false,
binaryBytesReceived: 0,
status: 'exited',
exitCode: 0,
};
mockShells.set(exitedShell.pid, exitedShell);
const { lastFrame } = render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={exitedShell.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={true}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
expect(lastFrame()).toMatchSnapshot();
});
it('unfocuses the shell when Shift+Tab is pressed', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
act(() => {
simulateKey({ name: 'tab', shift: true });
});
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
it('shows a warning when Tab is pressed', async () => {
render(
<ScrollProvider>
<BackgroundShellDisplay
shells={mockShells}
activePid={shell1.pid}
width={80}
height={24}
isFocused={true}
isListOpenProp={false}
/>
</ScrollProvider>,
);
await act(async () => {
await delay(0);
});
act(() => {
simulateKey({ name: 'tab' });
});
expect(mockHandleWarning).toHaveBeenCalledWith(
'Press Shift+Tab to focus out.',
);
expect(mockSetEmbeddedShellFocused).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,460 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useEffect, useState, useRef } from 'react';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { theme } from '../semantic-colors.js';
import {
ShellExecutionService,
type AnsiOutput,
type AnsiLine,
type AnsiToken,
} from '@google/gemini-cli-core';
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { Command, keyMatchers } from '../keyMatchers.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { commandDescriptions } from '../../config/keyBindings.js';
import {
ScrollableList,
type ScrollableListRef,
} from './shared/ScrollableList.js';
import { SCROLL_TO_ITEM_END } from './shared/VirtualizedList.js';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
interface BackgroundShellDisplayProps {
shells: Map<number, BackgroundShell>;
activePid: number;
width: number;
height: number;
isFocused: boolean;
isListOpenProp: boolean;
}
const CONTENT_PADDING_X = 1;
const BORDER_WIDTH = 2; // Left and Right border
const HEADER_HEIGHT = 3; // 2 for border, 1 for header
const TAB_DISPLAY_HORIZONTAL_PADDING = 4;
const formatShellCommandForDisplay = (command: string, maxWidth: number) => {
const commandFirstLine = command.split('\n')[0];
return cpLen(commandFirstLine) > maxWidth
? `${cpSlice(commandFirstLine, 0, maxWidth - 3)}...`
: commandFirstLine;
};
export const BackgroundShellDisplay = ({
shells,
activePid,
width,
height,
isFocused,
isListOpenProp,
}: BackgroundShellDisplayProps) => {
const {
dismissBackgroundShell,
setActiveBackgroundShellPid,
setIsBackgroundShellListOpen,
handleWarning,
setEmbeddedShellFocused,
} = useUIActions();
const activeShell = shells.get(activePid);
const [output, setOutput] = useState<string | AnsiOutput>(
activeShell?.output || '',
);
const [highlightedPid, setHighlightedPid] = useState<number | null>(
activePid,
);
const outputRef = useRef<ScrollableListRef<AnsiLine | string>>(null);
const subscribedRef = useRef(false);
useEffect(() => {
if (!activePid) return;
const ptyWidth = Math.max(1, width - BORDER_WIDTH - CONTENT_PADDING_X * 2);
const ptyHeight = Math.max(1, height - HEADER_HEIGHT);
ShellExecutionService.resizePty(activePid, ptyWidth, ptyHeight);
}, [activePid, width, height]);
useEffect(() => {
if (!activePid) {
setOutput('');
return;
}
// Set initial output from the shell object
const shell = shells.get(activePid);
if (shell) {
setOutput(shell.output);
}
subscribedRef.current = false;
// Subscribe to live updates for the active shell
const unsubscribe = ShellExecutionService.subscribe(activePid, (event) => {
if (event.type === 'data') {
if (typeof event.chunk === 'string') {
if (!subscribedRef.current) {
// Initial synchronous update contains full history
setOutput(event.chunk);
} else {
// Subsequent updates are deltas for child_process
setOutput((prev) =>
typeof prev === 'string' ? prev + event.chunk : event.chunk,
);
}
} else {
// PTY always sends full AnsiOutput
setOutput(event.chunk);
}
}
});
subscribedRef.current = true;
return () => {
unsubscribe();
subscribedRef.current = false;
};
}, [activePid, shells]);
// Sync highlightedPid with activePid when list opens
useEffect(() => {
if (isListOpenProp) {
setHighlightedPid(activePid);
}
}, [isListOpenProp, activePid]);
useKeypress(
(key) => {
if (!activeShell) return;
// Handle Shift+Tab or Tab (in list) to focus out
if (
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) ||
(isListOpenProp &&
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key))
) {
setEmbeddedShellFocused(false);
return true;
}
// Handle Tab to warn but propagate
if (
!isListOpenProp &&
keyMatchers[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING](key)
) {
handleWarning(
`Press ${commandDescriptions[Command.UNFOCUS_BACKGROUND_SHELL]} to focus out.`,
);
// Fall through to allow Tab to be sent to the shell
}
if (isListOpenProp) {
// Navigation (Up/Down/Enter) is handled by RadioButtonSelect
// We only handle special keys not consumed by RadioButtonSelect or overriding them if needed
// RadioButtonSelect handles Enter -> onSelect
if (keyMatchers[Command.BACKGROUND_SHELL_ESCAPE](key)) {
setIsBackgroundShellListOpen(false);
return true;
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
if (highlightedPid) {
dismissBackgroundShell(highlightedPid);
// If we killed the active one, the list might update via props
}
return true;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
if (highlightedPid) {
setActiveBackgroundShellPid(highlightedPid);
}
setIsBackgroundShellListOpen(false);
return true;
}
return false;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
return true;
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
dismissBackgroundShell(activeShell.pid);
return true;
}
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL_LIST](key)) {
setIsBackgroundShellListOpen(true);
return true;
}
if (keyMatchers[Command.BACKGROUND_SHELL_SELECT](key)) {
ShellExecutionService.writeToPty(activeShell.pid, '\r');
return true;
} else if (keyMatchers[Command.DELETE_CHAR_LEFT](key)) {
ShellExecutionService.writeToPty(activeShell.pid, '\b');
return true;
} else if (key.sequence) {
ShellExecutionService.writeToPty(activeShell.pid, key.sequence);
return true;
}
return false;
},
{ isActive: isFocused && !!activeShell },
);
const helpText = `${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL]} Hide | ${commandDescriptions[Command.KILL_BACKGROUND_SHELL]} Kill | ${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]} List`;
const renderTabs = () => {
const shellList = Array.from(shells.values()).filter(
(s) => s.status === 'running',
);
const pidInfoWidth = getCachedStringWidth(
` (PID: ${activePid}) ${isFocused ? '(Focused)' : ''}`,
);
const availableWidth =
width -
TAB_DISPLAY_HORIZONTAL_PADDING -
getCachedStringWidth(helpText) -
pidInfoWidth;
let currentWidth = 0;
const tabs = [];
for (let i = 0; i < shellList.length; i++) {
const shell = shellList[i];
// Account for " i: " (length 4 if i < 9) and spaces (length 2)
const labelOverhead = 4 + (i + 1).toString().length;
const maxTabLabelLength = Math.max(
1,
Math.floor(availableWidth / shellList.length) - labelOverhead,
);
const truncatedCommand = formatShellCommandForDisplay(
shell.command,
maxTabLabelLength,
);
const label = ` ${i + 1}: ${truncatedCommand} `;
const labelWidth = getCachedStringWidth(label);
// If this is the only shell, we MUST show it (truncated if necessary)
// even if it exceeds availableWidth, as there are no alternatives.
if (i > 0 && currentWidth + labelWidth > availableWidth) {
break;
}
const isActive = shell.pid === activePid;
tabs.push(
<Text
key={shell.pid}
color={isActive ? theme.text.primary : theme.text.secondary}
bold={isActive}
>
{label}
</Text>,
);
currentWidth += labelWidth;
}
if (shellList.length > tabs.length && !isListOpenProp) {
const overflowLabel = ` ... (${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]}) `;
const overflowWidth = getCachedStringWidth(overflowLabel);
// If we only have one tab, ensure we don't show the overflow if it's too cramped
// We want at least 10 chars for the overflow or we favor the first tab.
const shouldShowOverflow =
tabs.length > 1 || availableWidth - currentWidth >= overflowWidth;
if (shouldShowOverflow) {
tabs.push(
<Text key="overflow" color={theme.status.warning} bold>
{overflowLabel}
</Text>,
);
}
}
return tabs;
};
const renderProcessList = () => {
const maxCommandLength = Math.max(
0,
width - BORDER_WIDTH - CONTENT_PADDING_X * 2 - 10,
);
const items: Array<RadioSelectItem<number>> = Array.from(
shells.values(),
).map((shell, index) => {
const truncatedCommand = formatShellCommandForDisplay(
shell.command,
maxCommandLength,
);
let label = `${index + 1}: ${truncatedCommand} (PID: ${shell.pid})`;
if (shell.status === 'exited') {
label += ` (Exit Code: ${shell.exitCode})`;
}
return {
key: shell.pid.toString(),
value: shell.pid,
label,
};
});
const initialIndex = items.findIndex((item) => item.value === activePid);
return (
<Box flexDirection="column" height="100%" width="100%">
<Box flexShrink={0} marginBottom={1} paddingTop={1}>
<Text bold>
{`Select Process (${commandDescriptions[Command.BACKGROUND_SHELL_SELECT]} to select, ${commandDescriptions[Command.BACKGROUND_SHELL_ESCAPE]} to cancel):`}
</Text>
</Box>
<Box flexGrow={1} width="100%">
<RadioButtonSelect
items={items}
initialIndex={initialIndex >= 0 ? initialIndex : 0}
onSelect={(pid) => {
setActiveBackgroundShellPid(pid);
setIsBackgroundShellListOpen(false);
}}
onHighlight={(pid) => setHighlightedPid(pid)}
isFocused={isFocused}
maxItemsToShow={Math.max(1, height - HEADER_HEIGHT - 3)} // Adjust for header
renderItem={(
item,
{ isSelected: _isSelected, titleColor: _titleColor },
) => {
// Custom render to handle exit code coloring if needed,
// or just use default. The default RadioButtonSelect renderer
// handles standard label.
// But we want to color exit code differently?
// The previous implementation colored exit code green/red.
// Let's reimplement that.
// We need access to shell details here.
// We can put shell details in the item or lookup.
// Lookup from shells map.
const shell = shells.get(item.value);
if (!shell) return <Text>{item.label}</Text>;
const truncatedCommand = formatShellCommandForDisplay(
shell.command,
maxCommandLength,
);
return (
<Text>
{truncatedCommand} (PID: {shell.pid})
{shell.status === 'exited' ? (
<Text
color={
shell.exitCode === 0
? theme.status.success
: theme.status.error
}
>
{' '}
(Exit Code: {shell.exitCode})
</Text>
) : null}
</Text>
);
}}
/>
</Box>
</Box>
);
};
const renderOutput = () => {
const lines = typeof output === 'string' ? output.split('\n') : output;
return (
<ScrollableList
ref={outputRef}
data={lines}
renderItem={({ item: line, index }) => {
if (typeof line === 'string') {
return <Text key={index}>{line}</Text>;
}
return (
<Text key={index} wrap="truncate">
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
);
}}
estimatedItemHeight={() => 1}
keyExtractor={(_, index) => index.toString()}
hasFocus={isFocused}
initialScrollIndex={SCROLL_TO_ITEM_END}
/>
);
};
return (
<Box
flexDirection="column"
height="100%"
width="100%"
borderStyle="single"
borderColor={isFocused ? theme.border.focused : undefined}
>
<Box
flexDirection="row"
justifyContent="space-between"
borderStyle="single"
borderBottom={false}
borderLeft={false}
borderRight={false}
borderTop={false}
paddingX={1}
borderColor={isFocused ? theme.border.focused : undefined}
>
<Box flexDirection="row">
{renderTabs()}
<Text bold>
{' '}
(PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''}
</Text>
</Box>
<Text color={theme.text.accent}>{helpText}</Text>
</Box>
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
{isListOpenProp ? renderProcessList() : renderOutput()}
</Box>
</Box>
);
};
@@ -133,6 +133,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
nightly: false,
isTrustedFolder: true,
activeHooks: [],
isBackgroundShellVisible: false,
embeddedShellFocused: false,
...overrides,
}) as UIState;
@@ -310,6 +312,32 @@ describe('Composer', () => {
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('Should not show during confirmation');
});
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundShellVisible: true,
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
});
it('does NOT render LoadingIndicator when embedded shell is focused and background shell is NOT visible', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
embeddedShellFocused: true,
isBackgroundShellVisible: false,
});
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('LoadingIndicator');
});
});
describe('Message Queue Display', () => {
+1 -1
View File
@@ -54,7 +54,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
flexGrow={0}
flexShrink={0}
>
{!uiState.embeddedShellFocused && (
{(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && (
<LoadingIndicator
thought={
uiState.streamingState === StreamingState.WaitingForConfirmation ||
@@ -18,6 +18,7 @@ interface ContextSummaryDisplayProps {
blockedMcpServers?: Array<{ name: string; extensionName: string }>;
ideContext?: IdeContext;
skillCount: number;
backgroundProcessCount?: number;
}
export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
@@ -27,6 +28,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
blockedMcpServers,
ideContext,
skillCount,
backgroundProcessCount = 0,
}) => {
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
@@ -39,7 +41,8 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
mcpServerCount === 0 &&
blockedMcpServerCount === 0 &&
openFileCount === 0 &&
skillCount === 0
skillCount === 0 &&
backgroundProcessCount === 0
) {
return <Text> </Text>; // Render an empty space to reserve height
}
@@ -93,9 +96,22 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
return `${skillCount} skill${skillCount > 1 ? 's' : ''}`;
})();
const summaryParts = [openFilesText, geminiMdText, mcpText, skillText].filter(
Boolean,
);
const backgroundText = (() => {
if (backgroundProcessCount === 0) {
return '';
}
return `${backgroundProcessCount} Background process${
backgroundProcessCount > 1 ? 'es' : ''
}`;
})();
const summaryParts = [
openFilesText,
geminiMdText,
mcpText,
skillText,
backgroundText,
].filter(Boolean);
if (isNarrow) {
return (
+14 -3
View File
@@ -152,8 +152,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const kittyProtocol = useKittyKeyboardProtocol();
const isShellFocused = useShellFocusState();
const { setEmbeddedShellFocused } = useUIActions();
const { terminalWidth, activePtyId, history, terminalBackgroundColor } =
useUIState();
const {
terminalWidth,
activePtyId,
history,
terminalBackgroundColor,
backgroundShells,
backgroundShellHeight,
} = useUIState();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const escPressCount = useRef(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@@ -915,7 +921,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
// If we got here, Autocomplete didn't handle the key (e.g. no suggestions).
if (activePtyId) {
if (
activePtyId ||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
) {
setEmbeddedShellFocused(true);
}
return true;
@@ -967,6 +976,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onSubmit,
activePtyId,
setEmbeddedShellFocused,
backgroundShells.size,
backgroundShellHeight,
history,
],
);
@@ -9,6 +9,7 @@ import type React from 'react';
import { useKeypress } from '../hooks/useKeypress.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
import { Command, keyMatchers } from '../keyMatchers.js';
export interface ShellInputPromptProps {
activeShellPtyId: number | null;
@@ -31,22 +32,31 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
const handleInput = useCallback(
(key: Key) => {
if (!focus || !activeShellPtyId) {
return;
return false;
}
// Allow background shell toggle to bubble up
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
return false;
}
if (key.ctrl && key.shift && key.name === 'up') {
ShellExecutionService.scrollPty(activeShellPtyId, -1);
return;
return true;
}
if (key.ctrl && key.shift && key.name === 'down') {
ShellExecutionService.scrollPty(activeShellPtyId, 1);
return;
return true;
}
const ansiSequence = keyToAnsi(key);
if (ansiSequence) {
handleShellInputSubmit(ansiSequence);
return true;
}
return false;
},
[focus, handleShellInputSubmit, activeShellPtyId],
);
@@ -15,8 +15,14 @@ import type { TextBuffer } from './shared/text-buffer.js';
// Mock child components to simplify testing
vi.mock('./ContextSummaryDisplay.js', () => ({
ContextSummaryDisplay: (props: { skillCount: number }) => (
<Text>Mock Context Summary Display (Skills: {props.skillCount})</Text>
ContextSummaryDisplay: (props: {
skillCount: number;
backgroundProcessCount: number;
}) => (
<Text>
Mock Context Summary Display (Skills: {props.skillCount}, Shells:{' '}
{props.backgroundProcessCount})
</Text>
),
}));
@@ -41,6 +47,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
ideContextState: null,
geminiMdFileCount: 0,
contextFileNames: [],
backgroundShellCount: 0,
buffer: { text: '' },
history: [{ id: 1, type: 'user', text: 'test' }],
...overrides,
@@ -227,4 +234,15 @@ describe('StatusDisplay', () => {
);
expect(lastFrame()).toBe('');
});
it('passes backgroundShellCount to ContextSummaryDisplay', () => {
const uiState = createMockUIState({
backgroundShellCount: 3,
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toContain('Shells: 3');
});
});
@@ -81,6 +81,7 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
}
skillCount={config.getSkillManager().getDisplayableSkills().length}
backgroundProcessCount={uiState.backgroundShellCount}
/>
);
}
@@ -0,0 +1,56 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > keeps exit code status color even when selected 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1003) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ │
│ Select Process (Enter to select, Esc to cancel): │
│ │
│ 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
│ ● 3. exit 0 (PID: 1003) (Exit Code: 0) │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm start 2: tail -f log.txt (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm ... 2: tail... (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ Starting server... │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenProp is true 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ │
│ Select Process (Enter to select, Esc to cancel): │
│ │
│ ● 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm star... (PID: 1002) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
│ │
│ Select Process (Enter to select, Esc to cancel): │
│ │
│ 1. npm start (PID: 1001) │
│ ● 2. tail -f log.txt (PID: 1002) │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
@@ -1,12 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `"Mock Context Summary Display (Skills: 2)"`;
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `"Mock Context Summary Display (Skills: 2, Shells: 0)"`;
exports[`StatusDisplay > prioritizes Ctrl+C prompt over everything else (except system md) 1`] = `"Press Ctrl+C again to exit."`;
exports[`StatusDisplay > prioritizes warning over Ctrl+D 1`] = `"Warning"`;
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2)"`;
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2, Shells: 0)"`;
exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
+2
View File
@@ -116,6 +116,8 @@ export const INFORMATIVE_TIPS = [
'In menus, move up/down with k/j or the arrow keys…',
'In menus, select an item by typing its number…',
"If you're using an IDE, see the context with Ctrl+G…",
'Toggle background shells with Ctrl+B or /shells...',
'Toggle the background shell process list with Ctrl+L...',
// Keyboard shortcut tips end here
// Command tips start here
'Show version info with /about…',
@@ -67,7 +67,11 @@ export interface UIActions {
handleApiKeySubmit: (apiKey: string) => Promise<void>;
handleApiKeyCancel: () => void;
setBannerVisible: (visible: boolean) => void;
handleWarning: (message: string) => void;
setEmbeddedShellFocused: (value: boolean) => void;
dismissBackgroundShell: (pid: number) => void;
setActiveBackgroundShellPid: (pid: number) => void;
setIsBackgroundShellListOpen: (isOpen: boolean) => void;
setAuthContext: (context: { requiresRestart?: boolean }) => void;
handleRestart: () => void;
handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise<void>;
@@ -50,6 +50,7 @@ export interface ValidationDialogRequest {
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
export interface UIState {
history: HistoryItem[];
@@ -142,6 +143,8 @@ export interface UIState {
isRestarting: boolean;
extensionsUpdateState: Map<string, ExtensionUpdateState>;
activePtyId: number | undefined;
backgroundShellCount: number;
isBackgroundShellVisible: boolean;
embeddedShellFocused: boolean;
showDebugProfiler: boolean;
showFullTodos: boolean;
@@ -155,6 +158,10 @@ export interface UIState {
customDialog: React.ReactNode | null;
terminalBackgroundColor: TerminalBackgroundColor;
settingsNonce: number;
backgroundShells: Map<number, BackgroundShell>;
activeBackgroundShellPid: number | null;
backgroundShellHeight: number;
isBackgroundShellListOpen: boolean;
adminSettingsChanged: boolean;
newAgents: AgentDefinition[] | null;
}
@@ -4,6 +4,7 @@ exports[`useReactToolScheduler > should handle live output updates 1`] = `
{
"callId": "liveCall",
"contentLength": 12,
"data": undefined,
"error": undefined,
"errorType": undefined,
"outputFile": undefined,
@@ -26,6 +27,7 @@ exports[`useReactToolScheduler > should handle tool requiring confirmation - app
{
"callId": "callConfirm",
"contentLength": 16,
"data": undefined,
"error": undefined,
"errorType": undefined,
"outputFile": undefined,
@@ -75,6 +77,7 @@ exports[`useReactToolScheduler > should schedule and execute a tool call success
{
"callId": "call1",
"contentLength": 11,
"data": undefined,
"error": undefined,
"errorType": undefined,
"outputFile": undefined,
@@ -19,12 +19,34 @@ import {
const mockIsBinary = vi.hoisted(() => vi.fn());
const mockShellExecutionService = vi.hoisted(() => vi.fn());
const mockShellKill = vi.hoisted(() => vi.fn());
const mockShellBackground = vi.hoisted(() => vi.fn());
const mockShellSubscribe = vi.hoisted(() =>
vi.fn<
(pid: number, listener: (event: ShellOutputEvent) => void) => () => void
>(() => vi.fn()),
); // Returns unsubscribe
const mockShellOnExit = vi.hoisted(() =>
vi.fn<
(
pid: number,
callback: (exitCode: number, signal?: number) => void,
) => () => void
>(() => vi.fn()),
);
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
ShellExecutionService: { execute: mockShellExecutionService },
ShellExecutionService: {
execute: mockShellExecutionService,
kill: mockShellKill,
background: mockShellBackground,
subscribe: mockShellSubscribe,
onExit: mockShellOnExit,
},
isBinary: mockIsBinary,
};
});
@@ -113,7 +135,13 @@ describe('useShellCommandProcessor', () => {
const renderProcessorHook = () => {
let hookResult: ReturnType<typeof useShellCommandProcessor>;
function TestComponent() {
let renderCount = 0;
function TestComponent({
isWaitingForConfirmation,
}: {
isWaitingForConfirmation?: boolean;
}) {
renderCount++;
hookResult = useShellCommandProcessor(
addItemToHistoryMock,
setPendingHistoryItemMock,
@@ -122,16 +150,25 @@ describe('useShellCommandProcessor', () => {
mockConfig,
mockGeminiClient,
setShellInputFocusedMock,
undefined,
undefined,
undefined,
isWaitingForConfirmation,
);
return null;
}
render(<TestComponent />);
const { rerender } = render(<TestComponent />);
return {
result: {
get current() {
return hookResult;
},
},
getRenderCount: () => renderCount,
rerender: (isWaitingForConfirmation?: boolean) =>
rerender(
<TestComponent isWaitingForConfirmation={isWaitingForConfirmation} />,
),
};
};
@@ -723,4 +760,403 @@ describe('useShellCommandProcessor', () => {
expect(result.current.activeShellPtyId).toBeNull();
});
});
describe('Background Shell Management', () => {
it('should register a background shell and update count', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
expect(result.current.backgroundShellCount).toBe(1);
const shell = result.current.backgroundShells.get(1001);
expect(shell).toEqual(
expect.objectContaining({
pid: 1001,
command: 'bg-cmd',
output: 'initial',
}),
);
expect(mockShellOnExit).toHaveBeenCalledWith(1001, expect.any(Function));
expect(mockShellSubscribe).toHaveBeenCalledWith(
1001,
expect.any(Function),
);
});
it('should toggle background shell visibility', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
expect(result.current.isBackgroundShellVisible).toBe(false);
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(false);
});
it('should show info message when toggling background shells if none are active', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.toggleBackgroundShell();
});
expect(addItemToHistoryMock).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: 'No background shells are currently active.',
}),
expect.any(Number),
);
expect(result.current.isBackgroundShellVisible).toBe(false);
});
it('should dismiss a background shell and remove it from state', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.dismissBackgroundShell(1001);
});
expect(mockShellKill).toHaveBeenCalledWith(1001);
expect(result.current.backgroundShellCount).toBe(0);
expect(result.current.backgroundShells.has(1001)).toBe(false);
});
it('should handle backgrounding the current shell', async () => {
// Simulate an active shell
mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => {
mockShellOutputCallback = callback;
return Promise.resolve({
pid: 555,
result: new Promise((resolve) => {
resolveExecutionPromise = resolve;
}),
});
});
const { result } = renderProcessorHook();
await act(async () => {
result.current.handleShellCommand('top', new AbortController().signal);
});
expect(result.current.activeShellPtyId).toBe(555);
act(() => {
result.current.backgroundCurrentShell();
});
expect(mockShellBackground).toHaveBeenCalledWith(555);
// The actual state update happens when the promise resolves with backgrounded: true
// which is handled in handleShellCommand's .then block.
// We simulate that here:
await act(async () => {
resolveExecutionPromise(
createMockServiceResult({
backgrounded: true,
pid: 555,
output: 'running...',
}),
);
});
// Wait for promise resolution
await act(async () => await onExecMock.mock.calls[0][0]);
expect(result.current.backgroundShellCount).toBe(1);
expect(result.current.activeShellPtyId).toBeNull();
});
it('should persist background shell on successful exit and mark as exited', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(888, 'auto-exit', '');
});
// Find the exit callback registered
const exitCallback = mockShellOnExit.mock.calls.find(
(call) => call[0] === 888,
)?.[1];
expect(exitCallback).toBeDefined();
if (exitCallback) {
act(() => {
exitCallback(0);
});
}
// Should NOT be removed, but updated
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
expect(result.current.backgroundShells.has(888)).toBe(true); // Map has it
const shell = result.current.backgroundShells.get(888);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(0);
});
it('should persist background shell on failed exit', async () => {
const { result } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(999, 'fail-exit', '');
});
const exitCallback = mockShellOnExit.mock.calls.find(
(call) => call[0] === 999,
)?.[1];
expect(exitCallback).toBeDefined();
if (exitCallback) {
act(() => {
exitCallback(1);
});
}
// Should NOT be removed, but updated
expect(result.current.backgroundShellCount).toBe(0); // Badge count is 0
const shell = result.current.backgroundShells.get(999);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(1);
// Now dismiss it
act(() => {
result.current.dismissBackgroundShell(999);
});
expect(result.current.backgroundShellCount).toBe(0);
});
it('should NOT trigger re-render on background shell output when visible', async () => {
const { result, getRenderCount } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
// Show the background shells
act(() => {
result.current.toggleBackgroundShell();
});
const initialRenderCount = getRenderCount();
const subscribeCallback = mockShellSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
if (subscribeCallback) {
act(() => {
subscribeCallback({ type: 'data', chunk: ' + updated' });
});
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundShells.get(1001);
expect(shell?.output).toBe('initial + updated');
});
it('should NOT trigger re-render on background shell output when hidden', async () => {
const { result, getRenderCount } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
// Ensure background shells are hidden (default)
const initialRenderCount = getRenderCount();
const subscribeCallback = mockShellSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
if (subscribeCallback) {
act(() => {
subscribeCallback({ type: 'data', chunk: ' + updated' });
});
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundShells.get(1001);
expect(shell?.output).toBe('initial + updated');
});
it('should trigger re-render on binary progress when visible', async () => {
const { result, getRenderCount } = renderProcessorHook();
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
// Show the background shells
act(() => {
result.current.toggleBackgroundShell();
});
const initialRenderCount = getRenderCount();
const subscribeCallback = mockShellSubscribe.mock.calls.find(
(call) => call[0] === 1001,
)?.[1];
expect(subscribeCallback).toBeDefined();
if (subscribeCallback) {
act(() => {
subscribeCallback({ type: 'binary_progress', bytesReceived: 1024 });
});
}
expect(getRenderCount()).toBeGreaterThan(initialRenderCount);
const shell = result.current.backgroundShells.get(1001);
expect(shell?.isBinary).toBe(true);
expect(shell?.binaryBytesReceived).toBe(1024);
});
it('should NOT hide background shell when model is responding without confirmation', async () => {
const { result, rerender } = renderProcessorHook();
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Simulate model responding (not waiting for confirmation)
act(() => {
rerender(false); // isWaitingForConfirmation = false
});
// Should stay visible
expect(result.current.isBackgroundShellVisible).toBe(true);
});
it('should hide background shell when waiting for confirmation and restore after delay', async () => {
const { result, rerender } = renderProcessorHook();
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Simulate tool confirmation showing up
act(() => {
rerender(true); // isWaitingForConfirmation = true
});
// Should be hidden
expect(result.current.isBackgroundShellVisible).toBe(false);
// 3. Simulate confirmation accepted (waiting for PTY start)
act(() => {
rerender(false);
});
// Should STAY hidden during the 300ms gap
expect(result.current.isBackgroundShellVisible).toBe(false);
// 4. Wait for restore delay
await waitFor(() =>
expect(result.current.isBackgroundShellVisible).toBe(true),
);
});
it('should auto-hide background shell when foreground shell starts and restore when it ends', async () => {
const { result } = renderProcessorHook();
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Start foreground shell
act(() => {
result.current.handleShellCommand('ls', new AbortController().signal);
});
// Wait for PID to be set
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
// Should be hidden automatically
expect(result.current.isBackgroundShellVisible).toBe(false);
// 3. Complete foreground shell
act(() => {
resolveExecutionPromise(createMockServiceResult());
});
await waitFor(() => expect(result.current.activeShellPtyId).toBe(null));
// Should be restored automatically (after delay)
await waitFor(() =>
expect(result.current.isBackgroundShellVisible).toBe(true),
);
});
it('should NOT restore background shell if it was manually hidden during foreground execution', async () => {
const { result } = renderProcessorHook();
// 1. Register and show background shell
act(() => {
result.current.registerBackgroundShell(1001, 'bg-cmd', 'initial');
});
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
// 2. Start foreground shell
act(() => {
result.current.handleShellCommand('ls', new AbortController().signal);
});
await waitFor(() => expect(result.current.activeShellPtyId).toBe(12345));
expect(result.current.isBackgroundShellVisible).toBe(false);
// 3. Manually toggle visibility (e.g. user wants to peek)
act(() => {
result.current.toggleBackgroundShell();
});
expect(result.current.isBackgroundShellVisible).toBe(true);
// 4. Complete foreground shell
act(() => {
resolveExecutionPromise(createMockServiceResult());
});
await waitFor(() => expect(result.current.activeShellPtyId).toBe(null));
// It should NOT change visibility because manual toggle cleared the auto-restore flag
// After delay it should stay true (as it was manually toggled to true)
await waitFor(() =>
expect(result.current.isBackgroundShellVisible).toBe(true),
);
});
});
});
+348 -173
View File
@@ -9,13 +9,8 @@ import type {
IndividualToolCallDisplay,
} from '../types.js';
import { ToolCallStatus } from '../types.js';
import { useCallback, useState } from 'react';
import type {
AnsiOutput,
Config,
GeminiClient,
ShellExecutionResult,
} from '@google/gemini-cli-core';
import { useCallback, useReducer, useRef, useEffect } from 'react';
import type { AnsiOutput, Config, GeminiClient } from '@google/gemini-cli-core';
import { isBinary, ShellExecutionService } from '@google/gemini-cli-core';
import { type PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -26,8 +21,15 @@ import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
import { themeManager } from '../../ui/themes/theme-manager.js';
import {
shellReducer,
initialState,
type BackgroundShell,
} from './shellReducer.js';
export { type BackgroundShell };
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
const RESTORE_VISIBILITY_DELAY_MS = 300;
const MAX_OUTPUT_LENGTH = 10000;
function addShellCommandToGeminiHistory(
@@ -75,9 +77,190 @@ export const useShellCommandProcessor = (
setShellInputFocused: (value: boolean) => void,
terminalWidth?: number,
terminalHeight?: number,
activeToolPtyId?: number,
isWaitingForConfirmation?: boolean,
) => {
const [activeShellPtyId, setActiveShellPtyId] = useState<number | null>(null);
const [lastShellOutputTime, setLastShellOutputTime] = useState<number>(0);
const [state, dispatch] = useReducer(shellReducer, initialState);
// Consolidate stable tracking into a single manager object
const manager = useRef<{
wasVisibleBeforeForeground: boolean;
restoreTimeout: NodeJS.Timeout | null;
backgroundedPids: Set<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 || activeToolPtyId;
useEffect(() => {
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
if (isForegroundActive) {
if (m.restoreTimeout) {
clearTimeout(m.restoreTimeout);
m.restoreTimeout = null;
}
if (state.isBackgroundShellVisible && !m.wasVisibleBeforeForeground) {
m.wasVisibleBeforeForeground = true;
dispatch({ type: 'SET_VISIBILITY', visible: false });
}
} else if (m.wasVisibleBeforeForeground && !m.restoreTimeout) {
// Restore if it was automatically hidden, with a small delay to avoid
// flickering between model turn segments.
m.restoreTimeout = setTimeout(() => {
dispatch({ type: 'SET_VISIBILITY', visible: true });
m.wasVisibleBeforeForeground = false;
m.restoreTimeout = null;
}, RESTORE_VISIBILITY_DELAY_MS);
}
return () => {
if (m.restoreTimeout) {
clearTimeout(m.restoreTimeout);
}
};
}, [
activePtyId,
isWaitingForConfirmation,
state.isBackgroundShellVisible,
m,
dispatch,
]);
useEffect(
() => () => {
// Unsubscribe from all background shell events on unmount
for (const unsubscribe of m.subscriptions.values()) {
unsubscribe();
}
m.subscriptions.clear();
},
[m],
);
const toggleBackgroundShell = useCallback(() => {
if (state.backgroundShells.size > 0) {
const willBeVisible = !state.isBackgroundShellVisible;
dispatch({ type: 'TOGGLE_VISIBILITY' });
const isForegroundActive = !!activePtyId || !!isWaitingForConfirmation;
// If we are manually showing it during foreground, we set the restore flag
// so that useEffect doesn't immediately hide it again.
// If we are manually hiding it, we clear the restore flag so it stays hidden.
if (willBeVisible && isForegroundActive) {
m.wasVisibleBeforeForeground = true;
} else {
m.wasVisibleBeforeForeground = false;
}
if (willBeVisible) {
dispatch({ type: 'SYNC_BACKGROUND_SHELLS' });
}
} else {
dispatch({ type: 'SET_VISIBILITY', visible: false });
addItemToHistory(
{
type: 'info',
text: 'No background shells are currently active.',
},
Date.now(),
);
}
}, [
addItemToHistory,
state.backgroundShells.size,
state.isBackgroundShellVisible,
activePtyId,
isWaitingForConfirmation,
m,
dispatch,
]);
const backgroundCurrentShell = useCallback(() => {
const pidToBackground = state.activeShellPtyId || activeToolPtyId;
if (pidToBackground) {
ShellExecutionService.background(pidToBackground);
m.backgroundedPids.add(pidToBackground);
// Ensure backgrounding is silent and doesn't trigger restoration
m.wasVisibleBeforeForeground = false;
if (m.restoreTimeout) {
clearTimeout(m.restoreTimeout);
m.restoreTimeout = null;
}
}
}, [state.activeShellPtyId, activeToolPtyId, m]);
const dismissBackgroundShell = useCallback(
(pid: number) => {
const shell = state.backgroundShells.get(pid);
if (shell) {
if (shell.status === 'running') {
ShellExecutionService.kill(pid);
}
dispatch({ type: 'DISMISS_SHELL', pid });
m.backgroundedPids.delete(pid);
// Unsubscribe from updates
const unsubscribe = m.subscriptions.get(pid);
if (unsubscribe) {
unsubscribe();
m.subscriptions.delete(pid);
}
}
},
[state.backgroundShells, dispatch, m],
);
const registerBackgroundShell = useCallback(
(pid: number, command: string, initialOutput: string | AnsiOutput) => {
dispatch({ type: 'REGISTER_SHELL', pid, command, initialOutput });
// Subscribe to process exit directly
const exitUnsubscribe = ShellExecutionService.onExit(pid, (code) => {
dispatch({
type: 'UPDATE_SHELL',
pid,
update: { status: 'exited', exitCode: code },
});
m.backgroundedPids.delete(pid);
});
// Subscribe to future updates (data only)
const dataUnsubscribe = ShellExecutionService.subscribe(pid, (event) => {
if (event.type === 'data') {
dispatch({ type: 'APPEND_SHELL_OUTPUT', pid, chunk: event.chunk });
} else if (event.type === 'binary_detected') {
dispatch({ type: 'UPDATE_SHELL', pid, update: { isBinary: true } });
} else if (event.type === 'binary_progress') {
dispatch({
type: 'UPDATE_SHELL',
pid,
update: {
isBinary: true,
binaryBytesReceived: event.bytesReceived,
},
});
}
});
m.subscriptions.set(pid, () => {
exitUnsubscribe();
dataUnsubscribe();
});
},
[dispatch, m],
);
const handleShellCommand = useCallback(
(rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => {
@@ -109,9 +292,7 @@ export const useShellCommandProcessor = (
commandToExecute = `{ ${command} }; __code=$?; pwd > "${pwdFilePath}"; exit $__code`;
}
const executeCommand = async (
resolve: (value: void | PromiseLike<void>) => void,
) => {
const executeCommand = async () => {
let cumulativeStdout: string | AnsiOutput = '';
let isBinaryStream = false;
let binaryBytesReceived = 0;
@@ -151,84 +332,90 @@ export const useShellCommandProcessor = (
defaultBg: activeTheme.colors.Background,
};
const { pid, result } = await ShellExecutionService.execute(
commandToExecute,
targetDir,
(event) => {
let shouldUpdate = false;
switch (event.type) {
case 'data':
// Do not process text data if we've already switched to binary mode.
if (isBinaryStream) break;
// PTY provides the full screen state, so we just replace.
// Child process provides chunks, so we append.
if (config.getEnableInteractiveShell()) {
cumulativeStdout = event.chunk;
shouldUpdate = true;
} else if (
typeof event.chunk === 'string' &&
typeof cumulativeStdout === 'string'
) {
cumulativeStdout += event.chunk;
shouldUpdate = true;
}
break;
case 'binary_detected':
isBinaryStream = true;
// Force an immediate UI update to show the binary detection message.
shouldUpdate = true;
break;
case 'binary_progress':
isBinaryStream = true;
binaryBytesReceived = event.bytesReceived;
shouldUpdate = true;
break;
default: {
throw new Error('An unhandled ShellOutputEvent was found.');
}
}
const { pid, result: resultPromise } =
await ShellExecutionService.execute(
commandToExecute,
targetDir,
(event) => {
let shouldUpdate = false;
// Compute the display string based on the *current* state.
let currentDisplayOutput: string | AnsiOutput;
if (isBinaryStream) {
if (binaryBytesReceived > 0) {
currentDisplayOutput = `[Receiving binary output... ${formatBytes(
binaryBytesReceived,
)} received]`;
} else {
switch (event.type) {
case 'data':
if (isBinaryStream) break;
if (typeof event.chunk === 'string') {
if (typeof cumulativeStdout === 'string') {
cumulativeStdout += event.chunk;
} else {
cumulativeStdout = event.chunk;
}
} else {
// AnsiOutput (PTY) is always the full state
cumulativeStdout = event.chunk;
}
shouldUpdate = true;
break;
case 'binary_detected':
isBinaryStream = true;
shouldUpdate = true;
break;
case 'binary_progress':
isBinaryStream = true;
binaryBytesReceived = event.bytesReceived;
shouldUpdate = true;
break;
case 'exit':
// No action needed for exit event during streaming
break;
default:
throw new Error('An unhandled ShellOutputEvent was found.');
}
if (executionPid && m.backgroundedPids.has(executionPid)) {
// If already backgrounded, let the background shell subscription handle it.
dispatch({
type: 'APPEND_SHELL_OUTPUT',
pid: executionPid,
chunk:
event.type === 'data' ? event.chunk : cumulativeStdout,
});
return;
}
let currentDisplayOutput: string | AnsiOutput;
if (isBinaryStream) {
currentDisplayOutput =
'[Binary output detected. Halting stream...]';
binaryBytesReceived > 0
? `[Receiving binary output... ${formatBytes(binaryBytesReceived)} received]`
: '[Binary output detected. Halting stream...]';
} else {
currentDisplayOutput = cumulativeStdout;
}
} else {
currentDisplayOutput = cumulativeStdout;
}
// Throttle pending UI updates, but allow forced updates.
if (shouldUpdate) {
setLastShellOutputTime(Date.now());
setPendingHistoryItem((prevItem) => {
if (prevItem?.type === 'tool_group') {
return {
...prevItem,
tools: prevItem.tools.map((tool) =>
tool.callId === callId
? { ...tool, resultDisplay: currentDisplayOutput }
: tool,
),
};
}
return prevItem;
});
}
},
abortSignal,
config.getEnableInteractiveShell(),
shellExecutionConfig,
);
if (shouldUpdate) {
dispatch({ type: 'SET_OUTPUT_TIME', time: Date.now() });
setPendingHistoryItem((prevItem) => {
if (prevItem?.type === 'tool_group') {
return {
...prevItem,
tools: prevItem.tools.map((tool) =>
tool.callId === callId
? { ...tool, resultDisplay: currentDisplayOutput }
: tool,
),
};
}
return prevItem;
});
}
},
abortSignal,
config.getEnableInteractiveShell(),
shellExecutionConfig,
);
executionPid = pid;
if (pid) {
setActiveShellPtyId(pid);
dispatch({ type: 'SET_ACTIVE_PTY', pid });
setPendingHistoryItem((prevItem) => {
if (prevItem?.type === 'tool_group') {
return {
@@ -242,94 +429,69 @@ export const useShellCommandProcessor = (
});
}
result
.then((result: ShellExecutionResult) => {
setPendingHistoryItem(null);
const result = await resultPromise;
setPendingHistoryItem(null);
let mainContent: string;
if (result.backgrounded && result.pid) {
registerBackgroundShell(result.pid, rawQuery, cumulativeStdout);
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
}
if (isBinary(result.rawOutput)) {
mainContent =
'[Command produced binary output, which is not shown.]';
} else {
mainContent =
result.output.trim() || '(Command produced no output)';
}
let mainContent: string;
if (isBinary(result.rawOutput)) {
mainContent =
'[Command produced binary output, which is not shown.]';
} else {
mainContent =
result.output.trim() || '(Command produced no output)';
}
let finalOutput = mainContent;
let finalStatus = ToolCallStatus.Success;
let finalOutput = mainContent;
let finalStatus = ToolCallStatus.Success;
if (result.error) {
finalStatus = ToolCallStatus.Error;
finalOutput = `${result.error.message}\n${finalOutput}`;
} else if (result.aborted) {
finalStatus = ToolCallStatus.Canceled;
finalOutput = `Command was cancelled.\n${finalOutput}`;
} else if (result.signal) {
finalStatus = ToolCallStatus.Error;
finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`;
} else if (result.exitCode !== 0) {
finalStatus = ToolCallStatus.Error;
finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`;
}
if (result.error) {
finalStatus = ToolCallStatus.Error;
finalOutput = `${result.error.message}\n${finalOutput}`;
} else if (result.aborted) {
finalStatus = ToolCallStatus.Canceled;
finalOutput = `Command was cancelled.\n${finalOutput}`;
} else if (result.backgrounded) {
finalStatus = ToolCallStatus.Success;
finalOutput = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
} else if (result.signal) {
finalStatus = ToolCallStatus.Error;
finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`;
} else if (result.exitCode !== 0) {
finalStatus = ToolCallStatus.Error;
finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`;
}
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
if (finalPwd && finalPwd !== targetDir) {
const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
finalOutput = `${warning}\n\n${finalOutput}`;
}
}
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
if (finalPwd && finalPwd !== targetDir) {
const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
finalOutput = `${warning}\n\n${finalOutput}`;
}
}
const finalToolDisplay: IndividualToolCallDisplay = {
...initialToolDisplay,
status: finalStatus,
resultDisplay: finalOutput,
};
const finalToolDisplay: IndividualToolCallDisplay = {
...initialToolDisplay,
status: finalStatus,
resultDisplay: finalOutput,
};
// Add the complete, contextual result to the local UI history.
// We skip this for cancelled commands because useGeminiStream handles the
// immediate addition of the cancelled item to history to prevent flickering/duplicates.
if (finalStatus !== ToolCallStatus.Canceled) {
addItemToHistory(
{
type: 'tool_group',
tools: [finalToolDisplay],
} as HistoryItemWithoutId,
userMessageTimestamp,
);
}
if (finalStatus !== ToolCallStatus.Canceled) {
addItemToHistory(
{
type: 'tool_group',
tools: [finalToolDisplay],
} as HistoryItemWithoutId,
userMessageTimestamp,
);
}
// Add the same complete, contextual result to the LLM's history.
addShellCommandToGeminiHistory(
geminiClient,
rawQuery,
finalOutput,
);
})
.catch((err) => {
setPendingHistoryItem(null);
const errorMessage =
err instanceof Error ? err.message : String(err);
addItemToHistory(
{
type: 'error',
text: `An unexpected error occurred: ${errorMessage}`,
},
userMessageTimestamp,
);
})
.finally(() => {
abortSignal.removeEventListener('abort', abortHandler);
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
}
setActiveShellPtyId(null);
setShellInputFocused(false);
resolve();
});
addShellCommandToGeminiHistory(geminiClient, rawQuery, finalOutput);
} catch (err) {
// This block handles synchronous errors from `execute`
setPendingHistoryItem(null);
const errorMessage = err instanceof Error ? err.message : String(err);
addItemToHistory(
@@ -339,23 +501,18 @@ export const useShellCommandProcessor = (
},
userMessageTimestamp,
);
// Perform cleanup here as well
} finally {
abortSignal.removeEventListener('abort', abortHandler);
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
fs.unlinkSync(pwdFilePath);
}
setActiveShellPtyId(null);
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
setShellInputFocused(false);
resolve(); // Resolve the promise to unblock `onExec`
}
};
const execPromise = new Promise<void>((resolve) => {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
executeCommand(resolve);
});
onExec(execPromise);
onExec(executeCommand());
return true;
},
[
@@ -368,8 +525,26 @@ export const useShellCommandProcessor = (
setShellInputFocused,
terminalHeight,
terminalWidth,
registerBackgroundShell,
m,
dispatch,
],
);
return { handleShellCommand, activeShellPtyId, lastShellOutputTime };
const backgroundShellCount = Array.from(
state.backgroundShells.values(),
).filter((s: BackgroundShell) => s.status === 'running').length;
return {
handleShellCommand,
activeShellPtyId: state.activeShellPtyId,
lastShellOutputTime: state.lastShellOutputTime,
backgroundShellCount,
isBackgroundShellVisible: state.isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells: state.backgroundShells,
};
};
@@ -0,0 +1,193 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
shellReducer,
initialState,
type ShellState,
type ShellAction,
} from './shellReducer.js';
describe('shellReducer', () => {
it('should return the initial state', () => {
// @ts-expect-error - testing default case
expect(shellReducer(initialState, { type: 'UNKNOWN' })).toEqual(
initialState,
);
});
it('should handle SET_ACTIVE_PTY', () => {
const action: ShellAction = { type: 'SET_ACTIVE_PTY', pid: 12345 };
const state = shellReducer(initialState, action);
expect(state.activeShellPtyId).toBe(12345);
});
it('should handle SET_OUTPUT_TIME', () => {
const now = Date.now();
const action: ShellAction = { type: 'SET_OUTPUT_TIME', time: now };
const state = shellReducer(initialState, action);
expect(state.lastShellOutputTime).toBe(now);
});
it('should handle SET_VISIBILITY', () => {
const action: ShellAction = { type: 'SET_VISIBILITY', visible: true };
const state = shellReducer(initialState, action);
expect(state.isBackgroundShellVisible).toBe(true);
});
it('should handle TOGGLE_VISIBILITY', () => {
const action: ShellAction = { type: 'TOGGLE_VISIBILITY' };
let state = shellReducer(initialState, action);
expect(state.isBackgroundShellVisible).toBe(true);
state = shellReducer(state, action);
expect(state.isBackgroundShellVisible).toBe(false);
});
it('should handle REGISTER_SHELL', () => {
const action: ShellAction = {
type: 'REGISTER_SHELL',
pid: 1001,
command: 'ls',
initialOutput: 'init',
};
const state = shellReducer(initialState, action);
expect(state.backgroundShells.has(1001)).toBe(true);
expect(state.backgroundShells.get(1001)).toEqual({
pid: 1001,
command: 'ls',
output: 'init',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
});
});
it('should not REGISTER_SHELL if PID already exists', () => {
const action: ShellAction = {
type: 'REGISTER_SHELL',
pid: 1001,
command: 'ls',
initialOutput: 'init',
};
const state = shellReducer(initialState, action);
const state2 = shellReducer(state, { ...action, command: 'other' });
expect(state2).toBe(state);
expect(state2.backgroundShells.get(1001)?.command).toBe('ls');
});
it('should handle UPDATE_SHELL', () => {
const registeredState = shellReducer(initialState, {
type: 'REGISTER_SHELL',
pid: 1001,
command: 'ls',
initialOutput: 'init',
});
const action: ShellAction = {
type: 'UPDATE_SHELL',
pid: 1001,
update: { status: 'exited', exitCode: 0 },
};
const state = shellReducer(registeredState, action);
const shell = state.backgroundShells.get(1001);
expect(shell?.status).toBe('exited');
expect(shell?.exitCode).toBe(0);
// Map should be new
expect(state.backgroundShells).not.toBe(registeredState.backgroundShells);
});
it('should handle APPEND_SHELL_OUTPUT when visible (triggers re-render)', () => {
const visibleState: ShellState = {
...initialState,
isBackgroundShellVisible: true,
backgroundShells: new Map([
[
1001,
{
pid: 1001,
command: 'ls',
output: 'init',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
},
],
]),
};
const action: ShellAction = {
type: 'APPEND_SHELL_OUTPUT',
pid: 1001,
chunk: ' + more',
};
const state = shellReducer(visibleState, action);
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
// Drawer is visible, so we expect a NEW map object to trigger React re-render
expect(state.backgroundShells).not.toBe(visibleState.backgroundShells);
});
it('should handle APPEND_SHELL_OUTPUT when hidden (no re-render optimization)', () => {
const hiddenState: ShellState = {
...initialState,
isBackgroundShellVisible: false,
backgroundShells: new Map([
[
1001,
{
pid: 1001,
command: 'ls',
output: 'init',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
},
],
]),
};
const action: ShellAction = {
type: 'APPEND_SHELL_OUTPUT',
pid: 1001,
chunk: ' + more',
};
const state = shellReducer(hiddenState, action);
expect(state.backgroundShells.get(1001)?.output).toBe('init + more');
// Drawer is hidden, so we expect the SAME map object (mutation optimization)
expect(state.backgroundShells).toBe(hiddenState.backgroundShells);
});
it('should handle SYNC_BACKGROUND_SHELLS', () => {
const action: ShellAction = { type: 'SYNC_BACKGROUND_SHELLS' };
const state = shellReducer(initialState, action);
expect(state.backgroundShells).not.toBe(initialState.backgroundShells);
});
it('should handle DISMISS_SHELL', () => {
const registeredState: ShellState = {
...initialState,
isBackgroundShellVisible: true,
backgroundShells: new Map([
[
1001,
{
pid: 1001,
command: 'ls',
output: 'init',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
},
],
]),
};
const action: ShellAction = { type: 'DISMISS_SHELL', pid: 1001 };
const state = shellReducer(registeredState, action);
expect(state.backgroundShells.has(1001)).toBe(false);
expect(state.isBackgroundShellVisible).toBe(false); // Auto-hide if last shell
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { AnsiOutput } from '@google/gemini-cli-core';
export interface BackgroundShell {
pid: number;
command: string;
output: string | AnsiOutput;
isBinary: boolean;
binaryBytesReceived: number;
status: 'running' | 'exited';
exitCode?: number;
}
export interface ShellState {
activeShellPtyId: number | null;
lastShellOutputTime: number;
backgroundShells: Map<number, BackgroundShell>;
isBackgroundShellVisible: boolean;
}
export type ShellAction =
| { type: 'SET_ACTIVE_PTY'; pid: number | null }
| { type: 'SET_OUTPUT_TIME'; time: number }
| { type: 'SET_VISIBILITY'; visible: boolean }
| { type: 'TOGGLE_VISIBILITY' }
| {
type: 'REGISTER_SHELL';
pid: number;
command: string;
initialOutput: string | AnsiOutput;
}
| { type: 'UPDATE_SHELL'; pid: number; update: Partial<BackgroundShell> }
| { type: 'APPEND_SHELL_OUTPUT'; pid: number; chunk: string | AnsiOutput }
| { type: 'SYNC_BACKGROUND_SHELLS' }
| { type: 'DISMISS_SHELL'; pid: number };
export const initialState: ShellState = {
activeShellPtyId: null,
lastShellOutputTime: 0,
backgroundShells: new Map(),
isBackgroundShellVisible: false,
};
export function shellReducer(
state: ShellState,
action: ShellAction,
): ShellState {
switch (action.type) {
case 'SET_ACTIVE_PTY':
return { ...state, activeShellPtyId: action.pid };
case 'SET_OUTPUT_TIME':
return { ...state, lastShellOutputTime: action.time };
case 'SET_VISIBILITY':
return { ...state, isBackgroundShellVisible: action.visible };
case 'TOGGLE_VISIBILITY':
return {
...state,
isBackgroundShellVisible: !state.isBackgroundShellVisible,
};
case 'REGISTER_SHELL': {
if (state.backgroundShells.has(action.pid)) return state;
const nextShells = new Map(state.backgroundShells);
nextShells.set(action.pid, {
pid: action.pid,
command: action.command,
output: action.initialOutput,
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
});
return { ...state, backgroundShells: nextShells };
}
case 'UPDATE_SHELL': {
const shell = state.backgroundShells.get(action.pid);
if (!shell) return state;
const nextShells = new Map(state.backgroundShells);
const updatedShell = { ...shell, ...action.update };
// Maintain insertion order, move to end if status changed to exited
if (action.update.status === 'exited') {
nextShells.delete(action.pid);
}
nextShells.set(action.pid, updatedShell);
return { ...state, backgroundShells: nextShells };
}
case 'APPEND_SHELL_OUTPUT': {
const shell = state.backgroundShells.get(action.pid);
if (!shell) return state;
// Note: we mutate the shell object in the map for background updates
// to avoid re-rendering if the drawer is not visible.
// This is an intentional performance optimization for the CLI.
let newOutput = shell.output;
if (typeof action.chunk === 'string') {
newOutput =
typeof shell.output === 'string'
? shell.output + action.chunk
: action.chunk;
} else {
newOutput = action.chunk;
}
shell.output = newOutput;
if (state.isBackgroundShellVisible) {
return { ...state, backgroundShells: new Map(state.backgroundShells) };
}
return state;
}
case 'SYNC_BACKGROUND_SHELLS': {
return { ...state, backgroundShells: new Map(state.backgroundShells) };
}
case 'DISMISS_SHELL': {
const nextShells = new Map(state.backgroundShells);
nextShells.delete(action.pid);
return {
...state,
backgroundShells: nextShells,
isBackgroundShellVisible:
nextShells.size === 0 ? false : state.isBackgroundShellVisible,
};
}
default:
return state;
}
}
@@ -213,6 +213,7 @@ describe('useSlashCommandProcessor', () => {
toggleDebugProfiler: vi.fn(),
dispatchExtensionStateUpdate: vi.fn(),
addConfirmUpdateExtensionRequest: vi.fn(),
toggleBackgroundShell: vi.fn(),
setText: vi.fn(),
},
new Map(), // extensionsUpdateState
@@ -82,6 +82,7 @@ interface SlashCommandProcessorActions {
toggleDebugProfiler: () => void;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
toggleBackgroundShell: () => void;
setText: (text: string) => void;
}
@@ -237,6 +238,7 @@ export const useSlashCommandProcessor = (
addConfirmUpdateExtensionRequest:
actions.addConfirmUpdateExtensionRequest,
removeComponent: () => setCustomDialog(null),
toggleBackgroundShell: actions.toggleBackgroundShell,
},
session: {
stats: session.stats,
@@ -0,0 +1,191 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import {
useBackgroundShellManager,
type BackgroundShellManagerProps,
} from './useBackgroundShellManager.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { type BackgroundShell } from './shellReducer.js';
describe('useBackgroundShellManager', () => {
const setEmbeddedShellFocused = vi.fn();
const terminalHeight = 30;
beforeEach(() => {
vi.clearAllMocks();
});
const renderHook = (props: BackgroundShellManagerProps) => {
let hookResult: ReturnType<typeof useBackgroundShellManager>;
function TestComponent({ p }: { p: BackgroundShellManagerProps }) {
hookResult = useBackgroundShellManager(p);
return null;
}
const { rerender } = render(<TestComponent p={props} />);
return {
result: {
get current() {
return hookResult;
},
},
rerender: (newProps: BackgroundShellManagerProps) =>
rerender(<TestComponent p={newProps} />),
};
};
it('should initialize with correct default values', () => {
const backgroundShells = new Map<number, BackgroundShell>();
const { result } = renderHook({
backgroundShells,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.isBackgroundShellListOpen).toBe(false);
expect(result.current.activeBackgroundShellPid).toBe(null);
expect(result.current.backgroundShellHeight).toBe(0);
});
it('should auto-select the first background shell when added', () => {
const backgroundShells = new Map<number, BackgroundShell>();
const { result, rerender } = renderHook({
backgroundShells,
backgroundShellCount: 0,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
const newShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
rerender({
backgroundShells: newShells,
backgroundShellCount: 1,
isBackgroundShellVisible: false,
activePtyId: null,
embeddedShellFocused: false,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(123);
});
it('should reset state when all shells are removed', () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
const { result, rerender } = renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setIsBackgroundShellListOpen(true);
});
expect(result.current.isBackgroundShellListOpen).toBe(true);
rerender({
backgroundShells: new Map(),
backgroundShellCount: 0,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(null);
expect(result.current.isBackgroundShellListOpen).toBe(false);
});
it('should unfocus embedded shell when no shells are active', () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: false, // Background shell not visible
activePtyId: null, // No foreground shell
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(setEmbeddedShellFocused).toHaveBeenCalledWith(false);
});
it('should calculate backgroundShellHeight correctly when visible', () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
]);
const { result } = renderHook({
backgroundShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight: 100,
});
// 100 * 0.3 = 30
expect(result.current.backgroundShellHeight).toBe(30);
});
it('should maintain current active shell if it still exists', () => {
const backgroundShells = new Map<number, BackgroundShell>([
[123, {} as BackgroundShell],
[456, {} as BackgroundShell],
]);
const { result, rerender } = renderHook({
backgroundShells,
backgroundShellCount: 2,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
act(() => {
result.current.setActiveBackgroundShellPid(456);
});
expect(result.current.activeBackgroundShellPid).toBe(456);
// Remove the OTHER shell
const updatedShells = new Map<number, BackgroundShell>([
[456, {} as BackgroundShell],
]);
rerender({
backgroundShells: updatedShells,
backgroundShellCount: 1,
isBackgroundShellVisible: true,
activePtyId: null,
embeddedShellFocused: true,
setEmbeddedShellFocused,
terminalHeight,
});
expect(result.current.activeBackgroundShellPid).toBe(456);
});
});
@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } from 'react';
import { type BackgroundShell } from './shellCommandProcessor.js';
export interface BackgroundShellManagerProps {
backgroundShells: Map<number, BackgroundShell>;
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,
};
}
@@ -68,6 +68,9 @@ const MockedGeminiClientClass = vi.hoisted(() =>
recordToolCalls: vi.fn(),
getConversationFile: vi.fn(),
});
this.getCurrentSequenceModel = vi
.fn()
.mockReturnValue('gemini-2.0-flash-exp');
}),
);
+76 -17
View File
@@ -43,6 +43,7 @@ import type {
ServerGeminiStreamEvent as GeminiEvent,
ThoughtSummary,
ToolCallRequestInfo,
ToolCallResponseInfo,
GeminiErrorEventValue,
RetryAttemptPayload,
ToolCallConfirmationDetails,
@@ -72,6 +73,7 @@ import {
type TrackedCompletedToolCall,
type TrackedCancelledToolCall,
type TrackedWaitingToolCall,
type TrackedExecutingToolCall,
} from './useToolScheduler.js';
import { promises as fs } from 'node:fs';
import path from 'node:path';
@@ -79,12 +81,34 @@ import { useSessionStats } from '../contexts/SessionContext.js';
import { useKeypress } from './useKeypress.js';
import type { LoadedSettings } from '../../config/settings.js';
type ToolResponseWithParts = ToolCallResponseInfo & {
llmContent?: PartListUnion;
};
interface ShellToolData {
pid?: number;
command?: string;
initialOutput?: string;
}
enum StreamProcessingStatus {
Completed,
UserCancelled,
Error,
}
function isShellToolData(data: unknown): data is ShellToolData {
if (typeof data !== 'object' || data === null) {
return false;
}
const d = data as Partial<ShellToolData>;
return (
(d.pid === undefined || typeof d.pid === 'number') &&
(d.command === undefined || typeof d.command === 'string') &&
(d.initialOutput === undefined || typeof d.initialOutput === 'string')
);
}
function showCitations(settings: LoadedSettings): boolean {
const enabled = settings.merged.ui.showCitations;
if (enabled !== undefined) {
@@ -401,14 +425,11 @@ export const useGeminiStream = (
}, [toolCalls, pushedToolCallIds, config]);
const activeToolPtyId = useMemo(() => {
const executingShellTool = toolCalls?.find(
const executingShellTool = toolCalls.find(
(tc) =>
tc.status === 'executing' && tc.request.name === 'run_shell_command',
);
if (executingShellTool) {
return (executingShellTool as { pid?: number }).pid;
}
return undefined;
return (executingShellTool as TrackedExecutingToolCall | undefined)?.pid;
}, [toolCalls]);
const lastQueryRef = useRef<PartListUnion | null>(null);
@@ -426,18 +447,30 @@ export const useGeminiStream = (
await done;
setIsResponding(false);
}, []);
const { handleShellCommand, activeShellPtyId, lastShellOutputTime } =
useShellCommandProcessor(
addItem,
setPendingHistoryItem,
onExec,
onDebugMessage,
config,
geminiClient,
setShellInputFocused,
terminalWidth,
terminalHeight,
);
const {
handleShellCommand,
activeShellPtyId,
lastShellOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
registerBackgroundShell,
dismissBackgroundShell,
backgroundShells,
} = useShellCommandProcessor(
addItem,
setPendingHistoryItem,
onExec,
onDebugMessage,
config,
geminiClient,
setShellInputFocused,
terminalWidth,
terminalHeight,
activeToolPtyId,
);
const activePtyId = activeShellPtyId || activeToolPtyId;
@@ -1404,6 +1437,25 @@ export const useGeminiStream = (
!processedMemoryToolsRef.current.has(t.request.callId),
);
// Handle backgrounded shell tools
completedAndReadyToSubmitTools.forEach((t) => {
const isShell = t.request.name === 'run_shell_command';
// Access result from the tracked tool call response
const response = t.response as ToolResponseWithParts;
const rawData = response?.data;
const data = isShellToolData(rawData) ? rawData : undefined;
// Use data.pid for shell commands moved to the background.
const pid = data?.pid;
if (isShell && pid) {
const command = (data?.['command'] as string) ?? 'shell';
const initialOutput = (data?.['initialOutput'] as string) ?? '';
registerBackgroundShell(pid, command, initialOutput);
}
});
if (newSuccessfulMemorySaves.length > 0) {
// Perform the refresh only if there are new ones.
void performMemoryRefresh();
@@ -1510,6 +1562,7 @@ export const useGeminiStream = (
performMemoryRefresh,
modelSwitchedFromQuotaError,
addItem,
registerBackgroundShell,
],
);
@@ -1599,6 +1652,12 @@ export const useGeminiStream = (
activePtyId,
loopDetectionConfirmationRequest,
lastOutputTime,
backgroundShellCount,
isBackgroundShellVisible,
toggleBackgroundShell,
backgroundCurrentShell,
backgroundShells,
dismissBackgroundShell,
retryStatus,
};
};
@@ -40,7 +40,6 @@ export type TrackedWaitingToolCall = WaitingToolCall & {
};
export type TrackedExecutingToolCall = ExecutingToolCall & {
responseSubmittedToGemini?: boolean;
pid?: number;
};
export type TrackedCompletedToolCall = CompletedToolCall & {
responseSubmittedToGemini?: boolean;
@@ -134,7 +133,15 @@ export function useReactToolScheduler(
...coreTc,
responseSubmittedToGemini,
liveOutput,
pid: coreTc.pid,
};
} else if (
coreTc.status === 'success' ||
coreTc.status === 'error' ||
coreTc.status === 'cancelled'
) {
return {
...coreTc,
responseSubmittedToGemini,
};
} else {
return {
+20 -3
View File
@@ -59,8 +59,12 @@ describe('keyMatchers', () => {
},
{
command: Command.MOVE_LEFT,
positive: [createKey('left'), createKey('b', { ctrl: true })],
negative: [createKey('left', { ctrl: true }), createKey('b')],
positive: [createKey('left')],
negative: [
createKey('left', { ctrl: true }),
createKey('b'),
createKey('b', { ctrl: true }),
],
},
{
command: Command.MOVE_RIGHT,
@@ -285,7 +289,10 @@ describe('keyMatchers', () => {
{
command: Command.SHOW_ERROR_DETAILS,
positive: [createKey('f12')],
negative: [createKey('o', { ctrl: true }), createKey('f11')],
negative: [
createKey('o', { ctrl: true }),
createKey('b', { ctrl: true }),
],
},
{
command: Command.SHOW_FULL_TODOS,
@@ -357,6 +364,16 @@ describe('keyMatchers', () => {
positive: [createKey('tab', { shift: true })],
negative: [createKey('tab')],
},
{
command: Command.TOGGLE_BACKGROUND_SHELL,
positive: [createKey('b', { ctrl: true })],
negative: [createKey('f10'), createKey('b')],
},
{
command: Command.TOGGLE_BACKGROUND_SHELL_LIST,
positive: [createKey('l', { ctrl: true })],
negative: [createKey('l')],
},
];
describe('Data-driven key binding matches original logic', () => {
@@ -0,0 +1,132 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DefaultAppLayout } from './DefaultAppLayout.js';
import { StreamingState } from '../types.js';
import { Text } from 'ink';
import type { UIState } from '../contexts/UIStateContext.js';
import type { BackgroundShell } from '../hooks/shellCommandProcessor.js';
// Mock dependencies
const mockUIState = {
rootUiRef: { current: null },
terminalHeight: 24,
terminalWidth: 80,
mainAreaWidth: 80,
backgroundShells: new Map<number, BackgroundShell>(),
activeBackgroundShellPid: null as number | null,
backgroundShellHeight: 10,
embeddedShellFocused: false,
dialogsVisible: false,
streamingState: StreamingState.Idle,
isBackgroundShellListOpen: false,
mainControlsRef: { current: null },
customDialog: null,
historyManager: { addItem: vi.fn() },
history: [],
pendingHistoryItems: [],
slashCommands: [],
constrainHeight: false,
availableTerminalHeight: 20,
activePtyId: null,
isBackgroundShellVisible: true,
} as unknown as UIState;
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: () => mockUIState,
}));
vi.mock('../hooks/useFlickerDetector.js', () => ({
useFlickerDetector: vi.fn(),
}));
vi.mock('../hooks/useAlternateBuffer.js', () => ({
useAlternateBuffer: vi.fn(() => false),
}));
vi.mock('../contexts/ConfigContext.js', () => ({
useConfig: () => ({
getAccessibility: vi.fn(() => ({
enableLoadingPhrases: true,
})),
}),
}));
// Mock child components to simplify output
vi.mock('../components/LoadingIndicator.js', () => ({
LoadingIndicator: () => <Text>LoadingIndicator</Text>,
}));
vi.mock('../components/MainContent.js', () => ({
MainContent: () => <Text>MainContent</Text>,
}));
vi.mock('../components/Notifications.js', () => ({
Notifications: () => <Text>Notifications</Text>,
}));
vi.mock('../components/DialogManager.js', () => ({
DialogManager: () => <Text>DialogManager</Text>,
}));
vi.mock('../components/Composer.js', () => ({
Composer: () => <Text>Composer</Text>,
}));
vi.mock('../components/ExitWarning.js', () => ({
ExitWarning: () => <Text>ExitWarning</Text>,
}));
vi.mock('../components/CopyModeWarning.js', () => ({
CopyModeWarning: () => <Text>CopyModeWarning</Text>,
}));
vi.mock('../components/BackgroundShellDisplay.js', () => ({
BackgroundShellDisplay: () => <Text>BackgroundShellDisplay</Text>,
}));
const createMockShell = (pid: number): BackgroundShell => ({
pid,
command: 'test command',
output: 'test output',
isBinary: false,
binaryBytesReceived: 0,
status: 'running',
});
describe('<DefaultAppLayout />', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset mock state defaults
mockUIState.backgroundShells = new Map();
mockUIState.activeBackgroundShellPid = null;
mockUIState.streamingState = StreamingState.Idle;
});
it('renders BackgroundShellDisplay when shells exist and active', () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
const { lastFrame } = render(<DefaultAppLayout />);
expect(lastFrame()).toMatchSnapshot();
});
it('hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation', () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
mockUIState.streamingState = StreamingState.WaitingForConfirmation;
const { lastFrame } = render(<DefaultAppLayout />);
expect(lastFrame()).toMatchSnapshot();
});
it('shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation', () => {
mockUIState.backgroundShells.set(123, createMockShell(123));
mockUIState.activeBackgroundShellPid = 123;
mockUIState.backgroundShellHeight = 5;
mockUIState.streamingState = StreamingState.Responding;
const { lastFrame } = render(<DefaultAppLayout />);
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -15,6 +15,8 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { CopyModeWarning } from '../components/CopyModeWarning.js';
import { BackgroundShellDisplay } from '../components/BackgroundShellDisplay.js';
import { StreamingState } from '../types.js';
export const DefaultAppLayout: React.FC = () => {
const uiState = useUIState();
@@ -37,6 +39,24 @@ export const DefaultAppLayout: React.FC = () => {
>
<MainContent />
{uiState.isBackgroundShellVisible &&
uiState.backgroundShells.size > 0 &&
uiState.activeBackgroundShellPid &&
uiState.backgroundShellHeight > 0 &&
uiState.streamingState !== StreamingState.WaitingForConfirmation && (
<Box height={uiState.backgroundShellHeight} flexShrink={0}>
<BackgroundShellDisplay
shells={uiState.backgroundShells}
activePid={uiState.activeBackgroundShellPid}
width={uiState.terminalWidth}
height={uiState.backgroundShellHeight}
isFocused={
uiState.embeddedShellFocused && !uiState.dialogsVisible
}
isListOpenProp={uiState.isBackgroundShellListOpen}
/>
</Box>
)}
<Box
flexDirection="column"
ref={uiState.mainControlsRef}
@@ -0,0 +1,35 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<DefaultAppLayout /> > hides BackgroundShellDisplay when StreamingState is WaitingForConfirmation 1`] = `
"MainContent
Notifications
CopyModeWarning
Composer
ExitWarning"
`;
exports[`<DefaultAppLayout /> > renders BackgroundShellDisplay when shells exist and active 1`] = `
"MainContent
BackgroundShellDisplay
Notifications
CopyModeWarning
Composer
ExitWarning"
`;
exports[`<DefaultAppLayout /> > shows BackgroundShellDisplay when StreamingState is NOT WaitingForConfirmation 1`] = `
"MainContent
BackgroundShellDisplay
Notifications
CopyModeWarning
Composer
ExitWarning"
`;
@@ -29,5 +29,6 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
addConfirmUpdateExtensionRequest: (_request) => {},
removeComponent: () => {},
toggleBackgroundShell: () => {},
};
}
@@ -253,6 +253,7 @@ export class ToolExecutor {
errorType: undefined,
outputFile,
contentLength: typeof content === 'string' ? content.length : undefined,
data: toolResult.data,
};
const startTime = 'startTime' in call ? call.startTime : undefined;
+4
View File
@@ -38,6 +38,10 @@ export interface ToolCallResponseInfo {
errorType: ToolErrorType | undefined;
outputFile?: string | undefined;
contentLength?: number;
/**
* Optional data payload for passing structured information back to the caller.
*/
data?: Record<string, unknown>;
}
export type ValidatingToolCall = {
@@ -76,7 +76,13 @@ vi.mock('../utils/getPty.js', () => ({
getPty: mockGetPty,
}));
vi.mock('../utils/terminalSerializer.js', () => ({
serializeTerminalToObject: mockSerializeTerminalToObject,
// Avoid passing the heavy Terminal object to the spy to prevent OOM
serializeTerminalToObject: (
_terminal: unknown,
...args: [number | undefined, number | undefined]
) => mockSerializeTerminalToObject(...args),
convertColorToHex: () => '#000000',
ColorMode: { DEFAULT: 0, PALETTE: 1, RGB: 2 },
}));
vi.mock('../utils/systemEncoding.js', () => ({
getCachedEncodingForBuffer: vi.fn().mockReturnValue('utf-8'),
@@ -318,6 +324,7 @@ describe('ShellExecutionService', () => {
}
pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null });
},
{ ...shellExecutionConfig, maxSerializedLines: 100 },
);
expect(result.exitCode).toBe(0);
@@ -675,7 +682,7 @@ describe('ShellExecutionService', () => {
expect(result.rawOutput).toEqual(
Buffer.concat([binaryChunk1, binaryChunk2]),
);
expect(onOutputEventMock).toHaveBeenCalledTimes(3);
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
type: 'binary_detected',
});
@@ -687,6 +694,11 @@ describe('ShellExecutionService', () => {
type: 'binary_progress',
bytesReceived: 8,
});
expect(onOutputEventMock.mock.calls[3][0]).toEqual({
type: 'exit',
exitCode: 0,
signal: null,
});
});
it('should not emit data events after binary is detected', async () => {
@@ -705,6 +717,7 @@ describe('ShellExecutionService', () => {
'binary_detected',
'binary_progress',
'binary_progress',
'exit',
]);
});
});
@@ -763,9 +776,7 @@ describe('ShellExecutionService', () => {
coloredShellExecutionConfig,
);
expect(mockSerializeTerminalToObject).toHaveBeenCalledWith(
expect.anything(), // The terminal object
);
expect(mockSerializeTerminalToObject).toHaveBeenCalled();
expect(onOutputEventMock).toHaveBeenCalledWith(
expect.objectContaining({
@@ -932,11 +943,20 @@ describe('ShellExecutionService child_process fallback', () => {
expect(result.error).toBeNull();
expect(result.aborted).toBe(false);
expect(result.output).toBe('file1.txt\na warning');
expect(handle.pid).toBe(undefined);
expect(handle.pid).toBe(12345);
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'data',
chunk: 'file1.txt\na warning',
chunk: 'file1.txt\n',
});
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'data',
chunk: 'a warning',
});
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'exit',
exitCode: 0,
signal: null,
});
});
@@ -948,12 +968,15 @@ describe('ShellExecutionService child_process fallback', () => {
});
expect(result.output.trim()).toBe('aredword');
expect(onOutputEventMock).toHaveBeenCalledWith(
expect.objectContaining({
type: 'data',
chunk: 'aredword',
}),
);
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'data',
chunk: 'a\u001b[31mred\u001b[0mword',
});
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'exit',
exitCode: 0,
signal: null,
});
});
it('should correctly decode multi-byte characters split across chunks', async () => {
@@ -974,10 +997,14 @@ describe('ShellExecutionService child_process fallback', () => {
});
expect(result.output.trim()).toBe('');
expect(onOutputEventMock).not.toHaveBeenCalled();
expect(onOutputEventMock).toHaveBeenCalledWith({
type: 'exit',
exitCode: 0,
signal: null,
});
});
it.skip('should truncate stdout using a sliding window and show a warning', async () => {
it('should truncate stdout using a sliding window and show a warning', async () => {
const MAX_SIZE = 16 * 1024 * 1024;
const chunk1 = 'a'.repeat(MAX_SIZE / 2 - 5);
const chunk2 = 'b'.repeat(MAX_SIZE / 2 - 5);
@@ -1173,26 +1200,44 @@ describe('ShellExecutionService child_process fallback', () => {
expect(result.rawOutput).toEqual(
Buffer.concat([binaryChunk1, binaryChunk2]),
);
expect(onOutputEventMock).toHaveBeenCalledTimes(1);
expect(onOutputEventMock).toHaveBeenCalledTimes(4);
expect(onOutputEventMock.mock.calls[0][0]).toEqual({
type: 'binary_detected',
});
expect(onOutputEventMock.mock.calls[1][0]).toEqual({
type: 'binary_progress',
bytesReceived: 4,
});
expect(onOutputEventMock.mock.calls[2][0]).toEqual({
type: 'binary_progress',
bytesReceived: 8,
});
expect(onOutputEventMock.mock.calls[3][0]).toEqual({
type: 'exit',
exitCode: 0,
signal: null,
});
});
it('should not emit data events after binary is detected', async () => {
mockIsBinary.mockImplementation((buffer) => buffer.includes(0x00));
await simulateExecution('cat mixed_file', (cp) => {
cp.stdout?.emit('data', Buffer.from('some text'));
cp.stdout?.emit('data', Buffer.from([0x00, 0x01, 0x02]));
cp.stdout?.emit('data', Buffer.from('more text'));
cp.emit('exit', 0, null);
cp.emit('close', 0, null);
});
const eventTypes = onOutputEventMock.mock.calls.map(
(call: [ShellOutputEvent]) => call[0].type,
);
expect(eventTypes).toEqual(['binary_detected']);
expect(eventTypes).toEqual([
'binary_detected',
'binary_progress',
'binary_progress',
'exit',
]);
});
});
@@ -7,7 +7,7 @@
import stripAnsi from 'strip-ansi';
import type { PtyImplementation } from '../utils/getPty.js';
import { getPty } from '../utils/getPty.js';
import { spawn as cpSpawn } from 'node:child_process';
import { spawn as cpSpawn, type ChildProcess } from 'node:child_process';
import { TextDecoder } from 'node:util';
import os from 'node:os';
import type { IPty } from '@lydell/node-pty';
@@ -27,9 +27,9 @@ import {
sanitizeEnvironment,
type EnvironmentSanitizationConfig,
} from './environmentSanitization.js';
import { killProcessGroup } from '../utils/process-utils.js';
const { Terminal } = pkg;
const SIGKILL_TIMEOUT_MS = 200;
const MAX_CHILD_PROCESS_BUFFER_SIZE = 16 * 1024 * 1024; // 16MB
// We want to allow shell outputs that are close to the context window in size.
@@ -71,6 +71,8 @@ export interface ShellExecutionResult {
pid: number | undefined;
/** The method used to execute the shell command. */
executionMethod: 'lydell-node-pty' | 'node-pty' | 'child_process' | 'none';
/** Whether the command was moved to the background. */
backgrounded?: boolean;
}
/** A handle for an ongoing shell execution. */
@@ -92,6 +94,7 @@ export interface ShellExecutionConfig {
// Used for testing
disableDynamicLineTrimming?: boolean;
scrollback?: number;
maxSerializedLines?: number;
}
/**
@@ -113,11 +116,29 @@ export type ShellOutputEvent =
type: 'binary_progress';
/** The total number of bytes received so far. */
bytesReceived: number;
}
| {
/** Signals that the process has exited. */
type: 'exit';
/** The exit code of the process, if any. */
exitCode: number | null;
/** The signal that terminated the process, if any. */
signal: number | null;
};
interface ActivePty {
ptyProcess: IPty;
headlessTerminal: pkg.Terminal;
maxSerializedLines?: number;
}
interface ActiveChildProcess {
process: ChildProcess;
state: {
output: string;
truncated: boolean;
outputChunks: Buffer[];
};
}
const getFullBufferText = (terminal: pkg.Terminal): string => {
@@ -165,6 +186,19 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
export class ShellExecutionService {
private static activePtys = new Map<number, ActivePty>();
private static activeChildProcesses = new Map<number, ActiveChildProcess>();
private static exitedPtyInfo = new Map<
number,
{ exitCode: number; signal?: number }
>();
private static activeResolvers = new Map<
number,
(res: ShellExecutionResult) => void
>();
private static activeListeners = new Map<
number,
Set<(event: ShellOutputEvent) => void>
>();
/**
* Executes a shell command using `node-pty`, capturing all output and lifecycle events.
*
@@ -240,6 +274,13 @@ export class ShellExecutionService {
return { newBuffer: truncatedBuffer + chunk, truncated: true };
}
private static emitEvent(pid: number, event: ShellOutputEvent): void {
const listeners = this.activeListeners.get(pid);
if (listeners) {
listeners.forEach((listener) => listener(event));
}
}
private static childProcessFallback(
commandToExecute: string,
cwd: string,
@@ -268,15 +309,26 @@ export class ShellExecutionService {
},
});
const state = {
output: '',
truncated: false,
outputChunks: [] as Buffer[],
};
if (child.pid) {
this.activeChildProcesses.set(child.pid, {
process: child,
state,
});
}
const result = new Promise<ShellExecutionResult>((resolve) => {
if (child.pid) {
this.activeResolvers.set(child.pid, resolve);
}
let stdoutDecoder: TextDecoder | null = null;
let stderrDecoder: TextDecoder | null = null;
let stdout = '';
let stderr = '';
let stdoutTruncated = false;
let stderrTruncated = false;
const outputChunks: Buffer[] = [];
let error: Error | null = null;
let exited = false;
@@ -296,14 +348,17 @@ export class ShellExecutionService {
}
}
outputChunks.push(data);
state.outputChunks.push(data);
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
const sniffBuffer = Buffer.concat(state.outputChunks.slice(0, 20));
sniffedBytes = sniffBuffer.length;
if (isBinary(sniffBuffer)) {
isStreamingRawContent = false;
const event: ShellOutputEvent = { type: 'binary_detected' };
onOutputEvent(event);
if (child.pid) ShellExecutionService.emitEvent(child.pid, event);
}
}
@@ -311,27 +366,35 @@ export class ShellExecutionService {
const decoder = stream === 'stdout' ? stdoutDecoder : stderrDecoder;
const decodedChunk = decoder.decode(data, { stream: true });
if (stream === 'stdout') {
const { newBuffer, truncated } = this.appendAndTruncate(
stdout,
decodedChunk,
MAX_CHILD_PROCESS_BUFFER_SIZE,
);
stdout = newBuffer;
if (truncated) {
stdoutTruncated = true;
}
} else {
const { newBuffer, truncated } = this.appendAndTruncate(
stderr,
decodedChunk,
MAX_CHILD_PROCESS_BUFFER_SIZE,
);
stderr = newBuffer;
if (truncated) {
stderrTruncated = true;
}
const { newBuffer, truncated } = this.appendAndTruncate(
state.output,
decodedChunk,
MAX_CHILD_PROCESS_BUFFER_SIZE,
);
state.output = newBuffer;
if (truncated) {
state.truncated = true;
}
if (decodedChunk) {
const event: ShellOutputEvent = {
type: 'data',
chunk: decodedChunk,
};
onOutputEvent(event);
if (child.pid) ShellExecutionService.emitEvent(child.pid, event);
}
} else {
const totalBytes = state.outputChunks.reduce(
(sum, chunk) => sum + chunk.length,
0,
);
const event: ShellOutputEvent = {
type: 'binary_progress',
bytesReceived: totalBytes,
};
onOutputEvent(event);
if (child.pid) ShellExecutionService.emitEvent(child.pid, event);
}
};
@@ -340,12 +403,10 @@ export class ShellExecutionService {
signal: NodeJS.Signals | null,
) => {
const { finalBuffer } = cleanup();
// Ensure we don't add an extra newline if stdout already ends with one.
const separator = stdout.endsWith('\n') ? '' : '\n';
let combinedOutput =
stdout + (stderr ? (stdout ? separator : '') + stderr : '');
if (stdoutTruncated || stderrTruncated) {
let combinedOutput = state.output;
if (state.truncated) {
const truncationMessage = `\n[GEMINI_CLI_WARNING: Output truncated. The buffer is limited to ${
MAX_CHILD_PROCESS_BUFFER_SIZE / (1024 * 1024)
}MB.]`;
@@ -353,23 +414,31 @@ export class ShellExecutionService {
}
const finalStrippedOutput = stripAnsi(combinedOutput).trim();
const exitCode = code;
const exitSignal = signal ? os.constants.signals[signal] : null;
if (isStreamingRawContent) {
if (finalStrippedOutput) {
onOutputEvent({ type: 'data', chunk: finalStrippedOutput });
}
} else {
onOutputEvent({ type: 'binary_detected' });
if (child.pid) {
const event: ShellOutputEvent = {
type: 'exit',
exitCode,
signal: exitSignal,
};
onOutputEvent(event);
ShellExecutionService.emitEvent(child.pid, event);
this.activeChildProcesses.delete(child.pid);
this.activeResolvers.delete(child.pid);
this.activeListeners.delete(child.pid);
}
resolve({
rawOutput: finalBuffer,
output: finalStrippedOutput,
exitCode: code,
signal: signal ? os.constants.signals[signal] : null,
exitCode,
signal: exitSignal,
error,
aborted: abortSignal.aborted,
pid: undefined,
pid: child.pid,
executionMethod: 'child_process',
});
};
@@ -383,28 +452,17 @@ export class ShellExecutionService {
const abortHandler = async () => {
if (child.pid && !exited) {
if (isWindows) {
cpSpawn('taskkill', ['/pid', child.pid.toString(), '/f', '/t']);
} else {
try {
process.kill(-child.pid, 'SIGTERM');
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
if (!exited) {
process.kill(-child.pid, 'SIGKILL');
}
} catch (_e) {
if (!exited) child.kill('SIGKILL');
}
}
await killProcessGroup({
pid: child.pid,
escalate: true,
isExited: () => exited,
});
}
};
abortSignal.addEventListener('abort', abortHandler, { once: true });
child.on('exit', (code, signal) => {
if (child.pid) {
this.activePtys.delete(child.pid);
}
handleExit(code, signal);
});
@@ -414,23 +472,43 @@ export class ShellExecutionService {
if (stdoutDecoder) {
const remaining = stdoutDecoder.decode();
if (remaining) {
stdout += remaining;
state.output += remaining;
// If there's remaining output, we should technically emit it too,
// but it's rare to have partial utf8 chars at the very end of stream.
if (isStreamingRawContent && remaining) {
const event: ShellOutputEvent = {
type: 'data',
chunk: remaining,
};
onOutputEvent(event);
if (child.pid)
ShellExecutionService.emitEvent(child.pid, event);
}
}
}
if (stderrDecoder) {
const remaining = stderrDecoder.decode();
if (remaining) {
stderr += remaining;
state.output += remaining;
if (isStreamingRawContent && remaining) {
const event: ShellOutputEvent = {
type: 'data',
chunk: remaining,
};
onOutputEvent(event);
if (child.pid)
ShellExecutionService.emitEvent(child.pid, event);
}
}
}
const finalBuffer = Buffer.concat(outputChunks);
const finalBuffer = Buffer.concat(state.outputChunks);
return { stdout, stderr, finalBuffer };
return { finalBuffer };
}
});
return { pid: undefined, result };
return { pid: child.pid, result };
} catch (e) {
const error = e as Error;
return {
@@ -495,6 +573,8 @@ export class ShellExecutionService {
});
const result = new Promise<ShellExecutionResult>((resolve) => {
this.activeResolvers.set(ptyProcess.pid, resolve);
const headlessTerminal = new Terminal({
allowProposedApi: true,
cols,
@@ -503,7 +583,11 @@ export class ShellExecutionService {
});
headlessTerminal.scrollToTop();
this.activePtys.set(ptyProcess.pid, { ptyProcess, headlessTerminal });
this.activePtys.set(ptyProcess.pid, {
ptyProcess,
headlessTerminal,
maxSerializedLines: shellExecutionConfig.maxSerializedLines,
});
let processingChain = Promise.resolve();
let decoder: TextDecoder | null = null;
@@ -537,17 +621,29 @@ export class ShellExecutionService {
}
const buffer = headlessTerminal.buffer.active;
const endLine = buffer.length;
const startLine = Math.max(
0,
endLine - (shellExecutionConfig.maxSerializedLines ?? 2000),
);
let newOutput: AnsiOutput;
if (shellExecutionConfig.showColor) {
newOutput = serializeTerminalToObject(headlessTerminal);
newOutput = serializeTerminalToObject(
headlessTerminal,
startLine,
endLine,
);
} else {
newOutput = (serializeTerminalToObject(headlessTerminal) || []).map(
(line) =>
line.map((token) => {
token.fg = '';
token.bg = '';
return token;
}),
newOutput = (
serializeTerminalToObject(headlessTerminal, startLine, endLine) ||
[]
).map((line) =>
line.map((token) => {
token.fg = '';
token.bg = '';
return token;
}),
);
}
@@ -565,8 +661,11 @@ export class ShellExecutionService {
}
}
if (buffer.cursorY > lastNonEmptyLine) {
lastNonEmptyLine = buffer.cursorY;
const absoluteCursorY = buffer.baseY + buffer.cursorY;
const cursorRelativeIndex = absoluteCursorY - startLine;
if (cursorRelativeIndex > lastNonEmptyLine) {
lastNonEmptyLine = cursorRelativeIndex;
}
const trimmedOutput = newOutput.slice(0, lastNonEmptyLine + 1);
@@ -575,13 +674,14 @@ export class ShellExecutionService {
? newOutput
: trimmedOutput;
// Using stringify for a quick deep comparison.
if (JSON.stringify(output) !== JSON.stringify(finalOutput)) {
if (output !== finalOutput) {
output = finalOutput;
onOutputEvent({
const event: ShellOutputEvent = {
type: 'data',
chunk: finalOutput,
});
};
onOutputEvent(event);
ShellExecutionService.emitEvent(ptyProcess.pid, event);
}
};
@@ -631,7 +731,9 @@ export class ShellExecutionService {
if (isBinary(sniffBuffer)) {
isStreamingRawContent = false;
onOutputEvent({ type: 'binary_detected' });
const event: ShellOutputEvent = { type: 'binary_detected' };
onOutputEvent(event);
ShellExecutionService.emitEvent(ptyProcess.pid, event);
}
}
@@ -652,10 +754,12 @@ export class ShellExecutionService {
(sum, chunk) => sum + chunk.length,
0,
);
onOutputEvent({
const event: ShellOutputEvent = {
type: 'binary_progress',
bytesReceived: totalBytes,
});
};
onOutputEvent(event);
ShellExecutionService.emitEvent(ptyProcess.pid, event);
resolve();
}
}),
@@ -681,6 +785,28 @@ export class ShellExecutionService {
const finalize = () => {
render(true);
// Store exit info for late subscribers (e.g. backgrounding race condition)
this.exitedPtyInfo.set(ptyProcess.pid, { exitCode, signal });
setTimeout(
() => {
this.exitedPtyInfo.delete(ptyProcess.pid);
},
5 * 60 * 1000,
).unref();
this.activePtys.delete(ptyProcess.pid);
this.activeResolvers.delete(ptyProcess.pid);
const event: ShellOutputEvent = {
type: 'exit',
exitCode,
signal: signal ?? null,
};
onOutputEvent(event);
ShellExecutionService.emitEvent(ptyProcess.pid, event);
this.activeListeners.delete(ptyProcess.pid);
const finalBuffer = Buffer.concat(outputChunks);
resolve({
@@ -720,25 +846,12 @@ export class ShellExecutionService {
const abortHandler = async () => {
if (ptyProcess.pid && !exited) {
if (os.platform() === 'win32') {
ptyProcess.kill();
} else {
try {
// Kill the entire process group
process.kill(-ptyProcess.pid, 'SIGTERM');
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
if (!exited) {
process.kill(-ptyProcess.pid, 'SIGKILL');
}
} catch (_e) {
// Fallback to killing just the process if the group kill fails
ptyProcess.kill('SIGTERM');
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
if (!exited) {
ptyProcess.kill('SIGKILL');
}
}
}
await killProcessGroup({
pid: ptyProcess.pid,
escalate: true,
isExited: () => exited,
pty: ptyProcess,
});
}
};
@@ -780,6 +893,14 @@ export class ShellExecutionService {
* @param input The string to write to the terminal.
*/
static writeToPty(pid: number, input: string): void {
if (this.activeChildProcesses.has(pid)) {
const activeChild = this.activeChildProcesses.get(pid);
if (activeChild) {
activeChild.process.stdin?.write(input);
}
return;
}
if (!this.isPtyActive(pid)) {
return;
}
@@ -791,6 +912,14 @@ export class ShellExecutionService {
}
static isPtyActive(pid: number): boolean {
if (this.activeChildProcesses.has(pid)) {
try {
return process.kill(pid, 0);
} catch {
return false;
}
}
try {
// process.kill with signal 0 is a way to check for the existence of a process.
// It doesn't actually send a signal.
@@ -800,6 +929,162 @@ export class ShellExecutionService {
}
}
/**
* Registers a callback to be invoked when the process with the given PID exits.
* This attaches directly to the PTY's exit event.
*
* @param pid The process ID to watch.
* @param callback The function to call on exit.
* @returns An unsubscribe function.
*/
static onExit(
pid: number,
callback: (exitCode: number, signal?: number) => void,
): () => void {
const activePty = this.activePtys.get(pid);
if (activePty) {
const disposable = activePty.ptyProcess.onExit(
({ exitCode, signal }: { exitCode: number; signal?: number }) => {
callback(exitCode, signal);
disposable.dispose();
},
);
return () => disposable.dispose();
} else if (this.activeChildProcesses.has(pid)) {
const activeChild = this.activeChildProcesses.get(pid);
const listener = (code: number | null, signal: NodeJS.Signals | null) => {
let signalNumber: number | undefined;
if (signal) {
signalNumber = os.constants.signals[signal];
}
callback(code ?? 0, signalNumber);
};
activeChild?.process.on('exit', listener);
return () => {
activeChild?.process.removeListener('exit', listener);
};
} else {
// Check if it already exited recently
const exitedInfo = this.exitedPtyInfo.get(pid);
if (exitedInfo) {
callback(exitedInfo.exitCode, exitedInfo.signal);
}
return () => {};
}
}
/**
* Kills a process by its PID.
*
* @param pid The process ID to kill.
*/
static kill(pid: number): void {
const activePty = this.activePtys.get(pid);
const activeChild = this.activeChildProcesses.get(pid);
if (activeChild) {
killProcessGroup({ pid }).catch(() => {});
this.activeChildProcesses.delete(pid);
} else if (activePty) {
killProcessGroup({ pid, pty: activePty.ptyProcess }).catch(() => {});
this.activePtys.delete(pid);
}
this.activeResolvers.delete(pid);
this.activeListeners.delete(pid);
}
/**
* Moves a running shell command to the background.
* This resolves the execution promise but keeps the PTY active.
*
* @param pid The process ID of the target PTY.
*/
static background(pid: number): void {
const resolve = this.activeResolvers.get(pid);
if (resolve) {
let output = '';
const rawOutput = Buffer.from('');
const activePty = this.activePtys.get(pid);
const activeChild = this.activeChildProcesses.get(pid);
if (activePty) {
output = getFullBufferText(activePty.headlessTerminal);
resolve({
rawOutput,
output,
exitCode: null,
signal: null,
error: null,
aborted: false,
pid,
executionMethod: 'node-pty',
backgrounded: true,
});
} else if (activeChild) {
output = activeChild.state.output;
resolve({
rawOutput,
output,
exitCode: null,
signal: null,
error: null,
aborted: false,
pid,
executionMethod: 'child_process',
backgrounded: true,
});
}
this.activeResolvers.delete(pid);
}
}
static subscribe(
pid: number,
listener: (event: ShellOutputEvent) => void,
): () => void {
if (!this.activeListeners.has(pid)) {
this.activeListeners.set(pid, new Set());
}
this.activeListeners.get(pid)?.add(listener);
// Send current buffer content immediately
const activePty = this.activePtys.get(pid);
const activeChild = this.activeChildProcesses.get(pid);
if (activePty) {
// Use serializeTerminalToObject to preserve colors and structure
const endLine = activePty.headlessTerminal.buffer.active.length;
const startLine = Math.max(
0,
endLine - (activePty.maxSerializedLines ?? 2000),
);
const bufferData = serializeTerminalToObject(
activePty.headlessTerminal,
startLine,
endLine,
);
if (bufferData && bufferData.length > 0) {
listener({ type: 'data', chunk: bufferData });
}
} else if (activeChild) {
const output = activeChild.state.output;
if (output) {
listener({ type: 'data', chunk: output });
}
}
return () => {
this.activeListeners.get(pid)?.delete(listener);
if (this.activeListeners.get(pid)?.size === 0) {
this.activeListeners.delete(pid);
}
};
}
/**
* Resizes the pseudo-terminal (PTY) of a running process.
*
@@ -835,6 +1120,25 @@ export class ShellExecutionService {
}
}
}
// Force emit the new state after resize
if (activePty) {
const endLine = activePty.headlessTerminal.buffer.active.length;
const startLine = Math.max(
0,
endLine - (activePty.maxSerializedLines ?? 2000),
);
const bufferData = serializeTerminalToObject(
activePty.headlessTerminal,
startLine,
endLine,
);
const event: ShellOutputEvent = { type: 'data', chunk: bufferData };
const listeners = ShellExecutionService.activeListeners.get(pid);
if (listeners) {
listeners.forEach((listener) => listener(event));
}
}
}
/**
+80 -4
View File
@@ -18,8 +18,13 @@ import {
const mockPlatform = vi.hoisted(() => vi.fn());
const mockShellExecutionService = vi.hoisted(() => vi.fn());
const mockShellBackground = vi.hoisted(() => vi.fn());
vi.mock('../services/shellExecutionService.js', () => ({
ShellExecutionService: { execute: mockShellExecutionService },
ShellExecutionService: {
execute: mockShellExecutionService,
background: mockShellBackground,
},
}));
vi.mock('node:os', async (importOriginal) => {
@@ -38,6 +43,7 @@ vi.mock('../utils/summarizer.js');
import { initializeShellParsers } from '../utils/shell-utils.js';
import { ShellTool } from './shell.js';
import { debugLogger } from '../index.js';
import { type Config } from '../config/config.js';
import {
type ShellExecutionResult,
@@ -168,6 +174,20 @@ describe('ShellTool', () => {
}),
};
});
mockShellBackground.mockImplementation(() => {
resolveExecutionPromise({
output: '',
rawOutput: Buffer.from(''),
exitCode: null,
signal: null,
error: null,
aborted: false,
pid: 12345,
executionMethod: 'child_process',
backgrounded: true,
});
});
});
afterEach(() => {
@@ -305,6 +325,25 @@ describe('ShellTool', () => {
);
});
it('should handle is_background parameter by calling ShellExecutionService.background', async () => {
vi.useFakeTimers();
const invocation = shellTool.build({
command: 'sleep 10',
is_background: true,
});
const promise = invocation.execute(mockAbortSignal);
// We need to provide a PID for the background logic to trigger
resolveShellExecution({ pid: 12345 });
// Advance time to trigger the background timeout
await vi.advanceTimersByTimeAsync(250);
expect(mockShellBackground).toHaveBeenCalledWith(12345);
await promise;
});
itWindowsOnly(
'should not wrap command on windows',
async () => {
@@ -430,8 +469,6 @@ describe('ShellTool', () => {
// We can also verify that setTimeout was NOT called for the inactivity timeout.
// However, since we don't have direct access to the internal `resetTimeout`,
// we can infer success by the fact it didn't abort.
vi.useRealTimers();
});
it('should clean up the temp file on synchronous execution error', async () => {
@@ -450,10 +487,28 @@ describe('ShellTool', () => {
expect(fs.existsSync(tmpFile)).toBe(false);
});
it('should not log "missing pgrep output" when process is backgrounded', async () => {
vi.useFakeTimers();
const debugErrorSpy = vi.spyOn(debugLogger, 'error');
const invocation = shellTool.build({
command: 'sleep 10',
is_background: true,
});
const promise = invocation.execute(mockAbortSignal);
// Advance time to trigger backgrounding
await vi.advanceTimersByTimeAsync(200);
await promise;
expect(debugErrorSpy).not.toHaveBeenCalledWith('missing pgrep output');
});
describe('Streaming to `updateOutput`', () => {
let updateOutputMock: Mock;
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] });
updateOutputMock = vi.fn();
});
afterEach(() => {
@@ -503,6 +558,27 @@ describe('ShellTool', () => {
});
await promise;
});
it('should NOT call updateOutput if the command is backgrounded', async () => {
const invocation = shellTool.build({
command: 'sleep 10',
is_background: true,
});
const promise = invocation.execute(mockAbortSignal, updateOutputMock);
mockShellOutputCallback({ type: 'data', chunk: 'some output' });
expect(updateOutputMock).not.toHaveBeenCalled();
// We need to provide a PID for the background logic to trigger
resolveShellExecution({ pid: 12345 });
// Advance time to trigger the background timeout
await vi.advanceTimersByTimeAsync(250);
expect(mockShellBackground).toHaveBeenCalledWith(12345);
await promise;
});
});
});
+50 -9
View File
@@ -46,10 +46,14 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
export const OUTPUT_UPDATE_INTERVAL_MS = 1000;
// Delay so user does not see the output of the process before the process is moved to the background.
const BACKGROUND_DELAY_MS = 200;
export interface ShellToolParams {
command: string;
description?: string;
dir_path?: string;
is_background?: boolean;
}
export class ShellToolInvocation extends BaseToolInvocation<
@@ -79,6 +83,9 @@ export class ShellToolInvocation extends BaseToolInvocation<
if (this.params.description) {
description += ` (${this.params.description.replace(/\n/g, ' ')})`;
}
if (this.params.is_background) {
description += ' [background]';
}
return description;
}
@@ -249,12 +256,14 @@ export class ShellToolInvocation extends BaseToolInvocation<
shouldUpdate = true;
}
break;
case 'exit':
break;
default: {
throw new Error('An unhandled ShellOutputEvent was found.');
}
}
if (shouldUpdate) {
if (shouldUpdate && !this.params.is_background) {
updateOutput(cumulativeOutput);
lastUpdateTime = Date.now();
}
@@ -270,8 +279,17 @@ export class ShellToolInvocation extends BaseToolInvocation<
},
);
if (pid && setPidCallback) {
setPidCallback(pid);
if (pid) {
if (setPidCallback) {
setPidCallback(pid);
}
// If the model requested to run in the background, do so after a short delay.
if (this.params.is_background) {
setTimeout(() => {
ShellExecutionService.background(pid);
}, BACKGROUND_DELAY_MS);
}
}
const result = await resultPromise;
@@ -299,12 +317,14 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
}
} else {
if (!signal.aborted) {
if (!signal.aborted && !result.backgrounded) {
debugLogger.error('missing pgrep output');
}
}
}
let data: Record<string, unknown> | undefined;
let llmContent = '';
let timeoutMessage = '';
if (result.aborted) {
@@ -322,6 +342,13 @@ export class ShellToolInvocation extends BaseToolInvocation<
} else {
llmContent += ' There was no output before it was cancelled.';
}
} else if (this.params.is_background || result.backgrounded) {
llmContent = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
data = {
pid: result.pid,
command: this.params.command,
initialOutput: result.output,
};
} else {
// Create a formatted error string for display, replacing the wrapper command
// with the user-facing command.
@@ -356,7 +383,9 @@ export class ShellToolInvocation extends BaseToolInvocation<
if (this.config.getDebugMode()) {
returnDisplayMessage = llmContent;
} else {
if (result.output.trim()) {
if (this.params.is_background || result.backgrounded) {
returnDisplayMessage = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
} else if (result.output.trim()) {
returnDisplayMessage = result.output;
} else {
if (result.aborted) {
@@ -406,6 +435,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
return {
llmContent,
returnDisplay: returnDisplayMessage,
data,
...executionError,
};
} finally {
@@ -421,7 +451,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
}
function getShellToolDescription(): string {
function getShellToolDescription(enableInteractiveShell: boolean): string {
const returnedInfo = `
The following information is returned:
@@ -434,9 +464,15 @@ function getShellToolDescription(): string {
Process Group PGID: Only included if available.`;
if (os.platform() === 'win32') {
return `This tool executes a given shell command as \`powershell.exe -NoProfile -Command <command>\`. Command can start background processes using PowerShell constructs such as \`Start-Process -NoNewWindow\` or \`Start-Job\`.${returnedInfo}`;
const backgroundInstructions = enableInteractiveShell
? 'To run a command in the background, set the `is_background` parameter to true. Do NOT use PowerShell background constructs.'
: 'Command can start background processes using PowerShell constructs such as `Start-Process -NoNewWindow` or `Start-Job`.';
return `This tool executes a given shell command as \`powershell.exe -NoProfile -Command <command>\`. ${backgroundInstructions}${returnedInfo}`;
} else {
return `This tool executes a given shell command as \`bash -c <command>\`. Command can start background processes using \`&\`. Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${returnedInfo}`;
const backgroundInstructions = enableInteractiveShell
? 'To run a command in the background, set the `is_background` parameter to true. Do NOT use `&` to background commands.'
: 'Command can start background processes using `&`.';
return `This tool executes a given shell command as \`bash -c <command>\`. ${backgroundInstructions} Command is executed as a subprocess that leads its own process group. Command process group can be terminated as \`kill -- -PGID\` or signaled as \`kill -s SIGNAL -- -PGID\`.${returnedInfo}`;
}
}
@@ -464,7 +500,7 @@ export class ShellTool extends BaseDeclarativeTool<
super(
ShellTool.Name,
'Shell',
getShellToolDescription(),
getShellToolDescription(config.getEnableInteractiveShell()),
Kind.Execute,
{
type: 'object',
@@ -483,6 +519,11 @@ export class ShellTool extends BaseDeclarativeTool<
description:
'(OPTIONAL) The path of the directory to run the command in. If not provided, the project root directory is used. Must be a directory within the workspace and must already exist.',
},
is_background: {
type: 'boolean',
description:
'Set to true if this command should be run in the background (e.g. for long-running servers or watchers). The command will be started, allowed to run for a brief moment to check for immediate errors, and then moved to the background.',
},
},
required: ['command'],
},
+5
View File
@@ -550,6 +550,11 @@ export interface ToolResult {
message: string; // raw error message
type?: ToolErrorType; // An optional machine-readable error type (e.g., 'FILE_NOT_FOUND').
};
/**
* Optional data payload for passing structured information back to the caller.
*/
data?: Record<string, unknown>;
}
/**
@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'node:os';
import { spawn as cpSpawn } from 'node:child_process';
import { killProcessGroup, SIGKILL_TIMEOUT_MS } from './process-utils.js';
vi.mock('node:os');
vi.mock('node:child_process');
describe('process-utils', () => {
const mockProcessKill = vi
.spyOn(process, 'kill')
.mockImplementation(() => true);
const mockSpawn = vi.mocked(cpSpawn);
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('killProcessGroup', () => {
it('should use taskkill on Windows', async () => {
vi.mocked(os.platform).mockReturnValue('win32');
await killProcessGroup({ pid: 1234 });
expect(mockSpawn).toHaveBeenCalledWith('taskkill', [
'/pid',
'1234',
'/f',
'/t',
]);
expect(mockProcessKill).not.toHaveBeenCalled();
});
it('should use pty.kill() on Windows if pty is provided', async () => {
vi.mocked(os.platform).mockReturnValue('win32');
const mockPty = { kill: vi.fn() };
await killProcessGroup({ pid: 1234, pty: mockPty });
expect(mockPty.kill).toHaveBeenCalled();
expect(mockSpawn).not.toHaveBeenCalled();
});
it('should kill the process group on Unix with SIGKILL by default', async () => {
vi.mocked(os.platform).mockReturnValue('linux');
await killProcessGroup({ pid: 1234 });
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGKILL');
});
it('should use escalation on Unix if requested', async () => {
vi.mocked(os.platform).mockReturnValue('linux');
const exited = false;
const isExited = () => exited;
const killPromise = killProcessGroup({
pid: 1234,
escalate: true,
isExited,
});
// First call should be SIGTERM
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGTERM');
// Advance time
await vi.advanceTimersByTimeAsync(SIGKILL_TIMEOUT_MS);
// Second call should be SIGKILL
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGKILL');
await killPromise;
});
it('should skip SIGKILL if isExited returns true after SIGTERM', async () => {
vi.mocked(os.platform).mockReturnValue('linux');
let exited = false;
const isExited = vi.fn().mockImplementation(() => exited);
const killPromise = killProcessGroup({
pid: 1234,
escalate: true,
isExited,
});
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGTERM');
// Simulate process exiting
exited = true;
await vi.advanceTimersByTimeAsync(SIGKILL_TIMEOUT_MS);
expect(mockProcessKill).not.toHaveBeenCalledWith(-1234, 'SIGKILL');
await killPromise;
});
it('should fallback to specific process kill if group kill fails', async () => {
vi.mocked(os.platform).mockReturnValue('linux');
mockProcessKill.mockImplementationOnce(() => {
throw new Error('ESRCH');
});
await killProcessGroup({ pid: 1234 });
// Failed group kill
expect(mockProcessKill).toHaveBeenCalledWith(-1234, 'SIGKILL');
// Fallback individual kill
expect(mockProcessKill).toHaveBeenCalledWith(1234, 'SIGKILL');
});
it('should use pty fallback on Unix if group kill fails', async () => {
vi.mocked(os.platform).mockReturnValue('linux');
mockProcessKill.mockImplementationOnce(() => {
throw new Error('ESRCH');
});
const mockPty = { kill: vi.fn() };
await killProcessGroup({ pid: 1234, pty: mockPty });
expect(mockPty.kill).toHaveBeenCalledWith('SIGKILL');
});
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import os from 'node:os';
import { spawn as cpSpawn } from 'node:child_process';
/** Default timeout for SIGKILL escalation on Unix systems. */
export const SIGKILL_TIMEOUT_MS = 200;
/** Configuration for process termination. */
export interface KillOptions {
/** The process ID to terminate. */
pid: number;
/** Whether to attempt SIGTERM before SIGKILL on Unix systems. */
escalate?: boolean;
/** Initial signal to use (defaults to SIGTERM if escalate is true, else SIGKILL). */
signal?: NodeJS.Signals | number;
/** Callback to check if the process has already exited. */
isExited?: () => boolean;
/** Optional PTY object for PTY-specific kill methods. */
pty?: { kill: (signal?: string) => void };
}
/**
* Robustly terminates a process or process group across platforms.
*
* On Windows, it uses `taskkill /f /t` to ensure the entire tree is terminated,
* or the PTY's built-in kill method.
*
* On Unix, it attempts to kill the process group (using -pid) with escalation
* from SIGTERM to SIGKILL if requested.
*/
export async function killProcessGroup(options: KillOptions): Promise<void> {
const { pid, escalate = false, isExited = () => false, pty } = options;
const isWindows = os.platform() === 'win32';
if (isWindows) {
if (pty) {
try {
pty.kill();
} catch {
// Ignore errors for dead processes
}
} else {
cpSpawn('taskkill', ['/pid', pid.toString(), '/f', '/t']);
}
return;
}
// Unix logic
try {
const initialSignal = options.signal || (escalate ? 'SIGTERM' : 'SIGKILL');
// Try killing the process group first (-pid)
process.kill(-pid, initialSignal);
if (escalate && !isExited()) {
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
if (!isExited()) {
try {
process.kill(-pid, 'SIGKILL');
} catch {
// Ignore
}
}
}
} catch (_e) {
// Fallback to specific process kill if group kill fails or on error
if (!isExited()) {
if (pty) {
if (escalate) {
try {
pty.kill('SIGTERM');
await new Promise((res) => setTimeout(res, SIGKILL_TIMEOUT_MS));
if (!isExited()) pty.kill('SIGKILL');
} catch {
// Ignore
}
} else {
try {
pty.kill('SIGKILL');
} catch {
// Ignore
}
}
} else {
try {
process.kill(pid, 'SIGKILL');
} catch {
// Ignore
}
}
}
}
}
+39 -14
View File
@@ -34,12 +34,12 @@ export const enum ColorMode {
}
class Cell {
private readonly cell: IBufferCell | null;
private readonly x: number;
private readonly y: number;
private readonly cursorX: number;
private readonly cursorY: number;
private readonly attributes: number = 0;
private cell: IBufferCell | null = null;
private x = 0;
private y = 0;
private cursorX = 0;
private cursorY = 0;
private attributes: number = 0;
fg = 0;
bg = 0;
fgColorMode: ColorMode = ColorMode.DEFAULT;
@@ -51,12 +51,23 @@ class Cell {
y: number,
cursorX: number,
cursorY: number,
) {
this.update(cell, x, y, cursorX, cursorY);
}
update(
cell: IBufferCell | null,
x: number,
y: number,
cursorX: number,
cursorY: number,
) {
this.cell = cell;
this.x = x;
this.y = y;
this.cursorX = cursorX;
this.cursorY = cursorY;
this.attributes = 0;
if (!cell) {
return;
@@ -131,7 +142,11 @@ class Cell {
}
}
export function serializeTerminalToObject(terminal: Terminal): AnsiOutput {
export function serializeTerminalToObject(
terminal: Terminal,
startLine?: number,
endLine?: number,
): AnsiOutput {
const buffer = terminal.buffer.active;
const cursorX = buffer.cursorX;
const cursorY = buffer.cursorY;
@@ -140,22 +155,30 @@ export function serializeTerminalToObject(terminal: Terminal): AnsiOutput {
const result: AnsiOutput = [];
for (let y = 0; y < terminal.rows; y++) {
const line = buffer.getLine(buffer.viewportY + y);
// Reuse cell instances
const lastCell = new Cell(null, -1, -1, cursorX, cursorY);
const currentCell = new Cell(null, -1, -1, cursorX, cursorY);
const effectiveStart = startLine ?? buffer.viewportY;
const effectiveEnd = endLine ?? buffer.viewportY + terminal.rows;
for (let y = effectiveStart; y < effectiveEnd; y++) {
const line = buffer.getLine(y);
const currentLine: AnsiLine = [];
if (!line) {
result.push(currentLine);
continue;
}
let lastCell = new Cell(null, -1, -1, cursorX, cursorY);
// Reset lastCell for new line
lastCell.update(null, -1, -1, cursorX, cursorY);
let currentText = '';
for (let x = 0; x < terminal.cols; x++) {
const cellData = line.getCell(x);
const cell = new Cell(cellData || null, x, y, cursorX, cursorY);
currentCell.update(cellData || null, x, y, cursorX, cursorY);
if (x > 0 && !cell.equals(lastCell)) {
if (x > 0 && !currentCell.equals(lastCell)) {
if (currentText) {
const token: AnsiToken = {
text: currentText,
@@ -172,8 +195,10 @@ export function serializeTerminalToObject(terminal: Terminal): AnsiOutput {
}
currentText = '';
}
currentText += cell.getChars();
lastCell = cell;
currentText += currentCell.getChars();
// Copy state from currentCell to lastCell. Since we can't easily deep copy
// without allocating, we just update lastCell with the same data.
lastCell.update(cellData || null, x, y, cursorX, cursorY);
}
if (currentText) {