diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx index 31cf75e63c..7e0f3125a5 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -344,9 +344,10 @@ describe('ToolResultDisplay', () => { expect(output).not.toContain('Line 1'); expect(output).not.toContain('Line 2'); - expect(output).not.toContain('Line 3'); + expect(output).toContain('Line 3'); expect(output).toContain('Line 4'); expect(output).toContain('Line 5'); + expect(output).toMatchSnapshot(); unmount(); }); @@ -363,7 +364,7 @@ describe('ToolResultDisplay', () => { inverse: false, }, ]); - const { lastFrame, waitUntilReady, unmount } = await renderWithProviders( + const renderResult = await renderWithProviders( { uiState: { constrainHeight: true }, }, ); + const { waitUntilReady, unmount } = renderResult; await waitUntilReady(); - const output = lastFrame(); - // It SHOULD truncate to 25 lines because maxLines is provided - expect(output).not.toContain('Line 1'); - expect(output).toContain('Line 50'); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); }); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 4b51ae8ab8..92791328be 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -198,33 +198,35 @@ export const ToolResultDisplay: React.FC = ({ return content; }; + if (Array.isArray(resultDisplay)) { + const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES; + const listHeight = Math.min( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (resultDisplay as AnsiOutput).length, + limit, + ); + + const initialScrollIndex = + overflowDirection === 'bottom' ? 0 : SCROLL_TO_ITEM_END; + + return ( + + 1} + keyExtractor={keyExtractor} + initialScrollIndex={initialScrollIndex} + hasFocus={hasFocus} + /> + + ); + } + // ASB Mode Handling (Interactive/Fullscreen) if (isAlternateBuffer) { - // Virtualized path for large ANSI arrays - if (Array.isArray(resultDisplay)) { - const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES; - const listHeight = Math.min( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (resultDisplay as AnsiOutput).length, - limit, - ); - - return ( - - 1} - keyExtractor={keyExtractor} - initialScrollIndex={SCROLL_TO_ITEM_END} - hasFocus={hasFocus} - /> - - ); - } - // Standard path for strings/diffs in ASB return ( diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx index b224f089cf..ecd67c9798 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx @@ -96,10 +96,11 @@ describe('ToolResultDisplay Overflow', () => { expect(output).toContain('Line 1'); expect(output).toContain('Line 2'); - expect(output).not.toContain('Line 3'); + expect(output).toContain('Line 3'); expect(output).not.toContain('Line 4'); expect(output).not.toContain('Line 5'); - expect(output).toContain('hidden'); + // ScrollableList uses a scroll thumb rather than writing "hidden" + expect(output).toContain('█'); unmount(); }); }); diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay-ToolResultDisplay-truncates-ANSI-output-when-maxLines-is-provided-even-if-availableTerminalHeight-is-undefined.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay-ToolResultDisplay-truncates-ANSI-output-when-maxLines-is-provided-even-if-availableTerminalHeight-is-undefined.snap.svg new file mode 100644 index 0000000000..2638c4ad3b --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay-ToolResultDisplay-truncates-ANSI-output-when-maxLines-is-provided-even-if-availableTerminalHeight-is-undefined.snap.svg @@ -0,0 +1,46 @@ + + + + + Line 26 + Line 27 + Line 28 + Line 29 + Line 30 + Line 31 + Line 32 + Line 33 + Line 34 + Line 35 + Line 36 + Line 37 + Line 38 + + Line 39 + + Line 40 + + Line 41 + + Line 42 + + Line 43 + + Line 44 + + Line 45 + + Line 46 + + Line 47 + + Line 48 + + Line 49 + + Line 50 + + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap index f4b3a35884..162a71c967 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap @@ -36,6 +36,41 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp " `; +exports[`ToolResultDisplay > truncates ANSI output when maxLines is provided 1`] = ` +"Line 3 +Line 4 █ +Line 5 █ +" +`; + +exports[`ToolResultDisplay > truncates ANSI output when maxLines is provided, even if availableTerminalHeight is undefined 1`] = ` +"Line 26 +Line 27 +Line 28 +Line 29 +Line 30 +Line 31 +Line 32 +Line 33 +Line 34 +Line 35 +Line 36 +Line 37 +Line 38 ▄ +Line 39 █ +Line 40 █ +Line 41 █ +Line 42 █ +Line 43 █ +Line 44 █ +Line 45 █ +Line 46 █ +Line 47 █ +Line 48 █ +Line 49 █ +Line 50 █" +`; + exports[`ToolResultDisplay > truncates very long string results 1`] = ` "... 250 hidden (Ctrl+O) ... aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx b/packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx index 743bf90c04..d9af4fbcfa 100644 --- a/packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx +++ b/packages/cli/src/ui/hooks/useExecutionLifecycle.test.tsx @@ -101,6 +101,7 @@ import { type GeminiClient, type ShellExecutionResult, type ShellOutputEvent, + type AnsiOutput, CoreToolCallStatus, } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; @@ -521,6 +522,52 @@ describe('useExecutionLifecycle', () => { ); }); + it('should prepend warnings to AnsiOutput array', async () => { + const { result } = await renderProcessorHook(); + + act(() => { + result.current.handleShellCommand('ls', new AbortController().signal); + }); + const execPromise = onExecMock.mock.calls[0][0]; + + const ansiOutput: AnsiOutput = [ + [ + { + text: 'ansi line 1', + fg: '', + bg: '', + bold: false, + dim: false, + italic: false, + underline: false, + inverse: false, + }, + ], + ]; + + act(() => { + resolveExecutionPromise( + createMockServiceResult({ + exitCode: 1, + output: 'ansi line 1', + ansiOutput, + }), + ); + }); + await act(async () => await execPromise); + + expect(setPendingHistoryItemMock).toHaveBeenCalledWith(null); + expect(addItemToHistoryMock).toHaveBeenCalledTimes(2); + + const historyCall = addItemToHistoryMock.mock.calls[1][0]; + const display = historyCall.tools[0].resultDisplay; + + expect(Array.isArray(display)).toBe(true); + expect(display.length).toBe(3); // Error line, empty line, original output + expect(display[0][0].text).toBe('Command exited with code 1.'); + expect(display[2][0].text).toBe('ansi line 1'); + }); + it('should handle promise rejection and show an error', async () => { const { result } = await renderProcessorHook(); const testError = new Error('Unexpected failure'); diff --git a/packages/cli/src/ui/hooks/useExecutionLifecycle.ts b/packages/cli/src/ui/hooks/useExecutionLifecycle.ts index e0b5c3ffaa..4af4084813 100644 --- a/packages/cli/src/ui/hooks/useExecutionLifecycle.ts +++ b/packages/cli/src/ui/hooks/useExecutionLifecycle.ts @@ -534,31 +534,68 @@ export const useExecutionLifecycle = ( result.output.trim() || '(Command produced no output)'; } - let finalOutput = mainContent; + let finalOutput: string | AnsiOutput = + result.ansiOutput && result.ansiOutput.length > 0 + ? result.ansiOutput + : mainContent; let finalStatus = CoreToolCallStatus.Success; + const prependToAnsiOutput = ( + output: AnsiOutput, + text: string, + ): AnsiOutput => { + const newLines: AnsiOutput = text.split('\n').map((line) => [ + { + text: line, + fg: '', + bg: '', + dim: false, + bold: false, + italic: false, + underline: false, + inverse: false, + }, + ]); + return [...newLines, [], ...output]; + }; + + let prefix = ''; + if (result.error) { finalStatus = CoreToolCallStatus.Error; - finalOutput = `${result.error.message}\n${finalOutput}`; + prefix = result.error.message; } else if (result.aborted) { finalStatus = CoreToolCallStatus.Cancelled; - finalOutput = `Command was cancelled.\n${finalOutput}`; + prefix = 'Command was cancelled.'; } else if (result.backgrounded) { finalStatus = CoreToolCallStatus.Success; finalOutput = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`; + mainContent = finalOutput; } else if (result.signal) { finalStatus = CoreToolCallStatus.Error; - finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`; + prefix = `Command terminated by signal: ${result.signal}.`; } else if (result.exitCode !== 0) { finalStatus = CoreToolCallStatus.Error; - finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`; + prefix = `Command exited with code ${result.exitCode}.`; + } + + if (prefix) { + finalOutput = + typeof finalOutput === 'string' + ? `${prefix}\n${finalOutput}` + : prependToAnsiOutput(finalOutput, prefix); + mainContent = `${prefix}\n${mainContent}`; } if (pwdFilePath && fs.existsSync(pwdFilePath)) { const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim(); if (finalPwd && finalPwd !== targetDir) { const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`; - finalOutput = `${warning}\n\n${finalOutput}`; + finalOutput = + typeof finalOutput === 'string' + ? `${warning}\n\n${finalOutput}` + : prependToAnsiOutput(finalOutput, warning); + mainContent = `${warning}\n\n${mainContent}`; } } @@ -578,7 +615,7 @@ export const useExecutionLifecycle = ( ); } - addShellCommandToGeminiHistory(geminiClient, rawQuery, finalOutput); + addShellCommandToGeminiHistory(geminiClient, rawQuery, mainContent); } catch (err) { setPendingHistoryItem(null); const errorMessage = err instanceof Error ? err.message : String(err); diff --git a/packages/core/src/services/executionLifecycleService.ts b/packages/core/src/services/executionLifecycleService.ts index a559fea82c..a16717e3d0 100644 --- a/packages/core/src/services/executionLifecycleService.ts +++ b/packages/core/src/services/executionLifecycleService.ts @@ -19,6 +19,7 @@ export type ExecutionMethod = export interface ExecutionResult { rawOutput?: Buffer; output: string; + ansiOutput?: AnsiOutput; exitCode: number | null; signal: number | null; error: Error | null; @@ -452,10 +453,13 @@ export class ExecutionLifecycleService { } = options ?? {}; const output = execution.getBackgroundOutput?.() ?? execution.output; + const snapshot = execution.getSubscriptionSnapshot?.(); + const ansiOutput = Array.isArray(snapshot) ? snapshot : undefined; this.settleExecution(executionId, { rawOutput: Buffer.from(output, 'utf8'), output, + ansiOutput, exitCode, signal, error, diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index c8866167c9..08b03ec539 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -1123,9 +1123,21 @@ export class ShellExecutionService { ShellExecutionService.activePtys.delete(ptyPid); }); + const endLine = headlessTerminal.buffer.active.length; + const startLine = Math.max( + 0, + endLine - (shellExecutionConfig.maxSerializedLines ?? 2000), + ); + const ansiOutputSnapshot = serializeTerminalToObject( + headlessTerminal, + startLine, + endLine, + ); + ExecutionLifecycleService.completeWithResult(ptyPid, { rawOutput: Buffer.from(''), output: getFullBufferText(headlessTerminal), + ansiOutput: ansiOutputSnapshot, exitCode, signal: signal ?? null, error, diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 63b3b62b16..6c0e946596 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -661,33 +661,34 @@ export class ShellToolInvocation extends BaseToolInvocation< llmContent = llmContentParts.join('\n'); } - let returnDisplayMessage = ''; + let returnDisplay: string | AnsiOutput = ''; if (this.context.config.getDebugMode()) { - returnDisplayMessage = llmContent; + returnDisplay = llmContent; } else { if (this.params.is_background || result.backgrounded) { - returnDisplayMessage = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`; + returnDisplay = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`; } else if (result.aborted) { const cancelMsg = timeoutMessage || 'Command cancelled by user.'; if (result.output.trim()) { - returnDisplayMessage = `${cancelMsg}\n\nOutput before cancellation:\n${result.output}`; + returnDisplay = `${cancelMsg}\n\nOutput before cancellation:\n${result.output}`; } else { - returnDisplayMessage = cancelMsg; + returnDisplay = cancelMsg; } - } else if (result.output.trim()) { - returnDisplayMessage = result.output; + } else if (result.output.trim() || result.ansiOutput) { + returnDisplay = + result.ansiOutput && result.ansiOutput.length > 0 + ? result.ansiOutput + : result.output; } else { if (result.signal) { - returnDisplayMessage = `Command terminated by signal: ${result.signal}`; + returnDisplay = `Command terminated by signal: ${result.signal}`; } else if (result.error) { - returnDisplayMessage = `Command failed: ${getErrorMessage( - result.error, - )}`; + returnDisplay = `Command failed: ${getErrorMessage(result.error)}`; } else if (result.exitCode !== null && result.exitCode !== 0) { - returnDisplayMessage = `Command exited with code: ${result.exitCode}`; + returnDisplay = `Command exited with code: ${result.exitCode}`; } // If output is empty and command succeeded (code 0, no error/signal/abort), - // returnDisplayMessage will remain empty, which is fine. + // returnDisplay will remain empty, which is fine. } } @@ -824,7 +825,7 @@ export class ShellToolInvocation extends BaseToolInvocation< return { llmContent: 'Sandbox expansion required', - returnDisplay: returnDisplayMessage, + returnDisplay, error: { type: ToolErrorType.SANDBOX_EXPANSION_REQUIRED, message: JSON.stringify(confirmationDetails), @@ -856,14 +857,14 @@ export class ShellToolInvocation extends BaseToolInvocation< ); return { llmContent: summary, - returnDisplay: returnDisplayMessage, + returnDisplay, ...executionError, }; } return { llmContent, - returnDisplay: returnDisplayMessage, + returnDisplay, data, ...executionError, };