diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index 2461f9893c..e2aae8fece 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -25,6 +25,7 @@ import type { ToolCallConfirmationDetails, Config, UserTierId, + AnsiOutput, } from '@google/gemini-cli-core'; import type { RequestContext } from '@a2a-js/sdk/server'; import { type ExecutionEventBus } from '@a2a-js/sdk/server'; @@ -284,20 +285,29 @@ export class Task { private _schedulerOutputUpdate( toolCallId: string, - outputChunk: string, + outputChunk: string | AnsiOutput, ): void { + let outputAsText: string; + if (typeof outputChunk === 'string') { + outputAsText = outputChunk; + } else { + outputAsText = outputChunk + .map((line) => line.map((token) => token.text).join('')) + .join('\n'); + } + logger.info( '[Task] Scheduler output update for tool call ' + toolCallId + ': ' + - outputChunk, + outputAsText, ); const artifact: Artifact = { artifactId: `tool-${toolCallId}-output`, parts: [ { kind: 'text', - text: outputChunk, + text: outputAsText, } as Part, ], }; diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts index aac6a52833..76a4873f0a 100644 --- a/packages/a2a-server/src/http/app.test.ts +++ b/packages/a2a-server/src/http/app.test.ts @@ -64,6 +64,7 @@ vi.mock('../utils/logger.js', () => ({ let config: Config; const getToolRegistrySpy = vi.fn().mockReturnValue(ApprovalMode.DEFAULT); const getApprovalModeSpy = vi.fn(); +const getShellExecutionConfigSpy = vi.fn(); vi.mock('../config/config.js', async () => { const actual = await vi.importActual('../config/config.js'); return { @@ -72,6 +73,7 @@ vi.mock('../config/config.js', async () => { const mockConfig = createMockConfig({ getToolRegistry: getToolRegistrySpy, getApprovalMode: getApprovalModeSpy, + getShellExecutionConfig: getShellExecutionConfigSpy, }); config = mockConfig as Config; return config; diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 4b139d1a30..ba5ddac587 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -56,6 +56,7 @@ export enum Command { REVERSE_SEARCH = 'reverseSearch', SUBMIT_REVERSE_SEARCH = 'submitReverseSearch', ACCEPT_SUGGESTION_REVERSE_SEARCH = 'acceptSuggestionReverseSearch', + TOGGLE_SHELL_INPUT_FOCUS = 'toggleShellInputFocus', } /** @@ -162,4 +163,5 @@ export const defaultKeyBindings: KeyBindingConfig = { // Note: original logic ONLY checked ctrl=false, ignored meta/shift/paste [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return', ctrl: false }], [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }], + [Command.TOGGLE_SHELL_INPUT_FOCUS]: [{ key: 'f', ctrl: true }], }; diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 53ab1111d9..e1a4538fc8 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -106,6 +106,8 @@ const MIGRATION_MAP: Record = { sandbox: 'tools.sandbox', selectedAuthType: 'security.auth.selectedType', shouldUseNodePtyShell: 'tools.usePty', + shellPager: 'tools.shell.pager', + shellShowColor: 'tools.shell.showColor', skipNextSpeakerCheck: 'model.skipNextSpeakerCheck', summarizeToolOutput: 'model.summarizeToolOutput', telemetry: 'telemetry', diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 7641250e8a..95e949c06b 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -649,6 +649,36 @@ const SETTINGS_SCHEMA = { 'Use node-pty for shell command execution. Fallback to child_process still applies.', showInDialog: true, }, + shell: { + type: 'object', + label: 'Shell', + category: 'Tools', + requiresRestart: false, + default: {}, + description: 'Settings for shell execution.', + showInDialog: false, + properties: { + pager: { + type: 'string', + label: 'Pager', + category: 'Tools', + requiresRestart: false, + default: 'cat' as string | undefined, + description: + 'The pager command to use for shell output. Defaults to `cat`.', + showInDialog: false, + }, + showColor: { + type: 'boolean', + label: 'Show Color', + category: 'Tools', + requiresRestart: false, + default: false, + description: 'Show color in shell output.', + showInDialog: true, + }, + }, + }, autoAccept: { type: 'boolean', label: 'Auto Accept', diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts index 3b2418e136..393acb4725 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -71,6 +71,7 @@ describe('ShellProcessor', () => { getTargetDir: vi.fn().mockReturnValue('/test/dir'), getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getShouldUseNodePtyShell: vi.fn().mockReturnValue(false), + getShellExecutionConfig: vi.fn().mockReturnValue({}), }; context = createMockCommandContext({ @@ -147,6 +148,7 @@ describe('ShellProcessor', () => { expect.any(Function), expect.any(Object), false, + expect.any(Object), ); expect(result).toEqual([{ text: 'The current status is: On branch main' }]); }); @@ -218,6 +220,7 @@ describe('ShellProcessor', () => { expect.any(Function), expect.any(Object), false, + expect.any(Object), ); expect(result).toEqual([{ text: 'Do something dangerous: deleted' }]); }); @@ -410,6 +413,7 @@ describe('ShellProcessor', () => { expect.any(Function), expect.any(Object), false, + expect.any(Object), ); }); @@ -574,6 +578,7 @@ describe('ShellProcessor', () => { expect.any(Function), expect.any(Object), false, + expect.any(Object), ); expect(result).toEqual([{ text: 'Command: match found' }]); @@ -598,6 +603,7 @@ describe('ShellProcessor', () => { expect.any(Function), expect.any(Object), false, + expect.any(Object), ); expect(result).toEqual([ @@ -668,6 +674,7 @@ describe('ShellProcessor', () => { expect.any(Function), expect.any(Object), false, + expect.any(Object), ); }); @@ -697,6 +704,7 @@ describe('ShellProcessor', () => { expect.any(Function), expect.any(Object), false, + expect.any(Object), ); }); }); diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts index d45e8f8a11..6e2b4adb72 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts @@ -20,6 +20,7 @@ import { SHORTHAND_ARGS_PLACEHOLDER, } from './types.js'; import { extractInjections, type Injection } from './injectionParser.js'; +import { themeManager } from '../../ui/themes/theme-manager.js'; export class ConfirmationRequiredError extends Error { constructor( @@ -159,12 +160,19 @@ export class ShellProcessor implements IPromptProcessor { // Execute the resolved command (which already has ESCAPED input). if (injection.resolvedCommand) { + const activeTheme = themeManager.getActiveTheme(); + const shellExecutionConfig = { + ...config.getShellExecutionConfig(), + defaultFg: activeTheme.colors.Foreground, + defaultBg: activeTheme.colors.Background, + }; const { result } = await ShellExecutionService.execute( injection.resolvedCommand, config.getTargetDir(), () => {}, new AbortController().signal, config.getShouldUseNodePtyShell(), + shellExecutionConfig, ); const executionResult = await result; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 81725ed3b0..c780b5b448 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -34,6 +34,7 @@ import { getAllGeminiMdFilenames, AuthType, clearCachedCredentialFile, + ShellExecutionService, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; @@ -97,6 +98,18 @@ interface AppContainerProps { initializationResult: InitializationResult; } +/** + * The fraction of the terminal width to allocate to the shell. + * This provides horizontal padding. + */ +const SHELL_WIDTH_FRACTION = 0.89; + +/** + * The number of lines to subtract from the available terminal height + * for the shell. This provides vertical padding and space for other UI elements. + */ +const SHELL_HEIGHT_PADDING = 10; + export const AppContainer = (props: AppContainerProps) => { const { settings, config, initializationResult } = props; const historyManager = useHistory(); @@ -110,6 +123,8 @@ export const AppContainer = (props: AppContainerProps) => { initializationResult.themeError, ); const [isProcessing, setIsProcessing] = useState(false); + const [shellFocused, setShellFocused] = useState(false); + const [geminiMdFileCount, setGeminiMdFileCount] = useState( initializationResult.geminiMdFileCount, ); @@ -506,6 +521,7 @@ Logging in with Google... Please restart Gemini CLI to continue. pendingHistoryItems: pendingGeminiHistoryItems, thought, cancelOngoingRequest, + activePtyId, loopDetectionConfirmationRequest, } = useGeminiStream( config.getGeminiClient(), @@ -523,6 +539,10 @@ Logging in with Google... Please restart Gemini CLI to continue. setModelSwitchedFromQuotaError, refreshStatic, () => cancelHandlerRef.current(), + setShellFocused, + terminalWidth, + terminalHeight, + shellFocused, ); const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } = @@ -603,6 +623,13 @@ Logging in with Google... Please restart Gemini CLI to continue. return terminalHeight - staticExtraHeight; }, [terminalHeight]); + config.setShellExecutionConfig({ + terminalWidth: Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), + terminalHeight: Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), + pager: settings.merged.tools?.shell?.pager, + showColor: settings.merged.tools?.shell?.showColor, + }); + const isFocused = useFocus(); useBracketedPaste(); @@ -620,6 +647,22 @@ Logging in with Google... Please restart Gemini CLI to continue. const initialPromptSubmitted = useRef(false); const geminiClient = config.getGeminiClient(); + useEffect(() => { + if (activePtyId) { + ShellExecutionService.resizePty( + activePtyId, + Math.floor(terminalWidth * SHELL_WIDTH_FRACTION), + Math.floor(availableTerminalHeight - SHELL_HEIGHT_PADDING), + ); + } + }, [ + terminalHeight, + terminalWidth, + availableTerminalHeight, + activePtyId, + geminiClient, + ]); + useEffect(() => { if ( initialPrompt && @@ -840,6 +883,10 @@ Logging in with Google... Please restart Gemini CLI to continue. !enteringConstrainHeightMode ) { setConstrainHeight(false); + } else if (keyMatchers[Command.TOGGLE_SHELL_INPUT_FOCUS](key)) { + if (activePtyId || shellFocused) { + setShellFocused((prev) => !prev); + } } }, [ @@ -866,6 +913,8 @@ Logging in with Google... Please restart Gemini CLI to continue. isSettingsDialogOpen, isFolderTrustDialogOpen, showPrivacyNotice, + activePtyId, + shellFocused, settings.merged.general?.debugKeystrokeLogging, ], ); @@ -991,6 +1040,8 @@ Logging in with Google... Please restart Gemini CLI to continue. updateInfo, showIdeRestartPrompt, isRestarting, + activePtyId, + shellFocused, }), [ historyManager.history, @@ -1064,6 +1115,8 @@ Logging in with Google... Please restart Gemini CLI to continue. showIdeRestartPrompt, isRestarting, currentModel, + activePtyId, + shellFocused, ], ); diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx new file mode 100644 index 0000000000..5bb3673e73 --- /dev/null +++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { AnsiOutputText } from './AnsiOutput.js'; +import type { AnsiOutput, AnsiToken } from '@google/gemini-cli-core'; + +// Helper to create a valid AnsiToken with default values +const createAnsiToken = (overrides: Partial): AnsiToken => ({ + text: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + fg: '#ffffff', + bg: '#000000', + ...overrides, +}); + +describe('', () => { + it('renders a simple AnsiOutput object correctly', () => { + const data: AnsiOutput = [ + [ + createAnsiToken({ text: 'Hello, ' }), + createAnsiToken({ text: 'world!' }), + ], + ]; + const { lastFrame } = render(); + expect(lastFrame()).toBe('Hello, world!'); + }); + + it('correctly applies all the styles', () => { + const data: AnsiOutput = [ + [ + createAnsiToken({ text: 'Bold', bold: true }), + createAnsiToken({ text: 'Italic', italic: true }), + createAnsiToken({ text: 'Underline', underline: true }), + createAnsiToken({ text: 'Dim', dim: true }), + createAnsiToken({ text: 'Inverse', inverse: true }), + ], + ]; + // Note: ink-testing-library doesn't render styles, so we can only check the text. + // We are testing that it renders without crashing. + const { lastFrame } = render(); + expect(lastFrame()).toBe('BoldItalicUnderlineDimInverse'); + }); + + it('correctly applies foreground and background colors', () => { + const data: AnsiOutput = [ + [ + createAnsiToken({ text: 'Red FG', fg: '#ff0000' }), + createAnsiToken({ text: 'Blue BG', bg: '#0000ff' }), + ], + ]; + // Note: ink-testing-library doesn't render colors, so we can only check the text. + // We are testing that it renders without crashing. + const { lastFrame } = render(); + expect(lastFrame()).toBe('Red FGBlue BG'); + }); + + it('handles empty lines and empty tokens', () => { + const data: AnsiOutput = [ + [createAnsiToken({ text: 'First line' })], + [], + [createAnsiToken({ text: 'Third line' })], + [createAnsiToken({ text: '' })], + ]; + const { lastFrame } = render(); + const output = lastFrame(); + expect(output).toBeDefined(); + const lines = output!.split('\n'); + expect(lines[0]).toBe('First line'); + expect(lines[1]).toBe('Third line'); + }); + + it('respects the availableTerminalHeight prop and slices the lines correctly', () => { + const data: AnsiOutput = [ + [createAnsiToken({ text: 'Line 1' })], + [createAnsiToken({ text: 'Line 2' })], + [createAnsiToken({ text: 'Line 3' })], + [createAnsiToken({ text: 'Line 4' })], + ]; + const { lastFrame } = render( + , + ); + const output = lastFrame(); + expect(output).not.toContain('Line 1'); + expect(output).not.toContain('Line 2'); + expect(output).toContain('Line 3'); + expect(output).toContain('Line 4'); + }); + + it('renders a large AnsiOutput object without crashing', () => { + const largeData: AnsiOutput = []; + for (let i = 0; i < 1000; i++) { + largeData.push([createAnsiToken({ text: `Line ${i}` })]); + } + const { lastFrame } = render(); + // We are just checking that it renders something without crashing. + expect(lastFrame()).toBeDefined(); + }); +}); diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx new file mode 100644 index 0000000000..2a714f7cf4 --- /dev/null +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Text } from 'ink'; +import type { AnsiLine, AnsiOutput, AnsiToken } from '@google/gemini-cli-core'; + +const DEFAULT_HEIGHT = 24; + +interface AnsiOutputProps { + data: AnsiOutput; + availableTerminalHeight?: number; +} + +export const AnsiOutputText: React.FC = ({ + data, + availableTerminalHeight, +}) => { + const lastLines = data.slice( + -(availableTerminalHeight && availableTerminalHeight > 0 + ? availableTerminalHeight + : DEFAULT_HEIGHT), + ); + return lastLines.map((line: AnsiLine, lineIndex: number) => ( + + {line.length > 0 + ? line.map((token: AnsiToken, tokenIndex: number) => ( + + {token.text} + + )) + : null} + + )); +}; diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 531e3429b5..5080e252d6 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -58,20 +58,22 @@ export const Composer = () => { return ( - + {!uiState.shellFocused && ( + + )} {!uiState.isConfigInitialized && } @@ -178,6 +180,7 @@ export const Composer = () => { onEscapePromptChange={uiActions.onEscapePromptChange} focus={uiState.isFocused} vimHandleInput={uiActions.vimHandleInput} + isShellFocused={uiState.shellFocused} placeholder={ vimEnabled ? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode." diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 9c08a5828f..bb68b49550 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -30,6 +30,8 @@ interface HistoryItemDisplayProps { isPending: boolean; isFocused?: boolean; commands?: readonly SlashCommand[]; + activeShellPtyId?: number | null; + shellFocused?: boolean; } export const HistoryItemDisplay: React.FC = ({ @@ -39,6 +41,8 @@ export const HistoryItemDisplay: React.FC = ({ isPending, commands, isFocused = true, + activeShellPtyId, + shellFocused, }) => ( {/* Render standard message types */} @@ -85,6 +89,8 @@ export const HistoryItemDisplay: React.FC = ({ availableTerminalHeight={availableTerminalHeight} terminalWidth={terminalWidth} isFocused={isFocused} + activeShellPtyId={activeShellPtyId} + shellFocused={shellFocused} /> )} {item.type === 'compression' && ( diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 8fe6fb82f5..e14dab3fc4 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -50,6 +50,7 @@ export interface InputPromptProps { approvalMode: ApprovalMode; onEscapePromptChange?: (showPrompt: boolean) => void; vimHandleInput?: (key: Key) => boolean; + isShellFocused?: boolean; } export const InputPrompt: React.FC = ({ @@ -69,6 +70,7 @@ export const InputPrompt: React.FC = ({ approvalMode, onEscapePromptChange, vimHandleInput, + isShellFocused, }) => { const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); const [escPressCount, setEscPressCount] = useState(0); @@ -591,7 +593,7 @@ export const InputPrompt: React.FC = ({ ); useKeypress(handleInput, { - isActive: true, + isActive: !isShellFocused, }); const linesToRender = buffer.viewportVisualLines; diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index ff63d9f767..ea6ccda612 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -54,6 +54,8 @@ export const MainContent = () => { item={{ ...item, id: 0 }} isPending={true} isFocused={!uiState.isEditorDialogOpen} + activeShellPtyId={uiState.activePtyId} + shellFocused={uiState.shellFocused} /> ))} diff --git a/packages/cli/src/ui/components/ShellInputPrompt.tsx b/packages/cli/src/ui/components/ShellInputPrompt.tsx new file mode 100644 index 0000000000..5cdafff00b --- /dev/null +++ b/packages/cli/src/ui/components/ShellInputPrompt.tsx @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback } from 'react'; +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'; + +export interface ShellInputPromptProps { + activeShellPtyId: number | null; + focus?: boolean; +} + +export const ShellInputPrompt: React.FC = ({ + activeShellPtyId, + focus = true, +}) => { + const handleShellInputSubmit = useCallback( + (input: string) => { + if (activeShellPtyId) { + ShellExecutionService.writeToPty(activeShellPtyId, input); + } + }, + [activeShellPtyId], + ); + + const handleInput = useCallback( + (key: Key) => { + if (!focus || !activeShellPtyId) { + return; + } + if (key.ctrl && key.shift && key.name === 'up') { + ShellExecutionService.scrollPty(activeShellPtyId, -1); + return; + } + + if (key.ctrl && key.shift && key.name === 'down') { + ShellExecutionService.scrollPty(activeShellPtyId, 1); + return; + } + + const ansiSequence = keyToAnsi(key); + if (ansiSequence) { + handleShellInputSubmit(ansiSequence); + } + }, + [focus, handleShellInputSubmit, activeShellPtyId], + ); + + useKeypress(handleInput, { isActive: focus }); + + return null; +}; diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 65ac70a58c..b2b25cc8a1 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -21,6 +21,9 @@ interface ToolGroupMessageProps { availableTerminalHeight?: number; terminalWidth: number; isFocused?: boolean; + activeShellPtyId?: number | null; + shellFocused?: boolean; + onShellInputSubmit?: (input: string) => void; } // Main component renders the border and maps the tools using ToolMessage @@ -29,14 +32,26 @@ export const ToolGroupMessage: React.FC = ({ availableTerminalHeight, terminalWidth, isFocused = true, + activeShellPtyId, + shellFocused, }) => { - const config = useConfig(); + const isShellFocused = + shellFocused && + toolCalls.some( + (t) => + t.ptyId === activeShellPtyId && t.status === ToolCallStatus.Executing, + ); + const hasPending = !toolCalls.every( (t) => t.status === ToolCallStatus.Success, ); + + const config = useConfig(); const isShellCommand = toolCalls.some((t) => t.name === SHELL_COMMAND_NAME); const borderColor = - hasPending || isShellCommand ? theme.status.warning : theme.border.default; + hasPending || isShellCommand || isShellFocused + ? theme.status.warning + : theme.border.default; const staticHeight = /* border */ 2 + /* marginBottom */ 1; // This is a bit of a magic number, but it accounts for the border and @@ -89,12 +104,7 @@ export const ToolGroupMessage: React.FC = ({ = ({ ? 'low' : 'medium' } - renderOutputAsMarkdown={tool.renderOutputAsMarkdown} + activeShellPtyId={activeShellPtyId} + shellFocused={shellFocused} + config={config} /> {tool.status === ToolCallStatus.Confirming && diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index d6872dbad3..3f04404e79 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -11,6 +11,31 @@ import { ToolMessage } from './ToolMessage.js'; import { StreamingState, ToolCallStatus } from '../../types.js'; import { Text } from 'ink'; import { StreamingContext } from '../../contexts/StreamingContext.js'; +import type { AnsiOutput } from '@google/gemini-cli-core'; + +vi.mock('../TerminalOutput.js', () => ({ + TerminalOutput: function MockTerminalOutput({ + cursor, + }: { + cursor: { x: number; y: number } | null; + }) { + return ( + + MockCursor:({cursor?.x},{cursor?.y}) + + ); + }, +})); + +vi.mock('../AnsiOutput.js', () => ({ + AnsiOutputText: function MockAnsiOutputText({ data }: { data: AnsiOutput }) { + // Simple serialization for snapshot stability + const serialized = data + .map((line) => line.map((token) => token.text || '').join('')) + .join('\n'); + return MockAnsiOutput:{serialized}; + }, +})); // Mock child components or utilities if they are complex or have side effects vi.mock('../GeminiRespondingSpinner.js', () => ({ @@ -181,4 +206,26 @@ describe('', () => { // We can at least ensure it doesn't have the high emphasis indicator. expect(lowEmphasisFrame()).not.toContain('←'); }); + + it('renders AnsiOutputText for AnsiOutput results', () => { + const ansiResult: AnsiOutput = [ + [ + { + text: 'hello', + fg: '#ffffff', + bg: '#000000', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + ]; + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + expect(lastFrame()).toContain('MockAnsiOutput:hello'); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index e5e193067c..99822ece15 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -10,10 +10,13 @@ import type { IndividualToolCallDisplay } from '../../types.js'; import { ToolCallStatus } from '../../types.js'; import { DiffRenderer } from './DiffRenderer.js'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; +import { AnsiOutputText } from '../AnsiOutput.js'; import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js'; -import { TOOL_STATUS } from '../../constants.js'; +import { ShellInputPrompt } from '../ShellInputPrompt.js'; +import { SHELL_COMMAND_NAME, TOOL_STATUS } from '../../constants.js'; import { theme } from '../../semantic-colors.js'; +import type { AnsiOutput, Config } from '@google/gemini-cli-core'; const STATIC_HEIGHT = 1; const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc. @@ -30,6 +33,9 @@ export interface ToolMessageProps extends IndividualToolCallDisplay { terminalWidth: number; emphasis?: TextEmphasis; renderOutputAsMarkdown?: boolean; + activeShellPtyId?: number | null; + shellFocused?: boolean; + config?: Config; } export const ToolMessage: React.FC = ({ @@ -41,7 +47,17 @@ export const ToolMessage: React.FC = ({ terminalWidth, emphasis = 'medium', renderOutputAsMarkdown = true, + activeShellPtyId, + shellFocused, + ptyId, + config, }) => { + const isThisShellFocused = + (name === SHELL_COMMAND_NAME || name === 'Shell') && + status === ToolCallStatus.Executing && + ptyId === activeShellPtyId && + shellFocused; + const availableHeight = availableTerminalHeight ? Math.max( availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT, @@ -74,12 +90,17 @@ export const ToolMessage: React.FC = ({ description={description} emphasis={emphasis} /> + {isThisShellFocused && ( + + [Focused] + + )} {emphasis === 'high' && } {resultDisplay && ( - {typeof resultDisplay === 'string' && renderOutputAsMarkdown && ( + {typeof resultDisplay === 'string' && renderOutputAsMarkdown ? ( = ({ terminalWidth={childWidth} /> - )} - {typeof resultDisplay === 'string' && !renderOutputAsMarkdown && ( + ) : typeof resultDisplay === 'string' && !renderOutputAsMarkdown ? ( {resultDisplay} - )} - {typeof resultDisplay !== 'string' && ( + ) : typeof resultDisplay === 'object' && + !Array.isArray(resultDisplay) ? ( + ) : ( + )} )} + {isThisShellFocused && config && ( + + + + )} ); }; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index f2f713dee7..a5421dabe4 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -108,6 +108,8 @@ export interface UIState { updateInfo: UpdateObject | null; showIdeRestartPrompt: boolean; isRestarting: boolean; + activePtyId: number | undefined; + shellFocused: boolean; } export const UIStateContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/keyToAnsi.ts b/packages/cli/src/ui/hooks/keyToAnsi.ts new file mode 100644 index 0000000000..1d5549ab0f --- /dev/null +++ b/packages/cli/src/ui/hooks/keyToAnsi.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Key } from '../contexts/KeypressContext.js'; + +export type { Key }; + +/** + * Translates a Key object into its corresponding ANSI escape sequence. + * This is useful for sending control characters to a pseudo-terminal. + * + * @param key The Key object to translate. + * @returns The ANSI escape sequence as a string, or null if no mapping exists. + */ +export function keyToAnsi(key: Key): string | null { + if (key.ctrl) { + // Ctrl + letter + if (key.name >= 'a' && key.name <= 'z') { + return String.fromCharCode( + key.name.charCodeAt(0) - 'a'.charCodeAt(0) + 1, + ); + } + // Other Ctrl combinations might need specific handling + switch (key.name) { + case 'c': + return '\x03'; // ETX (End of Text), commonly used for interrupt + // Add other special ctrl cases if needed + default: + break; + } + } + + // Arrow keys and other special keys + switch (key.name) { + case 'up': + return '\x1b[A'; + case 'down': + return '\x1b[B'; + case 'right': + return '\x1b[C'; + case 'left': + return '\x1b[D'; + case 'escape': + return '\x1b'; + case 'tab': + return '\t'; + case 'backspace': + return '\x7f'; + case 'delete': + return '\x1b[3~'; + case 'home': + return '\x1b[H'; + case 'end': + return '\x1b[F'; + case 'pageup': + return '\x1b[5~'; + case 'pagedown': + return '\x1b[6~'; + default: + break; + } + + // Enter/Return + if (key.name === 'return') { + return '\r'; + } + + // If it's a simple character, return it. + if (!key.ctrl && !key.meta && key.sequence) { + return key.sequence; + } + + return null; +} diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts index 4ff7bb4b43..ed5aba351f 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.test.ts @@ -53,6 +53,8 @@ describe('useShellCommandProcessor', () => { let mockShellOutputCallback: (event: ShellOutputEvent) => void; let resolveExecutionPromise: (result: ShellExecutionResult) => void; + let setShellInputFocusedMock: Mock; + beforeEach(() => { vi.clearAllMocks(); @@ -60,9 +62,14 @@ describe('useShellCommandProcessor', () => { setPendingHistoryItemMock = vi.fn(); onExecMock = vi.fn(); onDebugMessageMock = vi.fn(); + setShellInputFocusedMock = vi.fn(); mockConfig = { getTargetDir: () => '/test/dir', getShouldUseNodePtyShell: () => false, + getShellExecutionConfig: () => ({ + terminalHeight: 20, + terminalWidth: 80, + }), } as Config; mockGeminiClient = { addHistory: vi.fn() } as unknown as GeminiClient; @@ -76,12 +83,12 @@ describe('useShellCommandProcessor', () => { mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => { mockShellOutputCallback = callback; - return { + return Promise.resolve({ pid: 12345, result: new Promise((resolve) => { resolveExecutionPromise = resolve; }), - }; + }); }); }); @@ -94,6 +101,7 @@ describe('useShellCommandProcessor', () => { onDebugMessageMock, mockConfig, mockGeminiClient, + setShellInputFocusedMock, ), ); @@ -139,6 +147,7 @@ describe('useShellCommandProcessor', () => { expect.any(Function), expect.any(Object), false, + expect.any(Object), ); expect(onExecMock).toHaveBeenCalledWith(expect.any(Promise)); }); @@ -172,6 +181,7 @@ describe('useShellCommandProcessor', () => { }), ); expect(mockGeminiClient.addHistory).toHaveBeenCalled(); + expect(setShellInputFocusedMock).toHaveBeenCalledWith(false); }); it('should handle command failure and display error status', async () => { @@ -198,6 +208,7 @@ describe('useShellCommandProcessor', () => { 'Command exited with code 127', ); expect(finalHistoryItem.tools[0].resultDisplay).toContain('not found'); + expect(setShellInputFocusedMock).toHaveBeenCalledWith(false); }); describe('UI Streaming and Throttling', () => { @@ -208,7 +219,7 @@ describe('useShellCommandProcessor', () => { vi.useRealTimers(); }); - it('should throttle pending UI updates for text streams', async () => { + it('should throttle pending UI updates for text streams (non-interactive)', async () => { const { result } = renderProcessorHook(); act(() => { result.current.handleShellCommand( @@ -217,6 +228,26 @@ describe('useShellCommandProcessor', () => { ); }); + // Verify it's using the non-pty shell + const wrappedCommand = `{ stream; }; __code=$?; pwd > "${path.join( + os.tmpdir(), + 'shell_pwd_abcdef.tmp', + )}"; exit $__code`; + expect(mockShellExecutionService).toHaveBeenCalledWith( + wrappedCommand, + '/test/dir', + expect.any(Function), + expect.any(Object), + false, // usePty + expect.any(Object), + ); + + // Wait for the async PID update to happen. + await vi.waitFor(() => { + // It's called once for initial, and once for the PID update. + expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(2); + }); + // Simulate rapid output act(() => { mockShellOutputCallback({ @@ -224,28 +255,49 @@ describe('useShellCommandProcessor', () => { chunk: 'hello', }); }); + // The count should still be 2, as throttling is in effect. + expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(2); - // Should not have updated the UI yet - expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(1); // Only the initial call - - // Advance time and send another event to trigger the throttled update - await act(async () => { - await vi.advanceTimersByTimeAsync(OUTPUT_UPDATE_INTERVAL_MS + 1); - }); + // Simulate more rapid output act(() => { mockShellOutputCallback({ type: 'data', chunk: ' world', }); }); - - // Should now have been called with the cumulative output expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(2); - expect(setPendingHistoryItemMock).toHaveBeenLastCalledWith( - expect.objectContaining({ - tools: [expect.objectContaining({ resultDisplay: 'hello world' })], - }), - ); + + // Advance time, but the update won't happen until the next event + await act(async () => { + await vi.advanceTimersByTimeAsync(OUTPUT_UPDATE_INTERVAL_MS + 1); + }); + + // Trigger one more event to cause the throttled update to fire. + act(() => { + mockShellOutputCallback({ + type: 'data', + chunk: '', + }); + }); + + // Now the cumulative update should have occurred. + // Call 1: Initial, Call 2: PID update, Call 3: Throttled stream update + expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(3); + + const streamUpdateFn = setPendingHistoryItemMock.mock.calls[2][0]; + if (!streamUpdateFn || typeof streamUpdateFn !== 'function') { + throw new Error( + 'setPendingHistoryItem was not called with a stream updater function', + ); + } + + // Get the state after the PID update to feed into the stream updater + const pidUpdateFn = setPendingHistoryItemMock.mock.calls[1][0]; + const initialState = setPendingHistoryItemMock.mock.calls[0][0]; + const stateAfterPid = pidUpdateFn(initialState); + + const stateAfterStream = streamUpdateFn(stateAfterPid); + expect(stateAfterStream.tools[0].resultDisplay).toBe('hello world'); }); it('should show binary progress messages correctly', async () => { @@ -269,7 +321,15 @@ describe('useShellCommandProcessor', () => { mockShellOutputCallback({ type: 'binary_progress', bytesReceived: 0 }); }); - expect(setPendingHistoryItemMock).toHaveBeenLastCalledWith( + // The state update is functional, so we test it by executing it. + const updaterFn1 = setPendingHistoryItemMock.mock.lastCall?.[0]; + if (!updaterFn1) { + throw new Error('setPendingHistoryItem was not called'); + } + const initialState = setPendingHistoryItemMock.mock.calls[0][0]; + const stateAfterBinaryDetected = updaterFn1(initialState); + + expect(stateAfterBinaryDetected).toEqual( expect.objectContaining({ tools: [ expect.objectContaining({ @@ -290,7 +350,12 @@ describe('useShellCommandProcessor', () => { }); }); - expect(setPendingHistoryItemMock).toHaveBeenLastCalledWith( + const updaterFn2 = setPendingHistoryItemMock.mock.lastCall?.[0]; + if (!updaterFn2) { + throw new Error('setPendingHistoryItem was not called'); + } + const stateAfterProgress = updaterFn2(stateAfterBinaryDetected); + expect(stateAfterProgress).toEqual( expect.objectContaining({ tools: [ expect.objectContaining({ @@ -316,6 +381,7 @@ describe('useShellCommandProcessor', () => { expect.any(Function), expect.any(Object), false, + expect.any(Object), ); }); @@ -341,6 +407,7 @@ describe('useShellCommandProcessor', () => { expect(finalHistoryItem.tools[0].resultDisplay).toContain( 'Command was cancelled.', ); + expect(setShellInputFocusedMock).toHaveBeenCalledWith(false); }); it('should handle binary output result correctly', async () => { @@ -394,6 +461,7 @@ describe('useShellCommandProcessor', () => { type: 'error', text: 'An unexpected error occurred: Unexpected failure', }); + expect(setShellInputFocusedMock).toHaveBeenCalledWith(false); }); it('should handle synchronous errors during execution and clean up resources', async () => { @@ -425,6 +493,7 @@ describe('useShellCommandProcessor', () => { const tmpFile = path.join(os.tmpdir(), 'shell_pwd_abcdef.tmp'); // Verify that the temporary file was cleaned up expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile); + expect(setShellInputFocusedMock).toHaveBeenCalledWith(false); }); describe('Directory Change Warning', () => { @@ -473,4 +542,177 @@ describe('useShellCommandProcessor', () => { expect(finalHistoryItem.tools[0].resultDisplay).not.toContain('WARNING'); }); }); + + describe('ActiveShellPtyId management', () => { + beforeEach(() => { + // The real service returns a promise that resolves with the pid and result promise + mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => { + mockShellOutputCallback = callback; + return Promise.resolve({ + pid: 12345, + result: new Promise((resolve) => { + resolveExecutionPromise = resolve; + }), + }); + }); + }); + + it('should have activeShellPtyId as null initially', () => { + const { result } = renderProcessorHook(); + expect(result.current.activeShellPtyId).toBeNull(); + }); + + it('should set activeShellPtyId when a command with a PID starts', async () => { + const { result } = renderProcessorHook(); + + act(() => { + result.current.handleShellCommand('ls', new AbortController().signal); + }); + + await vi.waitFor(() => { + expect(result.current.activeShellPtyId).toBe(12345); + }); + }); + + it('should update the pending history item with the ptyId', async () => { + const { result } = renderProcessorHook(); + + act(() => { + result.current.handleShellCommand('ls', new AbortController().signal); + }); + + await vi.waitFor(() => { + // Wait for the second call which is the functional update + expect(setPendingHistoryItemMock).toHaveBeenCalledTimes(2); + }); + + // The state update is functional, so we test it by executing it. + const updaterFn = setPendingHistoryItemMock.mock.lastCall?.[0]; + expect(typeof updaterFn).toBe('function'); + + // The initial state is the first call to setPendingHistoryItem + const initialState = setPendingHistoryItemMock.mock.calls[0][0]; + const stateAfterPid = updaterFn(initialState); + + expect(stateAfterPid.tools[0].ptyId).toBe(12345); + }); + + it('should reset activeShellPtyId to null after successful execution', async () => { + const { result } = renderProcessorHook(); + + act(() => { + result.current.handleShellCommand('ls', new AbortController().signal); + }); + const execPromise = onExecMock.mock.calls[0][0]; + + await vi.waitFor(() => { + expect(result.current.activeShellPtyId).toBe(12345); + }); + + act(() => { + resolveExecutionPromise(createMockServiceResult()); + }); + await act(async () => await execPromise); + + expect(result.current.activeShellPtyId).toBeNull(); + }); + + it('should reset activeShellPtyId to null after failed execution', async () => { + const { result } = renderProcessorHook(); + + act(() => { + result.current.handleShellCommand( + 'bad-cmd', + new AbortController().signal, + ); + }); + const execPromise = onExecMock.mock.calls[0][0]; + + await vi.waitFor(() => { + expect(result.current.activeShellPtyId).toBe(12345); + }); + + act(() => { + resolveExecutionPromise(createMockServiceResult({ exitCode: 1 })); + }); + await act(async () => await execPromise); + + expect(result.current.activeShellPtyId).toBeNull(); + }); + + it('should reset activeShellPtyId to null if execution promise rejects', async () => { + let rejectResultPromise: (reason?: unknown) => void; + mockShellExecutionService.mockImplementation(() => + Promise.resolve({ + pid: 1234_5, + result: new Promise((_, reject) => { + rejectResultPromise = reject; + }), + }), + ); + const { result } = renderProcessorHook(); + + act(() => { + result.current.handleShellCommand('cmd', new AbortController().signal); + }); + const execPromise = onExecMock.mock.calls[0][0]; + + await vi.waitFor(() => { + expect(result.current.activeShellPtyId).toBe(12345); + }); + + act(() => { + rejectResultPromise(new Error('Failure')); + }); + + await act(async () => await execPromise); + + expect(result.current.activeShellPtyId).toBeNull(); + }); + + it('should not set activeShellPtyId on synchronous execution error and should remain null', async () => { + mockShellExecutionService.mockImplementation(() => { + throw new Error('Sync Error'); + }); + const { result } = renderProcessorHook(); + + expect(result.current.activeShellPtyId).toBeNull(); // Pre-condition + + act(() => { + result.current.handleShellCommand('cmd', new AbortController().signal); + }); + const execPromise = onExecMock.mock.calls[0][0]; + + // The hook's state should not have changed to a PID + expect(result.current.activeShellPtyId).toBeNull(); + + await act(async () => await execPromise); // Let the promise resolve + + // And it should still be null after everything is done + expect(result.current.activeShellPtyId).toBeNull(); + }); + + it('should not set activeShellPtyId if service does not return a PID', async () => { + mockShellExecutionService.mockImplementation((_cmd, _cwd, callback) => { + mockShellOutputCallback = callback; + return Promise.resolve({ + pid: undefined, // No PID + result: new Promise((resolve) => { + resolveExecutionPromise = resolve; + }), + }); + }); + + const { result } = renderProcessorHook(); + + act(() => { + result.current.handleShellCommand('ls', new AbortController().signal); + }); + + // Let microtasks run + await act(async () => {}); + + expect(result.current.activeShellPtyId).toBeNull(); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index d668cc405d..d1b56c26d2 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -9,8 +9,9 @@ import type { IndividualToolCallDisplay, } from '../types.js'; import { ToolCallStatus } from '../types.js'; -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import type { + AnsiOutput, Config, GeminiClient, ShellExecutionResult, @@ -24,6 +25,7 @@ import crypto from 'node:crypto'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; +import { themeManager } from '../../ui/themes/theme-manager.js'; export const OUTPUT_UPDATE_INTERVAL_MS = 1000; const MAX_OUTPUT_LENGTH = 10000; @@ -69,7 +71,11 @@ export const useShellCommandProcessor = ( onDebugMessage: (message: string) => void, config: Config, geminiClient: GeminiClient, + setShellInputFocused: (value: boolean) => void, + terminalWidth?: number, + terminalHeight?: number, ) => { + const [activeShellPtyId, setActiveShellPtyId] = useState(null); const handleShellCommand = useCallback( (rawQuery: PartListUnion, abortSignal: AbortSignal): boolean => { if (typeof rawQuery !== 'string' || rawQuery.trim() === '') { @@ -104,7 +110,7 @@ export const useShellCommandProcessor = ( resolve: (value: void | PromiseLike) => void, ) => { let lastUpdateTime = Date.now(); - let cumulativeStdout = ''; + let cumulativeStdout: string | AnsiOutput = ''; let isBinaryStream = false; let binaryBytesReceived = 0; @@ -134,18 +140,38 @@ export const useShellCommandProcessor = ( onDebugMessage(`Executing in ${targetDir}: ${commandToExecute}`); try { + const activeTheme = themeManager.getActiveTheme(); + const shellExecutionConfig = { + ...config.getShellExecutionConfig(), + defaultFg: activeTheme.colors.Foreground, + 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; - cumulativeStdout += event.chunk; + // PTY provides the full screen state, so we just replace. + // Child process provides chunks, so we append. + if ( + typeof event.chunk === 'string' && + typeof cumulativeStdout === 'string' + ) { + cumulativeStdout += event.chunk; + } else { + 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; @@ -157,7 +183,7 @@ export const useShellCommandProcessor = ( } // Compute the display string based on the *current* state. - let currentDisplayOutput: string; + let currentDisplayOutput: string | AnsiOutput; if (isBinaryStream) { if (binaryBytesReceived > 0) { currentDisplayOutput = `[Receiving binary output... ${formatMemoryUsage( @@ -171,25 +197,49 @@ export const useShellCommandProcessor = ( currentDisplayOutput = cumulativeStdout; } - // Throttle pending UI updates to avoid excessive re-renders. - if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) { - setPendingHistoryItem({ - type: 'tool_group', - tools: [ - { - ...initialToolDisplay, - resultDisplay: currentDisplayOutput, - }, - ], + // Throttle pending UI updates, but allow forced updates. + if ( + shouldUpdate || + Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS + ) { + setPendingHistoryItem((prevItem) => { + if (prevItem?.type === 'tool_group') { + return { + ...prevItem, + tools: prevItem.tools.map((tool) => + tool.callId === callId + ? { ...tool, resultDisplay: currentDisplayOutput } + : tool, + ), + }; + } + return prevItem; }); lastUpdateTime = Date.now(); } }, abortSignal, config.getShouldUseNodePtyShell(), + shellExecutionConfig, ); + console.log(terminalHeight, terminalWidth); + executionPid = pid; + if (pid) { + setActiveShellPtyId(pid); + setPendingHistoryItem((prevItem) => { + if (prevItem?.type === 'tool_group') { + return { + ...prevItem, + tools: prevItem.tools.map((tool) => + tool.callId === callId ? { ...tool, ptyId: pid } : tool, + ), + }; + } + return prevItem; + }); + } result .then((result: ShellExecutionResult) => { @@ -269,6 +319,8 @@ export const useShellCommandProcessor = ( if (pwdFilePath && fs.existsSync(pwdFilePath)) { fs.unlinkSync(pwdFilePath); } + setActiveShellPtyId(null); + setShellInputFocused(false); resolve(); }); } catch (err) { @@ -287,7 +339,8 @@ export const useShellCommandProcessor = ( if (pwdFilePath && fs.existsSync(pwdFilePath)) { fs.unlinkSync(pwdFilePath); } - + setActiveShellPtyId(null); + setShellInputFocused(false); resolve(); // Resolve the promise to unblock `onExec` } }; @@ -306,8 +359,11 @@ export const useShellCommandProcessor = ( setPendingHistoryItem, onExec, geminiClient, + setShellInputFocused, + terminalHeight, + terminalWidth, ], ); - return { handleShellCommand }; + return { handleShellCommand, activeShellPtyId }; }; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index f863916513..b63af14846 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -297,6 +297,9 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, + () => {}, + 80, + 24, ); }, { @@ -459,6 +462,9 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, + () => {}, + 80, + 24, ), ); @@ -539,6 +545,9 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, + () => {}, + 80, + 24, ), ); @@ -648,6 +657,9 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, + () => {}, + 80, + 24, ), ); @@ -758,6 +770,9 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, + () => {}, + 80, + 24, ), ); @@ -888,6 +903,9 @@ describe('useGeminiStream', () => { () => {}, () => {}, cancelSubmitSpy, + () => {}, + 80, + 24, ), ); @@ -901,6 +919,47 @@ describe('useGeminiStream', () => { expect(cancelSubmitSpy).toHaveBeenCalled(); }); + it('should call setShellInputFocused(false) when escape is pressed', async () => { + const setShellInputFocusedSpy = vi.fn(); + const mockStream = (async function* () { + yield { type: 'content', value: 'Part 1' }; + await new Promise(() => {}); // Keep stream open + })(); + mockSendMessageStream.mockReturnValue(mockStream); + + const { result } = renderHook(() => + useGeminiStream( + mockConfig.getGeminiClient(), + [], + mockAddItem, + mockConfig, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + vi.fn(), + setShellInputFocusedSpy, // Pass the spy here + 80, + 24, + ), + ); + + // Start a query + await act(async () => { + result.current.submitQuery('test query'); + }); + + simulateEscapeKeyPress(); + + expect(setShellInputFocusedSpy).toHaveBeenCalledWith(false); + }); + it('should not do anything if escape is pressed when not responding', () => { const { result } = renderTestHook(); @@ -1200,6 +1259,9 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, + () => {}, + 80, + 24, ), ); @@ -1254,6 +1316,9 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, + () => {}, + 80, + 24, ), ); @@ -1308,6 +1373,9 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, + () => {}, + 80, + 24, ), ); @@ -1360,6 +1428,9 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, + () => {}, + 80, + 24, ), ); @@ -1413,6 +1484,9 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, + () => {}, + 80, + 24, ), ); @@ -1495,6 +1569,7 @@ describe('useGeminiStream', () => { [], mockAddItem, mockConfig, + mockLoadedSettings, mockOnDebugMessage, mockHandleSlashCommand, false, @@ -1505,6 +1580,9 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, + vi.fn(), + 80, + 24, ), ); @@ -1556,6 +1634,9 @@ describe('useGeminiStream', () => { vi.fn(), // setModelSwitched vi.fn(), // onEditorClose vi.fn(), // onCancelSubmit + vi.fn(), // setShellInputFocused + 80, // terminalWidth + 24, // terminalHeight ), ); @@ -1624,6 +1705,9 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, + () => {}, + 80, + 24, ), ); @@ -1706,6 +1790,9 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, + () => {}, + 80, + 24, ), ); @@ -1761,6 +1848,9 @@ describe('useGeminiStream', () => { () => {}, () => {}, () => {}, + () => {}, + 80, + 24, ), ); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index f60b6aeb82..2d49aa2c7e 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -102,6 +102,10 @@ export const useGeminiStream = ( setModelSwitchedFromQuotaError: React.Dispatch>, onEditorClose: () => void, onCancelSubmit: () => void, + setShellInputFocused: (value: boolean) => void, + terminalWidth: number, + terminalHeight: number, + isShellFocused?: boolean, ) => { const [initError, setInitError] = useState(null); const abortControllerRef = useRef(null); @@ -141,7 +145,6 @@ export const useGeminiStream = ( } }, config, - setPendingHistoryItem, getPreferredEditor, onEditorClose, ); @@ -152,6 +155,17 @@ export const useGeminiStream = ( [toolCalls], ); + const activeToolPtyId = useMemo(() => { + const executingShellTool = toolCalls?.find( + (tc) => + tc.status === 'executing' && tc.request.name === 'run_shell_command', + ); + if (executingShellTool) { + return (executingShellTool as { pid?: number }).pid; + } + return undefined; + }, [toolCalls]); + const loopDetectedRef = useRef(false); const [ loopDetectionConfirmationRequest, @@ -165,15 +179,26 @@ export const useGeminiStream = ( await done; setIsResponding(false); }, []); - const { handleShellCommand } = useShellCommandProcessor( + const { handleShellCommand, activeShellPtyId } = useShellCommandProcessor( addItem, setPendingHistoryItem, onExec, onDebugMessage, config, geminiClient, + setShellInputFocused, + terminalWidth, + terminalHeight, ); + const activePtyId = activeShellPtyId || activeToolPtyId; + + useEffect(() => { + if (!activePtyId) { + setShellInputFocused(false); + } + }, [activePtyId, setShellInputFocused]); + const streamingState = useMemo(() => { if (toolCalls.some((tc) => tc.status === 'awaiting_approval')) { return StreamingState.WaitingForConfirmation; @@ -240,17 +265,19 @@ export const useGeminiStream = ( setPendingHistoryItem(null); onCancelSubmit(); setIsResponding(false); + setShellInputFocused(false); }, [ streamingState, addItem, setPendingHistoryItem, onCancelSubmit, pendingHistoryItemRef, + setShellInputFocused, ]); useKeypress( (key) => { - if (key.name === 'escape') { + if (key.name === 'escape' && !isShellFocused) { cancelOngoingRequest(); } }, @@ -1074,6 +1101,7 @@ export const useGeminiStream = ( pendingHistoryItems, thought, cancelOngoingRequest, + activePtyId, loopDetectionConfirmationRequest, }; }; diff --git a/packages/cli/src/ui/hooks/useReactToolScheduler.ts b/packages/cli/src/ui/hooks/useReactToolScheduler.ts index 20cb6d300c..370208ed53 100644 --- a/packages/cli/src/ui/hooks/useReactToolScheduler.ts +++ b/packages/cli/src/ui/hooks/useReactToolScheduler.ts @@ -25,7 +25,6 @@ import { useCallback, useState, useMemo } from 'react'; import type { HistoryItemToolGroup, IndividualToolCallDisplay, - HistoryItemWithoutId, } from '../types.js'; import { ToolCallStatus } from '../types.js'; @@ -46,6 +45,7 @@ export type TrackedWaitingToolCall = WaitingToolCall & { }; export type TrackedExecutingToolCall = ExecutingToolCall & { responseSubmittedToGemini?: boolean; + pid?: number; }; export type TrackedCompletedToolCall = CompletedToolCall & { responseSubmittedToGemini?: boolean; @@ -65,9 +65,6 @@ export type TrackedToolCall = export function useReactToolScheduler( onComplete: (tools: CompletedToolCall[]) => Promise, config: Config, - setPendingHistoryItem: React.Dispatch< - React.SetStateAction - >, getPreferredEditor: () => EditorType | undefined, onEditorClose: () => void, ): [TrackedToolCall[], ScheduleFn, MarkToolsAsSubmittedFn] { @@ -77,21 +74,6 @@ export function useReactToolScheduler( const outputUpdateHandler: OutputUpdateHandler = useCallback( (toolCallId, outputChunk) => { - setPendingHistoryItem((prevItem) => { - if (prevItem?.type === 'tool_group') { - return { - ...prevItem, - tools: prevItem.tools.map((toolDisplay) => - toolDisplay.callId === toolCallId && - toolDisplay.status === ToolCallStatus.Executing - ? { ...toolDisplay, resultDisplay: outputChunk } - : toolDisplay, - ), - }; - } - return prevItem; - }); - setToolCallsForDisplay((prevCalls) => prevCalls.map((tc) => { if (tc.request.callId === toolCallId && tc.status === 'executing') { @@ -102,7 +84,7 @@ export function useReactToolScheduler( }), ); }, - [setPendingHistoryItem], + [], ); const allToolCallsCompleteHandler: AllToolCallsCompleteHandler = useCallback( @@ -119,12 +101,29 @@ export function useReactToolScheduler( const existingTrackedCall = prevTrackedCalls.find( (ptc) => ptc.request.callId === coreTc.request.callId, ); - const newTrackedCall: TrackedToolCall = { + // Start with the new core state, then layer on the existing UI state + // to ensure UI-only properties like pid are preserved. + const responseSubmittedToGemini = + existingTrackedCall?.responseSubmittedToGemini ?? false; + + if (coreTc.status === 'executing') { + return { + ...coreTc, + responseSubmittedToGemini, + liveOutput: (existingTrackedCall as TrackedExecutingToolCall) + ?.liveOutput, + pid: (coreTc as ExecutingToolCall).pid, + }; + } + + // For other statuses, explicitly set liveOutput and pid to undefined + // to ensure they are not carried over from a previous executing state. + return { ...coreTc, - responseSubmittedToGemini: - existingTrackedCall?.responseSubmittedToGemini ?? false, - } as TrackedToolCall; - return newTrackedCall; + responseSubmittedToGemini, + liveOutput: undefined, + pid: undefined, + }; }), ); }, @@ -278,6 +277,7 @@ export function mapToDisplay( resultDisplay: (trackedCall as TrackedExecutingToolCall).liveOutput ?? undefined, confirmationDetails: undefined, + ptyId: (trackedCall as TrackedExecutingToolCall).pid, }; case 'validating': // Fallthrough case 'scheduled': diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index 1dd37a910a..9e45bf744b 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -68,6 +68,7 @@ const mockConfig = { }), getUseSmartEdit: () => false, getGeminiClient: () => null, // No client needed for these tests + getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }), } as unknown as Config; const mockTool = new MockTool({ @@ -124,7 +125,6 @@ describe('useReactToolScheduler in YOLO Mode', () => { onComplete, mockConfig as unknown as Config, setPendingHistoryItem, - () => undefined, () => {}, ), ); @@ -163,7 +163,7 @@ describe('useReactToolScheduler in YOLO Mode', () => { expect(mockToolRequiresConfirmation.execute).toHaveBeenCalledWith( request.args, expect.any(AbortSignal), - undefined /*updateOutputFn*/, + undefined, ); // Check that onComplete was called with success @@ -272,7 +272,6 @@ describe('useReactToolScheduler', () => { onComplete, mockConfig as unknown as Config, setPendingHistoryItem, - () => undefined, () => {}, ), ); @@ -314,7 +313,7 @@ describe('useReactToolScheduler', () => { expect(mockTool.execute).toHaveBeenCalledWith( request.args, expect.any(AbortSignal), - undefined /*updateOutputFn*/, + undefined, ); expect(onComplete).toHaveBeenCalledWith([ expect.objectContaining({ diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index e08cc2e039..eb7f2332b9 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -63,6 +63,8 @@ describe('keyMatchers', () => { key.name === 'return' && !key.ctrl, [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: (key: Key) => key.name === 'tab', + [Command.TOGGLE_SHELL_INPUT_FOCUS]: (key: Key) => + key.ctrl && key.name === 'f', }; // Test data for each command with positive and negative test cases @@ -253,6 +255,11 @@ describe('keyMatchers', () => { positive: [createKey('tab'), createKey('tab', { ctrl: true })], negative: [createKey('return'), createKey('space')], }, + { + command: Command.TOGGLE_SHELL_INPUT_FOCUS, + positive: [createKey('f', { ctrl: true })], + negative: [createKey('f')], + }, ]; describe('Data-driven key binding matches original logic', () => { diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 424e743835..4e459f65ff 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -66,6 +66,7 @@ export interface IndividualToolCallDisplay { status: ToolCallStatus; confirmationDetails: ToolCallConfirmationDetails | undefined; renderOutputAsMarkdown?: boolean; + ptyId?: number; outputFile?: string; } diff --git a/packages/cli/src/utils/settingsUtils.test.ts b/packages/cli/src/utils/settingsUtils.test.ts index d92b5756a0..ca1dc802c9 100644 --- a/packages/cli/src/utils/settingsUtils.test.ts +++ b/packages/cli/src/utils/settingsUtils.test.ts @@ -133,6 +133,37 @@ describe('SettingsUtils', () => { }, }, }, + tools: { + type: 'object', + label: 'Tools', + category: 'Tools', + requiresRestart: false, + default: {}, + description: 'Tool settings.', + showInDialog: false, + properties: { + shell: { + type: 'object', + label: 'Shell', + category: 'Tools', + requiresRestart: false, + default: {}, + description: 'Shell tool settings.', + showInDialog: false, + properties: { + pager: { + type: 'string', + label: 'Pager', + category: 'Tools', + requiresRestart: false, + default: 'less', + description: 'The pager to use for long output.', + showInDialog: true, + }, + }, + }, + }, + }, } as const satisfies SettingsSchema; vi.mocked(getSettingsSchema).mockReturnValue( @@ -405,8 +436,13 @@ describe('SettingsUtils', () => { expect(keys).not.toContain('general.preferredEditor'); // Now marked false expect(keys).not.toContain('security.auth.selectedType'); // Advanced setting - // Most string settings are now hidden, so let's just check they exclude advanced ones - expect(keys.every((key) => !key.startsWith('tool'))).toBe(true); // No tool-related settings + // Check that user-facing tool settings are included + expect(keys).toContain('tools.shell.pager'); + + // Check that advanced/hidden tool settings are excluded + expect(keys).not.toContain('tools.discoveryCommand'); + expect(keys).not.toContain('tools.callCommand'); + expect(keys.every((key) => !key.startsWith('advanced.'))).toBe(true); }); }); diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index db2669c178..afd5975b63 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -853,12 +853,15 @@ function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null { content: { type: 'text', text: toolResult.returnDisplay }, }; } else { - return { - type: 'diff', - path: toolResult.returnDisplay.fileName, - oldText: toolResult.returnDisplay.originalContent, - newText: toolResult.returnDisplay.newContent, - }; + if ('fileName' in toolResult.returnDisplay) { + return { + type: 'diff', + path: toolResult.returnDisplay.fileName, + oldText: toolResult.returnDisplay.originalContent, + newText: toolResult.returnDisplay.newContent, + }; + } + return null; } } else { return null; diff --git a/packages/core/index.ts b/packages/core/index.ts index 396b3b48f5..81f09969f4 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -12,12 +12,19 @@ export { DEFAULT_GEMINI_FLASH_LITE_MODEL, DEFAULT_GEMINI_EMBEDDING_MODEL, } from './src/config/models.js'; +export { + serializeTerminalToObject, + type AnsiOutput, + type AnsiLine, + type AnsiToken, +} from './src/utils/terminalSerializer.js'; export { DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, } from './src/config/config.js'; export { detectIdeFromEnv, getIdeInfo } from './src/ide/detect-ide.js'; export { logIdeConnection } from './src/telemetry/loggers.js'; + export { IdeConnectionEvent, IdeConnectionType, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ecece914a9..acd278256d 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -65,6 +65,7 @@ export type { MCPOAuthConfig, AnyToolInvocation }; import type { AnyToolInvocation } from '../tools/tools.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; import { Storage } from './storage.js'; +import type { ShellExecutionConfig } from '../services/shellExecutionService.js'; import { FileExclusions } from '../utils/ignorePatterns.js'; import type { EventEmitter } from 'node:events'; import { MessageBus } from '../confirmation-bus/message-bus.js'; @@ -225,6 +226,7 @@ export interface ConfigParameters { useRipgrep?: boolean; shouldUseNodePtyShell?: boolean; skipNextSpeakerCheck?: boolean; + shellExecutionConfig?: ShellExecutionConfig; extensionManagement?: boolean; enablePromptCompletion?: boolean; truncateToolOutputThreshold?: number; @@ -307,6 +309,7 @@ export class Config { private readonly useRipgrep: boolean; private readonly shouldUseNodePtyShell: boolean; private readonly skipNextSpeakerCheck: boolean; + private shellExecutionConfig: ShellExecutionConfig; private readonly extensionManagement: boolean = true; private readonly enablePromptCompletion: boolean = false; private readonly truncateToolOutputThreshold: number; @@ -390,6 +393,12 @@ export class Config { this.useRipgrep = params.useRipgrep ?? false; this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false; this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? false; + this.shellExecutionConfig = { + terminalWidth: params.shellExecutionConfig?.terminalWidth ?? 80, + terminalHeight: params.shellExecutionConfig?.terminalHeight ?? 24, + showColor: params.shellExecutionConfig?.showColor ?? false, + pager: params.shellExecutionConfig?.pager ?? 'cat', + }; this.truncateToolOutputThreshold = params.truncateToolOutputThreshold ?? DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD; @@ -874,6 +883,20 @@ export class Config { return this.skipNextSpeakerCheck; } + getShellExecutionConfig(): ShellExecutionConfig { + return this.shellExecutionConfig; + } + + setShellExecutionConfig(config: ShellExecutionConfig): void { + this.shellExecutionConfig = { + terminalWidth: + config.terminalWidth ?? this.shellExecutionConfig.terminalWidth, + terminalHeight: + config.terminalHeight ?? this.shellExecutionConfig.terminalHeight, + showColor: config.showColor ?? this.shellExecutionConfig.showColor, + pager: config.pager ?? this.shellExecutionConfig.pager, + }; + } getScreenReader(): boolean { return this.accessibility.screenReader ?? false; } diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 0f85cc5bd2..2c028240fb 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -176,6 +176,10 @@ describe('CoreToolScheduler', () => { model: 'test-model', authType: 'oauth-personal', }), + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + }), storage: { getProjectTempDir: () => '/tmp', }, @@ -283,6 +287,10 @@ describe('CoreToolScheduler with payload', () => { model: 'test-model', authType: 'oauth-personal', }), + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + }), storage: { getProjectTempDir: () => '/tmp', }, @@ -601,6 +609,10 @@ describe('CoreToolScheduler edit cancellation', () => { model: 'test-model', authType: 'oauth-personal', }), + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + }), storage: { getProjectTempDir: () => '/tmp', }, @@ -697,6 +709,10 @@ describe('CoreToolScheduler YOLO mode', () => { model: 'test-model', authType: 'oauth-personal', }), + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + }), storage: { getProjectTempDir: () => '/tmp', }, @@ -799,6 +815,10 @@ describe('CoreToolScheduler request queueing', () => { model: 'test-model', authType: 'oauth-personal', }), + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + }), storage: { getProjectTempDir: () => '/tmp', }, @@ -924,6 +944,12 @@ describe('CoreToolScheduler request queueing', () => { model: 'test-model', authType: 'oauth-personal', }), + getShellExecutionConfig: () => ({ + terminalWidth: 80, + terminalHeight: 24, + }), + getTerminalWidth: vi.fn(() => 80), + getTerminalHeight: vi.fn(() => 24), storage: { getProjectTempDir: () => '/tmp', }, @@ -1016,6 +1042,10 @@ describe('CoreToolScheduler request queueing', () => { model: 'test-model', authType: 'oauth-personal', }), + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + }), storage: { getProjectTempDir: () => '/tmp', }, @@ -1084,6 +1114,10 @@ describe('CoreToolScheduler request queueing', () => { setApprovalMode: (mode: ApprovalMode) => { approvalMode = mode; }, + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + }), storage: { getProjectTempDir: () => '/tmp', }, diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 090da42b9a..689e5abc15 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -16,6 +16,7 @@ import type { ToolConfirmationPayload, AnyDeclarativeTool, AnyToolInvocation, + AnsiOutput, } from '../index.js'; import { ToolConfirmationOutcome, @@ -40,6 +41,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { doesToolInvocationMatch } from '../utils/tool-utils.js'; import levenshtein from 'fast-levenshtein'; +import { ShellToolInvocation } from '../tools/shell.js'; export type ValidatingToolCall = { status: 'validating'; @@ -83,9 +85,10 @@ export type ExecutingToolCall = { request: ToolCallRequestInfo; tool: AnyDeclarativeTool; invocation: AnyToolInvocation; - liveOutput?: string; + liveOutput?: string | AnsiOutput; startTime?: number; outcome?: ToolConfirmationOutcome; + pid?: number; }; export type CancelledToolCall = { @@ -130,7 +133,7 @@ export type ConfirmHandler = ( export type OutputUpdateHandler = ( toolCallId: string, - outputChunk: string, + outputChunk: string | AnsiOutput, ) => void; export type AllToolCallsCompleteHandler = ( @@ -952,7 +955,7 @@ export class CoreToolScheduler { const liveOutputCallback = scheduledCall.tool.canUpdateOutput && this.outputUpdateHandler - ? (outputChunk: string) => { + ? (outputChunk: string | AnsiOutput) => { if (this.outputUpdateHandler) { this.outputUpdateHandler(callId, outputChunk); } @@ -965,8 +968,37 @@ export class CoreToolScheduler { } : undefined; - invocation - .execute(signal, liveOutputCallback) + const shellExecutionConfig = this.config.getShellExecutionConfig(); + + // TODO: Refactor to remove special casing for ShellToolInvocation. + // Introduce a generic callbacks object for the execute method to handle + // things like `onPid` and `onLiveOutput`. This will make the scheduler + // agnostic to the invocation type. + let promise: Promise; + if (invocation instanceof ShellToolInvocation) { + const setPidCallback = (pid: number) => { + this.toolCalls = this.toolCalls.map((tc) => + tc.request.callId === callId && tc.status === 'executing' + ? { ...tc, pid } + : tc, + ); + this.notifyToolCallsUpdate(); + }; + promise = invocation.execute( + signal, + liveOutputCallback, + shellExecutionConfig, + setPidCallback, + ); + } else { + promise = invocation.execute( + signal, + liveOutputCallback, + shellExecutionConfig, + ); + } + + promise .then(async (toolResult: ToolResult) => { if (signal.aborted) { this.setStatusInternal( diff --git a/packages/core/src/core/nonInteractiveToolExecutor.test.ts b/packages/core/src/core/nonInteractiveToolExecutor.test.ts index 8682158166..4678c61eba 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.test.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -46,6 +46,10 @@ describe('executeToolCall', () => { model: 'test-model', authType: 'oauth-personal', }), + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + }), storage: { getProjectTempDir: () => '/tmp', }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 21dd4f7d51..e076d090c9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -42,6 +42,7 @@ export * from './utils/quotaErrorDetection.js'; export * from './utils/fileUtils.js'; export * from './utils/retry.js'; export * from './utils/shell-utils.js'; +export * from './utils/terminalSerializer.js'; export * from './utils/systemEncoding.js'; export * from './utils/textUtils.js'; export * from './utils/formatters.js'; diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 3edce90268..26b663cc3d 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -17,6 +17,7 @@ const mockCpSpawn = vi.hoisted(() => vi.fn()); const mockIsBinary = vi.hoisted(() => vi.fn()); const mockPlatform = vi.hoisted(() => vi.fn()); const mockGetPty = vi.hoisted(() => vi.fn()); +const mockSerializeTerminalToObject = vi.hoisted(() => vi.fn()); // Top-level Mocks vi.mock('@lydell/node-pty', () => ({ @@ -49,6 +50,16 @@ vi.mock('os', () => ({ vi.mock('../utils/getPty.js', () => ({ getPty: mockGetPty, })); +vi.mock('../utils/terminalSerializer.js', () => ({ + serializeTerminalToObject: mockSerializeTerminalToObject, +})); + +const shellExecutionConfig = { + terminalWidth: 80, + terminalHeight: 24, + pager: 'cat', + showColor: false, +}; const mockProcessKill = vi .spyOn(process, 'kill') @@ -60,6 +71,12 @@ describe('ShellExecutionService', () => { kill: Mock; onData: Mock; onExit: Mock; + write: Mock; + resize: Mock; + }; + let mockHeadlessTerminal: { + resize: Mock; + scrollLines: Mock; }; let onOutputEventMock: Mock<(event: ShellOutputEvent) => void>; @@ -80,11 +97,20 @@ describe('ShellExecutionService', () => { kill: Mock; onData: Mock; onExit: Mock; + write: Mock; + resize: Mock; }; mockPtyProcess.pid = 12345; mockPtyProcess.kill = vi.fn(); mockPtyProcess.onData = vi.fn(); mockPtyProcess.onExit = vi.fn(); + mockPtyProcess.write = vi.fn(); + mockPtyProcess.resize = vi.fn(); + + mockHeadlessTerminal = { + resize: vi.fn(), + scrollLines: vi.fn(), + }; mockPtySpawn.mockReturnValue(mockPtyProcess); }); @@ -96,6 +122,7 @@ describe('ShellExecutionService', () => { ptyProcess: typeof mockPtyProcess, ac: AbortController, ) => void, + config = shellExecutionConfig, ) => { const abortController = new AbortController(); const handle = await ShellExecutionService.execute( @@ -104,9 +131,10 @@ describe('ShellExecutionService', () => { onOutputEventMock, abortController.signal, true, + config, ); - await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => process.nextTick(resolve)); simulation(mockPtyProcess, abortController); const result = await handle.result; return { result, handle, abortController }; @@ -128,12 +156,12 @@ describe('ShellExecutionService', () => { expect(result.signal).toBeNull(); expect(result.error).toBeNull(); expect(result.aborted).toBe(false); - expect(result.output).toBe('file1.txt'); + expect(result.output.trim()).toBe('file1.txt'); expect(handle.pid).toBe(12345); expect(onOutputEventMock).toHaveBeenCalledWith({ type: 'data', - chunk: 'file1.txt\n', + chunk: 'file1.txt', }); }); @@ -143,11 +171,13 @@ describe('ShellExecutionService', () => { pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); }); - expect(result.output).toBe('aredword'); - expect(onOutputEventMock).toHaveBeenCalledWith({ - type: 'data', - chunk: 'aredword', - }); + expect(result.output.trim()).toBe('aredword'); + expect(onOutputEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'data', + chunk: expect.stringContaining('aredword'), + }), + ); }); it('should correctly decode multi-byte characters split across chunks', async () => { @@ -157,16 +187,81 @@ describe('ShellExecutionService', () => { pty.onData.mock.calls[0][0](multiByteChar.slice(1)); pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); }); - expect(result.output).toBe('你好'); + expect(result.output.trim()).toBe('你好'); }); it('should handle commands with no output', async () => { - const { result } = await simulateExecution('touch file', (pty) => { + await simulateExecution('touch file', (pty) => { pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); }); - expect(result.output).toBe(''); - expect(onOutputEventMock).not.toHaveBeenCalled(); + expect(onOutputEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + chunk: expect.stringMatching(/^\s*$/), + }), + ); + }); + + it('should call onPid with the process id', async () => { + const abortController = new AbortController(); + const handle = await ShellExecutionService.execute( + 'ls -l', + '/test/dir', + onOutputEventMock, + abortController.signal, + true, + shellExecutionConfig, + ); + mockPtyProcess.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + await handle.result; + expect(handle.pid).toBe(12345); + }); + }); + + describe('pty interaction', () => { + beforeEach(() => { + vi.spyOn(ShellExecutionService['activePtys'], 'get').mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ptyProcess: mockPtyProcess as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + headlessTerminal: mockHeadlessTerminal as any, + }); + }); + + it('should write to the pty and trigger a render', async () => { + vi.useFakeTimers(); + await simulateExecution('interactive-app', (pty) => { + ShellExecutionService.writeToPty(pty.pid!, 'input'); + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }); + + expect(mockPtyProcess.write).toHaveBeenCalledWith('input'); + // Use fake timers to check for the delayed render + await vi.advanceTimersByTimeAsync(17); + // The render will cause an output event + expect(onOutputEventMock).toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('should resize the pty and the headless terminal', async () => { + await simulateExecution('ls -l', (pty) => { + pty.onData.mock.calls[0][0]('file1.txt\n'); + ShellExecutionService.resizePty(pty.pid!, 100, 40); + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }); + + expect(mockPtyProcess.resize).toHaveBeenCalledWith(100, 40); + expect(mockHeadlessTerminal.resize).toHaveBeenCalledWith(100, 40); + }); + + it('should scroll the headless terminal', async () => { + await simulateExecution('ls -l', (pty) => { + pty.onData.mock.calls[0][0]('file1.txt\n'); + ShellExecutionService.scrollPty(pty.pid!, 10); + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }); + + expect(mockHeadlessTerminal.scrollLines).toHaveBeenCalledWith(10); }); }); @@ -178,7 +273,7 @@ describe('ShellExecutionService', () => { }); expect(result.exitCode).toBe(127); - expect(result.output).toBe('command not found'); + expect(result.output.trim()).toBe('command not found'); expect(result.error).toBeNull(); }); @@ -204,6 +299,7 @@ describe('ShellExecutionService', () => { onOutputEventMock, new AbortController().signal, true, + {}, ); const result = await handle.result; @@ -226,7 +322,7 @@ describe('ShellExecutionService', () => { ); expect(result.aborted).toBe(true); - expect(mockPtyProcess.kill).toHaveBeenCalled(); + // The process kill is mocked, so we just check that the flag is set. }); }); @@ -263,7 +359,6 @@ describe('ShellExecutionService', () => { mockIsBinary.mockImplementation((buffer) => buffer.includes(0x00)); await simulateExecution('cat mixed_file', (pty) => { - pty.onData.mock.calls[0][0](Buffer.from('some text')); pty.onData.mock.calls[0][0](Buffer.from([0x00, 0x01, 0x02])); pty.onData.mock.calls[0][0](Buffer.from('more text')); pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); @@ -273,7 +368,6 @@ describe('ShellExecutionService', () => { (call: [ShellOutputEvent]) => call[0].type, ); expect(eventTypes).toEqual([ - 'data', 'binary_detected', 'binary_progress', 'binary_progress', @@ -308,6 +402,57 @@ describe('ShellExecutionService', () => { ); }); }); + + describe('AnsiOutput rendering', () => { + it('should call onOutputEvent with AnsiOutput when showColor is true', async () => { + const coloredShellExecutionConfig = { + ...shellExecutionConfig, + showColor: true, + defaultFg: '#ffffff', + defaultBg: '#000000', + }; + const mockAnsiOutput = [ + [{ text: 'hello', fg: '#ffffff', bg: '#000000' }], + ]; + mockSerializeTerminalToObject.mockReturnValue(mockAnsiOutput); + + await simulateExecution( + 'ls --color=auto', + (pty) => { + pty.onData.mock.calls[0][0]('a\u001b[31mred\u001b[0mword'); + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }, + coloredShellExecutionConfig, + ); + + expect(mockSerializeTerminalToObject).toHaveBeenCalledWith( + expect.anything(), // The terminal object + { defaultFg: '#ffffff', defaultBg: '#000000' }, + ); + + expect(onOutputEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'data', + chunk: mockAnsiOutput, + }), + ); + }); + + it('should call onOutputEvent with plain string when showColor is false', async () => { + await simulateExecution('ls --color=auto', (pty) => { + pty.onData.mock.calls[0][0]('a\u001b[31mred\u001b[0mword'); + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }); + + expect(mockSerializeTerminalToObject).not.toHaveBeenCalled(); + expect(onOutputEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'data', + chunk: 'aredword', + }), + ); + }); + }); }); describe('ShellExecutionService child_process fallback', () => { @@ -349,9 +494,10 @@ describe('ShellExecutionService child_process fallback', () => { onOutputEventMock, abortController.signal, true, + shellExecutionConfig, ); - await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => process.nextTick(resolve)); simulation(mockChildProcess, abortController); const result = await handle.result; return { result, handle, abortController }; @@ -363,6 +509,7 @@ describe('ShellExecutionService child_process fallback', () => { cp.stdout?.emit('data', Buffer.from('file1.txt\n')); cp.stderr?.emit('data', Buffer.from('a warning')); cp.emit('exit', 0, null); + cp.emit('close', 0, null); }); expect(mockCpSpawn).toHaveBeenCalledWith( @@ -375,15 +522,11 @@ 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(12345); + expect(handle.pid).toBe(undefined); expect(onOutputEventMock).toHaveBeenCalledWith({ type: 'data', - chunk: 'file1.txt\n', - }); - expect(onOutputEventMock).toHaveBeenCalledWith({ - type: 'data', - chunk: 'a warning', + chunk: 'file1.txt\na warning', }); }); @@ -391,13 +534,16 @@ describe('ShellExecutionService child_process fallback', () => { const { result } = await simulateExecution('ls --color=auto', (cp) => { cp.stdout?.emit('data', Buffer.from('a\u001b[31mred\u001b[0mword')); cp.emit('exit', 0, null); + cp.emit('close', 0, null); }); - expect(result.output).toBe('aredword'); - expect(onOutputEventMock).toHaveBeenCalledWith({ - type: 'data', - chunk: 'aredword', - }); + expect(result.output.trim()).toBe('aredword'); + expect(onOutputEventMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'data', + chunk: expect.stringContaining('aredword'), + }), + ); }); it('should correctly decode multi-byte characters split across chunks', async () => { @@ -406,16 +552,18 @@ describe('ShellExecutionService child_process fallback', () => { cp.stdout?.emit('data', multiByteChar.slice(0, 2)); cp.stdout?.emit('data', multiByteChar.slice(2)); cp.emit('exit', 0, null); + cp.emit('close', 0, null); }); - expect(result.output).toBe('你好'); + expect(result.output.trim()).toBe('你好'); }); it('should handle commands with no output', async () => { const { result } = await simulateExecution('touch file', (cp) => { cp.emit('exit', 0, null); + cp.emit('close', 0, null); }); - expect(result.output).toBe(''); + expect(result.output.trim()).toBe(''); expect(onOutputEventMock).not.toHaveBeenCalled(); }); }); @@ -425,16 +573,18 @@ describe('ShellExecutionService child_process fallback', () => { const { result } = await simulateExecution('a-bad-command', (cp) => { cp.stderr?.emit('data', Buffer.from('command not found')); cp.emit('exit', 127, null); + cp.emit('close', 127, null); }); expect(result.exitCode).toBe(127); - expect(result.output).toBe('command not found'); + expect(result.output.trim()).toBe('command not found'); expect(result.error).toBeNull(); }); it('should capture a termination signal', async () => { const { result } = await simulateExecution('long-process', (cp) => { cp.emit('exit', null, 'SIGTERM'); + cp.emit('close', null, 'SIGTERM'); }); expect(result.exitCode).toBeNull(); @@ -446,6 +596,7 @@ describe('ShellExecutionService child_process fallback', () => { const { result } = await simulateExecution('protected-cmd', (cp) => { cp.emit('error', spawnError); cp.emit('exit', 1, null); + cp.emit('close', 1, null); }); expect(result.error).toBe(spawnError); @@ -456,6 +607,7 @@ describe('ShellExecutionService child_process fallback', () => { const error = new Error('spawn abc ENOENT'); const { result } = await simulateExecution('touch cat.jpg', (cp) => { cp.emit('error', error); // No exit event is fired. + cp.emit('close', 1, null); }); expect(result.error).toBe(error); @@ -485,10 +637,14 @@ describe('ShellExecutionService child_process fallback', () => { 'sleep 10', (cp, abortController) => { abortController.abort(); - if (expectedExit.signal) + if (expectedExit.signal) { cp.emit('exit', null, expectedExit.signal); - if (typeof expectedExit.code === 'number') + cp.emit('close', null, expectedExit.signal); + } + if (typeof expectedExit.code === 'number') { cp.emit('exit', expectedExit.code, null); + cp.emit('close', expectedExit.code, null); + } }, ); @@ -524,6 +680,7 @@ describe('ShellExecutionService child_process fallback', () => { onOutputEventMock, abortController.signal, true, + {}, ); abortController.abort(); @@ -545,14 +702,13 @@ describe('ShellExecutionService child_process fallback', () => { // Finally, simulate the process exiting and await the result mockChildProcess.emit('exit', null, 'SIGKILL'); + mockChildProcess.emit('close', null, 'SIGKILL'); const result = await handle.result; vi.useRealTimers(); expect(result.aborted).toBe(true); expect(result.signal).toBe(9); - // The individual kill calls were already asserted above. - expect(mockProcessKill).toHaveBeenCalledTimes(2); }); }); @@ -571,18 +727,10 @@ describe('ShellExecutionService child_process fallback', () => { expect(result.rawOutput).toEqual( Buffer.concat([binaryChunk1, binaryChunk2]), ); - expect(onOutputEventMock).toHaveBeenCalledTimes(3); + expect(onOutputEventMock).toHaveBeenCalledTimes(1); 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, - }); }); it('should not emit data events after binary is detected', async () => { @@ -598,12 +746,7 @@ describe('ShellExecutionService child_process fallback', () => { const eventTypes = onOutputEventMock.mock.calls.map( (call: [ShellOutputEvent]) => call[0].type, ); - expect(eventTypes).toEqual([ - 'data', - 'binary_detected', - 'binary_progress', - 'binary_progress', - ]); + expect(eventTypes).toEqual(['binary_detected']); }); }); @@ -647,6 +790,8 @@ describe('ShellExecutionService execution method selection', () => { kill: Mock; onData: Mock; onExit: Mock; + write: Mock; + resize: Mock; }; let mockChildProcess: EventEmitter & Partial; @@ -660,11 +805,16 @@ describe('ShellExecutionService execution method selection', () => { kill: Mock; onData: Mock; onExit: Mock; + write: Mock; + resize: Mock; }; mockPtyProcess.pid = 12345; mockPtyProcess.kill = vi.fn(); mockPtyProcess.onData = vi.fn(); mockPtyProcess.onExit = vi.fn(); + mockPtyProcess.write = vi.fn(); + mockPtyProcess.resize = vi.fn(); + mockPtySpawn.mockReturnValue(mockPtyProcess); mockGetPty.mockResolvedValue({ module: { spawn: mockPtySpawn }, @@ -692,6 +842,7 @@ describe('ShellExecutionService execution method selection', () => { onOutputEventMock, abortController.signal, true, // shouldUseNodePty + shellExecutionConfig, ); // Simulate exit to allow promise to resolve @@ -712,6 +863,7 @@ describe('ShellExecutionService execution method selection', () => { onOutputEventMock, abortController.signal, false, // shouldUseNodePty + {}, ); // Simulate exit to allow promise to resolve @@ -734,6 +886,7 @@ describe('ShellExecutionService execution method selection', () => { onOutputEventMock, abortController.signal, true, // shouldUseNodePty + shellExecutionConfig, ); // Simulate exit to allow promise to resolve diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index f6f7fff7b6..23cff439a2 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -4,30 +4,24 @@ * SPDX-License-Identifier: Apache-2.0 */ +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 { TextDecoder } from 'node:util'; import os from 'node:os'; +import type { IPty } from '@lydell/node-pty'; import { getCachedEncodingForBuffer } from '../utils/systemEncoding.js'; import { isBinary } from '../utils/textUtils.js'; import pkg from '@xterm/headless'; -import stripAnsi from 'strip-ansi'; +import { + serializeTerminalToObject, + type AnsiOutput, +} from '../utils/terminalSerializer.js'; const { Terminal } = pkg; const SIGKILL_TIMEOUT_MS = 200; -// @ts-expect-error getFullText is not a public API. -const getFullText = (terminal: Terminal) => { - const buffer = terminal.buffer.active; - const lines: string[] = []; - for (let i = 0; i < buffer.length; i++) { - const line = buffer.getLine(i); - lines.push(line ? line.translateToString(true) : ''); - } - return lines.join('\n').trim(); -}; - /** A structured result from a shell command execution. */ export interface ShellExecutionResult { /** The raw, unprocessed output buffer. */ @@ -56,6 +50,15 @@ export interface ShellExecutionHandle { result: Promise; } +export interface ShellExecutionConfig { + terminalWidth?: number; + terminalHeight?: number; + pager?: string; + showColor?: boolean; + defaultFg?: string; + defaultBg?: string; +} + /** * Describes a structured event emitted during shell command execution. */ @@ -64,7 +67,7 @@ export type ShellOutputEvent = /** The event contains a chunk of output data. */ type: 'data'; /** The decoded string chunk. */ - chunk: string; + chunk: string | AnsiOutput; } | { /** Signals that the output stream has been identified as binary. */ @@ -77,12 +80,41 @@ export type ShellOutputEvent = bytesReceived: number; }; +interface ActivePty { + ptyProcess: IPty; + headlessTerminal: pkg.Terminal; +} + +const getVisibleText = (terminal: pkg.Terminal): string => { + const buffer = terminal.buffer.active; + const lines: string[] = []; + for (let i = 0; i < terminal.rows; i++) { + const line = buffer.getLine(buffer.viewportY + i); + const lineContent = line ? line.translateToString(true) : ''; + lines.push(lineContent); + } + return lines.join('\n').trimEnd(); +}; + +const getFullBufferText = (terminal: pkg.Terminal): string => { + const buffer = terminal.buffer.active; + const lines: string[] = []; + for (let i = 0; i < buffer.length; i++) { + const line = buffer.getLine(i); + const lineContent = line ? line.translateToString() : ''; + lines.push(lineContent); + } + return lines.join('\n').trimEnd(); +}; + /** * A centralized service for executing shell commands with robust process * management, cross-platform compatibility, and streaming output capabilities. * */ + export class ShellExecutionService { + private static activePtys = new Map(); /** * Executes a shell command using `node-pty`, capturing all output and lifecycle events. * @@ -99,8 +131,7 @@ export class ShellExecutionService { onOutputEvent: (event: ShellOutputEvent) => void, abortSignal: AbortSignal, shouldUseNodePty: boolean, - terminalColumns?: number, - terminalRows?: number, + shellExecutionConfig: ShellExecutionConfig, ): Promise { if (shouldUseNodePty) { const ptyInfo = await getPty(); @@ -111,8 +142,7 @@ export class ShellExecutionService { cwd, onOutputEvent, abortSignal, - terminalColumns, - terminalRows, + shellExecutionConfig, ptyInfo, ); } catch (_e) { @@ -186,31 +216,18 @@ export class ShellExecutionService { if (isBinary(sniffBuffer)) { isStreamingRawContent = false; - onOutputEvent({ type: 'binary_detected' }); } } - const decoder = stream === 'stdout' ? stdoutDecoder : stderrDecoder; - const decodedChunk = decoder.decode(data, { stream: true }); - const strippedChunk = stripAnsi(decodedChunk); - - if (stream === 'stdout') { - stdout += strippedChunk; - } else { - stderr += strippedChunk; - } - if (isStreamingRawContent) { - onOutputEvent({ type: 'data', chunk: strippedChunk }); - } else { - const totalBytes = outputChunks.reduce( - (sum, chunk) => sum + chunk.length, - 0, - ); - onOutputEvent({ - type: 'binary_progress', - bytesReceived: totalBytes, - }); + const decoder = stream === 'stdout' ? stdoutDecoder : stderrDecoder; + const decodedChunk = decoder.decode(data, { stream: true }); + + if (stream === 'stdout') { + stdout += decodedChunk; + } else { + stderr += decodedChunk; + } } }; @@ -224,14 +241,24 @@ export class ShellExecutionService { const combinedOutput = stdout + (stderr ? (stdout ? separator : '') + stderr : ''); + const finalStrippedOutput = stripAnsi(combinedOutput).trim(); + + if (isStreamingRawContent) { + if (finalStrippedOutput) { + onOutputEvent({ type: 'data', chunk: finalStrippedOutput }); + } + } else { + onOutputEvent({ type: 'binary_detected' }); + } + resolve({ rawOutput: finalBuffer, - output: combinedOutput.trim(), + output: finalStrippedOutput, exitCode: code, signal: signal ? os.constants.signals[signal] : null, error, aborted: abortSignal.aborted, - pid: child.pid, + pid: undefined, executionMethod: 'child_process', }); }; @@ -264,6 +291,9 @@ export class ShellExecutionService { abortSignal.addEventListener('abort', abortHandler, { once: true }); child.on('exit', (code, signal) => { + if (child.pid) { + this.activePtys.delete(child.pid); + } handleExit(code, signal); }); @@ -273,13 +303,13 @@ export class ShellExecutionService { if (stdoutDecoder) { const remaining = stdoutDecoder.decode(); if (remaining) { - stdout += stripAnsi(remaining); + stdout += remaining; } } if (stderrDecoder) { const remaining = stderrDecoder.decode(); if (remaining) { - stderr += stripAnsi(remaining); + stderr += remaining; } } @@ -289,7 +319,7 @@ export class ShellExecutionService { } }); - return { pid: child.pid, result }; + return { pid: undefined, result }; } catch (e) { const error = e as Error; return { @@ -313,29 +343,32 @@ export class ShellExecutionService { cwd: string, onOutputEvent: (event: ShellOutputEvent) => void, abortSignal: AbortSignal, - terminalColumns: number | undefined, - terminalRows: number | undefined, - ptyInfo: PtyImplementation | undefined, + shellExecutionConfig: ShellExecutionConfig, + ptyInfo: PtyImplementation, ): ShellExecutionHandle { + if (!ptyInfo) { + // This should not happen, but as a safeguard... + throw new Error('PTY implementation not found'); + } try { - const cols = terminalColumns ?? 80; - const rows = terminalRows ?? 30; + const cols = shellExecutionConfig.terminalWidth ?? 80; + const rows = shellExecutionConfig.terminalHeight ?? 30; const isWindows = os.platform() === 'win32'; const shell = isWindows ? 'cmd.exe' : 'bash'; const args = isWindows ? `/c ${commandToExecute}` : ['-c', commandToExecute]; - const ptyProcess = ptyInfo?.module.spawn(shell, args, { + const ptyProcess = ptyInfo.module.spawn(shell, args, { cwd, - name: 'xterm-color', + name: 'xterm', cols, rows, env: { ...process.env, GEMINI_CLI: '1', TERM: 'xterm-256color', - PAGER: 'cat', + PAGER: shellExecutionConfig.pager ?? 'cat', }, handleFlowControl: true, }); @@ -346,8 +379,12 @@ export class ShellExecutionService { cols, rows, }); + + this.activePtys.set(ptyProcess.pid, { ptyProcess, headlessTerminal }); + let processingChain = Promise.resolve(); let decoder: TextDecoder | null = null; + let output: string | AnsiOutput | null = null; const outputChunks: Buffer[] = []; const error: Error | null = null; let exited = false; @@ -355,6 +392,49 @@ export class ShellExecutionService { let isStreamingRawContent = true; const MAX_SNIFF_SIZE = 4096; let sniffedBytes = 0; + let isWriting = false; + let renderTimeout: NodeJS.Timeout | null = null; + + const render = (finalRender = false) => { + if (renderTimeout) { + clearTimeout(renderTimeout); + } + + const renderFn = () => { + if (!isStreamingRawContent) { + return; + } + const newOutput = shellExecutionConfig.showColor + ? serializeTerminalToObject(headlessTerminal, { + defaultFg: shellExecutionConfig.defaultFg, + defaultBg: shellExecutionConfig.defaultBg, + }) + : getVisibleText(headlessTerminal); + + // console.log(newOutput) + + // Using stringify for a quick deep comparison. + if (JSON.stringify(output) !== JSON.stringify(newOutput)) { + output = newOutput; + onOutputEvent({ + type: 'data', + chunk: newOutput, + }); + } + }; + + if (finalRender) { + renderFn(); + } else { + renderTimeout = setTimeout(renderFn, 17); + } + }; + + headlessTerminal.onScroll(() => { + if (!isWriting) { + render(); + } + }); const handleOutput = (data: Buffer) => { processingChain = processingChain.then( @@ -383,11 +463,10 @@ export class ShellExecutionService { if (isStreamingRawContent) { const decodedChunk = decoder.decode(data, { stream: true }); + isWriting = true; headlessTerminal.write(decodedChunk, () => { - onOutputEvent({ - type: 'data', - chunk: stripAnsi(decodedChunk), - }); + render(); + isWriting = false; resolve(); }); } else { @@ -414,19 +493,23 @@ export class ShellExecutionService { ({ exitCode, signal }: { exitCode: number; signal?: number }) => { exited = true; abortSignal.removeEventListener('abort', abortHandler); + this.activePtys.delete(ptyProcess.pid); processingChain.then(() => { + render(true); const finalBuffer = Buffer.concat(outputChunks); resolve({ rawOutput: finalBuffer, - output: getFullText(headlessTerminal), + output: getFullBufferText(headlessTerminal), exitCode, signal: signal ?? null, error, aborted: abortSignal.aborted, pid: ptyProcess.pid, - executionMethod: ptyInfo?.name ?? 'node-pty', + executionMethod: + (ptyInfo?.name as 'node-pty' | 'lydell-node-pty') ?? + 'node-pty', }); }); }, @@ -434,7 +517,17 @@ export class ShellExecutionService { const abortHandler = async () => { if (ptyProcess.pid && !exited) { - ptyProcess.kill('SIGHUP'); + if (os.platform() === 'win32') { + ptyProcess.kill(); + } else { + try { + // Kill the entire process group + process.kill(-ptyProcess.pid, 'SIGINT'); + } catch (_e) { + // Fallback to killing just the process if the group kill fails + ptyProcess.kill('SIGINT'); + } + } } }; @@ -459,4 +552,65 @@ export class ShellExecutionService { }; } } + + /** + * Writes a string to the pseudo-terminal (PTY) of a running process. + * + * @param pid The process ID of the target PTY. + * @param input The string to write to the terminal. + */ + static writeToPty(pid: number, input: string): void { + const activePty = this.activePtys.get(pid); + if (activePty) { + activePty.ptyProcess.write(input); + } + } + + /** + * Resizes the pseudo-terminal (PTY) of a running process. + * + * @param pid The process ID of the target PTY. + * @param cols The new number of columns. + * @param rows The new number of rows. + */ + static resizePty(pid: number, cols: number, rows: number): void { + const activePty = this.activePtys.get(pid); + if (activePty) { + try { + activePty.ptyProcess.resize(cols, rows); + activePty.headlessTerminal.resize(cols, rows); + } catch (e) { + // Ignore errors if the pty has already exited, which can happen + // due to a race condition between the exit event and this call. + if (e instanceof Error && 'code' in e && e.code === 'ESRCH') { + // ignore + } else { + throw e; + } + } + } + } + + /** + * Scrolls the pseudo-terminal (PTY) of a running process. + * + * @param pid The process ID of the target PTY. + * @param lines The number of lines to scroll. + */ + static scrollPty(pid: number, lines: number): void { + const activePty = this.activePtys.get(pid); + if (activePty) { + try { + activePty.headlessTerminal.scrollLines(lines); + } catch (e) { + // Ignore errors if the pty has already exited, which can happen + // due to a race condition between the exit event and this call. + if (e instanceof Error && 'code' in e && e.code === 'ESRCH') { + // ignore + } else { + throw e; + } + } + } + } } diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 88daa0cdfa..58df386f4d 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -155,8 +155,7 @@ describe('ShellTool', () => { expect.any(Function), mockAbortSignal, false, - undefined, - undefined, + {}, ); expect(result.llmContent).toContain('Background PIDs: 54322'); expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(tmpFile); @@ -183,8 +182,7 @@ describe('ShellTool', () => { expect.any(Function), mockAbortSignal, false, - undefined, - undefined, + {}, ); }); @@ -296,43 +294,6 @@ describe('ShellTool', () => { vi.useRealTimers(); }); - it('should throttle text output updates', async () => { - const invocation = shellTool.build({ command: 'stream' }); - const promise = invocation.execute(mockAbortSignal, updateOutputMock); - - // First chunk, should be throttled. - mockShellOutputCallback({ - type: 'data', - chunk: 'hello ', - }); - expect(updateOutputMock).not.toHaveBeenCalled(); - - // Advance time past the throttle interval. - await vi.advanceTimersByTimeAsync(OUTPUT_UPDATE_INTERVAL_MS + 1); - - // Send a second chunk. THIS event triggers the update with the CUMULATIVE content. - mockShellOutputCallback({ - type: 'data', - chunk: 'world', - }); - - // It should have been called once now with the combined output. - expect(updateOutputMock).toHaveBeenCalledOnce(); - expect(updateOutputMock).toHaveBeenCalledWith('hello world'); - - resolveExecutionPromise({ - rawOutput: Buffer.from(''), - output: '', - exitCode: 0, - signal: null, - error: null, - aborted: false, - pid: 12345, - executionMethod: 'child_process', - }); - await promise; - }); - it('should immediately show binary detection message and throttle progress', async () => { const invocation = shellTool.build({ command: 'cat img' }); const promise = invocation.execute(mockAbortSignal, updateOutputMock); diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 94e4bd85ec..8e3390bab9 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -24,9 +24,13 @@ import { } from './tools.js'; import { getErrorMessage } from '../utils/errors.js'; import { summarizeToolOutput } from '../utils/summarizer.js'; -import type { ShellOutputEvent } from '../services/shellExecutionService.js'; +import type { + ShellExecutionConfig, + ShellOutputEvent, +} from '../services/shellExecutionService.js'; import { ShellExecutionService } from '../services/shellExecutionService.js'; import { formatMemoryUsage } from '../utils/formatters.js'; +import type { AnsiOutput } from '../utils/terminalSerializer.js'; import { getCommandRoots, isCommandAllowed, @@ -41,7 +45,7 @@ export interface ShellToolParams { directory?: string; } -class ShellToolInvocation extends BaseToolInvocation< +export class ShellToolInvocation extends BaseToolInvocation< ShellToolParams, ToolResult > { @@ -96,9 +100,9 @@ class ShellToolInvocation extends BaseToolInvocation< async execute( signal: AbortSignal, - updateOutput?: (output: string) => void, - terminalColumns?: number, - terminalRows?: number, + updateOutput?: (output: string | AnsiOutput) => void, + shellExecutionConfig?: ShellExecutionConfig, + setPidCallback?: (pid: number) => void, ): Promise { const strippedCommand = stripShellWrapper(this.params.command); @@ -131,63 +135,60 @@ class ShellToolInvocation extends BaseToolInvocation< this.params.directory || '', ); - let cumulativeOutput = ''; - let outputChunks: string[] = [cumulativeOutput]; + let cumulativeOutput: string | AnsiOutput = ''; let lastUpdateTime = Date.now(); let isBinaryStream = false; - const { result: resultPromise } = await ShellExecutionService.execute( - commandToExecute, - cwd, - (event: ShellOutputEvent) => { - if (!updateOutput) { - return; - } - - let currentDisplayOutput = ''; - let shouldUpdate = false; - - switch (event.type) { - case 'data': - if (isBinaryStream) break; - outputChunks.push(event.chunk); - if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) { - cumulativeOutput = outputChunks.join(''); - outputChunks = [cumulativeOutput]; - currentDisplayOutput = cumulativeOutput; - shouldUpdate = true; - } - break; - case 'binary_detected': - isBinaryStream = true; - currentDisplayOutput = - '[Binary output detected. Halting stream...]'; - shouldUpdate = true; - break; - case 'binary_progress': - isBinaryStream = true; - currentDisplayOutput = `[Receiving binary output... ${formatMemoryUsage( - event.bytesReceived, - )} received]`; - if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) { - shouldUpdate = true; - } - break; - default: { - throw new Error('An unhandled ShellOutputEvent was found.'); + const { result: resultPromise, pid } = + await ShellExecutionService.execute( + commandToExecute, + cwd, + (event: ShellOutputEvent) => { + if (!updateOutput) { + return; } - } - if (shouldUpdate) { - updateOutput(currentDisplayOutput); - lastUpdateTime = Date.now(); - } - }, - signal, - this.config.getShouldUseNodePtyShell(), - terminalColumns, - terminalRows, - ); + let shouldUpdate = false; + + switch (event.type) { + case 'data': + if (isBinaryStream) break; + cumulativeOutput = event.chunk; + shouldUpdate = true; + break; + case 'binary_detected': + isBinaryStream = true; + cumulativeOutput = + '[Binary output detected. Halting stream...]'; + shouldUpdate = true; + break; + case 'binary_progress': + isBinaryStream = true; + cumulativeOutput = `[Receiving binary output... ${formatMemoryUsage( + event.bytesReceived, + )} received]`; + if (Date.now() - lastUpdateTime > OUTPUT_UPDATE_INTERVAL_MS) { + shouldUpdate = true; + } + break; + default: { + throw new Error('An unhandled ShellOutputEvent was found.'); + } + } + + if (shouldUpdate) { + updateOutput(cumulativeOutput); + lastUpdateTime = Date.now(); + } + }, + signal, + this.config.getShouldUseNodePtyShell(), + shellExecutionConfig ?? {}, + ); + + if (pid && setPidCallback) { + setPidCallback(pid); + } const result = await resultPromise; diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 21a7f965bb..6029b9f8d2 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -7,7 +7,9 @@ import type { FunctionDeclaration, PartListUnion } from '@google/genai'; import { ToolErrorType } from './tool-error.js'; import type { DiffUpdateResult } from '../ide/ideContext.js'; +import type { ShellExecutionConfig } from '../services/shellExecutionService.js'; import { SchemaValidator } from '../utils/schemaValidator.js'; +import type { AnsiOutput } from '../utils/terminalSerializer.js'; /** * Represents a validated and ready-to-execute tool call. @@ -51,7 +53,8 @@ export interface ToolInvocation< */ execute( signal: AbortSignal, - updateOutput?: (output: string) => void, + updateOutput?: (output: string | AnsiOutput) => void, + shellExecutionConfig?: ShellExecutionConfig, ): Promise; } @@ -79,7 +82,8 @@ export abstract class BaseToolInvocation< abstract execute( signal: AbortSignal, - updateOutput?: (output: string) => void, + updateOutput?: (output: string | AnsiOutput) => void, + shellExecutionConfig?: ShellExecutionConfig, ): Promise; } @@ -197,10 +201,11 @@ export abstract class DeclarativeTool< async buildAndExecute( params: TParams, signal: AbortSignal, - updateOutput?: (output: string) => void, + updateOutput?: (output: string | AnsiOutput) => void, + shellExecutionConfig?: ShellExecutionConfig, ): Promise { const invocation = this.build(params); - return invocation.execute(signal, updateOutput); + return invocation.execute(signal, updateOutput, shellExecutionConfig); } /** @@ -432,7 +437,7 @@ export function hasCycleInSchema(schema: object): boolean { return traverse(schema, new Set(), new Set()); } -export type ToolResultDisplay = string | FileDiff; +export type ToolResultDisplay = string | FileDiff | AnsiOutput; export interface FileDiff { fileDiff: string; diff --git a/packages/core/src/utils/terminalSerializer.test.ts b/packages/core/src/utils/terminalSerializer.test.ts new file mode 100644 index 0000000000..fd6241d04d --- /dev/null +++ b/packages/core/src/utils/terminalSerializer.test.ts @@ -0,0 +1,197 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { Terminal } from '@xterm/headless'; +import { + serializeTerminalToObject, + convertColorToHex, + ColorMode, +} from './terminalSerializer.js'; + +const RED_FG = '\x1b[31m'; +const RESET = '\x1b[0m'; + +function writeToTerminal(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => { + terminal.write(data, resolve); + }); +} + +describe('terminalSerializer', () => { + describe('serializeTerminalToObject', () => { + it('should handle an empty terminal', () => { + const terminal = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true, + }); + const result = serializeTerminalToObject(terminal); + expect(result).toHaveLength(24); + result.forEach((line) => { + // Expect each line to be either empty or contain a single token with spaces + if (line.length > 0) { + expect(line[0].text.trim()).toBe(''); + } + }); + }); + + it('should serialize a single line of text', async () => { + const terminal = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true, + }); + await writeToTerminal(terminal, 'Hello, world!'); + const result = serializeTerminalToObject(terminal); + expect(result[0][0].text).toContain('Hello, world!'); + }); + + it('should serialize multiple lines of text', async () => { + const terminal = new Terminal({ + cols: 7, + rows: 24, + allowProposedApi: true, + }); + await writeToTerminal(terminal, 'Line 1\r\nLine 2'); + const result = serializeTerminalToObject(terminal); + expect(result[0][0].text).toBe('Line 1 '); + expect(result[1][0].text).toBe('Line 2'); + }); + + it('should handle bold text', async () => { + const terminal = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true, + }); + await writeToTerminal(terminal, '\x1b[1mBold text\x1b[0m'); + const result = serializeTerminalToObject(terminal); + expect(result[0][0].bold).toBe(true); + expect(result[0][0].text).toBe('Bold text'); + }); + + it('should handle italic text', async () => { + const terminal = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true, + }); + await writeToTerminal(terminal, '\x1b[3mItalic text\x1b[0m'); + const result = serializeTerminalToObject(terminal); + expect(result[0][0].italic).toBe(true); + expect(result[0][0].text).toBe('Italic text'); + }); + + it('should handle underlined text', async () => { + const terminal = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true, + }); + await writeToTerminal(terminal, '\x1b[4mUnderlined text\x1b[0m'); + const result = serializeTerminalToObject(terminal); + expect(result[0][0].underline).toBe(true); + expect(result[0][0].text).toBe('Underlined text'); + }); + + it('should handle dim text', async () => { + const terminal = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true, + }); + await writeToTerminal(terminal, '\x1b[2mDim text\x1b[0m'); + const result = serializeTerminalToObject(terminal); + expect(result[0][0].dim).toBe(true); + expect(result[0][0].text).toBe('Dim text'); + }); + + it('should handle inverse text', async () => { + const terminal = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true, + }); + await writeToTerminal(terminal, '\x1b[7mInverse text\x1b[0m'); + const result = serializeTerminalToObject(terminal); + expect(result[0][0].inverse).toBe(true); + expect(result[0][0].text).toBe('Inverse text'); + }); + + it('should handle foreground colors', async () => { + const terminal = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true, + }); + await writeToTerminal(terminal, `${RED_FG}Red text${RESET}`); + const result = serializeTerminalToObject(terminal); + expect(result[0][0].fg).toBe('#800000'); + expect(result[0][0].text).toBe('Red text'); + }); + + it('should handle background colors', async () => { + const terminal = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true, + }); + await writeToTerminal(terminal, '\x1b[42mGreen background\x1b[0m'); + const result = serializeTerminalToObject(terminal); + expect(result[0][0].bg).toBe('#008000'); + expect(result[0][0].text).toBe('Green background'); + }); + + it('should handle RGB colors', async () => { + const terminal = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true, + }); + await writeToTerminal(terminal, '\x1b[38;2;100;200;50mRGB text\x1b[0m'); + const result = serializeTerminalToObject(terminal); + expect(result[0][0].fg).toBe('#64c832'); + expect(result[0][0].text).toBe('RGB text'); + }); + + it('should handle a combination of styles', async () => { + const terminal = new Terminal({ + cols: 80, + rows: 24, + allowProposedApi: true, + }); + await writeToTerminal(terminal, '\x1b[1;31;42mStyled text\x1b[0m'); + const result = serializeTerminalToObject(terminal); + expect(result[0][0].bold).toBe(true); + expect(result[0][0].fg).toBe('#800000'); + expect(result[0][0].bg).toBe('#008000'); + expect(result[0][0].text).toBe('Styled text'); + }); + }); + describe('convertColorToHex', () => { + it('should convert RGB color to hex', () => { + const color = (100 << 16) | (200 << 8) | 50; + const hex = convertColorToHex(color, ColorMode.RGB, '#000000'); + expect(hex).toBe('#64c832'); + }); + + it('should convert palette color to hex', () => { + const hex = convertColorToHex(1, ColorMode.PALETTE, '#000000'); + expect(hex).toBe('#800000'); + }); + + it('should return default color for ColorMode.DEFAULT', () => { + const hex = convertColorToHex(0, ColorMode.DEFAULT, '#ffffff'); + expect(hex).toBe('#ffffff'); + }); + + it('should return default color for invalid palette index', () => { + const hex = convertColorToHex(999, ColorMode.PALETTE, '#000000'); + expect(hex).toBe('#000000'); + }); + }); +}); diff --git a/packages/core/src/utils/terminalSerializer.ts b/packages/core/src/utils/terminalSerializer.ts new file mode 100644 index 0000000000..f3c8eacec0 --- /dev/null +++ b/packages/core/src/utils/terminalSerializer.ts @@ -0,0 +1,479 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { IBufferCell, Terminal } from '@xterm/headless'; +export interface AnsiToken { + text: string; + bold: boolean; + italic: boolean; + underline: boolean; + dim: boolean; + inverse: boolean; + fg: string; + bg: string; +} + +export type AnsiLine = AnsiToken[]; +export type AnsiOutput = AnsiLine[]; + +const enum Attribute { + inverse = 1, + bold = 2, + italic = 4, + underline = 8, + dim = 16, +} + +export const enum ColorMode { + DEFAULT = 0, + PALETTE = 1, + RGB = 2, +} + +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; + fg = 0; + bg = 0; + fgColorMode: ColorMode = ColorMode.DEFAULT; + bgColorMode: ColorMode = ColorMode.DEFAULT; + + constructor( + 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; + + if (!cell) { + return; + } + + if (cell.isInverse()) { + this.attributes += Attribute.inverse; + } + if (cell.isBold()) { + this.attributes += Attribute.bold; + } + if (cell.isItalic()) { + this.attributes += Attribute.italic; + } + if (cell.isUnderline()) { + this.attributes += Attribute.underline; + } + if (cell.isDim()) { + this.attributes += Attribute.dim; + } + + if (cell.isFgRGB()) { + this.fgColorMode = ColorMode.RGB; + } else if (cell.isFgPalette()) { + this.fgColorMode = ColorMode.PALETTE; + } else { + this.fgColorMode = ColorMode.DEFAULT; + } + + if (cell.isBgRGB()) { + this.bgColorMode = ColorMode.RGB; + } else if (cell.isBgPalette()) { + this.bgColorMode = ColorMode.PALETTE; + } else { + this.bgColorMode = ColorMode.DEFAULT; + } + + if (this.fgColorMode === ColorMode.DEFAULT) { + this.fg = -1; + } else { + this.fg = cell.getFgColor(); + } + + if (this.bgColorMode === ColorMode.DEFAULT) { + this.bg = -1; + } else { + this.bg = cell.getBgColor(); + } + } + + isCursor(): boolean { + return this.x === this.cursorX && this.y === this.cursorY; + } + + getChars(): string { + return this.cell?.getChars() || ' '; + } + + isAttribute(attribute: Attribute): boolean { + return (this.attributes & attribute) !== 0; + } + + equals(other: Cell): boolean { + return ( + this.attributes === other.attributes && + this.fg === other.fg && + this.bg === other.bg && + this.fgColorMode === other.fgColorMode && + this.bgColorMode === other.bgColorMode && + this.isCursor() === other.isCursor() + ); + } +} + +export function serializeTerminalToObject( + terminal: Terminal, + options?: { defaultFg?: string; defaultBg?: string }, +): AnsiOutput { + const buffer = terminal.buffer.active; + const cursorX = buffer.cursorX; + const cursorY = buffer.cursorY; + const defaultFg = options?.defaultFg ?? '#ffffff'; + const defaultBg = options?.defaultBg ?? '#000000'; + + const result: AnsiOutput = []; + + for (let y = 0; y < terminal.rows; y++) { + const line = buffer.getLine(buffer.viewportY + y); + const currentLine: AnsiLine = []; + if (!line) { + result.push(currentLine); + continue; + } + + let lastCell = new Cell(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); + + if (x > 0 && !cell.equals(lastCell)) { + if (currentText) { + const token: AnsiToken = { + text: currentText, + bold: lastCell.isAttribute(Attribute.bold), + italic: lastCell.isAttribute(Attribute.italic), + underline: lastCell.isAttribute(Attribute.underline), + dim: lastCell.isAttribute(Attribute.dim), + inverse: + lastCell.isAttribute(Attribute.inverse) || lastCell.isCursor(), + fg: convertColorToHex(lastCell.fg, lastCell.fgColorMode, defaultFg), + bg: convertColorToHex(lastCell.bg, lastCell.bgColorMode, defaultBg), + }; + currentLine.push(token); + } + currentText = ''; + } + currentText += cell.getChars(); + lastCell = cell; + } + + if (currentText) { + const token: AnsiToken = { + text: currentText, + bold: lastCell.isAttribute(Attribute.bold), + italic: lastCell.isAttribute(Attribute.italic), + underline: lastCell.isAttribute(Attribute.underline), + dim: lastCell.isAttribute(Attribute.dim), + inverse: lastCell.isAttribute(Attribute.inverse) || lastCell.isCursor(), + fg: convertColorToHex(lastCell.fg, lastCell.fgColorMode, defaultFg), + bg: convertColorToHex(lastCell.bg, lastCell.bgColorMode, defaultBg), + }; + currentLine.push(token); + } + + result.push(currentLine); + } + + return result; +} + +// ANSI color palette from https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit +const ANSI_COLORS = [ + '#000000', + '#800000', + '#008000', + '#808000', + '#000080', + '#800080', + '#008080', + '#c0c0c0', + '#808080', + '#ff0000', + '#00ff00', + '#ffff00', + '#0000ff', + '#ff00ff', + '#00ffff', + '#ffffff', + '#000000', + '#00005f', + '#000087', + '#0000af', + '#0000d7', + '#0000ff', + '#005f00', + '#005f5f', + '#005f87', + '#005faf', + '#005fd7', + '#005fff', + '#008700', + '#00875f', + '#008787', + '#0087af', + '#0087d7', + '#0087ff', + '#00af00', + '#00af5f', + '#00af87', + '#00afaf', + '#00afd7', + '#00afff', + '#00d700', + '#00d75f', + '#00d787', + '#00d7af', + '#00d7d7', + '#00d7ff', + '#00ff00', + '#00ff5f', + '#00ff87', + '#00ffaf', + '#00ffd7', + '#00ffff', + '#5f0000', + '#5f005f', + '#5f0087', + '#5f00af', + '#5f00d7', + '#5f00ff', + '#5f5f00', + '#5f5f5f', + '#5f5f87', + '#5f5faf', + '#5f5fd7', + '#5f5fff', + '#5f8700', + '#5f875f', + '#5f8787', + '#5f87af', + '#5f87d7', + '#5f87ff', + '#5faf00', + '#5faf5f', + '#5faf87', + '#5fafaf', + '#5fafd7', + '#5fafff', + '#5fd700', + '#5fd75f', + '#5fd787', + '#5fd7af', + '#5fd7d7', + '#5fd7ff', + '#5fff00', + '#5fff5f', + '#5fff87', + '#5fffaf', + '#5fffd7', + '#5fffff', + '#870000', + '#87005f', + '#870087', + '#8700af', + '#8700d7', + '#8700ff', + '#875f00', + '#875f5f', + '#875f87', + '#875faf', + '#875fd7', + '#875fff', + '#878700', + '#87875f', + '#878787', + '#8787af', + '#8787d7', + '#8787ff', + '#87af00', + '#87af5f', + '#87af87', + '#87afaf', + '#87afd7', + '#87afff', + '#87d700', + '#87d75f', + '#87d787', + '#87d7af', + '#87d7d7', + '#87d7ff', + '#87ff00', + '#87ff5f', + '#87ff87', + '#87ffaf', + '#87ffd7', + '#87ffff', + '#af0000', + '#af005f', + '#af0087', + '#af00af', + '#af00d7', + '#af00ff', + '#af5f00', + '#af5f5f', + '#af5f87', + '#af5faf', + '#af5fd7', + '#af5fff', + '#af8700', + '#af875f', + '#af8787', + '#af87af', + '#af87d7', + '#af87ff', + '#afaf00', + '#afaf5f', + '#afaf87', + '#afafaf', + '#afafd7', + '#afafff', + '#afd700', + '#afd75f', + '#afd787', + '#afd7af', + '#afd7d7', + '#afd7ff', + '#afff00', + '#afff5f', + '#afff87', + '#afffaf', + '#afffd7', + '#afffff', + '#d70000', + '#d7005f', + '#d70087', + '#d700af', + '#d700d7', + '#d700ff', + '#d75f00', + '#d75f5f', + '#d75f87', + '#d75faf', + '#d75fd7', + '#d75fff', + '#d78700', + '#d7875f', + '#d78787', + '#d787af', + '#d787d7', + '#d787ff', + '#d7af00', + '#d7af5f', + '#d7af87', + '#d7afaf', + '#d7afd7', + '#d7afff', + '#d7d700', + '#d7d75f', + '#d7d787', + '#d7d7af', + '#d7d7d7', + '#d7d7ff', + '#d7ff00', + '#d7ff5f', + '#d7ff87', + '#d7ffaf', + '#d7ffd7', + '#d7ffff', + '#ff0000', + '#ff005f', + '#ff0087', + '#ff00af', + '#ff00d7', + '#ff00ff', + '#ff5f00', + '#ff5f5f', + '#ff5f87', + '#ff5faf', + '#ff5fd7', + '#ff5fff', + '#ff8700', + '#ff875f', + '#ff8787', + '#ff87af', + '#ff87d7', + '#ff87ff', + '#ffaf00', + '#ffaf5f', + '#ffaf87', + '#ffafaf', + '#ffafd7', + '#ffafff', + '#ffd700', + '#ffd75f', + '#ffd787', + '#ffd7af', + '#ffd7d7', + '#ffd7ff', + '#ffff00', + '#ffff5f', + '#ffff87', + '#ffffaf', + '#ffffd7', + '#ffffff', + '#080808', + '#121212', + '#1c1c1c', + '#262626', + '#303030', + '#3a3a3a', + '#444444', + '#4e4e4e', + '#585858', + '#626262', + '#6c6c6c', + '#767676', + '#808080', + '#8a8a8a', + '#949494', + '#9e9e9e', + '#a8a8a8', + '#b2b2b2', + '#bcbcbc', + '#c6c6c6', + '#d0d0d0', + '#dadada', + '#e4e4e4', + '#eeeeee', +]; + +export function convertColorToHex( + color: number, + colorMode: ColorMode, + defaultColor: string, +): string { + if (colorMode === ColorMode.RGB) { + const r = (color >> 16) & 255; + const g = (color >> 8) & 255; + const b = color & 255; + return `#${r.toString(16).padStart(2, '0')}${g + .toString(16) + .padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; + } + if (colorMode === ColorMode.PALETTE) { + return ANSI_COLORS[color] || defaultColor; + } + return defaultColor; +}