From f9573d6352960fb7e44318fa0acf6d06377bc106 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Fri, 23 Jan 2026 11:58:41 -0800 Subject: [PATCH 01/38] feat(cli): truncate shell command output in history Refines the display of shell command outputs in the history to prevent excessive vertical scrolling and improve readability. Key changes: - Implemented pruneShellOutput utility in textUtils to safely truncate strings and AnsiOutput arrays. - Updated useShellCommandProcessor to prune shell execution results to a maximum of 10 lines before adding them to the history. - Updated toolMapping to apply truncation to run_shell_command results when mapping core status to display status. - Added maxLines prop to AnsiOutputText and ToolResultDisplay components to enforce line limits during rendering. - Configured ShellToolMessage to pass a 10-line limit to the display component. This ensures that long shell outputs are truncated in the UI history while retaining the full output for the model context. --- packages/cli/src/ui/components/AnsiOutput.tsx | 15 ++++++++--- .../components/messages/ShellToolMessage.tsx | 1 + .../components/messages/ToolResultDisplay.tsx | 9 ++++++- .../ui/hooks/shellCommandProcessor.test.tsx | 4 ++- .../cli/src/ui/hooks/shellCommandProcessor.ts | 7 ++++- packages/cli/src/ui/hooks/toolMapping.ts | 8 +++++- packages/cli/src/ui/utils/textUtils.ts | 26 +++++++++++++++++++ 7 files changed, 62 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index d31ae62b28a..387c70e12fb 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -14,18 +14,25 @@ interface AnsiOutputProps { data: AnsiOutput; availableTerminalHeight?: number; width: number; + maxLines?: number; } export const AnsiOutputText: React.FC = ({ data, availableTerminalHeight, width, + maxLines, }) => { - const lastLines = data.slice( - -(availableTerminalHeight && availableTerminalHeight > 0 + const effectiveHeight = + availableTerminalHeight && availableTerminalHeight > 0 ? availableTerminalHeight - : DEFAULT_HEIGHT), - ); + : (maxLines ?? DEFAULT_HEIGHT); + + const limit = maxLines + ? Math.min(effectiveHeight, maxLines) + : effectiveHeight; + + const lastLines = data.slice(-limit); return ( {lastLines.map((line: AnsiLine, lineIndex: number) => ( diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index 9eaabbb4fc1..d2735a0f7a6 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -153,6 +153,7 @@ export const ShellToolMessage: React.FC = ({ availableTerminalHeight={availableTerminalHeight} terminalWidth={terminalWidth} renderOutputAsMarkdown={renderOutputAsMarkdown} + maxLines={10} /> {isThisShellFocused && config && ( diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index f8dde620577..273ce300b16 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -27,6 +27,7 @@ export interface ToolResultDisplayProps { availableTerminalHeight?: number; terminalWidth: number; renderOutputAsMarkdown?: boolean; + maxLines?: number; } interface FileDiffResult { @@ -39,16 +40,21 @@ export const ToolResultDisplay: React.FC = ({ availableTerminalHeight, terminalWidth, renderOutputAsMarkdown = true, + maxLines, }) => { const { renderMarkdown } = useUIState(); - const availableHeight = availableTerminalHeight + let availableHeight = availableTerminalHeight ? Math.max( availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT, MIN_LINES_SHOWN + 1, // enforce minimum lines shown ) : undefined; + if (maxLines && availableHeight) { + availableHeight = Math.min(availableHeight, maxLines); + } + const combinedPaddingAndBorderWidth = 4; const childWidth = terminalWidth - combinedPaddingAndBorderWidth; @@ -107,6 +113,7 @@ export const ToolResultDisplay: React.FC = ({ data={truncatedResultDisplay as AnsiOutput} availableTerminalHeight={availableHeight} width={childWidth} + maxLines={maxLines} /> ); } diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx index e99b594d0d6..02fb40cf731 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx @@ -43,7 +43,9 @@ vi.mock('node:os', async (importOriginal) => { }; }); vi.mock('node:crypto'); -vi.mock('../utils/textUtils.js'); +vi.mock('../utils/textUtils.js', () => ({ + pruneShellOutput: vi.fn((output) => output), +})); import { useShellCommandProcessor, diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index 48d26db0424..bfad6bacb6c 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -26,8 +26,10 @@ import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; import { themeManager } from '../../ui/themes/theme-manager.js'; +import { pruneShellOutput } from '../utils/textUtils.js'; export const OUTPUT_UPDATE_INTERVAL_MS = 1000; +const MAX_OUTPUT_LINES_HISTORY = 10; const MAX_OUTPUT_LENGTH = 10000; function addShellCommandToGeminiHistory( @@ -284,7 +286,10 @@ export const useShellCommandProcessor = ( const finalToolDisplay: IndividualToolCallDisplay = { ...initialToolDisplay, status: finalStatus, - resultDisplay: finalOutput, + resultDisplay: pruneShellOutput( + finalOutput, + MAX_OUTPUT_LINES_HISTORY, + ), }; // Add the complete, contextual result to the local UI history. diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts index f9c0a3eb190..cfda2bab659 100644 --- a/packages/cli/src/ui/hooks/toolMapping.ts +++ b/packages/cli/src/ui/hooks/toolMapping.ts @@ -17,6 +17,9 @@ import { type HistoryItemToolGroup, type IndividualToolCallDisplay, } from '../types.js'; +import { pruneShellOutput } from '../utils/textUtils.js'; + +const MAX_OUTPUT_LINES_HISTORY = 10; import { checkExhaustive } from '../../utils/checks.js'; @@ -117,7 +120,10 @@ export function mapToDisplay( return { ...baseDisplayProperties, status: mapCoreStatusToDisplayStatus(call.status), - resultDisplay, + resultDisplay: + call.request.name === 'run_shell_command' + ? pruneShellOutput(resultDisplay, MAX_OUTPUT_LINES_HISTORY) + : resultDisplay, confirmationDetails, outputFile, ptyId, diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index 65fbed1df9b..29d93f7ed13 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -10,6 +10,32 @@ import { stripVTControlCharacters } from 'node:util'; import stringWidth from 'string-width'; import { LRUCache } from 'mnemonist'; import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../constants.js'; +import type { AnsiOutput, ToolResultDisplay } from '@google/gemini-cli-core'; + +/** + * Prunes shell output to the last N lines. + * Handles both string output and AnsiOutput object. + */ +export function pruneShellOutput( + output: ToolResultDisplay | undefined, + maxLines: number, +): ToolResultDisplay | undefined { + if (output === undefined) return undefined; + + if (typeof output === 'string') { + const lines = output.split('\n'); + if (lines.length <= maxLines) return output; + return lines.slice(-maxLines).join('\n'); + } + + if (Array.isArray(output)) { + // Assume it's AnsiOutput (AnsiLine[]) + if (output.length <= maxLines) return output; + return output.slice(-maxLines) as AnsiOutput; + } + + return output; +} /** * Calculates the maximum width of a multi-line ASCII art string. From bb4df34f75ec13b11c9b9216ca6c1085c574ff8a Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Fri, 23 Jan 2026 20:54:33 -0800 Subject: [PATCH 02/38] feat(cli): refine shell output with scrollable viewport and optimized limits Improves the shell command experience by adding interactive scrolling and fine-tuning output truncation. Key changes: - Centralized shell output constants in ui/constants.ts. - Increased active shell visibility to 25 lines (when unfocused). - Decreased historical shell output to 5 lines for a more compact history. - Implemented full viewport scrolling for active shell processes when focused. - Added Ctrl+Shift+PageUp/Down keybindings for page-based scrolling. - Centralized all shell scrolling logic in ShellInputPrompt. - Refined mapping logic to ensure full output is available during execution. --- .../src/ui/components/ShellInputPrompt.tsx | 30 +++++++++++++------ .../components/messages/ShellToolMessage.tsx | 4 ++- packages/cli/src/ui/constants.ts | 6 ++++ .../cli/src/ui/hooks/shellCommandProcessor.ts | 5 ++-- packages/cli/src/ui/hooks/toolMapping.ts | 10 ++++--- 5 files changed, 38 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/ui/components/ShellInputPrompt.tsx b/packages/cli/src/ui/components/ShellInputPrompt.tsx index 5cdafff00b7..7904ace2214 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.tsx @@ -9,15 +9,18 @@ import type React from 'react'; import { useKeypress } from '../hooks/useKeypress.js'; import { ShellExecutionService } from '@google/gemini-cli-core'; import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js'; +import { ACTIVE_SHELL_MAX_LINES } from '../constants.js'; export interface ShellInputPromptProps { activeShellPtyId: number | null; focus?: boolean; + scrollPageSize?: number; } export const ShellInputPrompt: React.FC = ({ activeShellPtyId, focus = true, + scrollPageSize = ACTIVE_SHELL_MAX_LINES, }) => { const handleShellInputSubmit = useCallback( (input: string) => { @@ -33,14 +36,23 @@ export const ShellInputPrompt: React.FC = ({ 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; + if (key.ctrl && key.shift) { + if (key.name === 'up') { + ShellExecutionService.scrollPty(activeShellPtyId, -1); + return; + } + if (key.name === 'down') { + ShellExecutionService.scrollPty(activeShellPtyId, 1); + return; + } + if (key.name === 'pageup') { + ShellExecutionService.scrollPty(activeShellPtyId, -scrollPageSize); + return; + } + if (key.name === 'pagedown') { + ShellExecutionService.scrollPty(activeShellPtyId, scrollPageSize); + return; + } } const ansiSequence = keyToAnsi(key); @@ -48,7 +60,7 @@ export const ShellInputPrompt: React.FC = ({ handleShellInputSubmit(ansiSequence); } }, - [focus, handleShellInputSubmit, activeShellPtyId], + [focus, handleShellInputSubmit, activeShellPtyId, scrollPageSize], ); useKeypress(handleInput, { isActive: focus }); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index d2735a0f7a6..f1cffac4300 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -23,6 +23,7 @@ import { } from './ToolShared.js'; import type { ToolMessageProps } from './ToolMessage.js'; import type { Config } from '@google/gemini-cli-core'; +import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; export interface ShellToolMessageProps extends ToolMessageProps { activeShellPtyId?: number | null; @@ -153,13 +154,14 @@ export const ShellToolMessage: React.FC = ({ availableTerminalHeight={availableTerminalHeight} terminalWidth={terminalWidth} renderOutputAsMarkdown={renderOutputAsMarkdown} - maxLines={10} + maxLines={isThisShellFocused ? undefined : ACTIVE_SHELL_MAX_LINES} /> {isThisShellFocused && config && ( )} diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 75f17708379..d2f354dc02b 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -37,3 +37,9 @@ export const SHELL_SILENT_WORKING_TITLE_DELAY_MS = 120000; export const KEYBOARD_SHORTCUTS_URL = 'https://geminicli.com/docs/cli/keyboard-shortcuts/'; export const LRU_BUFFER_PERF_CACHE_LIMIT = 20000; + +// Max lines to show for active shell output when not focused +export const ACTIVE_SHELL_MAX_LINES = 25; + +// Max lines to preserve in history for completed shell commands +export const SHELL_HISTORY_MAX_LINES = 5; diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index bfad6bacb6c..1e382bbb0a3 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -19,7 +19,7 @@ import type { import { isBinary, ShellExecutionService } from '@google/gemini-cli-core'; import { type PartListUnion } from '@google/genai'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; -import { SHELL_COMMAND_NAME } from '../constants.js'; +import { SHELL_COMMAND_NAME , SHELL_HISTORY_MAX_LINES } from '../constants.js'; import { formatMemoryUsage } from '../utils/formatters.js'; import crypto from 'node:crypto'; import path from 'node:path'; @@ -29,7 +29,6 @@ import { themeManager } from '../../ui/themes/theme-manager.js'; import { pruneShellOutput } from '../utils/textUtils.js'; export const OUTPUT_UPDATE_INTERVAL_MS = 1000; -const MAX_OUTPUT_LINES_HISTORY = 10; const MAX_OUTPUT_LENGTH = 10000; function addShellCommandToGeminiHistory( @@ -288,7 +287,7 @@ export const useShellCommandProcessor = ( status: finalStatus, resultDisplay: pruneShellOutput( finalOutput, - MAX_OUTPUT_LINES_HISTORY, + SHELL_HISTORY_MAX_LINES, ), }; diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts index cfda2bab659..cb5b3061945 100644 --- a/packages/cli/src/ui/hooks/toolMapping.ts +++ b/packages/cli/src/ui/hooks/toolMapping.ts @@ -18,8 +18,7 @@ import { type IndividualToolCallDisplay, } from '../types.js'; import { pruneShellOutput } from '../utils/textUtils.js'; - -const MAX_OUTPUT_LINES_HISTORY = 10; +import { SHELL_HISTORY_MAX_LINES } from '../constants.js'; import { checkExhaustive } from '../../utils/checks.js'; @@ -121,8 +120,11 @@ export function mapToDisplay( ...baseDisplayProperties, status: mapCoreStatusToDisplayStatus(call.status), resultDisplay: - call.request.name === 'run_shell_command' - ? pruneShellOutput(resultDisplay, MAX_OUTPUT_LINES_HISTORY) + call.request.name === 'run_shell_command' && + (call.status === 'success' || + call.status === 'error' || + call.status === 'cancelled') + ? pruneShellOutput(resultDisplay, SHELL_HISTORY_MAX_LINES) : resultDisplay, confirmationDetails, outputFile, From 9ebd7d0ce1d9c2a0f2edafee05ff8d9c13beb72e Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Sat, 24 Jan 2026 03:35:04 -0800 Subject: [PATCH 03/38] test(cli): add comprehensive test coverage for output truncation and shell scrolling --- .../cli/src/ui/components/AnsiOutput.test.tsx | 39 ++++++++++++++ .../ui/components/ShellInputPrompt.test.tsx | 48 +++++++++++++++++ .../messages/ToolResultDisplay.test.tsx | 54 +++++++++++++++++++ .../cli/src/ui/hooks/shellCommandProcessor.ts | 2 +- packages/cli/src/ui/hooks/toolMapping.test.ts | 47 ++++++++++++++++ packages/cli/src/ui/utils/textUtils.test.ts | 48 +++++++++++++++++ 6 files changed, 237 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx index 2ecfe93e692..36922cb5053 100644 --- a/packages/cli/src/ui/components/AnsiOutput.test.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx @@ -89,6 +89,45 @@ describe('', () => { expect(output).toContain('Line 4'); }); + it('respects the maxLines 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('prioritizes maxLines over availableTerminalHeight if maxLines is smaller', () => { + const data: AnsiOutput = [ + [createAnsiToken({ text: 'Line 1' })], + [createAnsiToken({ text: 'Line 2' })], + [createAnsiToken({ text: 'Line 3' })], + [createAnsiToken({ text: 'Line 4' })], + ]; + // availableTerminalHeight=3, maxLines=2 => show 2 lines + const { lastFrame } = render( + , + ); + const output = lastFrame(); + 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++) { diff --git a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx index 5a204b0580b..36b156a30e4 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx @@ -78,6 +78,54 @@ describe('ShellInputPrompt', () => { expect(mockScrollPty).toHaveBeenCalledWith(1, direction); }); + it.each([ + ['pageup', -25], + ['pagedown', 25], + ])( + 'handles page scroll %s (Ctrl+Shift+%s) with default size', + (key, expectedScroll) => { + render(); + + const handler = mockUseKeypress.mock.calls[0][0]; + + handler({ name: key, shift: true, alt: false, ctrl: true, cmd: false }); + + expect(mockScrollPty).toHaveBeenCalledWith(1, expectedScroll); + }, + ); + + it('respects scrollPageSize prop', () => { + render( + , + ); + + const handler = mockUseKeypress.mock.calls[0][0]; + + // PageDown + handler({ + name: 'pagedown', + shift: true, + alt: false, + ctrl: true, + cmd: false, + }); + expect(mockScrollPty).toHaveBeenCalledWith(1, 10); + + // PageUp + handler({ + name: 'pageup', + shift: true, + alt: false, + ctrl: true, + cmd: false, + }); + expect(mockScrollPty).toHaveBeenCalledWith(1, -10); + }); + it('does not handle input when not focused', () => { render(); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx index 41f79aab085..3f0d27671ab 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -191,4 +191,58 @@ describe('ToolResultDisplay', () => { expect(output).toMatchSnapshot(); }); + + it('truncates ANSI output when maxLines is provided', () => { + const ansiResult: AnsiOutput = [ + [ + { + text: 'Line 1', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + [ + { + text: 'Line 2', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + [ + { + text: 'Line 3', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + ]; + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + expect(output).not.toContain('Line 1'); + expect(output).toContain('Line 2'); + expect(output).toContain('Line 3'); + }); }); diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index 1e382bbb0a3..f1bc6a31c11 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -19,7 +19,7 @@ import type { import { isBinary, ShellExecutionService } from '@google/gemini-cli-core'; import { type PartListUnion } from '@google/genai'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; -import { SHELL_COMMAND_NAME , SHELL_HISTORY_MAX_LINES } from '../constants.js'; +import { SHELL_COMMAND_NAME, SHELL_HISTORY_MAX_LINES } from '../constants.js'; import { formatMemoryUsage } from '../utils/formatters.js'; import crypto from 'node:crypto'; import path from 'node:path'; diff --git a/packages/cli/src/ui/hooks/toolMapping.test.ts b/packages/cli/src/ui/hooks/toolMapping.test.ts index 41dc974adb7..b90f58e1d4d 100644 --- a/packages/cli/src/ui/hooks/toolMapping.test.ts +++ b/packages/cli/src/ui/hooks/toolMapping.test.ts @@ -32,6 +32,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); +vi.mock('../utils/textUtils.js', () => ({ + pruneShellOutput: vi.fn((output) => `pruned: ${output}`), +})); + describe('toolMapping', () => { beforeEach(() => { vi.clearAllMocks(); @@ -256,5 +260,48 @@ describe('toolMapping', () => { expect(displayTool.status).toBe(ToolCallStatus.Canceled); expect(displayTool.resultDisplay).toBe('User cancelled'); }); + + it('prunes shell output for successful run_shell_command', () => { + const toolCall: SuccessfulToolCall = { + status: 'success', + request: { ...mockRequest, name: 'run_shell_command' }, + tool: { ...mockTool, name: 'run_shell_command' }, + invocation: mockInvocation, + response: { ...mockResponse, resultDisplay: 'long output' }, + }; + + const result = mapToDisplay(toolCall); + const displayTool = result.tools[0]; + + expect(displayTool.resultDisplay).toBe('pruned: long output'); + }); + + it('prunes shell output for error run_shell_command', () => { + const toolCall: ToolCall = { + status: 'error', + request: { ...mockRequest, name: 'run_shell_command' }, + response: { ...mockResponse, resultDisplay: 'long error output' }, + }; + + const result = mapToDisplay(toolCall); + const displayTool = result.tools[0]; + + expect(displayTool.resultDisplay).toBe('pruned: long error output'); + }); + + it('does NOT prune output for other tools', () => { + const toolCall: SuccessfulToolCall = { + status: 'success', + request: { ...mockRequest, name: 'read_file' }, + tool: { ...mockTool, name: 'read_file' }, + invocation: mockInvocation, + response: { ...mockResponse, resultDisplay: 'long file content' }, + }; + + const result = mapToDisplay(toolCall); + const displayTool = result.tools[0]; + + expect(displayTool.resultDisplay).toBe('long file content'); + }); }); }); diff --git a/packages/cli/src/ui/utils/textUtils.test.ts b/packages/cli/src/ui/utils/textUtils.test.ts index 3a7a6ac1f37..5e8293fb7b4 100644 --- a/packages/cli/src/ui/utils/textUtils.test.ts +++ b/packages/cli/src/ui/utils/textUtils.test.ts @@ -8,15 +8,63 @@ import { describe, it, expect } from 'vitest'; import type { ToolCallConfirmationDetails, ToolEditConfirmationDetails, + AnsiOutput, } from '@google/gemini-cli-core'; import { escapeAnsiCtrlCodes, stripUnsafeCharacters, getCachedStringWidth, sanitizeForListDisplay, + pruneShellOutput, } from './textUtils.js'; describe('textUtils', () => { + describe('pruneShellOutput', () => { + it('should return undefined if input is undefined', () => { + expect(pruneShellOutput(undefined, 5)).toBeUndefined(); + }); + + it('should return original string if shorter than limit', () => { + const output = 'line1\nline2'; + expect(pruneShellOutput(output, 5)).toBe(output); + }); + + it('should truncate string if longer than limit', () => { + const output = 'line1\nline2\nline3\nline4\nline5\nline6'; + const expected = 'line2\nline3\nline4\nline5\nline6'; + expect(pruneShellOutput(output, 5)).toBe(expected); + }); + + it('should return original AnsiOutput if shorter than limit', () => { + const output: AnsiOutput = ['line1', 'line2']; + expect(pruneShellOutput(output, 5)).toBe(output); + }); + + it('should truncate AnsiOutput if longer than limit', () => { + const output: AnsiOutput = [ + 'line1', + 'line2', + 'line3', + 'line4', + 'line5', + 'line6', + ]; + const expected: AnsiOutput = [ + 'line2', + 'line3', + 'line4', + 'line5', + 'line6', + ]; + expect(pruneShellOutput(output, 5)).toEqual(expected); + }); + + it('should return original input if not string or array', () => { + const output = 123 as unknown as string; + expect(pruneShellOutput(output, 5)).toBe(output); + }); + }); + describe('sanitizeForListDisplay', () => { it('should strip ANSI codes and replace newlines/tabs with spaces', () => { const input = '\u001b[31mLine 1\nLine 2\tTabbed\r\nEnd\u001b[0m'; From 298f99022439394f2fc1c7a60b4ce76986bdff5d Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Sat, 24 Jan 2026 03:40:08 -0800 Subject: [PATCH 04/38] fix(cli): optimize pruneShellOutput performance and trailing newline handling --- packages/cli/src/ui/utils/textUtils.test.ts | 5 +++++ packages/cli/src/ui/utils/textUtils.ts | 21 ++++++++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/utils/textUtils.test.ts b/packages/cli/src/ui/utils/textUtils.test.ts index 5e8293fb7b4..c08b00013ec 100644 --- a/packages/cli/src/ui/utils/textUtils.test.ts +++ b/packages/cli/src/ui/utils/textUtils.test.ts @@ -63,6 +63,11 @@ describe('textUtils', () => { const output = 123 as unknown as string; expect(pruneShellOutput(output, 5)).toBe(output); }); + + it('should correctly handle trailing newlines', () => { + const output = 'a\nb\n'; + expect(pruneShellOutput(output, 1)).toBe('b\n'); + }); }); describe('sanitizeForListDisplay', () => { diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index 29d93f7ed13..6100bc988ee 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -23,9 +23,24 @@ export function pruneShellOutput( if (output === undefined) return undefined; if (typeof output === 'string') { - const lines = output.split('\n'); - if (lines.length <= maxLines) return output; - return lines.slice(-maxLines).join('\n'); + if (maxLines <= 0) return ''; + + let searchEnd = output.length; + if (output.endsWith('\n')) { + searchEnd--; + } + + let newlineCount = 0; + for (let i = searchEnd - 1; i >= 0; i--) { + if (output[i] === '\n') { + newlineCount++; + if (newlineCount === maxLines) { + return output.substring(i + 1); + } + } + } + + return output; } if (Array.isArray(output)) { From fcbe4eef09423836ed40aa974634ad28dff4682e Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Sat, 24 Jan 2026 09:55:47 -0800 Subject: [PATCH 05/38] test(cli): fix type errors in toolMapping and textUtils mocks --- packages/cli/src/ui/hooks/toolMapping.test.ts | 10 ++++++-- packages/cli/src/ui/utils/textUtils.test.ts | 24 +++++++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/hooks/toolMapping.test.ts b/packages/cli/src/ui/hooks/toolMapping.test.ts index b90f58e1d4d..54c053505ed 100644 --- a/packages/cli/src/ui/hooks/toolMapping.test.ts +++ b/packages/cli/src/ui/hooks/toolMapping.test.ts @@ -265,7 +265,10 @@ describe('toolMapping', () => { const toolCall: SuccessfulToolCall = { status: 'success', request: { ...mockRequest, name: 'run_shell_command' }, - tool: { ...mockTool, name: 'run_shell_command' }, + tool: { + ...mockTool, + name: 'run_shell_command', + } as AnyDeclarativeTool, invocation: mockInvocation, response: { ...mockResponse, resultDisplay: 'long output' }, }; @@ -293,7 +296,10 @@ describe('toolMapping', () => { const toolCall: SuccessfulToolCall = { status: 'success', request: { ...mockRequest, name: 'read_file' }, - tool: { ...mockTool, name: 'read_file' }, + tool: { + ...mockTool, + name: 'read_file', + } as AnyDeclarativeTool, invocation: mockInvocation, response: { ...mockResponse, resultDisplay: 'long file content' }, }; diff --git a/packages/cli/src/ui/utils/textUtils.test.ts b/packages/cli/src/ui/utils/textUtils.test.ts index c08b00013ec..2135bdab13f 100644 --- a/packages/cli/src/ui/utils/textUtils.test.ts +++ b/packages/cli/src/ui/utils/textUtils.test.ts @@ -18,6 +18,20 @@ import { pruneShellOutput, } from './textUtils.js'; +const createMockAnsiOutput = (lines: string[]): AnsiOutput => + lines.map((line) => [ + { + text: line, + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + fg: '', + bg: '', + }, + ]); + describe('textUtils', () => { describe('pruneShellOutput', () => { it('should return undefined if input is undefined', () => { @@ -36,26 +50,26 @@ describe('textUtils', () => { }); it('should return original AnsiOutput if shorter than limit', () => { - const output: AnsiOutput = ['line1', 'line2']; + const output: AnsiOutput = createMockAnsiOutput(['line1', 'line2']); expect(pruneShellOutput(output, 5)).toBe(output); }); it('should truncate AnsiOutput if longer than limit', () => { - const output: AnsiOutput = [ + const output: AnsiOutput = createMockAnsiOutput([ 'line1', 'line2', 'line3', 'line4', 'line5', 'line6', - ]; - const expected: AnsiOutput = [ + ]); + const expected: AnsiOutput = createMockAnsiOutput([ 'line2', 'line3', 'line4', 'line5', 'line6', - ]; + ]); expect(pruneShellOutput(output, 5)).toEqual(expected); }); From fabd425f54ac3247213b2d33c52c25fe68c16a25 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Mon, 2 Feb 2026 19:35:05 -0800 Subject: [PATCH 06/38] feat(cli): increase shell history output to 25 lines --- packages/cli/src/ui/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 7e7b30efa18..64d3ad4b95e 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -44,4 +44,4 @@ export const LRU_BUFFER_PERF_CACHE_LIMIT = 20000; export const ACTIVE_SHELL_MAX_LINES = 25; // Max lines to preserve in history for completed shell commands -export const SHELL_HISTORY_MAX_LINES = 5; +export const SHELL_HISTORY_MAX_LINES = 25; From b4df51d52d58bf1c5fd87953d7f26af92f9bddb1 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Tue, 3 Feb 2026 01:35:06 -0800 Subject: [PATCH 07/38] refactor(cli): persist full shell output in history Removes premature truncation of shell output in the data layer (shellCommandProcessor, toolMapping) to allow full history access. Simplifies textUtils by removing unused pruning logic. --- .../ui/hooks/shellCommandProcessor.test.tsx | 3 - .../cli/src/ui/hooks/shellCommandProcessor.ts | 8 +-- packages/cli/src/ui/hooks/toolMapping.test.ts | 12 ++-- packages/cli/src/ui/hooks/toolMapping.ts | 10 +-- packages/cli/src/ui/utils/textUtils.test.ts | 67 ------------------- packages/cli/src/ui/utils/textUtils.ts | 41 ------------ 6 files changed, 7 insertions(+), 134 deletions(-) diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx index 10f76b0e3d1..d2626515909 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx @@ -65,9 +65,6 @@ vi.mock('node:os', async (importOriginal) => { }; }); vi.mock('node:crypto'); -vi.mock('../utils/textUtils.js', () => ({ - pruneShellOutput: vi.fn((output) => output), -})); import { useShellCommandProcessor, diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index 402261b4b4a..860bece5d8f 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -14,14 +14,13 @@ import type { AnsiOutput, Config, GeminiClient } from '@google/gemini-cli-core'; import { isBinary, ShellExecutionService } from '@google/gemini-cli-core'; import { type PartListUnion } from '@google/genai'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; -import { SHELL_COMMAND_NAME, SHELL_HISTORY_MAX_LINES } from '../constants.js'; +import { SHELL_COMMAND_NAME } from '../constants.js'; import { formatBytes } from '../utils/formatters.js'; import crypto from 'node:crypto'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; import { themeManager } from '../../ui/themes/theme-manager.js'; -import { pruneShellOutput } from '../utils/textUtils.js'; import { shellReducer, initialState, @@ -478,10 +477,7 @@ export const useShellCommandProcessor = ( const finalToolDisplay: IndividualToolCallDisplay = { ...initialToolDisplay, status: finalStatus, - resultDisplay: pruneShellOutput( - finalOutput, - SHELL_HISTORY_MAX_LINES, - ), + resultDisplay: finalOutput, }; if (finalStatus !== ToolCallStatus.Canceled) { diff --git a/packages/cli/src/ui/hooks/toolMapping.test.ts b/packages/cli/src/ui/hooks/toolMapping.test.ts index 54c053505ed..660c6feaaaf 100644 --- a/packages/cli/src/ui/hooks/toolMapping.test.ts +++ b/packages/cli/src/ui/hooks/toolMapping.test.ts @@ -32,10 +32,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); -vi.mock('../utils/textUtils.js', () => ({ - pruneShellOutput: vi.fn((output) => `pruned: ${output}`), -})); - describe('toolMapping', () => { beforeEach(() => { vi.clearAllMocks(); @@ -261,7 +257,7 @@ describe('toolMapping', () => { expect(displayTool.resultDisplay).toBe('User cancelled'); }); - it('prunes shell output for successful run_shell_command', () => { + it('does not prune shell output for successful run_shell_command', () => { const toolCall: SuccessfulToolCall = { status: 'success', request: { ...mockRequest, name: 'run_shell_command' }, @@ -276,10 +272,10 @@ describe('toolMapping', () => { const result = mapToDisplay(toolCall); const displayTool = result.tools[0]; - expect(displayTool.resultDisplay).toBe('pruned: long output'); + expect(displayTool.resultDisplay).toBe('long output'); }); - it('prunes shell output for error run_shell_command', () => { + it('does not prune shell output for error run_shell_command', () => { const toolCall: ToolCall = { status: 'error', request: { ...mockRequest, name: 'run_shell_command' }, @@ -289,7 +285,7 @@ describe('toolMapping', () => { const result = mapToDisplay(toolCall); const displayTool = result.tools[0]; - expect(displayTool.resultDisplay).toBe('pruned: long error output'); + expect(displayTool.resultDisplay).toBe('long error output'); }); it('does NOT prune output for other tools', () => { diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts index 0c29e2bce1b..902db1333e3 100644 --- a/packages/cli/src/ui/hooks/toolMapping.ts +++ b/packages/cli/src/ui/hooks/toolMapping.ts @@ -17,8 +17,6 @@ import { type HistoryItemToolGroup, type IndividualToolCallDisplay, } from '../types.js'; -import { pruneShellOutput } from '../utils/textUtils.js'; -import { SHELL_HISTORY_MAX_LINES } from '../constants.js'; import { checkExhaustive } from '../../utils/checks.js'; @@ -121,13 +119,7 @@ export function mapToDisplay( return { ...baseDisplayProperties, status: mapCoreStatusToDisplayStatus(call.status), - resultDisplay: - call.request.name === 'run_shell_command' && - (call.status === 'success' || - call.status === 'error' || - call.status === 'cancelled') - ? pruneShellOutput(resultDisplay, SHELL_HISTORY_MAX_LINES) - : resultDisplay, + resultDisplay, confirmationDetails, outputFile, ptyId, diff --git a/packages/cli/src/ui/utils/textUtils.test.ts b/packages/cli/src/ui/utils/textUtils.test.ts index e004da77fd0..62462dddf67 100644 --- a/packages/cli/src/ui/utils/textUtils.test.ts +++ b/packages/cli/src/ui/utils/textUtils.test.ts @@ -8,82 +8,15 @@ import { describe, it, expect } from 'vitest'; import type { ToolCallConfirmationDetails, ToolEditConfirmationDetails, - AnsiOutput, } from '@google/gemini-cli-core'; import { escapeAnsiCtrlCodes, stripUnsafeCharacters, getCachedStringWidth, sanitizeForDisplay, - pruneShellOutput, } from './textUtils.js'; -const createMockAnsiOutput = (lines: string[]): AnsiOutput => - lines.map((line) => [ - { - text: line, - bold: false, - italic: false, - underline: false, - dim: false, - inverse: false, - fg: '', - bg: '', - }, - ]); - describe('textUtils', () => { - describe('pruneShellOutput', () => { - it('should return undefined if input is undefined', () => { - expect(pruneShellOutput(undefined, 5)).toBeUndefined(); - }); - - it('should return original string if shorter than limit', () => { - const output = 'line1\nline2'; - expect(pruneShellOutput(output, 5)).toBe(output); - }); - - it('should truncate string if longer than limit', () => { - const output = 'line1\nline2\nline3\nline4\nline5\nline6'; - const expected = 'line2\nline3\nline4\nline5\nline6'; - expect(pruneShellOutput(output, 5)).toBe(expected); - }); - - it('should return original AnsiOutput if shorter than limit', () => { - const output: AnsiOutput = createMockAnsiOutput(['line1', 'line2']); - expect(pruneShellOutput(output, 5)).toBe(output); - }); - - it('should truncate AnsiOutput if longer than limit', () => { - const output: AnsiOutput = createMockAnsiOutput([ - 'line1', - 'line2', - 'line3', - 'line4', - 'line5', - 'line6', - ]); - const expected: AnsiOutput = createMockAnsiOutput([ - 'line2', - 'line3', - 'line4', - 'line5', - 'line6', - ]); - expect(pruneShellOutput(output, 5)).toEqual(expected); - }); - - it('should return original input if not string or array', () => { - const output = 123 as unknown as string; - expect(pruneShellOutput(output, 5)).toBe(output); - }); - - it('should correctly handle trailing newlines', () => { - const output = 'a\nb\n'; - expect(pruneShellOutput(output, 1)).toBe('b\n'); - }); - }); - describe('sanitizeForListDisplay', () => { it('should strip ANSI codes and replace newlines/tabs with spaces', () => { const input = '\u001b[31mLine 1\nLine 2\tTabbed\r\nEnd\u001b[0m'; diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index 2d74c8d42ee..569ede8697a 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -10,47 +10,6 @@ import { stripVTControlCharacters } from 'node:util'; import stringWidth from 'string-width'; import { LRUCache } from 'mnemonist'; import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../constants.js'; -import type { AnsiOutput, ToolResultDisplay } from '@google/gemini-cli-core'; - -/** - * Prunes shell output to the last N lines. - * Handles both string output and AnsiOutput object. - */ -export function pruneShellOutput( - output: ToolResultDisplay | undefined, - maxLines: number, -): ToolResultDisplay | undefined { - if (output === undefined) return undefined; - - if (typeof output === 'string') { - if (maxLines <= 0) return ''; - - let searchEnd = output.length; - if (output.endsWith('\n')) { - searchEnd--; - } - - let newlineCount = 0; - for (let i = searchEnd - 1; i >= 0; i--) { - if (output[i] === '\n') { - newlineCount++; - if (newlineCount === maxLines) { - return output.substring(i + 1); - } - } - } - - return output; - } - - if (Array.isArray(output)) { - // Assume it's AnsiOutput (AnsiLine[]) - if (output.length <= maxLines) return output; - return output.slice(-maxLines) as AnsiOutput; - } - - return output; -} /** * Calculates the maximum width of a multi-line ASCII art string. From ef0f934e353df44e58037da98aedc54168809505 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Tue, 3 Feb 2026 01:36:10 -0800 Subject: [PATCH 08/38] feat(cli): implement scrollable shell panes in alternate buffer Updates ToolResultDisplay to wrap content in a Scrollable component when in alternate buffer mode, enforcing a fixed height (25 lines) with scrolling support. Updates ShellToolMessage to expand focused panes. Adjusts tests and snapshots to reflect the new layout. --- packages/cli/src/ui/components/AnsiOutput.tsx | 4 +- .../components/messages/ShellToolMessage.tsx | 10 ++- .../messages/ToolResultDisplay.test.tsx | 39 +++++++++ .../components/messages/ToolResultDisplay.tsx | 45 +++++++++-- .../ToolResultDisplayOverflow.test.tsx | 1 + .../ToolGroupMessage.test.tsx.snap | 80 +++++++++++++++++++ .../ToolMessageRawMarkdown.test.tsx.snap | 4 +- .../ToolResultDisplay.test.tsx.snap | 5 +- .../ToolStickyHeaderRegression.test.tsx.snap | 8 +- 9 files changed, 181 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index 387c70e12fb..ffcfa0da910 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -15,6 +15,7 @@ interface AnsiOutputProps { availableTerminalHeight?: number; width: number; maxLines?: number; + disableTruncation?: boolean; } export const AnsiOutputText: React.FC = ({ @@ -22,6 +23,7 @@ export const AnsiOutputText: React.FC = ({ availableTerminalHeight, width, maxLines, + disableTruncation, }) => { const effectiveHeight = availableTerminalHeight && availableTerminalHeight > 0 @@ -32,7 +34,7 @@ export const AnsiOutputText: React.FC = ({ ? Math.min(effectiveHeight, maxLines) : effectiveHeight; - const lastLines = data.slice(-limit); + const lastLines = disableTruncation ? data : data.slice(-limit); return ( {lastLines.map((line: AnsiLine, lineIndex: number) => ( diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index f1cffac4300..04091d13150 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -22,8 +22,9 @@ import { FocusHint, } from './ToolShared.js'; import type { ToolMessageProps } from './ToolMessage.js'; -import type { Config } from '@google/gemini-cli-core'; import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; +import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; +import type { Config } from '@google/gemini-cli-core'; export interface ShellToolMessageProps extends ToolMessageProps { activeShellPtyId?: number | null; @@ -62,6 +63,7 @@ export const ShellToolMessage: React.FC = ({ borderDimColor, }) => { + const isAlternateBuffer = useAlternateBuffer(); const isThisShellFocused = checkIsShellFocused( name, status, @@ -154,7 +156,11 @@ export const ShellToolMessage: React.FC = ({ availableTerminalHeight={availableTerminalHeight} terminalWidth={terminalWidth} renderOutputAsMarkdown={renderOutputAsMarkdown} - maxLines={isThisShellFocused ? undefined : ACTIVE_SHELL_MAX_LINES} + maxLines={ + isAlternateBuffer && isThisShellFocused + ? undefined + : ACTIVE_SHELL_MAX_LINES + } /> {isThisShellFocused && config && ( diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx index 9eec7b96945..05cd3c88e97 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -61,6 +61,16 @@ vi.mock('../../contexts/OverflowContext.js', () => ({ }), })); +// Mock Scrollable +vi.mock('../shared/Scrollable.js', () => ({ + Scrollable: ({ children }: { children: React.ReactNode }) => ( + + Scrollable Container + {children} + + ), +})); + describe('ToolResultDisplay', () => { beforeEach(() => { vi.clearAllMocks(); @@ -68,6 +78,35 @@ describe('ToolResultDisplay', () => { mockUseAlternateBuffer.mockReturnValue(false); }); + it('uses Scrollable for ANSI output in alternate buffer mode', () => { + mockUseAlternateBuffer.mockReturnValue(true); + const ansiResult: AnsiOutput = [ + [ + { + text: 'ansi content', + fg: 'red', + bg: 'black', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + ]; + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + expect(output).toContain('Scrollable Container'); + expect(output).toContain('ansi content'); + }); + it('renders string result as markdown by default', () => { const { lastFrame } = render( , diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index bab64f852ed..33a62401c93 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -14,6 +14,8 @@ import { theme } from '../../semantic-colors.js'; import type { AnsiOutput } from '@google/gemini-cli-core'; import { useUIState } from '../../contexts/UIStateContext.js'; import { tryParseJSON } from '../../../utils/jsonoutput.js'; +import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; +import { Scrollable } from '../shared/Scrollable.js'; const STATIC_HEIGHT = 1; const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint @@ -44,6 +46,7 @@ export const ToolResultDisplay: React.FC = ({ maxLines, }) => { const { renderMarkdown } = useUIState(); + const isAlternateBuffer = useAlternateBuffer(); let availableHeight = availableTerminalHeight ? Math.max( @@ -60,13 +63,27 @@ export const ToolResultDisplay: React.FC = ({ const childWidth = terminalWidth - combinedPaddingAndBorderWidth; const truncatedResultDisplay = React.useMemo(() => { - if (typeof resultDisplay === 'string') { - if (resultDisplay.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) { - return '...' + resultDisplay.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS); + // Only truncate string output if not in alternate buffer mode to ensure + // we can scroll through the full output. + if (typeof resultDisplay === 'string' && !isAlternateBuffer) { + let text = resultDisplay; + if (text.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) { + text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS); } + if (maxLines) { + const hasTrailingNewline = text.endsWith('\n'); + const contentText = hasTrailingNewline ? text.slice(0, -1) : text; + const lines = contentText.split('\n'); + if (lines.length > maxLines) { + text = + lines.slice(-maxLines).join('\n') + + (hasTrailingNewline ? '\n' : ''); + } + } + return text; } return resultDisplay; - }, [resultDisplay]); + }, [resultDisplay, isAlternateBuffer, maxLines]); if (!truncatedResultDisplay) return null; @@ -129,13 +146,29 @@ export const ToolResultDisplay: React.FC = ({ content = ( ); } + if (isAlternateBuffer) { + return ( + + {content} + + ); + } + return ( diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx index 6e15d7902d7..f9911718610 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx @@ -49,6 +49,7 @@ describe('ToolResultDisplay Overflow', () => { streamingState: StreamingState.Idle, constrainHeight: true, }, + useAlternateBuffer: false, }, ); diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap index 925568daa61..d4dc7b89889 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap @@ -47,6 +47,30 @@ exports[` > Border Color Logic > uses yellow border for shel │ ✓ run_shell_command A tool for testing │ │ │ │ Test result │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ ╰──────────────────────────────────────────────────────────────────────────────╯" `; @@ -145,6 +169,30 @@ exports[` > Golden Snapshots > renders mixed tool calls incl │ │ │ Test result │ │ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ │ o write_file Write to file │ │ │ │ Test result │ @@ -172,6 +220,30 @@ exports[` > Golden Snapshots > renders shell command with ye │ ✓ run_shell_command Execute shell command │ │ │ │ Test result │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ ╰──────────────────────────────────────────────────────────────────────────────╯" `; @@ -230,9 +302,13 @@ exports[` > Golden Snapshots > renders with limited terminal │ │ │ This is a long result that might need height constraints │ │ │ +│ │ +│ │ │ ✓ another-tool Another tool │ │ │ │ More output here │ +│ │ +│ │ ╰──────────────────────────────────────────────────────────────────────────────╯" `; @@ -250,10 +326,14 @@ exports[` > Height Calculation > calculates available height │ │ │ Result 1 │ │ │ +│ │ +│ │ │ ✓ test-tool A tool for testing │ │ │ │ Result 2 │ │ │ +│ │ +│ │ │ ✓ test-tool A tool for testing │ │ │ ╰──────────────────────────────────────────────────────────────────────────────╯" diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageRawMarkdown.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageRawMarkdown.test.tsx.snap index 5a0a17f7e94..a40a9d1398e 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageRawMarkdown.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageRawMarkdown.test.tsx.snap @@ -32,7 +32,9 @@ exports[` - Raw Markdown Display Snapshots > renders with renderM "╭──────────────────────────────────────────────────────────────────────────────╮ │ ✓ test-tool A tool for testing │ │ │ -│ Test bold and code markdown │" +│ Test bold and code markdown │ +│ │ +│ │" `; exports[` - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=true '(default, alternate buffer)' 1`] = ` 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 e90c3659510..3bc12b1b3d1 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 @@ -2,7 +2,10 @@ exports[`ToolResultDisplay > does not fall back to plain text if availableHeight is set and not in alternate buffer 1`] = `"Some result"`; -exports[`ToolResultDisplay > keeps markdown if in alternate buffer even with availableHeight 1`] = `"Some result"`; +exports[`ToolResultDisplay > keeps markdown if in alternate buffer even with availableHeight 1`] = ` +"Scrollable Container +Some result" +`; exports[`ToolResultDisplay > renders ANSI output result 1`] = `"ansi content"`; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap index 9fa4d21ab95..0d2729d26cc 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap @@ -2,7 +2,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = ` "╭────────────────────────────────────────────────────────────────────────────╮ █ -│ ✓ Shell Command Description for Shell Command │ █ +│ ✓ Shell Command Description for Shell Command │ │ │ │ shell-01 │ │ shell-02 │" @@ -10,9 +10,9 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = ` "╭────────────────────────────────────────────────────────────────────────────╮ -│ ✓ Shell Command Description for Shell Command │ ▄ -│────────────────────────────────────────────────────────────────────────────│ █ -│ shell-06 │ ▀ +│ ✓ Shell Command Description for Shell Command │ █ +│────────────────────────────────────────────────────────────────────────────│ +│ shell-06 │ │ shell-07 │" `; From a91ca89dbafa20c4f61ca4be9370d0ecbdb0a8ac Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Tue, 3 Feb 2026 01:36:31 -0800 Subject: [PATCH 09/38] fix(cli): enable Shift+Tab focus navigation from shell Updates ShellInputPrompt to bubble Shift+Tab events and AppContainer to handle them by unfocusing the shell input, allowing users to navigate back to the main prompt. --- packages/cli/src/ui/AppContainer.tsx | 3 ++- .../src/ui/components/ShellInputPrompt.test.tsx | 17 +++++++++++++++++ .../cli/src/ui/components/ShellInputPrompt.tsx | 5 +++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 6de7a313ed7..80b33be8d8b 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1542,7 +1542,8 @@ Logging in with Google... Restarting Gemini CLI to continue. setConstrainHeight(false); return true; } else if ( - keyMatchers[Command.FOCUS_SHELL_INPUT](key) && + (keyMatchers[Command.FOCUS_SHELL_INPUT](key) || + keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) && (activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0)) ) { if (key.name === 'tab' && key.shift) { diff --git a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx index 36b156a30e4..4bfa21b6f5f 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx @@ -159,4 +159,21 @@ describe('ShellInputPrompt', () => { expect(mockWriteToPty).not.toHaveBeenCalled(); }); + + it('ignores Shift+Tab to allow focus navigation', () => { + render(); + + const handler = mockUseKeypress.mock.calls[0][0]; + + const result = handler({ + name: 'tab', + shift: true, + alt: false, + ctrl: false, + cmd: false, + }); + + expect(result).toBe(false); + expect(mockWriteToPty).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/ui/components/ShellInputPrompt.tsx b/packages/cli/src/ui/components/ShellInputPrompt.tsx index 1e1a53335be..1ddf5ceed77 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.tsx @@ -42,6 +42,11 @@ export const ShellInputPrompt: React.FC = ({ return false; } + // Allow Shift+Tab to bubble up for focus navigation + if (key.name === 'tab' && key.shift) { + return false; + } + if (key.ctrl && key.shift) { if (key.name === 'up') { ShellExecutionService.scrollPty(activeShellPtyId, -1); From 207cc12763acd30d99270949cd5af347a820cb64 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Tue, 3 Feb 2026 02:30:35 -0800 Subject: [PATCH 10/38] feat(cli): centralize shell input keybindings configuration --- docs/cli/keyboard-shortcuts.md | 21 ++++++++++++------- packages/cli/src/config/keyBindings.ts | 29 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index a1a28665b9c..d10ba76f876 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -44,14 +44,18 @@ available combinations. #### Scrolling -| Action | Keys | -| ------------------------ | --------------------------------- | -| Scroll content up. | `Shift + Up Arrow` | -| Scroll content down. | `Shift + Down Arrow` | -| Scroll to the top. | `Ctrl + Home`
`Shift + Home` | -| Scroll to the bottom. | `Ctrl + End`
`Shift + End` | -| Scroll up by one page. | `Page Up` | -| Scroll down by one page. | `Page Down` | +| Action | Keys | +| ------------------------------------------- | --------------------------------- | +| Scroll content up. | `Shift + Up Arrow` | +| Scroll content down. | `Shift + Down Arrow` | +| Scroll to the top. | `Ctrl + Home`
`Shift + Home` | +| Scroll to the bottom. | `Ctrl + End`
`Shift + End` | +| Scroll up by one page. | `Page Up` | +| Scroll down by one page. | `Page Down` | +| Scroll the embedded shell up. | `Shift + Ctrl + Up Arrow` | +| Scroll the embedded shell down. | `Shift + Ctrl + Down Arrow` | +| Scroll the embedded shell up by one page. | `Shift + Ctrl + Page Up` | +| Scroll the embedded shell down by one page. | `Shift + Ctrl + Page Down` | #### History & Search @@ -74,6 +78,7 @@ available combinations. | Move down within dialog options. | `Down Arrow (no Shift)`
`J (no Shift)` | | Move to the next item or question in a dialog. | `Tab (no Shift)` | | Move to the previous item or question in a dialog. | `Shift + Tab` | +| Move focus out of the embedded shell. | `Shift + Tab` | #### Suggestions & Completions diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 9b6a903a4b8..d3f563517fb 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -95,6 +95,13 @@ export enum Command { CLEAR_SCREEN = 'app.clearScreen', RESTART_APP = 'app.restart', SUSPEND_APP = 'app.suspend', + + // Shell Controls + SHELL_SCROLL_UP = 'shell.scrollUp', + SHELL_SCROLL_DOWN = 'shell.scrollDown', + SHELL_SCROLL_PAGE_UP = 'shell.scrollPageUp', + SHELL_SCROLL_PAGE_DOWN = 'shell.scrollPageDown', + SHELL_LEAVE_FOCUS = 'shell.leaveFocus', } /** @@ -292,6 +299,15 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], [Command.RESTART_APP]: [{ key: 'r' }], [Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }], + + // Shell Controls + [Command.SHELL_SCROLL_UP]: [{ key: 'up', ctrl: true, shift: true }], + [Command.SHELL_SCROLL_DOWN]: [{ key: 'down', ctrl: true, shift: true }], + [Command.SHELL_SCROLL_PAGE_UP]: [{ key: 'pageup', ctrl: true, shift: true }], + [Command.SHELL_SCROLL_PAGE_DOWN]: [ + { key: 'pagedown', ctrl: true, shift: true }, + ], + [Command.SHELL_LEAVE_FOCUS]: [{ key: 'tab', shift: true }], }; interface CommandCategory { @@ -343,6 +359,10 @@ export const commandCategories: readonly CommandCategory[] = [ Command.SCROLL_END, Command.PAGE_UP, Command.PAGE_DOWN, + Command.SHELL_SCROLL_UP, + Command.SHELL_SCROLL_DOWN, + Command.SHELL_SCROLL_PAGE_UP, + Command.SHELL_SCROLL_PAGE_DOWN, ], }, { @@ -365,6 +385,7 @@ export const commandCategories: readonly CommandCategory[] = [ Command.DIALOG_NAVIGATION_DOWN, Command.DIALOG_NEXT, Command.DIALOG_PREV, + Command.SHELL_LEAVE_FOCUS, ], }, { @@ -509,4 +530,12 @@ export const commandDescriptions: Readonly> = { [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', [Command.RESTART_APP]: 'Restart the application.', [Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).', + + // Shell Controls + [Command.SHELL_SCROLL_UP]: 'Scroll the embedded shell up.', + [Command.SHELL_SCROLL_DOWN]: 'Scroll the embedded shell down.', + [Command.SHELL_SCROLL_PAGE_UP]: 'Scroll the embedded shell up by one page.', + [Command.SHELL_SCROLL_PAGE_DOWN]: + 'Scroll the embedded shell down by one page.', + [Command.SHELL_LEAVE_FOCUS]: 'Move focus out of the embedded shell.', }; From dc2add403a50f9662edc72aee6d9582487d1a55b Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Tue, 3 Feb 2026 02:31:16 -0800 Subject: [PATCH 11/38] refactor(cli): implement centralized keybindings in ShellInputPrompt --- .../ui/components/ShellInputPrompt.test.tsx | 6 ++-- .../src/ui/components/ShellInputPrompt.tsx | 34 +++++++++---------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx index 4bfa21b6f5f..b0bb07939d6 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx @@ -68,7 +68,7 @@ describe('ShellInputPrompt', () => { it.each([ ['up', -1], ['down', 1], - ])('handles scroll %s (Ctrl+Shift+%s)', (key, direction) => { + ])('handles scroll %s (Command.SHELL_SCROLL_%s)', (key, direction) => { render(); const handler = mockUseKeypress.mock.calls[0][0]; @@ -82,7 +82,7 @@ describe('ShellInputPrompt', () => { ['pageup', -25], ['pagedown', 25], ])( - 'handles page scroll %s (Ctrl+Shift+%s) with default size', + 'handles page scroll %s (Command.SHELL_SCROLL_%s) with default size', (key, expectedScroll) => { render(); @@ -160,7 +160,7 @@ describe('ShellInputPrompt', () => { expect(mockWriteToPty).not.toHaveBeenCalled(); }); - it('ignores Shift+Tab to allow focus navigation', () => { + it('ignores Command.SHELL_LEAVE_FOCUS (Shift+Tab) to allow focus navigation', () => { render(); const handler = mockUseKeypress.mock.calls[0][0]; diff --git a/packages/cli/src/ui/components/ShellInputPrompt.tsx b/packages/cli/src/ui/components/ShellInputPrompt.tsx index 1ddf5ceed77..2d45a479025 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.tsx @@ -43,27 +43,25 @@ export const ShellInputPrompt: React.FC = ({ } // Allow Shift+Tab to bubble up for focus navigation - if (key.name === 'tab' && key.shift) { + if (keyMatchers[Command.SHELL_LEAVE_FOCUS](key)) { return false; } - if (key.ctrl && key.shift) { - if (key.name === 'up') { - ShellExecutionService.scrollPty(activeShellPtyId, -1); - return true; - } - if (key.name === 'down') { - ShellExecutionService.scrollPty(activeShellPtyId, 1); - return true; - } - if (key.name === 'pageup') { - ShellExecutionService.scrollPty(activeShellPtyId, -scrollPageSize); - return true; - } - if (key.name === 'pagedown') { - ShellExecutionService.scrollPty(activeShellPtyId, scrollPageSize); - return true; - } + if (keyMatchers[Command.SHELL_SCROLL_UP](key)) { + ShellExecutionService.scrollPty(activeShellPtyId, -1); + return true; + } + if (keyMatchers[Command.SHELL_SCROLL_DOWN](key)) { + ShellExecutionService.scrollPty(activeShellPtyId, 1); + return true; + } + if (keyMatchers[Command.SHELL_SCROLL_PAGE_UP](key)) { + ShellExecutionService.scrollPty(activeShellPtyId, -scrollPageSize); + return true; + } + if (keyMatchers[Command.SHELL_SCROLL_PAGE_DOWN](key)) { + ShellExecutionService.scrollPty(activeShellPtyId, scrollPageSize); + return true; } const ansiSequence = keyToAnsi(key); From aa6a7bfb5571efc49df62fac66d4f9492e629c53 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Tue, 3 Feb 2026 02:47:11 -0800 Subject: [PATCH 12/38] test(cli): improve coverage for toolMapping --- packages/cli/src/ui/hooks/toolMapping.test.ts | 56 ++++++------------- 1 file changed, 18 insertions(+), 38 deletions(-) diff --git a/packages/cli/src/ui/hooks/toolMapping.test.ts b/packages/cli/src/ui/hooks/toolMapping.test.ts index 660c6feaaaf..aa516d46df5 100644 --- a/packages/cli/src/ui/hooks/toolMapping.test.ts +++ b/packages/cli/src/ui/hooks/toolMapping.test.ts @@ -257,53 +257,33 @@ describe('toolMapping', () => { expect(displayTool.resultDisplay).toBe('User cancelled'); }); - it('does not prune shell output for successful run_shell_command', () => { - const toolCall: SuccessfulToolCall = { - status: 'success', - request: { ...mockRequest, name: 'run_shell_command' }, - tool: { - ...mockTool, - name: 'run_shell_command', - } as AnyDeclarativeTool, + it('propagates borderTop and borderBottom options correctly', () => { + const toolCall: ScheduledToolCall = { + status: 'scheduled', + request: mockRequest, + tool: mockTool, invocation: mockInvocation, - response: { ...mockResponse, resultDisplay: 'long output' }, - }; - - const result = mapToDisplay(toolCall); - const displayTool = result.tools[0]; - - expect(displayTool.resultDisplay).toBe('long output'); - }); - - it('does not prune shell output for error run_shell_command', () => { - const toolCall: ToolCall = { - status: 'error', - request: { ...mockRequest, name: 'run_shell_command' }, - response: { ...mockResponse, resultDisplay: 'long error output' }, }; - const result = mapToDisplay(toolCall); - const displayTool = result.tools[0]; - - expect(displayTool.resultDisplay).toBe('long error output'); + const result = mapToDisplay(toolCall, { + borderTop: true, + borderBottom: false, + }); + expect(result.borderTop).toBe(true); + expect(result.borderBottom).toBe(false); }); - it('does NOT prune output for other tools', () => { - const toolCall: SuccessfulToolCall = { - status: 'success', - request: { ...mockRequest, name: 'read_file' }, - tool: { - ...mockTool, - name: 'read_file', - } as AnyDeclarativeTool, + it('sets resultDisplay to undefined for pre-execution statuses', () => { + const toolCall: ScheduledToolCall = { + status: 'scheduled', + request: mockRequest, + tool: mockTool, invocation: mockInvocation, - response: { ...mockResponse, resultDisplay: 'long file content' }, }; const result = mapToDisplay(toolCall); - const displayTool = result.tools[0]; - - expect(displayTool.resultDisplay).toBe('long file content'); + expect(result.tools[0].resultDisplay).toBeUndefined(); + expect(result.tools[0].status).toBe(ToolCallStatus.Pending); }); }); }); From 298076a58e53bf63cbfd18ff5a7e0fc8d415db5f Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Tue, 3 Feb 2026 10:28:19 -0800 Subject: [PATCH 13/38] fix(cli): restore ctrl+o expansion for shell output The following adjustments were made: - ShellToolMessage now correctly signals unconstrained height to TootResultDisplay. - ToolResultDisplay now disables data truncation for ANSI output when in unconstrained mode, allowing the overflow hint to appear. - Added tests to ShellToolMessage.test.tsx and ToolResultDisplay.test.tsx to verify maxLines propagation and truncation behavior in both standard and expanded views. --- .../messages/ShellToolMessage.test.tsx | 54 +++++++++++++++++++ .../components/messages/ShellToolMessage.tsx | 3 +- .../messages/ToolResultDisplay.test.tsx | 28 ++++++++++ .../components/messages/ToolResultDisplay.tsx | 4 +- 4 files changed, 87 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 7f288f53a26..2d5a43a7ac0 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -54,6 +54,12 @@ vi.mock('../../utils/MarkdownDisplay.js', () => ({ }, })); +vi.mock('./ToolResultDisplay.js', () => ({ + ToolResultDisplay: vi.fn(({ maxLines }) => ( + MockToolResultDisplay:maxLines={String(maxLines)} + )), +})); + describe('', () => { const baseProps: ShellToolMessageProps = { callId: 'tool-123', @@ -176,4 +182,52 @@ describe('', () => { }); }); }); + + describe('maxLines logic (constrained vs unconstrained)', () => { + it('passes maxLines as undefined when availableTerminalHeight is undefined', async () => { + const { ToolResultDisplay: MockToolResultDisplay } = await import( + './ToolResultDisplay.js' + ); + + const props: ShellToolMessageProps = { + ...baseProps, + availableTerminalHeight: undefined, // Unconstrained + }; + + renderWithProviders(, { uiActions }); + + await waitFor(() => { + // Verify the props of the last call + const lastCallProps = vi + .mocked(MockToolResultDisplay) + .mock.calls.at(-1)?.[0]; + expect(lastCallProps).toMatchObject({ + maxLines: undefined, + }); + }); + }); + + it('passes ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is defined', async () => { + const { ToolResultDisplay: MockToolResultDisplay } = await import( + './ToolResultDisplay.js' + ); + const { ACTIVE_SHELL_MAX_LINES } = await import('../../constants.js'); + + const props: ShellToolMessageProps = { + ...baseProps, + availableTerminalHeight: 20, // Constrained + }; + + renderWithProviders(, { uiActions }); + + await waitFor(() => { + const lastCallProps = vi + .mocked(MockToolResultDisplay) + .mock.calls.at(-1)?.[0]; + expect(lastCallProps).toMatchObject({ + maxLines: ACTIVE_SHELL_MAX_LINES, + }); + }); + }); + }); }); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index 04091d13150..fd3235ff1b2 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -157,7 +157,8 @@ export const ShellToolMessage: React.FC = ({ terminalWidth={terminalWidth} renderOutputAsMarkdown={renderOutputAsMarkdown} maxLines={ - isAlternateBuffer && isThisShellFocused + (isAlternateBuffer && isThisShellFocused) || + availableTerminalHeight === undefined ? undefined : ACTIVE_SHELL_MAX_LINES } diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx index 05cd3c88e97..c869df9f600 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -287,4 +287,32 @@ describe('ToolResultDisplay', () => { expect(output).toContain('Line 2'); expect(output).toContain('Line 3'); }); + + it('does not truncate ANSI output when availableTerminalHeight is undefined', () => { + const ansiResult: AnsiOutput = Array.from({ length: 50 }, (_, i) => [ + { + text: `Line ${i + 1}`, + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ]); + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + // In unconstrained mode, it should NOT truncate to 25 lines + expect(output).toContain('Line 1'); + expect(output).toContain('Line 50'); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 33a62401c93..9331b086dbb 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -151,7 +151,9 @@ export const ToolResultDisplay: React.FC = ({ } width={childWidth} maxLines={isAlternateBuffer ? undefined : maxLines} - disableTruncation={isAlternateBuffer} + disableTruncation={ + isAlternateBuffer || availableTerminalHeight === undefined + } /> ); } From 7d99570a1d4cdf2b0061a059a331e6d150d5a233 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Tue, 3 Feb 2026 11:30:06 -0800 Subject: [PATCH 14/38] test(cli): update snapshots for tool group and sticky headers --- .../ToolGroupMessage.test.tsx.snap | 72 ------------------- .../ToolStickyHeaderRegression.test.tsx.snap | 8 +-- 2 files changed, 4 insertions(+), 76 deletions(-) diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap index d4dc7b89889..65529156a56 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap @@ -47,30 +47,6 @@ exports[` > Border Color Logic > uses yellow border for shel │ ✓ run_shell_command A tool for testing │ │ │ │ Test result │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ ╰──────────────────────────────────────────────────────────────────────────────╯" `; @@ -169,30 +145,6 @@ exports[` > Golden Snapshots > renders mixed tool calls incl │ │ │ Test result │ │ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ │ o write_file Write to file │ │ │ │ Test result │ @@ -220,30 +172,6 @@ exports[` > Golden Snapshots > renders shell command with ye │ ✓ run_shell_command Execute shell command │ │ │ │ Test result │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ -│ │ ╰──────────────────────────────────────────────────────────────────────────────╯" `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap index 0d2729d26cc..9fa4d21ab95 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap @@ -2,7 +2,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = ` "╭────────────────────────────────────────────────────────────────────────────╮ █ -│ ✓ Shell Command Description for Shell Command │ +│ ✓ Shell Command Description for Shell Command │ █ │ │ │ shell-01 │ │ shell-02 │" @@ -10,9 +10,9 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = ` "╭────────────────────────────────────────────────────────────────────────────╮ -│ ✓ Shell Command Description for Shell Command │ █ -│────────────────────────────────────────────────────────────────────────────│ -│ shell-06 │ +│ ✓ Shell Command Description for Shell Command │ ▄ +│────────────────────────────────────────────────────────────────────────────│ █ +│ shell-06 │ ▀ │ shell-07 │" `; From 1dcb6950a0e15d30982bd099125dfa1f6799dbbc Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Tue, 3 Feb 2026 12:30:20 -0800 Subject: [PATCH 15/38] fix(cli): prevent extra blank lines in tool results by using maxHeight Changed Scrollable height to maxHeight in ToolResultDisplay to allow the container to shrink to fit content shorter than the maximum allowed height. Updated test snapshots to reflect the removal of unnecessary padding. --- .../cli/src/ui/components/messages/ToolResultDisplay.tsx | 2 +- .../messages/__snapshots__/ToolGroupMessage.test.tsx.snap | 8 -------- .../__snapshots__/ToolMessageRawMarkdown.test.tsx.snap | 4 +--- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 9331b086dbb..d1d97485cd1 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -162,7 +162,7 @@ export const ToolResultDisplay: React.FC = ({ return ( diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap index 65529156a56..925568daa61 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap @@ -230,13 +230,9 @@ exports[` > Golden Snapshots > renders with limited terminal │ │ │ This is a long result that might need height constraints │ │ │ -│ │ -│ │ │ ✓ another-tool Another tool │ │ │ │ More output here │ -│ │ -│ │ ╰──────────────────────────────────────────────────────────────────────────────╯" `; @@ -254,14 +250,10 @@ exports[` > Height Calculation > calculates available height │ │ │ Result 1 │ │ │ -│ │ -│ │ │ ✓ test-tool A tool for testing │ │ │ │ Result 2 │ │ │ -│ │ -│ │ │ ✓ test-tool A tool for testing │ │ │ ╰──────────────────────────────────────────────────────────────────────────────╯" diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageRawMarkdown.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageRawMarkdown.test.tsx.snap index a40a9d1398e..5a0a17f7e94 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageRawMarkdown.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessageRawMarkdown.test.tsx.snap @@ -32,9 +32,7 @@ exports[` - Raw Markdown Display Snapshots > renders with renderM "╭──────────────────────────────────────────────────────────────────────────────╮ │ ✓ test-tool A tool for testing │ │ │ -│ Test bold and code markdown │ -│ │ -│ │" +│ Test bold and code markdown │" `; exports[` - Raw Markdown Display Snapshots > renders with renderMarkdown=true, useAlternateBuffer=true '(default, alternate buffer)' 1`] = ` From eaa14248640d0440cf6d71d322f23fa880522c70 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Tue, 3 Feb 2026 15:05:54 -0800 Subject: [PATCH 16/38] perf(cli): virtualize shell output in alternate buffer mode using ScrollableList --- packages/cli/src/ui/components/AnsiOutput.tsx | 40 ++++++++++--------- .../components/messages/ToolMessage.test.tsx | 8 ++++ .../messages/ToolResultDisplay.test.tsx | 37 ++++++++++++++++- .../components/messages/ToolResultDisplay.tsx | 27 ++++++++++++- .../__snapshots__/ToolMessage.test.tsx.snap | 2 +- 5 files changed, 91 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index ffcfa0da910..fdaa71cc034 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -38,25 +38,29 @@ export const AnsiOutputText: React.FC = ({ return ( {lastLines.map((line: AnsiLine, lineIndex: number) => ( - - {line.length > 0 - ? line.map((token: AnsiToken, tokenIndex: number) => ( - - {token.text} - - )) - : null} - + ))} ); }; + +export const AnsiLineText: React.FC<{ line: AnsiLine }> = ({ line }) => ( + + {line.length > 0 + ? line.map((token: AnsiToken, tokenIndex: number) => ( + + {token.text} + + )) + : null} + +); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index c3ed4fcc762..6bd75097b08 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -37,6 +37,14 @@ vi.mock('../AnsiOutput.js', () => ({ .join('\n'); return MockAnsiOutput:{serialized}; }, + AnsiLineText: function MockAnsiLineText({ + line, + }: { + line: AnsiOutput[number]; + }) { + const serialized = line.map((token) => token.text || '').join(''); + return MockAnsiLine:{serialized}; + }, })); // Mock child components or utilities if they are complex or have side effects diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx index c869df9f600..1e83c52b7e7 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -71,6 +71,24 @@ vi.mock('../shared/Scrollable.js', () => ({ ), })); +// Mock ScrollableList +vi.mock('../shared/ScrollableList.js', () => ({ + ScrollableList: ({ + data, + renderItem, + }: { + data: unknown[]; + renderItem: (info: { item: unknown; index: number }) => React.ReactNode; + }) => ( + + ScrollableList Container + {data.map((item, index) => ( + {renderItem({ item, index })} + ))} + + ), +})); + describe('ToolResultDisplay', () => { beforeEach(() => { vi.clearAllMocks(); @@ -78,7 +96,7 @@ describe('ToolResultDisplay', () => { mockUseAlternateBuffer.mockReturnValue(false); }); - it('uses Scrollable for ANSI output in alternate buffer mode', () => { + it('uses ScrollableList for ANSI output in alternate buffer mode', () => { mockUseAlternateBuffer.mockReturnValue(true); const ansiResult: AnsiOutput = [ [ @@ -103,10 +121,25 @@ describe('ToolResultDisplay', () => { ); const output = lastFrame(); - expect(output).toContain('Scrollable Container'); + expect(output).toContain('ScrollableList Container'); expect(output).toContain('ansi content'); }); + it('uses Scrollable for non-ANSI output in alternate buffer mode', () => { + mockUseAlternateBuffer.mockReturnValue(true); + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + expect(output).toContain('Scrollable Container'); + expect(output).toContain('Markdown content'); + }); + it('renders string result as markdown by default', () => { const { lastFrame } = render( , diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index d1d97485cd1..5b6e88048c3 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -8,14 +8,16 @@ import React from 'react'; import { Box, Text } from 'ink'; import { DiffRenderer } from './DiffRenderer.js'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; -import { AnsiOutputText } from '../AnsiOutput.js'; +import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js'; import { MaxSizedBox } from '../shared/MaxSizedBox.js'; import { theme } from '../../semantic-colors.js'; -import type { AnsiOutput } from '@google/gemini-cli-core'; +import type { AnsiOutput, AnsiLine } from '@google/gemini-cli-core'; import { useUIState } from '../../contexts/UIStateContext.js'; import { tryParseJSON } from '../../../utils/jsonoutput.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; import { Scrollable } from '../shared/Scrollable.js'; +import { ScrollableList } from '../shared/ScrollableList.js'; +import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js'; const STATIC_HEIGHT = 1; const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint @@ -159,6 +161,27 @@ export const ToolResultDisplay: React.FC = ({ } if (isAlternateBuffer) { + if (Array.isArray(truncatedResultDisplay)) { + return ( + + ( + + )} + estimatedItemHeight={() => 1} + keyExtractor={(_: AnsiLine, index: number) => index.toString()} + initialScrollIndex={SCROLL_TO_ITEM_END} + hasFocus={true} + /> + + ); + } return ( > renders AnsiOutputText for AnsiOutput results 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ✓ test-tool A tool for testing │ │ │ -│ MockAnsiOutput:hello │" +│ MockAnsiLine:hello │" `; exports[` > renders DiffRenderer for diff results 1`] = ` From 14fb4a2ae9d72a04dbc91f4d249b7ac9c4925b40 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Tue, 3 Feb 2026 15:29:35 -0800 Subject: [PATCH 17/38] ui(cli): use overflow:hidden instead of ellipses for shell output truncation --- packages/cli/src/ui/components/AnsiOutput.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index fdaa71cc034..ccfd36a01bf 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -36,7 +36,7 @@ export const AnsiOutputText: React.FC = ({ const lastLines = disableTruncation ? data : data.slice(-limit); return ( - + {lastLines.map((line: AnsiLine, lineIndex: number) => ( ))} @@ -45,7 +45,7 @@ export const AnsiOutputText: React.FC = ({ }; export const AnsiLineText: React.FC<{ line: AnsiLine }> = ({ line }) => ( - + {line.length > 0 ? line.map((token: AnsiToken, tokenIndex: number) => ( Date: Tue, 3 Feb 2026 16:33:36 -0800 Subject: [PATCH 18/38] refactor(cli): dynamically propagate focus to scrollable tool result displays --- .../ui/components/messages/ShellToolMessage.tsx | 1 + .../cli/src/ui/components/messages/ToolMessage.tsx | 1 + .../components/messages/ToolResultDisplay.test.tsx | 14 ++++++++++++++ .../ui/components/messages/ToolResultDisplay.tsx | 6 ++++-- 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index fd3235ff1b2..209e6ca5cfc 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -156,6 +156,7 @@ export const ShellToolMessage: React.FC = ({ availableTerminalHeight={availableTerminalHeight} terminalWidth={terminalWidth} renderOutputAsMarkdown={renderOutputAsMarkdown} + hasFocus={isThisShellFocused} maxLines={ (isAlternateBuffer && isThisShellFocused) || availableTerminalHeight === undefined diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index bf2b557657e..06ad6b3f7b4 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -113,6 +113,7 @@ export const ToolMessage: React.FC = ({ availableTerminalHeight={availableTerminalHeight} terminalWidth={terminalWidth} renderOutputAsMarkdown={renderOutputAsMarkdown} + hasFocus={isThisShellFocused} /> {isThisShellFocused && config && ( diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx index 1e83c52b7e7..b6dba8257e1 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -140,6 +140,20 @@ describe('ToolResultDisplay', () => { expect(output).toContain('Markdown content'); }); + it('passes hasFocus prop to scrollable components', () => { + mockUseAlternateBuffer.mockReturnValue(true); + const { lastFrame } = render( + , + ); + // Since Scrollable is mocked, we can't easily check its props directly in the string output + // without changing the mock. But we can at least ensure it renders without crashing. + expect(lastFrame()).toContain('Scrollable Container'); + }); + it('renders string result as markdown by default', () => { const { lastFrame } = render( , diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 5b6e88048c3..347526a02c1 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -33,6 +33,7 @@ export interface ToolResultDisplayProps { terminalWidth: number; renderOutputAsMarkdown?: boolean; maxLines?: number; + hasFocus?: boolean; } interface FileDiffResult { @@ -46,6 +47,7 @@ export const ToolResultDisplay: React.FC = ({ terminalWidth, renderOutputAsMarkdown = true, maxLines, + hasFocus = false, }) => { const { renderMarkdown } = useUIState(); const isAlternateBuffer = useAlternateBuffer(); @@ -177,7 +179,7 @@ export const ToolResultDisplay: React.FC = ({ estimatedItemHeight={() => 1} keyExtractor={(_: AnsiLine, index: number) => index.toString()} initialScrollIndex={SCROLL_TO_ITEM_END} - hasFocus={true} + hasFocus={hasFocus} /> ); @@ -186,7 +188,7 @@ export const ToolResultDisplay: React.FC = ({ {content} From 649025daab305a067564e1ebb47a8841f1ff8b0f Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Tue, 3 Feb 2026 23:56:49 -0800 Subject: [PATCH 19/38] fix(cli): prevent shell output rendering loops and simplify ToolResultDisplay logic by separating content determination from container paths --- .../cli/src/ui/components/AnsiOutput.test.tsx | 5 +- packages/cli/src/ui/components/AnsiOutput.tsx | 4 +- .../components/messages/ToolResultDisplay.tsx | 68 +++++++++++-------- 3 files changed, 47 insertions(+), 30 deletions(-) diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx index 36922cb5053..6f1accf6085 100644 --- a/packages/cli/src/ui/components/AnsiOutput.test.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx @@ -68,8 +68,9 @@ describe('', () => { const output = lastFrame(); expect(output).toBeDefined(); const lines = output!.split('\n'); - expect(lines[0]).toBe('First line'); - expect(lines[1]).toBe('Third line'); + expect(lines[0].trim()).toBe('First line'); + expect(lines[1].trim()).toBe(''); + expect(lines[2].trim()).toBe('Third line'); }); it('respects the availableTerminalHeight prop and slices the lines correctly', () => { diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index ccfd36a01bf..f80696b791c 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -38,7 +38,9 @@ export const AnsiOutputText: React.FC = ({ return ( {lastLines.map((line: AnsiLine, lineIndex: number) => ( - + + + ))} ); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 347526a02c1..fd6413a1001 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -18,6 +18,7 @@ import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; import { Scrollable } from '../shared/Scrollable.js'; import { ScrollableList } from '../shared/ScrollableList.js'; import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js'; +import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; const STATIC_HEIGHT = 1; const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint @@ -91,6 +92,45 @@ export const ToolResultDisplay: React.FC = ({ if (!truncatedResultDisplay) return null; + // 1. Early return for background tools (Todos) + if ( + typeof truncatedResultDisplay === 'object' && + 'todos' in truncatedResultDisplay + ) { + // display nothing, as the TodoTray will handle rendering todos + return null; + } + + // 2. High-performance path: Virtualized ANSI in interactive mode + if (isAlternateBuffer && Array.isArray(truncatedResultDisplay)) { + // If availableHeight is undefined, fallback to a safe default to prevents infinite loop + // where Container grows -> List renders more -> Container grows. + const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES; + const listHeight = Math.min( + (truncatedResultDisplay as AnsiOutput).length, + limit, + ); + + return ( + + ( + + + + )} + estimatedItemHeight={() => 1} + keyExtractor={(_: AnsiLine, index: number) => index.toString()} + initialScrollIndex={SCROLL_TO_ITEM_END} + hasFocus={hasFocus} + /> + + ); + } + + // 3. Compute content node for non-virtualized paths // Check if string content is valid JSON and pretty-print it const prettyJSON = typeof truncatedResultDisplay === 'string' @@ -140,12 +180,6 @@ export const ToolResultDisplay: React.FC = ({ terminalWidth={childWidth} /> ); - } else if ( - typeof truncatedResultDisplay === 'object' && - 'todos' in truncatedResultDisplay - ) { - // display nothing, as the TodoTray will handle rendering todos - return null; } else { content = ( = ({ ); } + // 4. Final render based on session mode if (isAlternateBuffer) { - if (Array.isArray(truncatedResultDisplay)) { - return ( - - ( - - )} - estimatedItemHeight={() => 1} - keyExtractor={(_: AnsiLine, index: number) => index.toString()} - initialScrollIndex={SCROLL_TO_ITEM_END} - hasFocus={hasFocus} - /> - - ); - } return ( Date: Wed, 4 Feb 2026 10:05:01 -0800 Subject: [PATCH 20/38] test(cli): use real Scrollable/AnsiOutput in ToolResultDisplay tests Removes mocks for Scrollable, ScrollableList, and AnsiOutput components to improve test fidelity. Updates context mocks to use 'importOriginal' to ensure real Context objects are preserved for providers. --- .../messages/ToolResultDisplay.test.tsx | 108 ++++++++---------- .../ToolResultDisplay.test.tsx.snap | 5 +- 2 files changed, 51 insertions(+), 62 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx index b6dba8257e1..cbfeaed1592 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render } from '../../../test-utils/render.js'; +import { renderWithProviders } from '../../../test-utils/render.js'; import { ToolResultDisplay } from './ToolResultDisplay.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Box, Text } from 'ink'; @@ -27,11 +27,16 @@ vi.mock('./DiffRenderer.js', () => ({ ), })); -// Mock UIStateContext +// Mock UIStateContext partially const mockUseUIState = vi.fn(); -vi.mock('../../contexts/UIStateContext.js', () => ({ - useUIState: () => mockUseUIState(), -})); +vi.mock('../../contexts/UIStateContext.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useUIState: () => mockUseUIState(), + }; +}); // Mock useAlternateBuffer const mockUseAlternateBuffer = vi.fn(); @@ -39,55 +44,40 @@ vi.mock('../../hooks/useAlternateBuffer.js', () => ({ useAlternateBuffer: () => mockUseAlternateBuffer(), })); -// Mock useSettings -vi.mock('../../contexts/SettingsContext.js', () => ({ - useSettings: () => ({ - merged: { - ui: { - useAlternateBuffer: false, +// Mock SettingsContext partially +vi.mock('../../contexts/SettingsContext.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useSettings: () => ({ + merged: { + ui: { + useAlternateBuffer: false, + }, }, - }, - }), -})); - -// Mock useOverflowActions -vi.mock('../../contexts/OverflowContext.js', () => ({ - useOverflowActions: () => ({ - addOverflowingId: vi.fn(), - removeOverflowingId: vi.fn(), - }), - useOverflowState: () => ({ - overflowingIds: new Set(), - }), -})); + }), + }; +}); -// Mock Scrollable -vi.mock('../shared/Scrollable.js', () => ({ - Scrollable: ({ children }: { children: React.ReactNode }) => ( - - Scrollable Container - {children} - - ), -})); +// Mock OverflowContext partially +vi.mock('../../contexts/OverflowContext.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useOverflowActions: () => ({ + addOverflowingId: vi.fn(), + removeOverflowingId: vi.fn(), + }), + useOverflowState: () => ({ + overflowingIds: new Set(), + }), + }; +}); -// Mock ScrollableList -vi.mock('../shared/ScrollableList.js', () => ({ - ScrollableList: ({ - data, - renderItem, - }: { - data: unknown[]; - renderItem: (info: { item: unknown; index: number }) => React.ReactNode; - }) => ( - - ScrollableList Container - {data.map((item, index) => ( - {renderItem({ item, index })} - ))} - - ), -})); +// NOTE: Scrollable, ScrollableList, and AnsiOutput mocks have been removed +// to use the real implementations. describe('ToolResultDisplay', () => { beforeEach(() => { @@ -96,12 +86,16 @@ describe('ToolResultDisplay', () => { mockUseAlternateBuffer.mockReturnValue(false); }); + // Helper to use renderWithProviders + const render = (ui: React.ReactElement) => renderWithProviders(ui); + it('uses ScrollableList for ANSI output in alternate buffer mode', () => { mockUseAlternateBuffer.mockReturnValue(true); + const content = 'ansi content'; const ansiResult: AnsiOutput = [ [ { - text: 'ansi content', + text: content, fg: 'red', bg: 'black', bold: false, @@ -121,8 +115,7 @@ describe('ToolResultDisplay', () => { ); const output = lastFrame(); - expect(output).toContain('ScrollableList Container'); - expect(output).toContain('ansi content'); + expect(output).toContain(content); }); it('uses Scrollable for non-ANSI output in alternate buffer mode', () => { @@ -136,7 +129,7 @@ describe('ToolResultDisplay', () => { ); const output = lastFrame(); - expect(output).toContain('Scrollable Container'); + // With real components, we check for the content itself expect(output).toContain('Markdown content'); }); @@ -149,9 +142,8 @@ describe('ToolResultDisplay', () => { hasFocus={true} />, ); - // Since Scrollable is mocked, we can't easily check its props directly in the string output - // without changing the mock. But we can at least ensure it renders without crashing. - expect(lastFrame()).toContain('Scrollable Container'); + + expect(lastFrame()).toContain('Some result'); }); it('renders string result as markdown by default', () => { 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 3bc12b1b3d1..e90c3659510 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 @@ -2,10 +2,7 @@ exports[`ToolResultDisplay > does not fall back to plain text if availableHeight is set and not in alternate buffer 1`] = `"Some result"`; -exports[`ToolResultDisplay > keeps markdown if in alternate buffer even with availableHeight 1`] = ` -"Scrollable Container -Some result" -`; +exports[`ToolResultDisplay > keeps markdown if in alternate buffer even with availableHeight 1`] = `"Some result"`; exports[`ToolResultDisplay > renders ANSI output result 1`] = `"ansi content"`; From c36eff17c662c7ac7ff0a0185d6f2e412ca43c92 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Wed, 4 Feb 2026 12:42:15 -0800 Subject: [PATCH 21/38] perf(cli): optimize ToolResultDisplay virtualization callbacks Memoize 'renderItem' and 'keyExtractor' props passed to ScrollableList to prevents unnecessary re-renders of the virtualized list during state updates. --- .../components/messages/ToolResultDisplay.tsx | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index fd6413a1001..e3a7cbe7ea9 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -67,6 +67,20 @@ export const ToolResultDisplay: React.FC = ({ const combinedPaddingAndBorderWidth = 4; const childWidth = terminalWidth - combinedPaddingAndBorderWidth; + const keyExtractor = React.useCallback( + (_: AnsiLine, index: number) => index.toString(), + [], + ); + + const renderVirtualizedAnsiLine = React.useCallback( + ({ item }: { item: AnsiLine }) => ( + + + + ), + [], + ); + const truncatedResultDisplay = React.useMemo(() => { // Only truncate string output if not in alternate buffer mode to ensure // we can scroll through the full output. @@ -116,13 +130,9 @@ export const ToolResultDisplay: React.FC = ({ ( - - - - )} + renderItem={renderVirtualizedAnsiLine} estimatedItemHeight={() => 1} - keyExtractor={(_: AnsiLine, index: number) => index.toString()} + keyExtractor={keyExtractor} initialScrollIndex={SCROLL_TO_ITEM_END} hasFocus={hasFocus} /> From a83ac7a683f1ec1d175a23074c89bd0c09bc6912 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Wed, 4 Feb 2026 13:45:44 -0800 Subject: [PATCH 22/38] fix(cli): use separate constants for active shell and history truncation --- packages/cli/src/ui/AppContainer.tsx | 2 +- .../messages/ShellToolMessage.test.tsx | 24 +++++++++++++++++++ .../components/messages/ShellToolMessage.tsx | 12 ++++++++-- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 42b71718269..5ed1ee89b2a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1550,7 +1550,7 @@ Logging in with Google... Restarting Gemini CLI to continue. keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) && (activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0)) ) { - if (key.name === 'tab' && key.shift) { + if (keyMatchers[Command.SHELL_LEAVE_FOCUS](key)) { // Always change focus setEmbeddedShellFocused(false); return true; diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 2d5a43a7ac0..09ac3876d99 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -229,5 +229,29 @@ describe('', () => { }); }); }); + + it('passes SHELL_HISTORY_MAX_LINES when status is Success', async () => { + const { ToolResultDisplay: MockToolResultDisplay } = await import( + './ToolResultDisplay.js' + ); + const { SHELL_HISTORY_MAX_LINES } = await import('../../constants.js'); + + const props: ShellToolMessageProps = { + ...baseProps, + status: ToolCallStatus.Success, + availableTerminalHeight: 20, // Constrained + }; + + renderWithProviders(, { uiActions }); + + await waitFor(() => { + const lastCallProps = vi + .mocked(MockToolResultDisplay) + .mock.calls.at(-1)?.[0]; + expect(lastCallProps).toMatchObject({ + maxLines: SHELL_HISTORY_MAX_LINES, + }); + }); + }); }); }); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index 209e6ca5cfc..bb8f3cd82e1 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -22,7 +22,11 @@ import { FocusHint, } from './ToolShared.js'; import type { ToolMessageProps } from './ToolMessage.js'; -import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; +import { ToolCallStatus } from '../../types.js'; +import { + ACTIVE_SHELL_MAX_LINES, + SHELL_HISTORY_MAX_LINES, +} from '../../constants.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; import type { Config } from '@google/gemini-cli-core'; @@ -161,7 +165,11 @@ export const ShellToolMessage: React.FC = ({ (isAlternateBuffer && isThisShellFocused) || availableTerminalHeight === undefined ? undefined - : ACTIVE_SHELL_MAX_LINES + : status === ToolCallStatus.Success || + status === ToolCallStatus.Error || + status === ToolCallStatus.Canceled + ? SHELL_HISTORY_MAX_LINES + : ACTIVE_SHELL_MAX_LINES } /> {isThisShellFocused && config && ( From 0f508c3a88ab953579f4c63203c40aad37aa1f1d Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Wed, 4 Feb 2026 16:25:17 -0800 Subject: [PATCH 23/38] feat(cli): constrain completed shell output to 25 lines in history Ensure shell command output remains truncated to 25 lines (SHELL_HISTORY_MAX_LINES) after completion, even in Alternate Screen Buffer mode or when available terminal height is undefined. This prevents finished commands from expanding to their full length in the scrollback history. - Abstracted shell output line limit logic into getShellMaxLines helper. - Updated ToolResultDisplay to respect maxLines constraints for history items. - Added comprehensive tests for shell output truncation across different states. --- .../messages/ShellToolMessage.test.tsx | 83 +++++++++++++++++++ .../components/messages/ShellToolMessage.tsx | 49 ++++++++--- .../messages/ToolResultDisplay.test.tsx | 6 +- .../components/messages/ToolResultDisplay.tsx | 8 +- 4 files changed, 130 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 09ac3876d99..918c506e7ed 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -253,5 +253,88 @@ describe('', () => { }); }); }); + + it('passes SHELL_HISTORY_MAX_LINES when status is Success even if availableTerminalHeight is undefined', async () => { + const { ToolResultDisplay: MockToolResultDisplay } = await import( + './ToolResultDisplay.js' + ); + const { SHELL_HISTORY_MAX_LINES } = await import('../../constants.js'); + + const props: ShellToolMessageProps = { + ...baseProps, + status: ToolCallStatus.Success, + availableTerminalHeight: undefined, + }; + + renderWithProviders(, { uiActions }); + + await waitFor(() => { + const lastCallProps = vi + .mocked(MockToolResultDisplay) + .mock.calls.at(-1)?.[0]; + expect(lastCallProps).toMatchObject({ + maxLines: SHELL_HISTORY_MAX_LINES, + }); + }); + }); + + it('passes undefined when in alternate buffer and focused while executing', async () => { + const { ToolResultDisplay: MockToolResultDisplay } = await import( + './ToolResultDisplay.js' + ); + + const props: ShellToolMessageProps = { + ...baseProps, + status: ToolCallStatus.Executing, + availableTerminalHeight: 20, + ptyId: 1, + activeShellPtyId: 1, + embeddedShellFocused: true, + }; + + renderWithProviders(, { + uiActions, + useAlternateBuffer: true, + }); + + await waitFor(() => { + const lastCallProps = vi + .mocked(MockToolResultDisplay) + .mock.calls.at(-1)?.[0]; + expect(lastCallProps).toMatchObject({ + maxLines: undefined, + }); + }); + }); + + it('passes SHELL_HISTORY_MAX_LINES when finished even if previously in alternate buffer and focused', async () => { + const { ToolResultDisplay: MockToolResultDisplay } = await import( + './ToolResultDisplay.js' + ); + const { SHELL_HISTORY_MAX_LINES } = await import('../../constants.js'); + + const props: ShellToolMessageProps = { + ...baseProps, + status: ToolCallStatus.Success, + availableTerminalHeight: 20, + ptyId: 1, + activeShellPtyId: 1, + embeddedShellFocused: true, + }; + + renderWithProviders(, { + uiActions, + useAlternateBuffer: true, + }); + + await waitFor(() => { + const lastCallProps = vi + .mocked(MockToolResultDisplay) + .mock.calls.at(-1)?.[0]; + expect(lastCallProps).toMatchObject({ + maxLines: SHELL_HISTORY_MAX_LINES, + }); + }); + }); }); }); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index bb8f3cd82e1..22b1dc0c963 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -161,16 +161,12 @@ export const ShellToolMessage: React.FC = ({ terminalWidth={terminalWidth} renderOutputAsMarkdown={renderOutputAsMarkdown} hasFocus={isThisShellFocused} - maxLines={ - (isAlternateBuffer && isThisShellFocused) || - availableTerminalHeight === undefined - ? undefined - : status === ToolCallStatus.Success || - status === ToolCallStatus.Error || - status === ToolCallStatus.Canceled - ? SHELL_HISTORY_MAX_LINES - : ACTIVE_SHELL_MAX_LINES - } + maxLines={getShellMaxLines( + status, + isAlternateBuffer, + isThisShellFocused, + availableTerminalHeight, + )} /> {isThisShellFocused && config && ( @@ -185,3 +181,36 @@ export const ShellToolMessage: React.FC = ({ ); }; + +/** + * Calculates the maximum number of lines to display for shell output. + * + * For completed processes (Success, Error, Canceled), it returns SHELL_HISTORY_MAX_LINES. + * For active processes, it returns undefined (unlimited) if the terminal is in + * alternate buffer mode and focused, or if availableTerminalHeight is undefined + * (signaling unlimited). Otherwise, it returns ACTIVE_SHELL_MAX_LINES. + */ +function getShellMaxLines( + status: ToolCallStatus, + isAlternateBuffer: boolean, + isThisShellFocused: boolean, + availableTerminalHeight: number | undefined, +): number | undefined { + if ( + status === ToolCallStatus.Success || + status === ToolCallStatus.Error || + status === ToolCallStatus.Canceled + ) { + return SHELL_HISTORY_MAX_LINES; + } + + if ( + (isAlternateBuffer && isThisShellFocused) || + availableTerminalHeight === undefined + ) { + // availableTerminalHeight being undefined means unlimited + return undefined; + } + + return ACTIVE_SHELL_MAX_LINES; +} diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx index cbfeaed1592..e2012c42a6e 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -327,7 +327,7 @@ describe('ToolResultDisplay', () => { expect(output).toContain('Line 3'); }); - it('does not truncate ANSI output when availableTerminalHeight is undefined', () => { + it('truncates ANSI output when maxLines is provided, even if availableTerminalHeight is undefined', () => { const ansiResult: AnsiOutput = Array.from({ length: 50 }, (_, i) => [ { text: `Line ${i + 1}`, @@ -350,8 +350,8 @@ describe('ToolResultDisplay', () => { ); const output = lastFrame(); - // In unconstrained mode, it should NOT truncate to 25 lines - expect(output).toContain('Line 1'); + // It SHOULD truncate to 25 lines because maxLines is provided + expect(output).not.toContain('Line 1'); expect(output).toContain('Line 50'); }); }); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index e3a7cbe7ea9..2bdc74bec32 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -191,6 +191,10 @@ export const ToolResultDisplay: React.FC = ({ /> ); } else { + const shouldDisableTruncation = + isAlternateBuffer || + (availableTerminalHeight === undefined && maxLines === undefined); + content = ( = ({ } width={childWidth} maxLines={isAlternateBuffer ? undefined : maxLines} - disableTruncation={ - isAlternateBuffer || availableTerminalHeight === undefined - } + disableTruncation={shouldDisableTruncation} /> ); } From 2ba4dd99987e5f5090c2ae46c1cd98bb33291da4 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Wed, 4 Feb 2026 23:52:04 -0800 Subject: [PATCH 24/38] fix(cli): when applicable, ensure tool result output can be scrolled with mouse wheel and scrollbar dragging Forcing hasFocus to true in ToolResultDisplay ensures that users can interact with and scroll through tool output content --- packages/cli/src/ui/components/messages/ToolResultDisplay.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 2bdc74bec32..f954ca613e7 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -81,6 +81,9 @@ export const ToolResultDisplay: React.FC = ({ [], ); + // override hasFocus value to ensure content is always scrollable + hasFocus = true; + const truncatedResultDisplay = React.useMemo(() => { // Only truncate string output if not in alternate buffer mode to ensure // we can scroll through the full output. From 89c060c664422e6932662e54fe4ad32600ea4f64 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Thu, 5 Feb 2026 23:19:15 -0800 Subject: [PATCH 25/38] style(cli): add horizontal margins to tool message boxes for improved readability Introduces TOOL_MESSAGE_HORIZONTAL_MARGIN and TOOL_CONFIRMATION_INTERNAL_PADDING constants to provide a consistent 4-character margin on both sides of tool messages in the conversation history. This visually separates tool operations from the main conversation flow. --- ...ternateBufferQuittingDisplay.test.tsx.snap | 32 +- .../components/messages/ToolGroupMessage.tsx | 16 +- ...lConfirmationMessageOverflow.test.tsx.snap | 28 +- .../ToolGroupMessage.test.tsx.snap | 362 +++++++++--------- .../ToolResultDisplayOverflow.test.tsx.snap | 20 +- .../ToolStickyHeaderRegression.test.tsx.snap | 50 +-- 6 files changed, 258 insertions(+), 250 deletions(-) diff --git a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap index 24e92f85ce2..5dcb7069023 100644 --- a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap @@ -39,14 +39,14 @@ Tips for getting started: 2. Be specific for the best results. 3. Create GEMINI.md files to customize your interactions with Gemini. 4. /help for more information. -╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ tool1 Description for tool 1 │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────╯ -╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ tool2 Description for tool 2 │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────╯" + ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ tool1 Description for tool 1 │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯ + ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ tool2 Description for tool 2 │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = ` @@ -83,14 +83,14 @@ Tips for getting started: 2. Be specific for the best results. 3. Create GEMINI.md files to customize your interactions with Gemini. 4. /help for more information. -╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ tool1 Description for tool 1 │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────╯ -╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ tool2 Description for tool 2 │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────╯" + ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ tool1 Description for tool 1 │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯ + ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ tool2 Description for tool 2 │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = ` diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 14272995d54..dc84c75b188 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -42,6 +42,9 @@ const isAskUserInProgress = (t: IndividualToolCallDisplay): boolean => ].includes(t.status); // Main component renders the border and maps the tools using ToolMessage +const TOOL_MESSAGE_HORIZONTAL_MARGIN = 4; +const TOOL_CONFIRMATION_INTERNAL_PADDING = 4; + export const ToolGroupMessage: React.FC = ({ toolCalls: allToolCalls, availableTerminalHeight, @@ -142,6 +145,8 @@ export const ToolGroupMessage: React.FC = ({ ) : undefined; + const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN * 2; + return ( // This box doesn't have a border even though it conceptually does because // we need to allow the sticky headers to render the borders themselves so @@ -155,6 +160,7 @@ export const ToolGroupMessage: React.FC = ({ cause tearing. */ width={terminalWidth} + paddingX={TOOL_MESSAGE_HORIZONTAL_MARGIN} > {visibleToolCalls.map((tool, index) => { const isConfirming = toolAwaitingApproval?.callId === tool.callId; @@ -164,7 +170,7 @@ export const ToolGroupMessage: React.FC = ({ const commonProps = { ...tool, availableTerminalHeight: availableTerminalHeightPerToolMessage, - terminalWidth, + terminalWidth: contentWidth, emphasis: isConfirming ? ('high' as const) : toolAwaitingApproval @@ -183,7 +189,7 @@ export const ToolGroupMessage: React.FC = ({ key={tool.callId} flexDirection="column" minHeight={1} - width={terminalWidth} + width={contentWidth} > {isShellToolCall ? ( = ({ availableTerminalHeight={ availableTerminalHeightPerToolMessage } - terminalWidth={terminalWidth - 4} + terminalWidth={ + contentWidth - TOOL_CONFIRMATION_INTERNAL_PADDING + } /> )} {tool.outputFile && ( @@ -240,7 +248,7 @@ export const ToolGroupMessage: React.FC = ({ (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && ( should display "press ctrl-o" hint when content overflows in ToolGroupMessage 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ? test-tool a test tool ← │ -│ │ -│ ... first 49 lines hidden ... │ -│ 50 line 50 │ -│ Apply this change? │ -│ │ -│ ● 1. Allow once │ -│ 2. Allow for this session │ -│ 3. Modify with external editor │ -│ 4. No, suggest changes (esc) │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────╯ - Press ctrl-o to show more lines" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ? test-tool a test tool ← │ + │ │ + │ ... first 49 lines hidden ... │ + │ 50 line 50 │ + │ Apply this change? │ + │ │ + │ ● 1. Allow once │ + │ 2. Allow for this session │ + │ 3. Modify with external editor │ + │ 4. No, suggest changes (esc) │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯ + Press ctrl-o to show more lines" `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap index 925568daa61..e74e4a76c95 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap @@ -1,19 +1,19 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[` > Ask User Filtering > does NOT filter out ask_user when status is Error 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ x Ask User │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ x Ask User │ + │ │ + │ Test result │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Ask User Filtering > does NOT filter out ask_user when status is Success 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ Ask User │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ Ask User │ + │ │ + │ Test result │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Ask User Filtering > filters out ask_user when status is Confirming 1`] = `""`; @@ -23,89 +23,89 @@ exports[` > Ask User Filtering > filters out ask_user when s exports[` > Ask User Filtering > filters out ask_user when status is Pending 1`] = `""`; exports[` > Ask User Filtering > shows other tools when ask_user is filtered out 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ other-tool A tool for testing │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ other-tool A tool for testing │ + │ │ + │ Test result │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Border Color Logic > uses gray border when all tools are successful and no shell commands 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ test-tool A tool for testing │ -│ │ -│ Test result │ -│ │ -│ ✓ another-tool A tool for testing │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ test-tool A tool for testing │ + │ │ + │ Test result │ + │ │ + │ ✓ another-tool A tool for testing │ + │ │ + │ Test result │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Border Color Logic > uses yellow border for shell commands even when successful 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ run_shell_command A tool for testing │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ run_shell_command A tool for testing │ + │ │ + │ Test result │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Border Color Logic > uses yellow border when tools are pending 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ o test-tool A tool for testing │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ o test-tool A tool for testing │ + │ │ + │ Test result │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Confirmation Handling > renders confirmation with permanent approval disabled 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ? confirm-tool A tool for testing ← │ -│ │ -│ Test result │ -│ Do you want to proceed? │ -│ Do you want to proceed? │ -│ │ -│ ● 1. Allow once │ -│ 2. Allow for this session │ -│ 3. No, suggest changes (esc) │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ? confirm-tool A tool for testing ← │ + │ │ + │ Test result │ + │ Do you want to proceed? │ + │ Do you want to proceed? │ + │ │ + │ ● 1. Allow once │ + │ 2. Allow for this session │ + │ 3. No, suggest changes (esc) │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Confirmation Handling > renders confirmation with permanent approval enabled 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ? confirm-tool A tool for testing ← │ -│ │ -│ Test result │ -│ Do you want to proceed? │ -│ Do you want to proceed? │ -│ │ -│ ● 1. Allow once │ -│ 2. Allow for this session │ -│ 3. Allow for all future sessions │ -│ 4. No, suggest changes (esc) │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ? confirm-tool A tool for testing ← │ + │ │ + │ Test result │ + │ Do you want to proceed? │ + │ Do you want to proceed? │ + │ │ + │ ● 1. Allow once │ + │ 2. Allow for this session │ + │ 3. Allow for all future sessions │ + │ 4. No, suggest changes (esc) │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Confirmation Handling > shows confirmation dialog for first confirming tool only 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ? first-confirm A tool for testing ← │ -│ │ -│ Test result │ -│ Confirm first tool │ -│ Do you want to proceed? │ -│ │ -│ ● 1. Allow once │ -│ 2. Allow for this session │ -│ 3. No, suggest changes (esc) │ -│ │ -│ │ -│ ? second-confirm A tool for testing │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ? first-confirm A tool for testing ← │ + │ │ + │ Test result │ + │ Confirm first tool │ + │ Do you want to proceed? │ + │ │ + │ ● 1. Allow once │ + │ 2. Allow for this session │ + │ 3. No, suggest changes (esc) │ + │ │ + │ │ + │ ? second-confirm A tool for testing │ + │ │ + │ Test result │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Event-Driven Scheduler > hides confirming tools when event-driven scheduler is enabled 1`] = `""`; @@ -113,148 +113,148 @@ exports[` > Event-Driven Scheduler > hides confirming tools exports[` > Event-Driven Scheduler > renders nothing when only tool is in-progress AskUser with borderBottom=false 1`] = `""`; exports[` > Event-Driven Scheduler > shows only successful tools when mixed with confirming tools 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ success-tool A tool for testing │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ success-tool A tool for testing │ + │ │ + │ Test result │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders empty tool calls array 1`] = `""`; exports[` > Golden Snapshots > renders header when scrolled 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ tool-1 Description 1. This is a long description that will need to be tr… │ -│──────────────────────────────────────────────────────────────────────────────│ -│ line5 │ █ -│ │ █ -│ ✓ tool-2 Description 2 │ █ -│ │ █ -│ line1 │ █ -│ line2 │ █ -╰──────────────────────────────────────────────────────────────────────────────╯ █" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ tool-1 Description 1. This is a long description that will need … │ + │──────────────────────────────────────────────────────────────────────│ + │ line5 │ █ + │ │ █ + │ ✓ tool-2 Description 2 │ █ + │ │ █ + │ line1 │ █ + │ line2 │ █ + ╰──────────────────────────────────────────────────────────────────────╯ █" `; exports[` > Golden Snapshots > renders mixed tool calls including shell command 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ read_file Read a file │ -│ │ -│ Test result │ -│ │ -│ ⊷ run_shell_command Run command │ -│ │ -│ Test result │ -│ │ -│ o write_file Write to file │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ read_file Read a file │ + │ │ + │ Test result │ + │ │ + │ ⊷ run_shell_command Run command │ + │ │ + │ Test result │ + │ │ + │ o write_file Write to file │ + │ │ + │ Test result │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders multiple tool calls with different statuses 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ successful-tool This tool succeeded │ -│ │ -│ Test result │ -│ │ -│ o pending-tool This tool is pending │ -│ │ -│ Test result │ -│ │ -│ x error-tool This tool failed │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ successful-tool This tool succeeded │ + │ │ + │ Test result │ + │ │ + │ o pending-tool This tool is pending │ + │ │ + │ Test result │ + │ │ + │ x error-tool This tool failed │ + │ │ + │ Test result │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders shell command with yellow border 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ run_shell_command Execute shell command │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ run_shell_command Execute shell command │ + │ │ + │ Test result │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders single successful tool call 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ test-tool A tool for testing │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ test-tool A tool for testing │ + │ │ + │ Test result │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders tool call awaiting confirmation 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ? confirmation-tool This tool needs confirmation ← │ -│ │ -│ Test result │ -│ Are you sure you want to proceed? │ -│ Do you want to proceed? │ -│ │ -│ ● 1. Allow once │ -│ 2. Allow for this session │ -│ 3. No, suggest changes (esc) │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ? confirmation-tool This tool needs confirmation ← │ + │ │ + │ Test result │ + │ Are you sure you want to proceed? │ + │ Do you want to proceed? │ + │ │ + │ ● 1. Allow once │ + │ 2. Allow for this session │ + │ 3. No, suggest changes (esc) │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders tool call with outputFile 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ tool-with-file Tool that saved output to file │ -│ │ -│ Test result │ -│ Output too long and was saved to: /path/to/output.txt │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ tool-with-file Tool that saved output to file │ + │ │ + │ Test result │ + │ Output too long and was saved to: /path/to/output.txt │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = ` -"╰──────────────────────────────────────────────────────────────────────────────╯ -╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ tool-2 Description 2 │ -│ │ ▄ -│ line1 │ █ -╰──────────────────────────────────────────────────────────────────────────────╯ █" +" ╰──────────────────────────────────────────────────────────────────────╯ + ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ tool-2 Description 2 │ + │ │ ▄ + │ line1 │ █ + ╰──────────────────────────────────────────────────────────────────────╯ █" `; exports[` > Golden Snapshots > renders when not focused 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ test-tool A tool for testing │ -│ │ -│ Test result │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ test-tool A tool for testing │ + │ │ + │ Test result │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders with limited terminal height 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ tool-with-result Tool with output │ -│ │ -│ This is a long result that might need height constraints │ -│ │ -│ ✓ another-tool Another tool │ -│ │ -│ More output here │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ tool-with-result Tool with output │ + │ │ + │ This is a long result that might need height constraints │ + │ │ + │ ✓ another-tool Another tool │ + │ │ + │ More output here │ + ╰──────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders with narrow terminal width 1`] = ` -"╭──────────────────────────────────────╮ -│ ✓ very-long-tool-name-that-might-w… │ -│ │ -│ Test result │ -╰──────────────────────────────────────╯" +" ╭──────────────────────────────╮ + │ ✓ very-long-tool-name-that… │ + │ │ + │ Test result │ + ╰──────────────────────────────╯" `; exports[` > Height Calculation > calculates available height correctly with multiple tools with results 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ test-tool A tool for testing │ -│ │ -│ Result 1 │ -│ │ -│ ✓ test-tool A tool for testing │ -│ │ -│ Result 2 │ -│ │ -│ ✓ test-tool A tool for testing │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────╯" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ test-tool A tool for testing │ + │ │ + │ Result 1 │ + │ │ + │ ✓ test-tool A tool for testing │ + │ │ + │ Result 2 │ + │ │ + │ ✓ test-tool A tool for testing │ + │ │ + ╰──────────────────────────────────────────────────────────────────────╯" `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap index 09a1cef39f0..95bc331edda 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap @@ -1,14 +1,14 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`ToolResultDisplay Overflow > should display "press ctrl-o" hint when content overflows in ToolGroupMessage 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ ✓ test-tool a test tool │ -│ │ -│ ... first 46 lines hidden ... │ -│ line 47 │ -│ line 48 │ -│ line 49 │ -│ line 50 │ -╰──────────────────────────────────────────────────────────────────────────────╯ - Press ctrl-o to show more lines" +" ╭──────────────────────────────────────────────────────────────────────╮ + │ ✓ test-tool a test tool │ + │ │ + │ ... first 46 lines hidden ... │ + │ line 47 │ + │ line 48 │ + │ line 49 │ + │ line 50 │ + ╰──────────────────────────────────────────────────────────────────────╯ + Press ctrl-o to show more lines" `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap index 9fa4d21ab95..4942ba03a9b 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap @@ -1,41 +1,41 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = ` -"╭────────────────────────────────────────────────────────────────────────────╮ █ -│ ✓ Shell Command Description for Shell Command │ █ -│ │ -│ shell-01 │ -│ shell-02 │" +" ╭────────────────────────────────────────────────────────────────────╮ █ + │ ✓ Shell Command Description for Shell Command │ █ + │ │ + │ shell-01 │ + │ shell-02 │" `; exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = ` -"╭────────────────────────────────────────────────────────────────────────────╮ -│ ✓ Shell Command Description for Shell Command │ ▄ -│────────────────────────────────────────────────────────────────────────────│ █ -│ shell-06 │ ▀ -│ shell-07 │" +" ╭────────────────────────────────────────────────────────────────────╮ + │ ✓ Shell Command Description for Shell Command │ ▄ + │────────────────────────────────────────────────────────────────────│ █ + │ shell-06 │ ▀ + │ shell-07 │" `; exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 1`] = ` -"╭────────────────────────────────────────────────────────────────────────────╮ █ -│ ✓ tool-1 Description for tool-1 │ -│ │ -│ c1-01 │ -│ c1-02 │" +" ╭────────────────────────────────────────────────────────────────────╮ █ + │ ✓ tool-1 Description for tool-1 │ + │ │ + │ c1-01 │ + │ c1-02 │" `; exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 2`] = ` -"╭────────────────────────────────────────────────────────────────────────────╮ -│ ✓ tool-1 Description for tool-1 │ █ -│────────────────────────────────────────────────────────────────────────────│ -│ c1-06 │ -│ c1-07 │" +" ╭────────────────────────────────────────────────────────────────────╮ + │ ✓ tool-1 Description for tool-1 │ █ + │────────────────────────────────────────────────────────────────────│ + │ c1-06 │ + │ c1-07 │" `; exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 3`] = ` -"│ │ -│ ✓ tool-2 Description for tool-2 │ -│────────────────────────────────────────────────────────────────────────────│ -│ c2-10 │ -╰────────────────────────────────────────────────────────────────────────────╯ █" +" │ │ + │ ✓ tool-2 Description for tool-2 │ + │────────────────────────────────────────────────────────────────────│ + │ c2-10 │ + ╰────────────────────────────────────────────────────────────────────╯ █" `; From e356608d5f6f0c54d8c4c6b183c1a50ae38e802b Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Fri, 6 Feb 2026 01:43:58 -0800 Subject: [PATCH 26/38] feat(cli): reduce shell output height and refine truncation Reduces the default maximum height for active and completed shell command output from 25 to 15 lines. Refines the AnsiOutput truncation logic to respect available terminal height more accurately. Updates ShellToolMessage to use the new COMPLETED_SHELL_MAX_LINES constant. Updates tests and snapshots to reflect these changes. --- packages/cli/src/ui/components/AnsiOutput.tsx | 8 +- .../ui/components/ShellInputPrompt.test.tsx | 4 +- .../messages/ShellToolMessage.test.tsx | 214 ++++-------------- .../components/messages/ShellToolMessage.tsx | 4 +- .../ShellToolMessage.test.tsx.snap | 37 +++ packages/cli/src/ui/constants.ts | 4 +- 6 files changed, 87 insertions(+), 184 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index f80696b791c..b283f2e8909 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -25,15 +25,11 @@ export const AnsiOutputText: React.FC = ({ maxLines, disableTruncation, }) => { - const effectiveHeight = + const limit = availableTerminalHeight && availableTerminalHeight > 0 - ? availableTerminalHeight + ? Math.min(availableTerminalHeight, maxLines ?? Infinity) : (maxLines ?? DEFAULT_HEIGHT); - const limit = maxLines - ? Math.min(effectiveHeight, maxLines) - : effectiveHeight; - const lastLines = disableTruncation ? data : data.slice(-limit); return ( diff --git a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx index b0bb07939d6..1ea52ef84a1 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx @@ -79,8 +79,8 @@ describe('ShellInputPrompt', () => { }); it.each([ - ['pageup', -25], - ['pagedown', 25], + ['pageup', -15], + ['pagedown', 15], ])( 'handles page scroll %s (Command.SHELL_SCROLL_%s) with default size', (key, expectedScroll) => { diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 918c506e7ed..7ac1847e382 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -10,55 +10,12 @@ import { type ShellToolMessageProps, } from './ShellToolMessage.js'; import { StreamingState, ToolCallStatus } from '../../types.js'; -import { Text } from 'ink'; import type { Config } from '@google/gemini-cli-core'; import { renderWithProviders } from '../../../test-utils/render.js'; import { waitFor } from '../../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SHELL_TOOL_NAME } from '@google/gemini-cli-core'; import { SHELL_COMMAND_NAME } from '../../constants.js'; -import { StreamingContext } from '../../contexts/StreamingContext.js'; - -vi.mock('../TerminalOutput.js', () => ({ - TerminalOutput: function MockTerminalOutput({ - cursor, - }: { - cursor: { x: number; y: number } | null; - }) { - return ( - - MockCursor:({cursor?.x},{cursor?.y}) - - ); - }, -})); - -// Mock child components or utilities if they are complex or have side effects -vi.mock('../GeminiRespondingSpinner.js', () => ({ - GeminiRespondingSpinner: ({ - nonRespondingDisplay, - }: { - nonRespondingDisplay?: string; - }) => { - const streamingState = React.useContext(StreamingContext)!; - if (streamingState === StreamingState.Responding) { - return MockRespondingSpinner; - } - return nonRespondingDisplay ? {nonRespondingDisplay} : null; - }, -})); - -vi.mock('../../utils/MarkdownDisplay.js', () => ({ - MarkdownDisplay: function MockMarkdownDisplay({ text }: { text: string }) { - return MockMarkdown:{text}; - }, -})); - -vi.mock('./ToolResultDisplay.js', () => ({ - ToolResultDisplay: vi.fn(({ maxLines }) => ( - MockToolResultDisplay:maxLines={String(maxLines)} - )), -})); describe('', () => { const baseProps: ShellToolMessageProps = { @@ -183,157 +140,70 @@ describe('', () => { }); }); - describe('maxLines logic (constrained vs unconstrained)', () => { - it('passes maxLines as undefined when availableTerminalHeight is undefined', async () => { - const { ToolResultDisplay: MockToolResultDisplay } = await import( - './ToolResultDisplay.js' + describe('Snapshots', () => { + it('renders in Executing state', async () => { + const { lastFrame } = renderWithProviders( + , + { uiActions }, ); - - const props: ShellToolMessageProps = { - ...baseProps, - availableTerminalHeight: undefined, // Unconstrained - }; - - renderWithProviders(, { uiActions }); - await waitFor(() => { - // Verify the props of the last call - const lastCallProps = vi - .mocked(MockToolResultDisplay) - .mock.calls.at(-1)?.[0]; - expect(lastCallProps).toMatchObject({ - maxLines: undefined, - }); + expect(lastFrame()).toMatchSnapshot(); }); }); - it('passes ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is defined', async () => { - const { ToolResultDisplay: MockToolResultDisplay } = await import( - './ToolResultDisplay.js' + it('renders in Success state (history mode)', async () => { + const { lastFrame } = renderWithProviders( + , + { uiActions }, ); - const { ACTIVE_SHELL_MAX_LINES } = await import('../../constants.js'); - - const props: ShellToolMessageProps = { - ...baseProps, - availableTerminalHeight: 20, // Constrained - }; - - renderWithProviders(, { uiActions }); - await waitFor(() => { - const lastCallProps = vi - .mocked(MockToolResultDisplay) - .mock.calls.at(-1)?.[0]; - expect(lastCallProps).toMatchObject({ - maxLines: ACTIVE_SHELL_MAX_LINES, - }); + expect(lastFrame()).toMatchSnapshot(); }); }); - it('passes SHELL_HISTORY_MAX_LINES when status is Success', async () => { - const { ToolResultDisplay: MockToolResultDisplay } = await import( - './ToolResultDisplay.js' + it('renders in Error state', async () => { + const { lastFrame } = renderWithProviders( + , + { uiActions }, ); - const { SHELL_HISTORY_MAX_LINES } = await import('../../constants.js'); - - const props: ShellToolMessageProps = { - ...baseProps, - status: ToolCallStatus.Success, - availableTerminalHeight: 20, // Constrained - }; - - renderWithProviders(, { uiActions }); - await waitFor(() => { - const lastCallProps = vi - .mocked(MockToolResultDisplay) - .mock.calls.at(-1)?.[0]; - expect(lastCallProps).toMatchObject({ - maxLines: SHELL_HISTORY_MAX_LINES, - }); + expect(lastFrame()).toMatchSnapshot(); }); }); - it('passes SHELL_HISTORY_MAX_LINES when status is Success even if availableTerminalHeight is undefined', async () => { - const { ToolResultDisplay: MockToolResultDisplay } = await import( - './ToolResultDisplay.js' + it('renders in Alternate Buffer mode while focused', async () => { + const { lastFrame } = renderWithProviders( + , + { uiActions, useAlternateBuffer: true }, ); - const { SHELL_HISTORY_MAX_LINES } = await import('../../constants.js'); - - const props: ShellToolMessageProps = { - ...baseProps, - status: ToolCallStatus.Success, - availableTerminalHeight: undefined, - }; - - renderWithProviders(, { uiActions }); - await waitFor(() => { - const lastCallProps = vi - .mocked(MockToolResultDisplay) - .mock.calls.at(-1)?.[0]; - expect(lastCallProps).toMatchObject({ - maxLines: SHELL_HISTORY_MAX_LINES, - }); + expect(lastFrame()).toMatchSnapshot(); }); }); - it('passes undefined when in alternate buffer and focused while executing', async () => { - const { ToolResultDisplay: MockToolResultDisplay } = await import( - './ToolResultDisplay.js' + it('renders in Alternate Buffer mode while unfocused', async () => { + const { lastFrame } = renderWithProviders( + , + { uiActions, useAlternateBuffer: true }, ); - - const props: ShellToolMessageProps = { - ...baseProps, - status: ToolCallStatus.Executing, - availableTerminalHeight: 20, - ptyId: 1, - activeShellPtyId: 1, - embeddedShellFocused: true, - }; - - renderWithProviders(, { - uiActions, - useAlternateBuffer: true, - }); - - await waitFor(() => { - const lastCallProps = vi - .mocked(MockToolResultDisplay) - .mock.calls.at(-1)?.[0]; - expect(lastCallProps).toMatchObject({ - maxLines: undefined, - }); - }); - }); - - it('passes SHELL_HISTORY_MAX_LINES when finished even if previously in alternate buffer and focused', async () => { - const { ToolResultDisplay: MockToolResultDisplay } = await import( - './ToolResultDisplay.js' - ); - const { SHELL_HISTORY_MAX_LINES } = await import('../../constants.js'); - - const props: ShellToolMessageProps = { - ...baseProps, - status: ToolCallStatus.Success, - availableTerminalHeight: 20, - ptyId: 1, - activeShellPtyId: 1, - embeddedShellFocused: true, - }; - - renderWithProviders(, { - uiActions, - useAlternateBuffer: true, - }); - await waitFor(() => { - const lastCallProps = vi - .mocked(MockToolResultDisplay) - .mock.calls.at(-1)?.[0]; - expect(lastCallProps).toMatchObject({ - maxLines: SHELL_HISTORY_MAX_LINES, - }); + expect(lastFrame()).toMatchSnapshot(); }); }); }); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index 22b1dc0c963..1106495edce 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -25,7 +25,7 @@ import type { ToolMessageProps } from './ToolMessage.js'; import { ToolCallStatus } from '../../types.js'; import { ACTIVE_SHELL_MAX_LINES, - SHELL_HISTORY_MAX_LINES, + COMPLETED_SHELL_MAX_LINES, } from '../../constants.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; import type { Config } from '@google/gemini-cli-core'; @@ -201,7 +201,7 @@ function getShellMaxLines( status === ToolCallStatus.Error || status === ToolCallStatus.Canceled ) { - return SHELL_HISTORY_MAX_LINES; + return COMPLETED_SHELL_MAX_LINES; } if ( diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap new file mode 100644 index 00000000000..12cf4b38bfd --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap @@ -0,0 +1,37 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > Snapshots > renders in Alternate Buffer mode while focused 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ⊷ Shell Command A shell command (Focused) │ +│ │ +│ Test result │ +│ │" +`; + +exports[` > Snapshots > renders in Alternate Buffer mode while unfocused 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ⊷ Shell Command A shell command │ +│ │ +│ Test result │" +`; + +exports[` > Snapshots > renders in Error state 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ x Shell Command A shell command │ +│ │ +│ Error output │" +`; + +exports[` > Snapshots > renders in Executing state 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ⊷ Shell Command A shell command │ +│ │ +│ Test result │" +`; + +exports[` > Snapshots > renders in Success state (history mode) 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ✓ Shell Command A shell command │ +│ │ +│ Test result │" +`; diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 64d3ad4b95e..6d33e55d454 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -41,7 +41,7 @@ export const KEYBOARD_SHORTCUTS_URL = export const LRU_BUFFER_PERF_CACHE_LIMIT = 20000; // Max lines to show for active shell output when not focused -export const ACTIVE_SHELL_MAX_LINES = 25; +export const ACTIVE_SHELL_MAX_LINES = 15; // Max lines to preserve in history for completed shell commands -export const SHELL_HISTORY_MAX_LINES = 25; +export const COMPLETED_SHELL_MAX_LINES = 15; From ae8b93e0b71a7844c1e148a70f68c06dbe52754e Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Fri, 6 Feb 2026 01:44:34 -0800 Subject: [PATCH 27/38] chore(test): clean up test infrastructure and remove excessive mocks Refactors several UI tests to remove manual component mocks, allowing tests to use real implementations for better coverage and reliability. Standardizes on the project's internal 'waitFor' utility instead of 'vi.waitFor'. Updates snapshots to reflect the changes in rendering behavior. --- .../src/ui/components/Notifications.test.tsx | 3 +- .../components/messages/ToolMessage.test.tsx | 58 +------------------ .../messages/ToolResultDisplay.test.tsx | 53 ----------------- .../__snapshots__/ToolMessage.test.tsx.snap | 33 +++++------ .../ToolResultDisplay.test.tsx.snap | 8 ++- .../ui/contexts/ToolActionsContext.test.tsx | 5 +- 6 files changed, 29 insertions(+), 131 deletions(-) diff --git a/packages/cli/src/ui/components/Notifications.test.tsx b/packages/cli/src/ui/components/Notifications.test.tsx index 6870eb03734..744a7902277 100644 --- a/packages/cli/src/ui/components/Notifications.test.tsx +++ b/packages/cli/src/ui/components/Notifications.test.tsx @@ -5,6 +5,7 @@ */ import { render, persistentStateMock } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { Notifications } from './Notifications.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useAppContext, type AppState } from '../contexts/AppContext.js'; @@ -172,7 +173,7 @@ describe('Notifications', () => { render(); await act(async () => { - await vi.waitFor(() => { + await waitFor(() => { expect(persistentStateMock.set).toHaveBeenCalledWith( 'hasSeenScreenReaderNudge', true, diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 6bd75097b08..b5f05eff3ce 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -4,13 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import type { ToolMessageProps } from './ToolMessage.js'; +import type React from 'react'; +import { ToolMessage, type ToolMessageProps } from './ToolMessage.js'; import { describe, it, expect, vi } from 'vitest'; -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'; import { renderWithProviders } from '../../../test-utils/render.js'; import { tryParseJSON } from '../../../utils/jsonoutput.js'; @@ -29,53 +27,6 @@ vi.mock('../TerminalOutput.js', () => ({ }, })); -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}; - }, - AnsiLineText: function MockAnsiLineText({ - line, - }: { - line: AnsiOutput[number]; - }) { - const serialized = line.map((token) => token.text || '').join(''); - return MockAnsiLine:{serialized}; - }, -})); - -// Mock child components or utilities if they are complex or have side effects -vi.mock('../GeminiRespondingSpinner.js', () => ({ - GeminiRespondingSpinner: ({ - nonRespondingDisplay, - }: { - nonRespondingDisplay?: string; - }) => { - const streamingState = React.useContext(StreamingContext)!; - if (streamingState === StreamingState.Responding) { - return MockRespondingSpinner; - } - return nonRespondingDisplay ? {nonRespondingDisplay} : null; - }, -})); -vi.mock('./DiffRenderer.js', () => ({ - DiffRenderer: function MockDiffRenderer({ - diffContent, - }: { - diffContent: string; - }) { - return MockDiff:{diffContent}; - }, -})); -vi.mock('../../utils/MarkdownDisplay.js', () => ({ - MarkdownDisplay: function MockMarkdownDisplay({ text }: { text: string }) { - return MockMarkdown:{text}; - }, -})); - describe('', () => { const baseProps: ToolMessageProps = { callId: 'tool-123', @@ -139,7 +90,6 @@ describe('', () => { expect(output).toContain('"a": 1'); expect(output).toContain('"b": ['); // Should not use markdown renderer for JSON - expect(output).not.toContain('MockMarkdown:'); }); it('renders pretty JSON in ink frame', () => { @@ -151,9 +101,6 @@ describe('', () => { const frame = lastFrame(); expect(frame).toMatchSnapshot(); - expect(frame).not.toContain('MockMarkdown:'); - expect(frame).not.toContain('MockAnsiOutput:'); - expect(frame).not.toMatch(/MockDiff:/); }); it('uses JSON renderer even when renderOutputAsMarkdown=true is true', () => { @@ -175,7 +122,6 @@ describe('', () => { expect(output).toContain('"a": 1'); expect(output).toContain('"b": ['); // Should not use markdown renderer for JSON even when renderOutputAsMarkdown=true - expect(output).not.toContain('MockMarkdown:'); }); it('falls back to plain text for malformed JSON', () => { const testJSONstring = 'a": 1, "b": [2, 3]}'; diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx index e2012c42a6e..797e405b624 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -7,26 +7,8 @@ import { renderWithProviders } from '../../../test-utils/render.js'; import { ToolResultDisplay } from './ToolResultDisplay.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { Box, Text } from 'ink'; import type { AnsiOutput } from '@google/gemini-cli-core'; -// Mock child components to simplify testing -vi.mock('./DiffRenderer.js', () => ({ - DiffRenderer: ({ - diffContent, - filename, - }: { - diffContent: string; - filename: string; - }) => ( - - - DiffRenderer: {filename} - {diffContent} - - - ), -})); - // Mock UIStateContext partially const mockUseUIState = vi.fn(); vi.mock('../../contexts/UIStateContext.js', async (importOriginal) => { @@ -44,41 +26,6 @@ vi.mock('../../hooks/useAlternateBuffer.js', () => ({ useAlternateBuffer: () => mockUseAlternateBuffer(), })); -// Mock SettingsContext partially -vi.mock('../../contexts/SettingsContext.js', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - useSettings: () => ({ - merged: { - ui: { - useAlternateBuffer: false, - }, - }, - }), - }; -}); - -// Mock OverflowContext partially -vi.mock('../../contexts/OverflowContext.js', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - useOverflowActions: () => ({ - addOverflowingId: vi.fn(), - removeOverflowingId: vi.fn(), - }), - useOverflowState: () => ({ - overflowingIds: new Set(), - }), - }; -}); - -// NOTE: Scrollable, ScrollableList, and AnsiOutput mocks have been removed -// to use the real implementations. - describe('ToolResultDisplay', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap index 2a276d611ef..599c9e68daf 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap @@ -14,93 +14,90 @@ exports[` > ToolStatusIndicator rendering > shows ? for Confirmin "╭──────────────────────────────────────────────────────────────────────────────╮ │ ? test-tool A tool for testing │ │ │ -│ MockMarkdown:Test result │" +│ Test result │" `; exports[` > ToolStatusIndicator rendering > shows - for Canceled status 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ - test-tool A tool for testing │ │ │ -│ MockMarkdown:Test result │" +│ Test result │" `; exports[` > ToolStatusIndicator rendering > shows MockRespondingSpinner for Executing status when streamingState is Responding 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ -│ MockRespondingSpinnertest-tool A tool for testing │ +│ ⊶ test-tool A tool for testing │ │ │ -│ MockMarkdown:Test result │" +│ Test result │" `; exports[` > ToolStatusIndicator rendering > shows o for Pending status 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ o test-tool A tool for testing │ │ │ -│ MockMarkdown:Test result │" +│ Test result │" `; exports[` > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is Idle 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊷ test-tool A tool for testing │ │ │ -│ MockMarkdown:Test result │" +│ Test result │" `; exports[` > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is WaitingForConfirmation 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊷ test-tool A tool for testing │ │ │ -│ MockMarkdown:Test result │" +│ Test result │" `; exports[` > ToolStatusIndicator rendering > shows x for Error status 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ x test-tool A tool for testing │ │ │ -│ MockMarkdown:Test result │" +│ Test result │" `; exports[` > ToolStatusIndicator rendering > shows ✓ for Success status 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ✓ test-tool A tool for testing │ │ │ -│ MockMarkdown:Test result │" +│ Test result │" `; exports[` > renders AnsiOutputText for AnsiOutput results 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ✓ test-tool A tool for testing │ │ │ -│ MockAnsiLine:hello │" +│ hello │" `; exports[` > renders DiffRenderer for diff results 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ✓ test-tool A tool for testing │ │ │ -│ MockDiff:--- a/file.txt │ -│ +++ b/file.txt │ -│ @@ -1 +1 @@ │ -│ -old │ -│ +new │" +│ 1 - old │ +│ 1 + new │" `; exports[` > renders basic tool information 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ✓ test-tool A tool for testing │ │ │ -│ MockMarkdown:Test result │" +│ Test result │" `; exports[` > renders emphasis correctly 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ✓ test-tool A tool for testing ← │ │ │ -│ MockMarkdown:Test result │" +│ Test result │" `; exports[` > renders emphasis correctly 2`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ✓ test-tool A tool for testing │ │ │ -│ MockMarkdown:Test result │" +│ Test result │" `; 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 e90c3659510..4149cfbcc47 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 @@ -6,7 +6,13 @@ exports[`ToolResultDisplay > keeps markdown if in alternate buffer even with ava exports[`ToolResultDisplay > renders ANSI output result 1`] = `"ansi content"`; -exports[`ToolResultDisplay > renders file diff result 1`] = `"DiffRenderer: test.ts - diff content"`; +exports[`ToolResultDisplay > renders file diff result 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────╮ +│ │ +│ No changes detected. │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯" +`; exports[`ToolResultDisplay > renders nothing for todos result 1`] = `""`; diff --git a/packages/cli/src/ui/contexts/ToolActionsContext.test.tsx b/packages/cli/src/ui/contexts/ToolActionsContext.test.tsx index 5ab94971062..3260ff3f0f3 100644 --- a/packages/cli/src/ui/contexts/ToolActionsContext.test.tsx +++ b/packages/cli/src/ui/contexts/ToolActionsContext.test.tsx @@ -7,6 +7,7 @@ import { act } from 'react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; import { ToolActionsProvider, useToolActions } from './ToolActionsContext.js'; import { type Config, @@ -155,7 +156,7 @@ describe('ToolActionsContext', () => { // Wait for IdeClient initialization in useEffect await act(async () => { - await vi.waitFor(() => expect(IdeClient.getInstance).toHaveBeenCalled()); + await waitFor(() => expect(IdeClient.getInstance).toHaveBeenCalled()); // Give React a chance to update state await new Promise((resolve) => setTimeout(resolve, 0)); }); @@ -195,7 +196,7 @@ describe('ToolActionsContext', () => { // Wait for initialization await act(async () => { - await vi.waitFor(() => expect(IdeClient.getInstance).toHaveBeenCalled()); + await waitFor(() => expect(IdeClient.getInstance).toHaveBeenCalled()); await new Promise((resolve) => setTimeout(resolve, 0)); }); From 77277f7f0d4cca2c3f3b27467def225a7739de96 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Fri, 6 Feb 2026 10:06:09 -0800 Subject: [PATCH 28/38] fix(cli): prevent shell output overflow by constraining height to available space Ensures that shell output in active mode does not exceed the available terminal height, resolving overflow issues in the alternate buffer that caused a suboptimal scrolling experience. Updates getShellMaxLines to cap output at ACTIVE_SHELL_MAX_LINES or (availableTerminalHeight - 2), whichever is smaller. Includes new tests and snapshots to verify the height constraints in both small and large terminal scenarios. --- .../messages/ShellToolMessage.test.tsx | 68 ++++++++++++++++++- .../components/messages/ShellToolMessage.tsx | 3 +- .../ShellToolMessage.test.tsx.snap | 35 ++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 7ac1847e382..452c5a0e10d 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -15,7 +15,7 @@ import { renderWithProviders } from '../../../test-utils/render.js'; import { waitFor } from '../../../test-utils/async.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SHELL_TOOL_NAME } from '@google/gemini-cli-core'; -import { SHELL_COMMAND_NAME } from '../../constants.js'; +import { SHELL_COMMAND_NAME, ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; describe('', () => { const baseProps: ShellToolMessageProps = { @@ -207,4 +207,70 @@ describe('', () => { }); }); }); + + describe('Height Constraints', () => { + it('respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES', async () => { + const availableTerminalHeight = 10; + // We expect fewer lines than ACTIVE_SHELL_MAX_LINES (15). + // Logic: Math.min(15, Math.max(1, 10 - 2)) = 8. + + const { lastFrame } = renderWithProviders( + `Line ${i + 1}`, + ).join('\n')} + renderOutputAsMarkdown={false} + availableTerminalHeight={availableTerminalHeight} + activeShellPtyId={1} + ptyId={2} // not focused + status={ToolCallStatus.Executing} + />, + { + uiActions, + useAlternateBuffer: true, + }, + ); + + await waitFor(() => { + const frame = lastFrame(); + const matches = frame!.match(/Line \d+/g); + // We expect <= 8 lines visible + expect(matches?.length).toBeLessThanOrEqual(8); + expect(matches?.length).toBeGreaterThan(0); + expect(frame).toMatchSnapshot(); + }); + }); + + it('uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large', async () => { + const availableTerminalHeight = 100; + const { lastFrame } = renderWithProviders( + `Line ${i + 1}`, + ).join('\n')} + renderOutputAsMarkdown={false} + availableTerminalHeight={availableTerminalHeight} + activeShellPtyId={1} + ptyId={2} // not focused + status={ToolCallStatus.Executing} + />, + { + uiActions, + useAlternateBuffer: true, + }, + ); + + await waitFor(() => { + const frame = lastFrame(); + const matches = frame!.match(/Line \d+/g); + // Should be limited by ACTIVE_SHELL_MAX_LINES (15). + expect(matches?.length).toBe(ACTIVE_SHELL_MAX_LINES); + expect(frame).toMatchSnapshot(); + }); + }); + }); }); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index 1106495edce..16d2cb71eaa 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -212,5 +212,6 @@ function getShellMaxLines( return undefined; } - return ACTIVE_SHELL_MAX_LINES; + const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2); + return Math.min(maxLinesBasedOnHeight, ACTIVE_SHELL_MAX_LINES); } diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap index 12cf4b38bfd..88f89ee1376 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap @@ -1,5 +1,40 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[` > Height Constraints > respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ⊷ Shell Command A shell command │ +│ │ +│ Line 43 │ +│ Line 44 │ +│ Line 45 │ +│ Line 46 │ +│ Line 47 │ +│ Line 48 │ +│ Line 49 ▄ │ +│ Line 50 █ │" +`; + +exports[` > Height Constraints > uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ⊷ Shell Command A shell command │ +│ │ +│ 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[` > Snapshots > renders in Alternate Buffer mode while focused 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command A shell command (Focused) │ From 405b4f0691b507d5603cc5da7fb84c66d6e01d83 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Fri, 6 Feb 2026 14:10:51 -0800 Subject: [PATCH 29/38] refactor(cli): enhance scrollable components and focus scoping Decoupled mouse scrolling from keyboard focus in ScrollProvider and ensured that scrollable components consume keyboard events to prevent bubbling. Updated MainContent to yield focus when a shell pane is active, ensuring scrolling is correctly scoped to the focused component. --- .../cli/src/ui/components/MainContent.tsx | 2 +- .../components/messages/ToolResultDisplay.tsx | 3 --- .../src/ui/components/shared/Scrollable.tsx | 26 +++++++++++++------ .../ui/components/shared/ScrollableList.tsx | 8 +++++- .../cli/src/ui/contexts/ScrollProvider.tsx | 2 +- 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index e97b7a62110..2350eb2f126 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -148,7 +148,7 @@ export const MainContent = () => { return ( = ({ [], ); - // override hasFocus value to ensure content is always scrollable - hasFocus = true; - const truncatedResultDisplay = React.useMemo(() => { // Only truncate string output if not in alternate buffer mode to ensure // we can scroll through the full output. diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx index 16436be7c6f..39083bf84cd 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.tsx @@ -17,6 +17,7 @@ import { useKeypress, type Key } from '../../hooks/useKeypress.js'; import { useScrollable } from '../../contexts/ScrollProvider.js'; import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js'; import { useBatchedScroll } from '../../hooks/useBatchedScroll.js'; +import { keyMatchers, Command } from '../../keyMatchers.js'; interface ScrollableProps { children?: React.ReactNode; @@ -103,14 +104,23 @@ export const Scrollable: React.FC = ({ useKeypress( (key: Key) => { - if (key.shift) { - if (key.name === 'up') { - scrollByWithAnimation(-1); - } - if (key.name === 'down') { - scrollByWithAnimation(1); - } + if (keyMatchers[Command.SCROLL_UP](key)) { + scrollByWithAnimation(-1); + return true; } + if (keyMatchers[Command.SCROLL_DOWN](key)) { + scrollByWithAnimation(1); + return true; + } + if (keyMatchers[Command.PAGE_UP](key)) { + scrollByWithAnimation(-sizeRef.current.innerHeight); + return true; + } + if (keyMatchers[Command.PAGE_DOWN](key)) { + scrollByWithAnimation(sizeRef.current.innerHeight); + return true; + } + return false; }, { isActive: hasFocus }, ); @@ -137,7 +147,7 @@ export const Scrollable: React.FC = ({ [getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar], ); - useScrollable(scrollableEntry, hasFocus && ref.current !== null); + useScrollable(scrollableEntry, true); return ( ( if (keyMatchers[Command.SCROLL_UP](key)) { stopSmoothScroll(); scrollByWithAnimation(-1); + return true; } else if (keyMatchers[Command.SCROLL_DOWN](key)) { stopSmoothScroll(); scrollByWithAnimation(1); + return true; } else if ( keyMatchers[Command.PAGE_UP](key) || keyMatchers[Command.PAGE_DOWN](key) @@ -200,11 +202,15 @@ function ScrollableList( : scrollState.scrollTop; const innerHeight = scrollState.innerHeight; smoothScrollTo(current + direction * innerHeight); + return true; } else if (keyMatchers[Command.SCROLL_HOME](key)) { smoothScrollTo(0); + return true; } else if (keyMatchers[Command.SCROLL_END](key)) { smoothScrollTo(SCROLL_TO_ITEM_END); + return true; } + return false; }, { isActive: hasFocus }, ); @@ -229,7 +235,7 @@ function ScrollableList( ], ); - useScrollable(scrollableEntry, hasFocus); + useScrollable(scrollableEntry, true); return ( = []; for (const entry of scrollables.values()) { - if (!entry.ref.current || !entry.hasFocus()) { + if (!entry.ref.current) { continue; } From c9fdaf8967da0036bfef43592fcab5a69537df35 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Fri, 6 Feb 2026 14:11:22 -0800 Subject: [PATCH 30/38] refactor(cli): unify scrolling keybindings and sync documentation Removed redundant shell-specific scrolling commands and unified them under standard Shift+Up/Down and PageUp/Down shortcuts. Updated ShellInputPrompt to use these standard commands and verified the behavior with updated tests. Regenerated the keyboard shortcut documentation to reflect these changes. --- docs/cli/keyboard-shortcuts.md | 20 ++++++++----------- packages/cli/src/config/keyBindings.ts | 20 ------------------- .../ui/components/ShellInputPrompt.test.tsx | 16 +++++++-------- .../src/ui/components/ShellInputPrompt.tsx | 8 ++++---- packages/cli/src/ui/keyMatchers.test.ts | 14 +++++++++---- 5 files changed, 30 insertions(+), 48 deletions(-) diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index d10ba76f876..926b8772664 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -44,18 +44,14 @@ available combinations. #### Scrolling -| Action | Keys | -| ------------------------------------------- | --------------------------------- | -| Scroll content up. | `Shift + Up Arrow` | -| Scroll content down. | `Shift + Down Arrow` | -| Scroll to the top. | `Ctrl + Home`
`Shift + Home` | -| Scroll to the bottom. | `Ctrl + End`
`Shift + End` | -| Scroll up by one page. | `Page Up` | -| Scroll down by one page. | `Page Down` | -| Scroll the embedded shell up. | `Shift + Ctrl + Up Arrow` | -| Scroll the embedded shell down. | `Shift + Ctrl + Down Arrow` | -| Scroll the embedded shell up by one page. | `Shift + Ctrl + Page Up` | -| Scroll the embedded shell down by one page. | `Shift + Ctrl + Page Down` | +| Action | Keys | +| ------------------------ | --------------------------------- | +| Scroll content up. | `Shift + Up Arrow` | +| Scroll content down. | `Shift + Down Arrow` | +| Scroll to the top. | `Ctrl + Home`
`Shift + Home` | +| Scroll to the bottom. | `Ctrl + End`
`Shift + End` | +| Scroll up by one page. | `Page Up` | +| Scroll down by one page. | `Page Down` | #### History & Search diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index d3f563517fb..b787350561e 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -96,11 +96,6 @@ export enum Command { RESTART_APP = 'app.restart', SUSPEND_APP = 'app.suspend', - // Shell Controls - SHELL_SCROLL_UP = 'shell.scrollUp', - SHELL_SCROLL_DOWN = 'shell.scrollDown', - SHELL_SCROLL_PAGE_UP = 'shell.scrollPageUp', - SHELL_SCROLL_PAGE_DOWN = 'shell.scrollPageDown', SHELL_LEAVE_FOCUS = 'shell.leaveFocus', } @@ -301,12 +296,6 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }], // Shell Controls - [Command.SHELL_SCROLL_UP]: [{ key: 'up', ctrl: true, shift: true }], - [Command.SHELL_SCROLL_DOWN]: [{ key: 'down', ctrl: true, shift: true }], - [Command.SHELL_SCROLL_PAGE_UP]: [{ key: 'pageup', ctrl: true, shift: true }], - [Command.SHELL_SCROLL_PAGE_DOWN]: [ - { key: 'pagedown', ctrl: true, shift: true }, - ], [Command.SHELL_LEAVE_FOCUS]: [{ key: 'tab', shift: true }], }; @@ -359,10 +348,6 @@ export const commandCategories: readonly CommandCategory[] = [ Command.SCROLL_END, Command.PAGE_UP, Command.PAGE_DOWN, - Command.SHELL_SCROLL_UP, - Command.SHELL_SCROLL_DOWN, - Command.SHELL_SCROLL_PAGE_UP, - Command.SHELL_SCROLL_PAGE_DOWN, ], }, { @@ -532,10 +517,5 @@ export const commandDescriptions: Readonly> = { [Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).', // Shell Controls - [Command.SHELL_SCROLL_UP]: 'Scroll the embedded shell up.', - [Command.SHELL_SCROLL_DOWN]: 'Scroll the embedded shell down.', - [Command.SHELL_SCROLL_PAGE_UP]: 'Scroll the embedded shell up by one page.', - [Command.SHELL_SCROLL_PAGE_DOWN]: - 'Scroll the embedded shell down by one page.', [Command.SHELL_LEAVE_FOCUS]: 'Move focus out of the embedded shell.', }; diff --git a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx index 1ea52ef84a1..62e6bcc16d5 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx @@ -68,12 +68,12 @@ describe('ShellInputPrompt', () => { it.each([ ['up', -1], ['down', 1], - ])('handles scroll %s (Command.SHELL_SCROLL_%s)', (key, direction) => { + ])('handles scroll %s (Command.SCROLL_%s)', (key, direction) => { render(); const handler = mockUseKeypress.mock.calls[0][0]; - handler({ name: key, shift: true, alt: false, ctrl: true, cmd: false }); + handler({ name: key, shift: true, alt: false, ctrl: false, cmd: false }); expect(mockScrollPty).toHaveBeenCalledWith(1, direction); }); @@ -82,13 +82,13 @@ describe('ShellInputPrompt', () => { ['pageup', -15], ['pagedown', 15], ])( - 'handles page scroll %s (Command.SHELL_SCROLL_%s) with default size', + 'handles page scroll %s (Command.PAGE_%s) with default size', (key, expectedScroll) => { render(); const handler = mockUseKeypress.mock.calls[0][0]; - handler({ name: key, shift: true, alt: false, ctrl: true, cmd: false }); + handler({ name: key, shift: false, alt: false, ctrl: false, cmd: false }); expect(mockScrollPty).toHaveBeenCalledWith(1, expectedScroll); }, @@ -108,9 +108,9 @@ describe('ShellInputPrompt', () => { // PageDown handler({ name: 'pagedown', - shift: true, + shift: false, alt: false, - ctrl: true, + ctrl: false, cmd: false, }); expect(mockScrollPty).toHaveBeenCalledWith(1, 10); @@ -118,9 +118,9 @@ describe('ShellInputPrompt', () => { // PageUp handler({ name: 'pageup', - shift: true, + shift: false, alt: false, - ctrl: true, + ctrl: false, cmd: false, }); expect(mockScrollPty).toHaveBeenCalledWith(1, -10); diff --git a/packages/cli/src/ui/components/ShellInputPrompt.tsx b/packages/cli/src/ui/components/ShellInputPrompt.tsx index 2d45a479025..5702ae4f037 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.tsx @@ -47,19 +47,19 @@ export const ShellInputPrompt: React.FC = ({ return false; } - if (keyMatchers[Command.SHELL_SCROLL_UP](key)) { + if (keyMatchers[Command.SCROLL_UP](key)) { ShellExecutionService.scrollPty(activeShellPtyId, -1); return true; } - if (keyMatchers[Command.SHELL_SCROLL_DOWN](key)) { + if (keyMatchers[Command.SCROLL_DOWN](key)) { ShellExecutionService.scrollPty(activeShellPtyId, 1); return true; } - if (keyMatchers[Command.SHELL_SCROLL_PAGE_UP](key)) { + if (keyMatchers[Command.PAGE_UP](key)) { ShellExecutionService.scrollPty(activeShellPtyId, -scrollPageSize); return true; } - if (keyMatchers[Command.SHELL_SCROLL_PAGE_DOWN](key)) { + if (keyMatchers[Command.PAGE_DOWN](key)) { ShellExecutionService.scrollPty(activeShellPtyId, scrollPageSize); return true; } diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index e65fd4077c2..3b7c14d8966 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -166,21 +166,27 @@ describe('keyMatchers', () => { { command: Command.SCROLL_UP, positive: [createKey('up', { shift: true })], - negative: [createKey('up'), createKey('up', { ctrl: true })], + negative: [createKey('up')], }, { command: Command.SCROLL_DOWN, positive: [createKey('down', { shift: true })], - negative: [createKey('down'), createKey('down', { ctrl: true })], + negative: [createKey('down')], }, { command: Command.SCROLL_HOME, - positive: [createKey('home', { ctrl: true })], + positive: [ + createKey('home', { ctrl: true }), + createKey('home', { shift: true }), + ], negative: [createKey('end'), createKey('home')], }, { command: Command.SCROLL_END, - positive: [createKey('end', { ctrl: true })], + positive: [ + createKey('end', { ctrl: true }), + createKey('end', { shift: true }), + ], negative: [createKey('home'), createKey('end')], }, { From f662c7a80d16ba040f8a5b7555144bf414c1b1f7 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Fri, 6 Feb 2026 15:15:51 -0800 Subject: [PATCH 31/38] style(cli): adjust tool output margin to right-side only After further review with UX team have modified ToolGroupMessage to apply a 4-character margin specifically to the right side, reverting the left side to its original 1-character margin. Updated width calculations and test snapshots to align with the new layout. --- ...ternateBufferQuittingDisplay.test.tsx.snap | 32 +- .../components/messages/ToolGroupMessage.tsx | 4 +- ...lConfirmationMessageOverflow.test.tsx.snap | 28 +- .../ToolGroupMessage.test.tsx.snap | 362 +++++++++--------- .../ToolResultDisplayOverflow.test.tsx.snap | 20 +- .../ToolStickyHeaderRegression.test.tsx.snap | 50 +-- 6 files changed, 248 insertions(+), 248 deletions(-) diff --git a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap index 5dcb7069023..72a031d7f37 100644 --- a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap @@ -39,14 +39,14 @@ Tips for getting started: 2. Be specific for the best results. 3. Create GEMINI.md files to customize your interactions with Gemini. 4. /help for more information. - ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ tool1 Description for tool 1 │ - │ │ - ╰──────────────────────────────────────────────────────────────────────╯ - ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ tool2 Description for tool 2 │ - │ │ - ╰──────────────────────────────────────────────────────────────────────╯" +╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ tool1 Description for tool 1 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ tool2 Description for tool 2 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = ` @@ -83,14 +83,14 @@ Tips for getting started: 2. Be specific for the best results. 3. Create GEMINI.md files to customize your interactions with Gemini. 4. /help for more information. - ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ tool1 Description for tool 1 │ - │ │ - ╰──────────────────────────────────────────────────────────────────────╯ - ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ tool2 Description for tool 2 │ - │ │ - ╰──────────────────────────────────────────────────────────────────────╯" +╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ tool1 Description for tool 1 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ tool2 Description for tool 2 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = ` diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index dc84c75b188..118b198edfb 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -145,7 +145,7 @@ export const ToolGroupMessage: React.FC = ({ ) : undefined; - const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN * 2; + const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN; return ( // This box doesn't have a border even though it conceptually does because @@ -160,7 +160,7 @@ export const ToolGroupMessage: React.FC = ({ cause tearing. */ width={terminalWidth} - paddingX={TOOL_MESSAGE_HORIZONTAL_MARGIN} + paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN} > {visibleToolCalls.map((tool, index) => { const isConfirming = toolAwaitingApproval?.callId === tool.callId; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessageOverflow.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessageOverflow.test.tsx.snap index 0d69c964c7c..2bbad0dc708 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessageOverflow.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessageOverflow.test.tsx.snap @@ -1,18 +1,18 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`ToolConfirmationMessage Overflow > should display "press ctrl-o" hint when content overflows in ToolGroupMessage 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ? test-tool a test tool ← │ - │ │ - │ ... first 49 lines hidden ... │ - │ 50 line 50 │ - │ Apply this change? │ - │ │ - │ ● 1. Allow once │ - │ 2. Allow for this session │ - │ 3. Modify with external editor │ - │ 4. No, suggest changes (esc) │ - │ │ - ╰──────────────────────────────────────────────────────────────────────╯ - Press ctrl-o to show more lines" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ? test-tool a test tool ← │ +│ │ +│ ... first 49 lines hidden ... │ +│ 50 line 50 │ +│ Apply this change? │ +│ │ +│ ● 1. Allow once │ +│ 2. Allow for this session │ +│ 3. Modify with external editor │ +│ 4. No, suggest changes (esc) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯ + Press ctrl-o to show more lines" `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap index e74e4a76c95..369fa591741 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap @@ -1,19 +1,19 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[` > Ask User Filtering > does NOT filter out ask_user when status is Error 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ x Ask User │ - │ │ - │ Test result │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ x Ask User │ +│ │ +│ Test result │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Ask User Filtering > does NOT filter out ask_user when status is Success 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ Ask User │ - │ │ - │ Test result │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ Ask User │ +│ │ +│ Test result │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Ask User Filtering > filters out ask_user when status is Confirming 1`] = `""`; @@ -23,89 +23,89 @@ exports[` > Ask User Filtering > filters out ask_user when s exports[` > Ask User Filtering > filters out ask_user when status is Pending 1`] = `""`; exports[` > Ask User Filtering > shows other tools when ask_user is filtered out 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ other-tool A tool for testing │ - │ │ - │ Test result │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ other-tool A tool for testing │ +│ │ +│ Test result │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Border Color Logic > uses gray border when all tools are successful and no shell commands 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ test-tool A tool for testing │ - │ │ - │ Test result │ - │ │ - │ ✓ another-tool A tool for testing │ - │ │ - │ Test result │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ test-tool A tool for testing │ +│ │ +│ Test result │ +│ │ +│ ✓ another-tool A tool for testing │ +│ │ +│ Test result │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Border Color Logic > uses yellow border for shell commands even when successful 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ run_shell_command A tool for testing │ - │ │ - │ Test result │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ run_shell_command A tool for testing │ +│ │ +│ Test result │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Border Color Logic > uses yellow border when tools are pending 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ o test-tool A tool for testing │ - │ │ - │ Test result │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ o test-tool A tool for testing │ +│ │ +│ Test result │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Confirmation Handling > renders confirmation with permanent approval disabled 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ? confirm-tool A tool for testing ← │ - │ │ - │ Test result │ - │ Do you want to proceed? │ - │ Do you want to proceed? │ - │ │ - │ ● 1. Allow once │ - │ 2. Allow for this session │ - │ 3. No, suggest changes (esc) │ - │ │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ? confirm-tool A tool for testing ← │ +│ │ +│ Test result │ +│ Do you want to proceed? │ +│ Do you want to proceed? │ +│ │ +│ ● 1. Allow once │ +│ 2. Allow for this session │ +│ 3. No, suggest changes (esc) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Confirmation Handling > renders confirmation with permanent approval enabled 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ? confirm-tool A tool for testing ← │ - │ │ - │ Test result │ - │ Do you want to proceed? │ - │ Do you want to proceed? │ - │ │ - │ ● 1. Allow once │ - │ 2. Allow for this session │ - │ 3. Allow for all future sessions │ - │ 4. No, suggest changes (esc) │ - │ │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ? confirm-tool A tool for testing ← │ +│ │ +│ Test result │ +│ Do you want to proceed? │ +│ Do you want to proceed? │ +│ │ +│ ● 1. Allow once │ +│ 2. Allow for this session │ +│ 3. Allow for all future sessions │ +│ 4. No, suggest changes (esc) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Confirmation Handling > shows confirmation dialog for first confirming tool only 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ? first-confirm A tool for testing ← │ - │ │ - │ Test result │ - │ Confirm first tool │ - │ Do you want to proceed? │ - │ │ - │ ● 1. Allow once │ - │ 2. Allow for this session │ - │ 3. No, suggest changes (esc) │ - │ │ - │ │ - │ ? second-confirm A tool for testing │ - │ │ - │ Test result │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ? first-confirm A tool for testing ← │ +│ │ +│ Test result │ +│ Confirm first tool │ +│ Do you want to proceed? │ +│ │ +│ ● 1. Allow once │ +│ 2. Allow for this session │ +│ 3. No, suggest changes (esc) │ +│ │ +│ │ +│ ? second-confirm A tool for testing │ +│ │ +│ Test result │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Event-Driven Scheduler > hides confirming tools when event-driven scheduler is enabled 1`] = `""`; @@ -113,148 +113,148 @@ exports[` > Event-Driven Scheduler > hides confirming tools exports[` > Event-Driven Scheduler > renders nothing when only tool is in-progress AskUser with borderBottom=false 1`] = `""`; exports[` > Event-Driven Scheduler > shows only successful tools when mixed with confirming tools 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ success-tool A tool for testing │ - │ │ - │ Test result │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ success-tool A tool for testing │ +│ │ +│ Test result │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders empty tool calls array 1`] = `""`; exports[` > Golden Snapshots > renders header when scrolled 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ tool-1 Description 1. This is a long description that will need … │ - │──────────────────────────────────────────────────────────────────────│ - │ line5 │ █ - │ │ █ - │ ✓ tool-2 Description 2 │ █ - │ │ █ - │ line1 │ █ - │ line2 │ █ - ╰──────────────────────────────────────────────────────────────────────╯ █" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ tool-1 Description 1. This is a long description that will need to b… │ +│──────────────────────────────────────────────────────────────────────────│ +│ line5 │ █ +│ │ █ +│ ✓ tool-2 Description 2 │ █ +│ │ █ +│ line1 │ █ +│ line2 │ █ +╰──────────────────────────────────────────────────────────────────────────╯ █" `; exports[` > Golden Snapshots > renders mixed tool calls including shell command 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ read_file Read a file │ - │ │ - │ Test result │ - │ │ - │ ⊷ run_shell_command Run command │ - │ │ - │ Test result │ - │ │ - │ o write_file Write to file │ - │ │ - │ Test result │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ read_file Read a file │ +│ │ +│ Test result │ +│ │ +│ ⊷ run_shell_command Run command │ +│ │ +│ Test result │ +│ │ +│ o write_file Write to file │ +│ │ +│ Test result │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders multiple tool calls with different statuses 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ successful-tool This tool succeeded │ - │ │ - │ Test result │ - │ │ - │ o pending-tool This tool is pending │ - │ │ - │ Test result │ - │ │ - │ x error-tool This tool failed │ - │ │ - │ Test result │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ successful-tool This tool succeeded │ +│ │ +│ Test result │ +│ │ +│ o pending-tool This tool is pending │ +│ │ +│ Test result │ +│ │ +│ x error-tool This tool failed │ +│ │ +│ Test result │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders shell command with yellow border 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ run_shell_command Execute shell command │ - │ │ - │ Test result │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ run_shell_command Execute shell command │ +│ │ +│ Test result │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders single successful tool call 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ test-tool A tool for testing │ - │ │ - │ Test result │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ test-tool A tool for testing │ +│ │ +│ Test result │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders tool call awaiting confirmation 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ? confirmation-tool This tool needs confirmation ← │ - │ │ - │ Test result │ - │ Are you sure you want to proceed? │ - │ Do you want to proceed? │ - │ │ - │ ● 1. Allow once │ - │ 2. Allow for this session │ - │ 3. No, suggest changes (esc) │ - │ │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ? confirmation-tool This tool needs confirmation ← │ +│ │ +│ Test result │ +│ Are you sure you want to proceed? │ +│ Do you want to proceed? │ +│ │ +│ ● 1. Allow once │ +│ 2. Allow for this session │ +│ 3. No, suggest changes (esc) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders tool call with outputFile 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ tool-with-file Tool that saved output to file │ - │ │ - │ Test result │ - │ Output too long and was saved to: /path/to/output.txt │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ tool-with-file Tool that saved output to file │ +│ │ +│ Test result │ +│ Output too long and was saved to: /path/to/output.txt │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = ` -" ╰──────────────────────────────────────────────────────────────────────╯ - ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ tool-2 Description 2 │ - │ │ ▄ - │ line1 │ █ - ╰──────────────────────────────────────────────────────────────────────╯ █" +"╰──────────────────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ tool-2 Description 2 │ +│ │ ▄ +│ line1 │ █ +╰──────────────────────────────────────────────────────────────────────────╯ █" `; exports[` > Golden Snapshots > renders when not focused 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ test-tool A tool for testing │ - │ │ - │ Test result │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ test-tool A tool for testing │ +│ │ +│ Test result │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders with limited terminal height 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ tool-with-result Tool with output │ - │ │ - │ This is a long result that might need height constraints │ - │ │ - │ ✓ another-tool Another tool │ - │ │ - │ More output here │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ tool-with-result Tool with output │ +│ │ +│ This is a long result that might need height constraints │ +│ │ +│ ✓ another-tool Another tool │ +│ │ +│ More output here │ +╰──────────────────────────────────────────────────────────────────────────╯" `; exports[` > Golden Snapshots > renders with narrow terminal width 1`] = ` -" ╭──────────────────────────────╮ - │ ✓ very-long-tool-name-that… │ - │ │ - │ Test result │ - ╰──────────────────────────────╯" +"╭──────────────────────────────────╮ +│ ✓ very-long-tool-name-that-mig… │ +│ │ +│ Test result │ +╰──────────────────────────────────╯" `; exports[` > Height Calculation > calculates available height correctly with multiple tools with results 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ test-tool A tool for testing │ - │ │ - │ Result 1 │ - │ │ - │ ✓ test-tool A tool for testing │ - │ │ - │ Result 2 │ - │ │ - │ ✓ test-tool A tool for testing │ - │ │ - ╰──────────────────────────────────────────────────────────────────────╯" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ test-tool A tool for testing │ +│ │ +│ Result 1 │ +│ │ +│ ✓ test-tool A tool for testing │ +│ │ +│ Result 2 │ +│ │ +│ ✓ test-tool A tool for testing │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯" `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap index 95bc331edda..5d64da232b3 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap @@ -1,14 +1,14 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`ToolResultDisplay Overflow > should display "press ctrl-o" hint when content overflows in ToolGroupMessage 1`] = ` -" ╭──────────────────────────────────────────────────────────────────────╮ - │ ✓ test-tool a test tool │ - │ │ - │ ... first 46 lines hidden ... │ - │ line 47 │ - │ line 48 │ - │ line 49 │ - │ line 50 │ - ╰──────────────────────────────────────────────────────────────────────╯ - Press ctrl-o to show more lines" +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ test-tool a test tool │ +│ │ +│ ... first 46 lines hidden ... │ +│ line 47 │ +│ line 48 │ +│ line 49 │ +│ line 50 │ +╰──────────────────────────────────────────────────────────────────────────╯ + Press ctrl-o to show more lines" `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap index 4942ba03a9b..58cb3697f3c 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap @@ -1,41 +1,41 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = ` -" ╭────────────────────────────────────────────────────────────────────╮ █ - │ ✓ Shell Command Description for Shell Command │ █ - │ │ - │ shell-01 │ - │ shell-02 │" +"╭────────────────────────────────────────────────────────────────────────╮ █ +│ ✓ Shell Command Description for Shell Command │ █ +│ │ +│ shell-01 │ +│ shell-02 │" `; exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = ` -" ╭────────────────────────────────────────────────────────────────────╮ - │ ✓ Shell Command Description for Shell Command │ ▄ - │────────────────────────────────────────────────────────────────────│ █ - │ shell-06 │ ▀ - │ shell-07 │" +"╭────────────────────────────────────────────────────────────────────────╮ +│ ✓ Shell Command Description for Shell Command │ ▄ +│────────────────────────────────────────────────────────────────────────│ █ +│ shell-06 │ ▀ +│ shell-07 │" `; exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 1`] = ` -" ╭────────────────────────────────────────────────────────────────────╮ █ - │ ✓ tool-1 Description for tool-1 │ - │ │ - │ c1-01 │ - │ c1-02 │" +"╭────────────────────────────────────────────────────────────────────────╮ █ +│ ✓ tool-1 Description for tool-1 │ +│ │ +│ c1-01 │ +│ c1-02 │" `; exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 2`] = ` -" ╭────────────────────────────────────────────────────────────────────╮ - │ ✓ tool-1 Description for tool-1 │ █ - │────────────────────────────────────────────────────────────────────│ - │ c1-06 │ - │ c1-07 │" +"╭────────────────────────────────────────────────────────────────────────╮ +│ ✓ tool-1 Description for tool-1 │ █ +│────────────────────────────────────────────────────────────────────────│ +│ c1-06 │ +│ c1-07 │" `; exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 3`] = ` -" │ │ - │ ✓ tool-2 Description for tool-2 │ - │────────────────────────────────────────────────────────────────────│ - │ c2-10 │ - ╰────────────────────────────────────────────────────────────────────╯ █" +"│ │ +│ ✓ tool-2 Description for tool-2 │ +│────────────────────────────────────────────────────────────────────────│ +│ c2-10 │ +╰────────────────────────────────────────────────────────────────────────╯ █" `; From 3d0643055fb9929e9e6c400419a972db5fd3e803 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Fri, 6 Feb 2026 16:35:25 -0800 Subject: [PATCH 32/38] refactor(cli): rename SHELL_LEAVE_FOCUS to UNFOCUS_SHELL For consistency with other keybinding commands, SHELL_LEAVE_FOCUS has been renamed to UNFOCUS_SHELL. All usages have been updated to reflect this change. --- packages/cli/src/config/keyBindings.ts | 8 ++++---- packages/cli/src/ui/AppContainer.tsx | 2 +- packages/cli/src/ui/components/ShellInputPrompt.test.tsx | 2 +- packages/cli/src/ui/components/ShellInputPrompt.tsx | 3 ++- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index b787350561e..d73f877f541 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -96,7 +96,7 @@ export enum Command { RESTART_APP = 'app.restart', SUSPEND_APP = 'app.suspend', - SHELL_LEAVE_FOCUS = 'shell.leaveFocus', + UNFOCUS_SHELL = 'shell.unfocus', } /** @@ -296,7 +296,7 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }], // Shell Controls - [Command.SHELL_LEAVE_FOCUS]: [{ key: 'tab', shift: true }], + [Command.UNFOCUS_SHELL]: [{ key: 'tab', shift: true }], }; interface CommandCategory { @@ -370,7 +370,7 @@ export const commandCategories: readonly CommandCategory[] = [ Command.DIALOG_NAVIGATION_DOWN, Command.DIALOG_NEXT, Command.DIALOG_PREV, - Command.SHELL_LEAVE_FOCUS, + Command.UNFOCUS_SHELL, ], }, { @@ -517,5 +517,5 @@ export const commandDescriptions: Readonly> = { [Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).', // Shell Controls - [Command.SHELL_LEAVE_FOCUS]: 'Move focus out of the embedded shell.', + [Command.UNFOCUS_SHELL]: 'Move focus out of the embedded shell.', }; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b99019b12b8..86fec88a898 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1504,7 +1504,7 @@ Logging in with Google... Restarting Gemini CLI to continue. keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) && (activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0)) ) { - if (keyMatchers[Command.SHELL_LEAVE_FOCUS](key)) { + if (keyMatchers[Command.UNFOCUS_SHELL](key)) { // Always change focus setEmbeddedShellFocused(false); return true; diff --git a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx index 62e6bcc16d5..3f82ddc7673 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx @@ -160,7 +160,7 @@ describe('ShellInputPrompt', () => { expect(mockWriteToPty).not.toHaveBeenCalled(); }); - it('ignores Command.SHELL_LEAVE_FOCUS (Shift+Tab) to allow focus navigation', () => { + it('ignores Command.UNFOCUS_SHELL (Shift+Tab) to allow focus navigation', () => { render(); const handler = mockUseKeypress.mock.calls[0][0]; diff --git a/packages/cli/src/ui/components/ShellInputPrompt.tsx b/packages/cli/src/ui/components/ShellInputPrompt.tsx index 5702ae4f037..3494029b39e 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.tsx @@ -43,7 +43,7 @@ export const ShellInputPrompt: React.FC = ({ } // Allow Shift+Tab to bubble up for focus navigation - if (keyMatchers[Command.SHELL_LEAVE_FOCUS](key)) { + if (keyMatchers[Command.UNFOCUS_SHELL](key)) { return false; } @@ -55,6 +55,7 @@ export const ShellInputPrompt: React.FC = ({ ShellExecutionService.scrollPty(activeShellPtyId, 1); return true; } + // TODO: Check pty service actually scrolls (request)[https://github.com/google-gemini/gemini-cli/pull/17438/changes/c9fdaf8967da0036bfef43592fcab5a69537df35#r2776479023]. if (keyMatchers[Command.PAGE_UP](key)) { ShellExecutionService.scrollPty(activeShellPtyId, -scrollPageSize); return true; From 57ce58cfacbcd1ad95514eeb5e5fc717882eb7bd Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Fri, 6 Feb 2026 17:24:24 -0800 Subject: [PATCH 33/38] refactor(cli): address PR feedback - Refactor AnsiOutput.tsx to remove Infinity and use undefined for consistency - Refactor ShellToolMessage.test.tsx to use it.each to reduce verbosity --- packages/cli/src/ui/components/AnsiOutput.tsx | 13 +- .../messages/ShellToolMessage.test.tsx | 204 ++++++------------ 2 files changed, 80 insertions(+), 137 deletions(-) diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index b283f2e8909..cc17b6b6b0c 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -25,12 +25,17 @@ export const AnsiOutputText: React.FC = ({ maxLines, disableTruncation, }) => { - const limit = + const availableHeightLimit = availableTerminalHeight && availableTerminalHeight > 0 - ? Math.min(availableTerminalHeight, maxLines ?? Infinity) - : (maxLines ?? DEFAULT_HEIGHT); + ? availableTerminalHeight + : undefined; - const lastLines = disableTruncation ? data : data.slice(-limit); + const numLinesRetained = + availableHeightLimit !== undefined && maxLines !== undefined + ? Math.min(availableHeightLimit, maxLines) + : (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT); + + const lastLines = disableTruncation ? data : data.slice(-numLinesRetained); return ( {lastLines.map((line: AnsiLine, lineIndex: number) => ( diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 452c5a0e10d..5d55dff4a83 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -55,38 +55,12 @@ describe('', () => { }); describe('interactive shell focus', () => { - const shellProps: ShellToolMessageProps = { - ...baseProps, - }; - - it('clicks inside the shell area sets focus to true', async () => { - const { stdin, lastFrame, simulateClick } = renderWithProviders( - , - { - mouseEventsEnabled: true, - uiActions, - }, - ); - - await waitFor(() => { - expect(lastFrame()).toContain('A shell command'); // Wait for render - }); - - await simulateClick(stdin, 2, 2); // Click at column 2, row 2 (1-based) - - await waitFor(() => { - expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true); - }); - }); - - it('handles focus for SHELL_TOOL_NAME (core shell tool)', async () => { - const coreShellProps: ShellToolMessageProps = { - ...shellProps, - name: SHELL_TOOL_NAME, - }; - + it.each([ + ['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME], + ['SHELL_TOOL_NAME', SHELL_TOOL_NAME], + ])('clicks inside the shell area sets focus for %s', async (_, name) => { const { stdin, lastFrame, simulateClick } = renderWithProviders( - , + , { mouseEventsEnabled: true, uiActions, @@ -112,7 +86,7 @@ describe('', () => { updateStatus = setStatus; return ( ', () => { }); describe('Snapshots', () => { - it('renders in Executing state', async () => { - const { lastFrame } = renderWithProviders( - , - { uiActions }, - ); - await waitFor(() => { - expect(lastFrame()).toMatchSnapshot(); - }); - }); - - it('renders in Success state (history mode)', async () => { - const { lastFrame } = renderWithProviders( - , - { uiActions }, - ); - await waitFor(() => { - expect(lastFrame()).toMatchSnapshot(); - }); - }); - - it('renders in Error state', async () => { - const { lastFrame } = renderWithProviders( - , - { uiActions }, - ); - await waitFor(() => { - expect(lastFrame()).toMatchSnapshot(); - }); - }); - - it('renders in Alternate Buffer mode while focused', async () => { - const { lastFrame } = renderWithProviders( - , - { uiActions, useAlternateBuffer: true }, - ); - await waitFor(() => { - expect(lastFrame()).toMatchSnapshot(); - }); - }); - - it('renders in Alternate Buffer mode while unfocused', async () => { - const { lastFrame } = renderWithProviders( - , - { uiActions, useAlternateBuffer: true }, - ); - await waitFor(() => { - expect(lastFrame()).toMatchSnapshot(); - }); - }); + it.each([ + ['renders in Executing state', { status: ToolCallStatus.Executing }], + [ + 'renders in Success state (history mode)', + { status: ToolCallStatus.Success }, + ], + [ + 'renders in Error state', + { status: ToolCallStatus.Error, resultDisplay: 'Error output' }, + ], + [ + 'renders in Alternate Buffer mode while focused', + { + status: ToolCallStatus.Executing, + embeddedShellFocused: true, + activeShellPtyId: 1, + ptyId: 1, + useAlternateBuffer: true, + }, + ], + [ + 'renders in Alternate Buffer mode while unfocused', + { + status: ToolCallStatus.Executing, + embeddedShellFocused: false, + activeShellPtyId: 1, + ptyId: 1, + useAlternateBuffer: true, + }, + ], + ])( + '%s', + async ( + _, + props: Partial & { + useAlternateBuffer?: boolean; + }, + ) => { + const { useAlternateBuffer, ...componentProps } = props; + const { lastFrame } = renderWithProviders( + , + { uiActions, useAlternateBuffer }, + ); + await waitFor(() => { + expect(lastFrame()).toMatchSnapshot(); + }); + }, + ); }); describe('Height Constraints', () => { - it('respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES', async () => { - const availableTerminalHeight = 10; - // We expect fewer lines than ACTIVE_SHELL_MAX_LINES (15). - // Logic: Math.min(15, Math.max(1, 10 - 2)) = 8. - - const { lastFrame } = renderWithProviders( - `Line ${i + 1}`, - ).join('\n')} - renderOutputAsMarkdown={false} - availableTerminalHeight={availableTerminalHeight} - activeShellPtyId={1} - ptyId={2} // not focused - status={ToolCallStatus.Executing} - />, + it.each([ + [ + 'respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES', + { availableTerminalHeight: 10, expectedMaxLines: 8 }, + ], + [ + 'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large', { - uiActions, - useAlternateBuffer: true, + availableTerminalHeight: 100, + expectedMaxLines: ACTIVE_SHELL_MAX_LINES, }, - ); - - await waitFor(() => { - const frame = lastFrame(); - const matches = frame!.match(/Line \d+/g); - // We expect <= 8 lines visible - expect(matches?.length).toBeLessThanOrEqual(8); - expect(matches?.length).toBeGreaterThan(0); - expect(frame).toMatchSnapshot(); - }); - }); - - it('uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large', async () => { - const availableTerminalHeight = 100; + ], + ])('%s', async (_, { availableTerminalHeight, expectedMaxLines }) => { const { lastFrame } = renderWithProviders( ', () => { await waitFor(() => { const frame = lastFrame(); const matches = frame!.match(/Line \d+/g); - // Should be limited by ACTIVE_SHELL_MAX_LINES (15). - expect(matches?.length).toBe(ACTIVE_SHELL_MAX_LINES); + if (availableTerminalHeight < ACTIVE_SHELL_MAX_LINES) { + expect(matches?.length).toBeLessThanOrEqual(expectedMaxLines); + expect(matches?.length).toBeGreaterThan(0); + } else { + expect(matches?.length).toBe(expectedMaxLines); + } expect(frame).toMatchSnapshot(); }); }); From 320c8fb816e8795f5cd1cee58ccf89018339f837 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Fri, 6 Feb 2026 17:24:24 -0800 Subject: [PATCH 34/38] test(cli): simplify ShellToolMessage tests using renderShell helper and `it.each` Reduce boilerplate with renderShell utility func, extracts shared test constants, and consolidates repetitive cases into parameterized tests to improve maintainability. --- .../messages/ShellToolMessage.test.tsx | 100 +++++++----------- 1 file changed, 39 insertions(+), 61 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 5d55dff4a83..f057a654721 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -35,19 +35,23 @@ describe('', () => { } as unknown as Config, }; + const LONG_OUTPUT = Array.from( + { length: 50 }, + (_, i) => `Line ${i + 1}`, + ).join('\n'); + const mockSetEmbeddedShellFocused = vi.fn(); const uiActions = { setEmbeddedShellFocused: mockSetEmbeddedShellFocused, }; - // Helper to render with context - const renderWithContext = ( - ui: React.ReactElement, - streamingState: StreamingState, + const renderShell = ( + props: Partial = {}, + options: Parameters[1] = {}, ) => - renderWithProviders(ui, { + renderWithProviders(, { uiActions, - uiState: { streamingState }, + ...options, }); beforeEach(() => { @@ -59,12 +63,9 @@ describe('', () => { ['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME], ['SHELL_TOOL_NAME', SHELL_TOOL_NAME], ])('clicks inside the shell area sets focus for %s', async (_, name) => { - const { stdin, lastFrame, simulateClick } = renderWithProviders( - , - { - mouseEventsEnabled: true, - uiActions, - }, + const { stdin, lastFrame, simulateClick } = renderShell( + { name }, + { mouseEventsEnabled: true }, ); await waitFor(() => { @@ -95,7 +96,10 @@ describe('', () => { ); }; - const { lastFrame } = renderWithContext(, StreamingState.Idle); + const { lastFrame } = renderWithProviders(, { + uiActions, + uiState: { streamingState: StreamingState.Idle }, + }); // Verify it is initially focused await waitFor(() => { @@ -132,8 +136,8 @@ describe('', () => { embeddedShellFocused: true, activeShellPtyId: 1, ptyId: 1, - useAlternateBuffer: true, }, + { useAlternateBuffer: true }, ], [ 'renders in Alternate Buffer mode while unfocused', @@ -142,71 +146,45 @@ describe('', () => { embeddedShellFocused: false, activeShellPtyId: 1, ptyId: 1, - useAlternateBuffer: true, }, + { useAlternateBuffer: true }, ], - ])( - '%s', - async ( - _, - props: Partial & { - useAlternateBuffer?: boolean; - }, - ) => { - const { useAlternateBuffer, ...componentProps } = props; - const { lastFrame } = renderWithProviders( - , - { uiActions, useAlternateBuffer }, - ); - await waitFor(() => { - expect(lastFrame()).toMatchSnapshot(); - }); - }, - ); + ])('%s', async (_, props, options) => { + const { lastFrame } = renderShell(props, options); + await waitFor(() => { + expect(lastFrame()).toMatchSnapshot(); + }); + }); }); describe('Height Constraints', () => { it.each([ [ 'respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES', - { availableTerminalHeight: 10, expectedMaxLines: 8 }, + 10, + 8, ], [ 'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large', - { - availableTerminalHeight: 100, - expectedMaxLines: ACTIVE_SHELL_MAX_LINES, - }, + 100, + ACTIVE_SHELL_MAX_LINES, ], - ])('%s', async (_, { availableTerminalHeight, expectedMaxLines }) => { - const { lastFrame } = renderWithProviders( - `Line ${i + 1}`, - ).join('\n')} - renderOutputAsMarkdown={false} - availableTerminalHeight={availableTerminalHeight} - activeShellPtyId={1} - ptyId={2} // not focused - status={ToolCallStatus.Executing} - />, + ])('%s', async (_, availableTerminalHeight, expectedMaxLines) => { + const { lastFrame } = renderShell( { - uiActions, - useAlternateBuffer: true, + resultDisplay: LONG_OUTPUT, + renderOutputAsMarkdown: false, + availableTerminalHeight, + activeShellPtyId: 1, + ptyId: 2, // not focused + status: ToolCallStatus.Executing, }, + { useAlternateBuffer: true }, ); await waitFor(() => { const frame = lastFrame(); - const matches = frame!.match(/Line \d+/g); - if (availableTerminalHeight < ACTIVE_SHELL_MAX_LINES) { - expect(matches?.length).toBeLessThanOrEqual(expectedMaxLines); - expect(matches?.length).toBeGreaterThan(0); - } else { - expect(matches?.length).toBe(expectedMaxLines); - } + expect(frame!.match(/Line \d+/g)?.length).toBe(expectedMaxLines); expect(frame).toMatchSnapshot(); }); }); From 6f15db568efac3e5364e295444e9c529d16a48b6 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Sat, 7 Feb 2026 16:05:13 -0800 Subject: [PATCH 35/38] Enable shell tool output expansion in Alternate Buffer mode Updates ShellToolMessage to use the full available terminal height for output display when the shell is focused and running in Alternate Screen Buffer mode. This improves visibility of long-running command outputs. --- .../messages/ShellToolMessage.test.tsx | 29 ++- .../components/messages/ShellToolMessage.tsx | 24 +-- .../ShellToolMessage.test.tsx.snap | 166 +++++++++++++++--- 3 files changed, 184 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index f057a654721..a98bcc4ea47 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -36,7 +36,7 @@ describe('', () => { }; const LONG_OUTPUT = Array.from( - { length: 50 }, + { length: 100 }, (_, i) => `Line ${i + 1}`, ).join('\n'); @@ -120,14 +120,20 @@ describe('', () => { describe('Snapshots', () => { it.each([ - ['renders in Executing state', { status: ToolCallStatus.Executing }], + [ + 'renders in Executing state', + { status: ToolCallStatus.Executing }, + undefined, + ], [ 'renders in Success state (history mode)', { status: ToolCallStatus.Success }, + undefined, ], [ 'renders in Error state', { status: ToolCallStatus.Error, resultDisplay: 'Error output' }, + undefined, ], [ 'renders in Alternate Buffer mode while focused', @@ -163,21 +169,36 @@ describe('', () => { 'respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES', 10, 8, + false, ], [ 'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large', 100, ACTIVE_SHELL_MAX_LINES, + false, + ], + [ + 'uses full availableTerminalHeight when focused in alternate buffer mode', + 100, + 98, // 100 - 2 + true, + ], + [ + 'defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined', + undefined, + ACTIVE_SHELL_MAX_LINES, + false, ], - ])('%s', async (_, availableTerminalHeight, expectedMaxLines) => { + ])('%s', async (_, availableTerminalHeight, expectedMaxLines, focused) => { const { lastFrame } = renderShell( { resultDisplay: LONG_OUTPUT, renderOutputAsMarkdown: false, availableTerminalHeight, activeShellPtyId: 1, - ptyId: 2, // not focused + ptyId: focused ? 1 : 2, status: ToolCallStatus.Executing, + embeddedShellFocused: focused, }, { useAlternateBuffer: true }, ); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index 16d2cb71eaa..cb94726b87e 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -185,17 +185,18 @@ export const ShellToolMessage: React.FC = ({ /** * Calculates the maximum number of lines to display for shell output. * - * For completed processes (Success, Error, Canceled), it returns SHELL_HISTORY_MAX_LINES. - * For active processes, it returns undefined (unlimited) if the terminal is in - * alternate buffer mode and focused, or if availableTerminalHeight is undefined - * (signaling unlimited). Otherwise, it returns ACTIVE_SHELL_MAX_LINES. + * For completed processes (Success, Error, Canceled), it returns COMPLETED_SHELL_MAX_LINES. + * For active processes, it returns the available terminal height if in alternate buffer mode + * and focused. Otherwise, it returns ACTIVE_SHELL_MAX_LINES. + * + * This function ensures a finite number of lines is always returned to prevent performance issues. */ function getShellMaxLines( status: ToolCallStatus, isAlternateBuffer: boolean, isThisShellFocused: boolean, availableTerminalHeight: number | undefined, -): number | undefined { +): number { if ( status === ToolCallStatus.Success || status === ToolCallStatus.Error || @@ -204,14 +205,15 @@ function getShellMaxLines( return COMPLETED_SHELL_MAX_LINES; } - if ( - (isAlternateBuffer && isThisShellFocused) || - availableTerminalHeight === undefined - ) { - // availableTerminalHeight being undefined means unlimited - return undefined; + if (availableTerminalHeight === undefined) { + return ACTIVE_SHELL_MAX_LINES; } const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2); + + if (isAlternateBuffer && isThisShellFocused) { + return maxLinesBasedOnHeight; + } + return Math.min(maxLinesBasedOnHeight, ACTIVE_SHELL_MAX_LINES); } diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap index 88f89ee1376..9d2a96b6909 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap @@ -1,38 +1,164 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[` > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ⊷ Shell Command A shell command │ +│ │ +│ Line 86 │ +│ Line 87 │ +│ Line 88 │ +│ Line 89 │ +│ Line 90 │ +│ Line 91 │ +│ Line 92 │ +│ Line 93 │ +│ Line 94 │ +│ Line 95 │ +│ Line 96 │ +│ Line 97 │ +│ Line 98 ▄ │ +│ Line 99 █ │ +│ Line 100 █ │" +`; + exports[` > Height Constraints > respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command A shell command │ │ │ -│ Line 43 │ -│ Line 44 │ -│ Line 45 │ -│ Line 46 │ -│ Line 47 │ -│ Line 48 │ -│ Line 49 ▄ │ -│ Line 50 █ │" +│ Line 93 │ +│ Line 94 │ +│ Line 95 │ +│ Line 96 │ +│ Line 97 │ +│ Line 98 │ +│ Line 99 │ +│ Line 100 █ │" `; exports[` > Height Constraints > uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊷ Shell Command A shell command │ │ │ -│ Line 36 │ -│ Line 37 │ -│ Line 38 │ -│ Line 39 │ -│ Line 40 │ -│ Line 41 │ -│ Line 42 │ -│ Line 43 │ -│ Line 44 │ -│ Line 45 │ -│ Line 46 ▄ │ +│ Line 86 │ +│ Line 87 │ +│ Line 88 │ +│ Line 89 │ +│ Line 90 │ +│ Line 91 │ +│ Line 92 │ +│ Line 93 │ +│ Line 94 │ +│ Line 95 │ +│ Line 96 │ +│ Line 97 │ +│ Line 98 ▄ │ +│ Line 99 █ │ +│ Line 100 █ │" +`; + +exports[` > Height Constraints > uses full availableTerminalHeight when focused in alternate buffer mode 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ⊷ Shell Command A shell command (Focused) │ +│ │ +│ Line 3 │ +│ Line 4 │ +│ Line 5 █ │ +│ Line 6 █ │ +│ Line 7 █ │ +│ Line 8 █ │ +│ Line 9 █ │ +│ Line 10 █ │ +│ Line 11 █ │ +│ Line 12 █ │ +│ Line 13 █ │ +│ Line 14 █ │ +│ Line 15 █ │ +│ Line 16 █ │ +│ Line 17 █ │ +│ Line 18 █ │ +│ Line 19 █ │ +│ Line 20 █ │ +│ Line 21 █ │ +│ Line 22 █ │ +│ Line 23 █ │ +│ Line 24 █ │ +│ Line 25 █ │ +│ 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 █ │" +│ Line 50 █ │ +│ Line 51 █ │ +│ Line 52 █ │ +│ Line 53 █ │ +│ Line 54 █ │ +│ Line 55 █ │ +│ Line 56 █ │ +│ Line 57 █ │ +│ Line 58 █ │ +│ Line 59 █ │ +│ Line 60 █ │ +│ Line 61 █ │ +│ Line 62 █ │ +│ Line 63 █ │ +│ Line 64 █ │ +│ Line 65 █ │ +│ Line 66 █ │ +│ Line 67 █ │ +│ Line 68 █ │ +│ Line 69 █ │ +│ Line 70 █ │ +│ Line 71 █ │ +│ Line 72 █ │ +│ Line 73 █ │ +│ Line 74 █ │ +│ Line 75 █ │ +│ Line 76 █ │ +│ Line 77 █ │ +│ Line 78 █ │ +│ Line 79 █ │ +│ Line 80 █ │ +│ Line 81 █ │ +│ Line 82 █ │ +│ Line 83 █ │ +│ Line 84 █ │ +│ Line 85 █ │ +│ Line 86 █ │ +│ Line 87 █ │ +│ Line 88 █ │ +│ Line 89 █ │ +│ Line 90 █ │ +│ Line 91 █ │ +│ Line 92 █ │ +│ Line 93 █ │ +│ Line 94 █ │ +│ Line 95 █ │ +│ Line 96 █ │ +│ Line 97 █ │ +│ Line 98 █ │ +│ Line 99 █ │ +│ Line 100 █ │ +│ │" `; exports[` > Snapshots > renders in Alternate Buffer mode while focused 1`] = ` From d87fd62c013359ed76a2b55bb9e5b32b485fb1ab Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Sat, 7 Feb 2026 16:16:54 -0800 Subject: [PATCH 36/38] Propagate available height to history items in Alternate Buffer mode Ensures MainContent passes the correct 'availableTerminalHeight' to HistoryItemDisplay in ASB mode, enabling child components like ShellToolMessage to expand their output pane. --- .../src/ui/components/MainContent.test.tsx | 197 +++++++++++++----- .../cli/src/ui/components/MainContent.tsx | 3 +- .../__snapshots__/MainContent.test.tsx.snap | 112 +++++++++- 3 files changed, 256 insertions(+), 56 deletions(-) diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index f38a6350faf..0445b11b4bc 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -10,6 +10,10 @@ import { MainContent } from './MainContent.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { Box, Text } from 'ink'; import type React from 'react'; +import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; +import { ToolCallStatus } from '../types.js'; +import { SHELL_COMMAND_NAME } from '../constants.js'; +import type { UIState } from '../contexts/UIStateContext.js'; // Mock dependencies vi.mock('../contexts/AppContext.js', async () => { @@ -22,53 +26,10 @@ vi.mock('../contexts/AppContext.js', async () => { }; }); -vi.mock('../contexts/UIStateContext.js', async () => { - const actual = await vi.importActual('../contexts/UIStateContext.js'); - return { - ...actual, - useUIState: () => ({ - history: [ - { id: 1, role: 'user', content: 'Hello' }, - { id: 2, role: 'model', content: 'Hi there' }, - ], - pendingHistoryItems: [], - mainAreaWidth: 80, - staticAreaMaxItemHeight: 20, - availableTerminalHeight: 24, - slashCommands: [], - constrainHeight: false, - isEditorDialogOpen: false, - activePtyId: undefined, - embeddedShellFocused: false, - historyRemountKey: 0, - }), - }; -}); - vi.mock('../hooks/useAlternateBuffer.js', () => ({ useAlternateBuffer: vi.fn(), })); -vi.mock('./HistoryItemDisplay.js', () => ({ - HistoryItemDisplay: ({ - item, - availableTerminalHeight, - }: { - item: { content: string }; - availableTerminalHeight?: number; - }) => ( - - - HistoryItem: {item.content} (height:{' '} - {availableTerminalHeight === undefined - ? 'undefined' - : availableTerminalHeight} - ) - - - ), -})); - vi.mock('./AppHeader.js', () => ({ AppHeader: () => AppHeader, })); @@ -95,39 +56,169 @@ vi.mock('./shared/ScrollableList.js', () => ({ SCROLL_TO_ITEM_END: 0, })); -import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; - describe('MainContent', () => { + const defaultMockUiState = { + history: [ + { id: 1, type: 'user', text: 'Hello' }, + { id: 2, type: 'gemini', text: 'Hi there' }, + ], + pendingHistoryItems: [], + mainAreaWidth: 80, + staticAreaMaxItemHeight: 20, + availableTerminalHeight: 24, + slashCommands: [], + constrainHeight: false, + isEditorDialogOpen: false, + activePtyId: undefined, + embeddedShellFocused: false, + historyRemountKey: 0, + bannerData: { defaultText: '', warningText: '' }, + bannerVisible: false, + }; + beforeEach(() => { vi.mocked(useAlternateBuffer).mockReturnValue(false); }); it('renders in normal buffer mode', async () => { - const { lastFrame } = renderWithProviders(); + const { lastFrame } = renderWithProviders(, { + uiState: defaultMockUiState as Partial, + }); await waitFor(() => expect(lastFrame()).toContain('AppHeader')); const output = lastFrame(); - expect(output).toContain('HistoryItem: Hello (height: 20)'); - expect(output).toContain('HistoryItem: Hi there (height: 20)'); + expect(output).toContain('Hello'); + expect(output).toContain('Hi there'); }); it('renders in alternate buffer mode', async () => { vi.mocked(useAlternateBuffer).mockReturnValue(true); - const { lastFrame } = renderWithProviders(); + const { lastFrame } = renderWithProviders(, { + uiState: defaultMockUiState as Partial, + }); await waitFor(() => expect(lastFrame()).toContain('ScrollableList')); const output = lastFrame(); expect(output).toContain('AppHeader'); - expect(output).toContain('HistoryItem: Hello (height: undefined)'); - expect(output).toContain('HistoryItem: Hi there (height: undefined)'); + expect(output).toContain('Hello'); + expect(output).toContain('Hi there'); }); it('does not constrain height in alternate buffer mode', async () => { vi.mocked(useAlternateBuffer).mockReturnValue(true); - const { lastFrame } = renderWithProviders(); - await waitFor(() => expect(lastFrame()).toContain('HistoryItem: Hello')); + const { lastFrame } = renderWithProviders(, { + uiState: defaultMockUiState as Partial, + }); + await waitFor(() => expect(lastFrame()).toContain('Hello')); const output = lastFrame(); expect(output).toMatchSnapshot(); }); + + describe('MainContent Tool Output Height Logic', () => { + const testCases = [ + { + name: 'ASB mode - Focused shell should expand', + isAlternateBuffer: true, + embeddedShellFocused: true, + constrainHeight: true, + shouldShowLine1: true, + }, + { + name: 'ASB mode - Unfocused shell', + isAlternateBuffer: true, + embeddedShellFocused: false, + constrainHeight: true, + shouldShowLine1: false, + }, + { + name: 'Normal mode - Constrained height', + isAlternateBuffer: false, + embeddedShellFocused: false, + constrainHeight: true, + shouldShowLine1: false, + }, + { + name: 'Normal mode - Unconstrained height', + isAlternateBuffer: false, + embeddedShellFocused: false, + constrainHeight: false, + shouldShowLine1: false, + }, + ]; + + it.each(testCases)( + '$name', + async ({ + isAlternateBuffer, + embeddedShellFocused, + constrainHeight, + shouldShowLine1, + }) => { + vi.mocked(useAlternateBuffer).mockReturnValue(isAlternateBuffer); + const ptyId = 123; + const uiState = { + history: [], + pendingHistoryItems: [ + { + type: 'tool_group' as const, + id: 1, + tools: [ + { + callId: 'call_1', + name: SHELL_COMMAND_NAME, + status: ToolCallStatus.Executing, + description: 'Running a long command...', + // 20 lines of output. + // Default max is 15, so Line 1-5 will be truncated/scrolled out if not expanded. + resultDisplay: Array.from( + { length: 20 }, + (_, i) => `Line ${i + 1}`, + ).join('\n'), + ptyId, + confirmationDetails: undefined, + }, + ], + }, + ], + availableTerminalHeight: 30, // In ASB mode, focused shell should get ~28 lines + terminalHeight: 50, + terminalWidth: 100, + mainAreaWidth: 100, + embeddedShellFocused, + activePtyId: embeddedShellFocused ? ptyId : undefined, + constrainHeight, + isEditorDialogOpen: false, + slashCommands: [], + historyRemountKey: 0, + bannerData: { + defaultText: '', + warningText: '', + }, + bannerVisible: false, + }; + + const { lastFrame } = renderWithProviders(, { + uiState: uiState as Partial, + useAlternateBuffer: isAlternateBuffer, + }); + + const output = lastFrame(); + + // Sanity checks - Use regex with word boundary to avoid matching "Line 10" etc. + const line1Regex = /\bLine 1\b/; + if (shouldShowLine1) { + expect(output).toMatch(line1Regex); + } else { + expect(output).not.toMatch(line1Regex); + } + + // All cases should show the last line + expect(output).toContain('Line 20'); + + // Snapshots for visual verification + expect(output).toMatchSnapshot(); + }, + ); + }); }); diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 2350eb2f126..32c70e8cadc 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -81,7 +81,8 @@ export const MainContent = () => { MainContent Tool Output Height Logic > 'ASB mode - Focused shell should expand' 1`] = ` +"ScrollableList +AppHeader +╭──────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ⊷ Shell Command Running a long command... │ +│ │ +│ Line 1 │ +│ Line 2 │ +│ Line 3 │ +│ Line 4 │ +│ Line 5 │ +│ Line 6 │ +│ Line 7 │ +│ Line 8 │ +│ Line 9 │ +│ Line 10 │ +│ Line 11 │ +│ Line 12 │ +│ Line 13 │ +│ Line 14 │ +│ Line 15 │ +│ Line 16 │ +│ Line 17 │ +│ Line 18 │ +│ Line 19 │ +│ Line 20 │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + ShowMoreLines" +`; + +exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Unfocused shell' 1`] = ` +"ScrollableList +AppHeader +╭──────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ⊷ Shell Command Running a long command... │ +│ │ +│ Line 6 │ +│ Line 7 │ +│ Line 8 │ +│ Line 9 ▄ │ +│ Line 10 █ │ +│ Line 11 █ │ +│ Line 12 █ │ +│ Line 13 █ │ +│ Line 14 █ │ +│ Line 15 █ │ +│ Line 16 █ │ +│ Line 17 █ │ +│ Line 18 █ │ +│ Line 19 █ │ +│ Line 20 █ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + ShowMoreLines" +`; + +exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Constrained height' 1`] = ` +"AppHeader +╭──────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ⊷ Shell Command Running a long command... │ +│ │ +│ Line 6 │ +│ Line 7 │ +│ Line 8 │ +│ Line 9 │ +│ Line 10 │ +│ Line 11 │ +│ Line 12 │ +│ Line 13 │ +│ Line 14 │ +│ Line 15 │ +│ Line 16 │ +│ Line 17 │ +│ Line 18 │ +│ Line 19 │ +│ Line 20 │ +╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + ShowMoreLines" +`; + +exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unconstrained height' 1`] = ` +"AppHeader +╭──────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ⊷ Shell Command Running a long command... │ +│ │ +│ Line 6 │ +│ Line 7 │ +│ Line 8 │ +│ Line 9 │ +│ Line 10 │ +│ Line 11 │ +│ Line 12 │ +│ Line 13 │ +│ Line 14 │ +│ Line 15 │ +│ Line 16 │ +│ Line 17 │ +│ Line 18 │ +│ Line 19 │ +│ Line 20 │ +╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + ShowMoreLines" +`; + exports[`MainContent > does not constrain height in alternate buffer mode 1`] = ` "ScrollableList AppHeader -HistoryItem: Hello (height: undefined) -HistoryItem: Hi there (height: undefined)" +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > Hello +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +✦ Hi there + ShowMoreLines +" `; From 70ae6fa2150cf9746415175dea63384e211d706b Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Sat, 7 Feb 2026 17:27:25 -0800 Subject: [PATCH 37/38] refactor(cli): handle scroll keypress boundaries in Scrollable Only capture scroll events when room to scroll exists, otherwise allow keypress events to bubble. (boundary tests added) --- .../ui/components/shared/Scrollable.test.tsx | 87 +++++++++++++++++++ .../src/ui/components/shared/Scrollable.tsx | 43 ++++++--- 2 files changed, 116 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/ui/components/shared/Scrollable.test.tsx b/packages/cli/src/ui/components/shared/Scrollable.test.tsx index 22c2055f49b..321d9b0ab0c 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.test.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.test.tsx @@ -117,4 +117,91 @@ describe('', () => { }); expect(capturedEntry.getScrollState().scrollTop).toBe(1); }); + + describe('keypress handling', () => { + it.each([ + { + name: 'scrolls down when overflow exists and not at bottom', + initialScrollTop: 0, + scrollHeight: 10, + keySequence: '\u001B[1;2B', // Shift+Down + expectedScrollTop: 1, + }, + { + name: 'scrolls up when overflow exists and not at top', + initialScrollTop: 2, + scrollHeight: 10, + keySequence: '\u001B[1;2A', // Shift+Up + expectedScrollTop: 1, + }, + { + name: 'does not scroll up when at top (allows event to bubble)', + initialScrollTop: 0, + scrollHeight: 10, + keySequence: '\u001B[1;2A', // Shift+Up + expectedScrollTop: 0, + }, + { + name: 'does not scroll down when at bottom (allows event to bubble)', + initialScrollTop: 5, // maxScroll = 10 - 5 = 5 + scrollHeight: 10, + keySequence: '\u001B[1;2B', // Shift+Down + expectedScrollTop: 5, + }, + { + name: 'does not scroll when content fits (allows event to bubble)', + initialScrollTop: 0, + scrollHeight: 5, // Same as innerHeight (5) + keySequence: '\u001B[1;2B', // Shift+Down + expectedScrollTop: 0, + }, + ])( + '$name', + async ({ + initialScrollTop, + scrollHeight, + keySequence, + expectedScrollTop, + }) => { + // Dynamically import ink to mock getScrollHeight + const ink = await import('ink'); + vi.mocked(ink.getScrollHeight).mockReturnValue(scrollHeight); + + let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined; + vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation( + (entry, isActive) => { + if (isActive) { + capturedEntry = entry as ScrollProviderModule.ScrollableEntry; + } + }, + ); + + const { stdin } = renderWithProviders( + + Content + , + ); + + // Ensure initial state using existing scrollBy method + act(() => { + // Reset to top first, then scroll to desired start position + capturedEntry!.scrollBy(-100); + if (initialScrollTop > 0) { + capturedEntry!.scrollBy(initialScrollTop); + } + }); + expect(capturedEntry!.getScrollState().scrollTop).toBe( + initialScrollTop, + ); + + act(() => { + stdin.write(keySequence); + }); + + expect(capturedEntry!.getScrollState().scrollTop).toBe( + expectedScrollTop, + ); + }, + ); + }); }); diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx index 39083bf84cd..a4c5e6fedfd 100644 --- a/packages/cli/src/ui/components/shared/Scrollable.tsx +++ b/packages/cli/src/ui/components/shared/Scrollable.tsx @@ -104,22 +104,37 @@ export const Scrollable: React.FC = ({ useKeypress( (key: Key) => { - if (keyMatchers[Command.SCROLL_UP](key)) { - scrollByWithAnimation(-1); - return true; - } - if (keyMatchers[Command.SCROLL_DOWN](key)) { - scrollByWithAnimation(1); - return true; - } - if (keyMatchers[Command.PAGE_UP](key)) { - scrollByWithAnimation(-sizeRef.current.innerHeight); - return true; + const { scrollHeight, innerHeight } = sizeRef.current; + const scrollTop = getScrollTop(); + const maxScroll = Math.max(0, scrollHeight - innerHeight); + + // Only capture scroll-up events if there's room; + // otherwise allow events to bubble. + if (scrollTop > 0) { + if (keyMatchers[Command.PAGE_UP](key)) { + scrollByWithAnimation(-innerHeight); + return true; + } + if (keyMatchers[Command.SCROLL_UP](key)) { + scrollByWithAnimation(-1); + return true; + } } - if (keyMatchers[Command.PAGE_DOWN](key)) { - scrollByWithAnimation(sizeRef.current.innerHeight); - return true; + + // Only capture scroll-down events if there's room; + // otherwise allow events to bubble. + if (scrollTop < maxScroll) { + if (keyMatchers[Command.PAGE_DOWN](key)) { + scrollByWithAnimation(innerHeight); + return true; + } + if (keyMatchers[Command.SCROLL_DOWN](key)) { + scrollByWithAnimation(1); + return true; + } } + + // bubble keypress return false; }, { isActive: hasFocus }, From 8c627431a1cd543a2a310841baa5144d1ba9d8c0 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan Date: Sat, 7 Feb 2026 21:31:02 -0800 Subject: [PATCH 38/38] refactor(cli): unify shell unfocus commands into UNFOCUS_SHELL_INPUT --- docs/cli/keyboard-shortcuts.md | 3 +-- packages/cli/src/config/keyBindings.ts | 9 +-------- packages/cli/src/ui/AppContainer.tsx | 2 +- packages/cli/src/ui/components/ShellInputPrompt.tsx | 2 +- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index 926b8772664..6c79fbbd0fe 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -74,7 +74,6 @@ available combinations. | Move down within dialog options. | `Down Arrow (no Shift)`
`J (no Shift)` | | Move to the next item or question in a dialog. | `Tab (no Shift)` | | Move to the previous item or question in a dialog. | `Shift + Tab` | -| Move focus out of the embedded shell. | `Shift + Tab` | #### Suggestions & Completions @@ -116,7 +115,7 @@ available combinations. | Tab | `Tab (no Shift)` | | Tab | `Tab (no Shift)` | | Focus the shell input from the gemini input. | `Tab (no Shift)` | -| Focus the Gemini input from the shell input. | `Tab` | +| Focus the Gemini input from the shell input. | `Shift + Tab` | | Clear the terminal screen and redraw the UI. | `Ctrl + L` | | Restart the application. | `R` | | Suspend the application (not yet implemented). | `Ctrl + Z` | diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index d73f877f541..4b1bf26bc70 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -95,8 +95,6 @@ export enum Command { CLEAR_SCREEN = 'app.clearScreen', RESTART_APP = 'app.restart', SUSPEND_APP = 'app.suspend', - - UNFOCUS_SHELL = 'shell.unfocus', } /** @@ -290,13 +288,12 @@ export const defaultKeyBindings: KeyBindingConfig = { { key: 's', ctrl: true }, ], [Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }], - [Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }], [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }], [Command.RESTART_APP]: [{ key: 'r' }], [Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }], // Shell Controls - [Command.UNFOCUS_SHELL]: [{ key: 'tab', shift: true }], + [Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }], }; interface CommandCategory { @@ -370,7 +367,6 @@ export const commandCategories: readonly CommandCategory[] = [ Command.DIALOG_NAVIGATION_DOWN, Command.DIALOG_NEXT, Command.DIALOG_PREV, - Command.UNFOCUS_SHELL, ], }, { @@ -515,7 +511,4 @@ export const commandDescriptions: Readonly> = { [Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.', [Command.RESTART_APP]: 'Restart the application.', [Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).', - - // Shell Controls - [Command.UNFOCUS_SHELL]: 'Move focus out of the embedded shell.', }; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 86fec88a898..06f2dd903e8 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1504,7 +1504,7 @@ Logging in with Google... Restarting Gemini CLI to continue. keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) && (activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0)) ) { - if (keyMatchers[Command.UNFOCUS_SHELL](key)) { + if (keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) { // Always change focus setEmbeddedShellFocused(false); return true; diff --git a/packages/cli/src/ui/components/ShellInputPrompt.tsx b/packages/cli/src/ui/components/ShellInputPrompt.tsx index 3494029b39e..26e32d946f7 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.tsx @@ -43,7 +43,7 @@ export const ShellInputPrompt: React.FC = ({ } // Allow Shift+Tab to bubble up for focus navigation - if (keyMatchers[Command.UNFOCUS_SHELL](key)) { + if (keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) { return false; }