From 4a48d7cf930d0d3bd070139e52311ffe45edab55 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan <150866123+jwhelangoog@users.noreply.github.com> Date: Sun, 8 Feb 2026 00:09:48 -0800 Subject: [PATCH] feat(cli): truncate shell output in UI history and improve active shell display (#17438) --- package-lock.json | 25 +- .../cli/src/ui/components/AnsiOutput.test.tsx | 44 ++- packages/cli/src/ui/components/AnsiOutput.tsx | 62 +-- .../src/ui/components/MainContent.test.tsx | 197 +++++++--- .../cli/src/ui/components/MainContent.tsx | 5 +- .../src/ui/components/Notifications.test.tsx | 3 +- .../ui/components/ShellInputPrompt.test.tsx | 69 +++- .../src/ui/components/ShellInputPrompt.tsx | 22 +- ...ternateBufferQuittingDisplay.test.tsx.snap | 32 +- .../__snapshots__/MainContent.test.tsx.snap | 112 +++++- .../messages/ShellToolMessage.test.tsx | 228 +++++++---- .../components/messages/ShellToolMessage.tsx | 63 +++ .../components/messages/ToolGroupMessage.tsx | 16 +- .../components/messages/ToolMessage.test.tsx | 50 +-- .../ui/components/messages/ToolMessage.tsx | 1 + .../messages/ToolResultDisplay.test.tsx | 197 +++++++--- .../components/messages/ToolResultDisplay.tsx | 121 +++++- .../ToolResultDisplayOverflow.test.tsx | 1 + .../ShellToolMessage.test.tsx.snap | 198 ++++++++++ ...lConfirmationMessageOverflow.test.tsx.snap | 26 +- .../ToolGroupMessage.test.tsx.snap | 362 +++++++++--------- .../__snapshots__/ToolMessage.test.tsx.snap | 33 +- .../ToolResultDisplay.test.tsx.snap | 8 +- .../ToolResultDisplayOverflow.test.tsx.snap | 18 +- .../ToolStickyHeaderRegression.test.tsx.snap | 50 +-- .../ui/components/shared/Scrollable.test.tsx | 87 +++++ .../src/ui/components/shared/Scrollable.tsx | 37 +- .../ui/components/shared/ScrollableList.tsx | 8 +- packages/cli/src/ui/constants.ts | 6 + .../cli/src/ui/contexts/ScrollProvider.tsx | 2 +- .../ui/contexts/ToolActionsContext.test.tsx | 5 +- .../ui/hooks/shellCommandProcessor.test.tsx | 1 - packages/cli/src/ui/hooks/toolMapping.test.ts | 29 ++ packages/cli/src/ui/keyMatchers.test.ts | 14 +- 34 files changed, 1553 insertions(+), 579 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap diff --git a/package-lock.json b/package-lock.json index b59d5a3c3a..0268f4980f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2253,7 +2253,6 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2434,7 +2433,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2468,7 +2466,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2837,7 +2834,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2871,7 +2867,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" @@ -2924,7 +2919,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -4140,7 +4134,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4435,7 +4428,6 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5428,7 +5420,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8438,7 +8429,6 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8979,7 +8969,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -10581,7 +10570,6 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz", "integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -14366,7 +14354,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14377,7 +14364,6 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -16614,7 +16600,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16838,8 +16823,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -16847,7 +16831,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -17020,7 +17003,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17228,7 +17210,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -17342,7 +17323,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17355,7 +17335,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -18060,7 +18039,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18357,7 +18335,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx index 2ecfe93e69..6f1accf608 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', () => { @@ -89,6 +90,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/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index d31ae62b28..cc17b6b6b0 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -14,40 +14,56 @@ interface AnsiOutputProps { data: AnsiOutput; availableTerminalHeight?: number; width: number; + maxLines?: number; + disableTruncation?: boolean; } export const AnsiOutputText: React.FC = ({ data, availableTerminalHeight, width, + maxLines, + disableTruncation, }) => { - const lastLines = data.slice( - -(availableTerminalHeight && availableTerminalHeight > 0 + const availableHeightLimit = + availableTerminalHeight && availableTerminalHeight > 0 ? availableTerminalHeight - : DEFAULT_HEIGHT), - ); + : undefined; + + 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) => ( - - {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/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index f38a6350fa..0445b11b4b 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 e97b7a6211..32c70e8cad 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -81,7 +81,8 @@ export const MainContent = () => { { return ( { render(); await act(async () => { - await vi.waitFor(() => { + await waitFor(() => { expect(persistentStateMock.set).toHaveBeenCalledWith( 'hasSeenScreenReaderNudge', true, diff --git a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx index 94f009bedb..b374e54829 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.test.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.test.tsx @@ -95,16 +95,64 @@ describe('ShellInputPrompt', () => { it.each([ ['up', -1], ['down', 1], - ])('handles scroll %s (Ctrl+Shift+%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); }); + it.each([ + ['pageup', -15], + ['pagedown', 15], + ])( + 'handles page scroll %s (Command.PAGE_%s) with default size', + (key, expectedScroll) => { + render(); + + const handler = mockUseKeypress.mock.calls[0][0]; + + handler({ name: key, shift: false, alt: false, ctrl: false, cmd: false }); + + expect(mockScrollPty).toHaveBeenCalledWith(1, expectedScroll); + }, + ); + + it('respects scrollPageSize prop', () => { + render( + , + ); + + const handler = mockUseKeypress.mock.calls[0][0]; + + // PageDown + handler({ + name: 'pagedown', + shift: false, + alt: false, + ctrl: false, + cmd: false, + }); + expect(mockScrollPty).toHaveBeenCalledWith(1, 10); + + // PageUp + handler({ + name: 'pageup', + shift: false, + alt: false, + ctrl: false, + cmd: false, + }); + expect(mockScrollPty).toHaveBeenCalledWith(1, -10); + }); + it('does not handle input when not focused', () => { render(); @@ -138,4 +186,21 @@ describe('ShellInputPrompt', () => { expect(mockWriteToPty).not.toHaveBeenCalled(); }); + + it('ignores Command.UNFOCUS_SHELL (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 976831f1f4..26e32d946f 100644 --- a/packages/cli/src/ui/components/ShellInputPrompt.tsx +++ b/packages/cli/src/ui/components/ShellInputPrompt.tsx @@ -9,16 +9,19 @@ 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'; import { Command, keyMatchers } from '../keyMatchers.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) => { @@ -34,26 +37,33 @@ export const ShellInputPrompt: React.FC = ({ if (!focus || !activeShellPtyId) { return false; } - // Allow background shell toggle to bubble up if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { return false; } - // Allow unfocus to bubble up + // Allow Shift+Tab to bubble up for focus navigation if (keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) { return false; } - if (key.ctrl && key.shift && key.name === 'up') { + if (keyMatchers[Command.SCROLL_UP](key)) { ShellExecutionService.scrollPty(activeShellPtyId, -1); return true; } - - if (key.ctrl && key.shift && key.name === 'down') { + if (keyMatchers[Command.SCROLL_DOWN](key)) { 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; + } + if (keyMatchers[Command.PAGE_DOWN](key)) { + ShellExecutionService.scrollPty(activeShellPtyId, scrollPageSize); + return true; + } const ansiSequence = keyToAnsi(key); if (ansiSequence) { @@ -63,7 +73,7 @@ export const ShellInputPrompt: React.FC = ({ return false; }, - [focus, handleShellInputSubmit, activeShellPtyId], + [focus, handleShellInputSubmit, activeShellPtyId, scrollPageSize], ); useKeypress(handleInput, { isActive: focus }); 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 24e92f85ce..72a031d7f3 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/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap index 73621e041f..c134cde022 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -1,8 +1,116 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`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 +" `; diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 99a045c4ea..bdd2c77809 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -4,55 +4,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { act } from 'react'; import { ShellToolMessage, 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}; - }, -})); +import { SHELL_COMMAND_NAME, ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; describe('', () => { const baseProps: ShellToolMessageProps = { @@ -72,52 +35,36 @@ describe('', () => { } as unknown as Config, }; + const LONG_OUTPUT = Array.from( + { length: 100 }, + (_, i) => `Line ${i + 1}`, + ).join('\n'); + const mockSetEmbeddedShellFocused = vi.fn(); const uiActions = { setEmbeddedShellFocused: mockSetEmbeddedShellFocused, }; + const renderShell = ( + props: Partial = {}, + options: Parameters[1] = {}, + ) => + renderWithProviders(, { + uiActions, + ...options, + }); beforeEach(() => { vi.clearAllMocks(); }); 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, - }; - - const { stdin, lastFrame, simulateClick } = renderWithProviders( - , - { - mouseEventsEnabled: true, - uiActions, - }, + 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 } = renderShell( + { name }, + { mouseEventsEnabled: true }, ); await waitFor(() => { @@ -130,5 +77,136 @@ describe('', () => { expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true); }); }); + it('resets focus when shell finishes', async () => { + let updateStatus: (s: ToolCallStatus) => void = () => {}; + + const Wrapper = () => { + const [status, setStatus] = React.useState(ToolCallStatus.Executing); + updateStatus = setStatus; + return ( + + ); + }; + + const { lastFrame } = renderWithProviders(, { + uiActions, + uiState: { streamingState: StreamingState.Idle }, + }); + + // Verify it is initially focused + await waitFor(() => { + expect(lastFrame()).toContain('(Shift+Tab to unfocus)'); + }); + + // Now update status to Success + await act(async () => { + updateStatus(ToolCallStatus.Success); + }); + + // Should call setEmbeddedShellFocused(false) because isThisShellFocused became false + await waitFor(() => { + expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false); + expect(lastFrame()).not.toContain('(Shift+Tab to unfocus)'); + }); + }); + }); + + describe('Snapshots', () => { + it.each([ + [ + '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', + { + 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, 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', + 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, focused) => { + const { lastFrame } = renderShell( + { + resultDisplay: LONG_OUTPUT, + renderOutputAsMarkdown: false, + availableTerminalHeight, + activeShellPtyId: 1, + ptyId: focused ? 1 : 2, + status: ToolCallStatus.Executing, + embeddedShellFocused: focused, + }, + { useAlternateBuffer: true }, + ); + + await waitFor(() => { + const frame = lastFrame(); + expect(frame!.match(/Line \d+/g)?.length).toBe(expectedMaxLines); + expect(frame).toMatchSnapshot(); + }); + }); }); }); diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index 998b8cf6d8..80e5e0ff8e 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -22,6 +22,12 @@ import { FocusHint, } from './ToolShared.js'; import type { ToolMessageProps } from './ToolMessage.js'; +import { ToolCallStatus } from '../../types.js'; +import { + ACTIVE_SHELL_MAX_LINES, + COMPLETED_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 { @@ -61,6 +67,7 @@ export const ShellToolMessage: React.FC = ({ borderDimColor, }) => { + const isAlternateBuffer = useAlternateBuffer(); const isThisShellFocused = checkIsShellFocused( name, status, @@ -70,6 +77,18 @@ export const ShellToolMessage: React.FC = ({ ); const { setEmbeddedShellFocused } = useUIActions(); + const wasFocusedRef = React.useRef(false); + + React.useEffect(() => { + if (isThisShellFocused) { + wasFocusedRef.current = true; + } else if (wasFocusedRef.current) { + if (embeddedShellFocused) { + setEmbeddedShellFocused(false); + } + wasFocusedRef.current = false; + } + }, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]); const headerRef = React.useRef(null); @@ -139,12 +158,20 @@ export const ShellToolMessage: React.FC = ({ availableTerminalHeight={availableTerminalHeight} terminalWidth={terminalWidth} renderOutputAsMarkdown={renderOutputAsMarkdown} + hasFocus={isThisShellFocused} + maxLines={getShellMaxLines( + status, + isAlternateBuffer, + isThisShellFocused, + availableTerminalHeight, + )} /> {isThisShellFocused && config && ( )} @@ -152,3 +179,39 @@ export const ShellToolMessage: React.FC = ({ ); }; + +/** + * Calculates the maximum number of lines to display for shell output. + * + * 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 { + if ( + status === ToolCallStatus.Success || + status === ToolCallStatus.Error || + status === ToolCallStatus.Canceled + ) { + return COMPLETED_SHELL_MAX_LINES; + } + + 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/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 14272995d5..118b198edf 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; + 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} + paddingRight={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) && ( ({ }, })); -vi.mock('../AnsiOutput.js', () => ({ - AnsiOutputText: function MockAnsiOutputText({ data }: { data: AnsiOutput }) { - // Simple serialization for snapshot stability - const serialized = data - .map((line) => line.map((token) => token.text || '').join('')) - .join('\n'); - return MockAnsiOutput:{serialized}; - }, -})); - -// Mock child components or utilities if they are complex or have side effects -vi.mock('../GeminiRespondingSpinner.js', () => ({ - 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', @@ -131,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', () => { @@ -143,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', () => { @@ -167,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/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index bf2b557657..06ad6b3f7b 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 b0e6236496..797e405b62 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -4,34 +4,21 @@ * 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'; 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 +// 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,28 +26,6 @@ vi.mock('../../hooks/useAlternateBuffer.js', () => ({ useAlternateBuffer: () => mockUseAlternateBuffer(), })); -// Mock useSettings -vi.mock('../../contexts/SettingsContext.js', () => ({ - useSettings: () => ({ - merged: { - ui: { - useAlternateBuffer: false, - }, - }, - }), -})); - -// Mock useOverflowActions -vi.mock('../../contexts/OverflowContext.js', () => ({ - useOverflowActions: () => ({ - addOverflowingId: vi.fn(), - removeOverflowingId: vi.fn(), - }), - useOverflowState: () => ({ - overflowingIds: new Set(), - }), -})); - describe('ToolResultDisplay', () => { beforeEach(() => { vi.clearAllMocks(); @@ -68,6 +33,66 @@ 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: content, + fg: 'red', + bg: 'black', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + ]; + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + expect(output).toContain(content); + }); + + it('uses Scrollable for non-ANSI output in alternate buffer mode', () => { + mockUseAlternateBuffer.mockReturnValue(true); + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + // With real components, we check for the content itself + expect(output).toContain('Markdown content'); + }); + + it('passes hasFocus prop to scrollable components', () => { + mockUseAlternateBuffer.mockReturnValue(true); + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('Some result'); + }); + it('renders string result as markdown by default', () => { const { lastFrame } = render( , @@ -194,4 +219,86 @@ 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'); + }); + + 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}`, + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ]); + const { lastFrame } = render( + , + ); + const output = lastFrame(); + + // 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 a729366044..2bdc74bec3 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -8,12 +8,17 @@ 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'; +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 @@ -28,6 +33,8 @@ export interface ToolResultDisplayProps { availableTerminalHeight?: number; terminalWidth: number; renderOutputAsMarkdown?: boolean; + maxLines?: number; + hasFocus?: boolean; } interface FileDiffResult { @@ -40,30 +47,100 @@ export const ToolResultDisplay: React.FC = ({ availableTerminalHeight, terminalWidth, renderOutputAsMarkdown = true, + maxLines, + hasFocus = false, }) => { const { renderMarkdown } = useUIState(); + const isAlternateBuffer = useAlternateBuffer(); - 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; + const keyExtractor = React.useCallback( + (_: AnsiLine, index: number) => index.toString(), + [], + ); + + const renderVirtualizedAnsiLine = React.useCallback( + ({ item }: { item: AnsiLine }) => ( + + + + ), + [], + ); + 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; + // 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 ( + + 1} + keyExtractor={keyExtractor} + 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' @@ -113,22 +190,38 @@ 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 { + const shouldDisableTruncation = + isAlternateBuffer || + (availableTerminalHeight === undefined && maxLines === undefined); + content = ( ); } + // 4. Final render based on session mode + 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 6e15d7902d..f991171861 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__/ShellToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap new file mode 100644 index 0000000000..e8b04b7fce --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap @@ -0,0 +1,198 @@ +// 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 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 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 (Shift+Tab to unfocus) │ +│ │ +│ 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 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`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │ +│ │ +│ 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/components/messages/__snapshots__/ToolConfirmationMessageOverflow.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessageOverflow.test.tsx.snap index 0511704c9f..2bbad0dc70 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) │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────╯ +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ? 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 925568daa6..369fa59174 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 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-might-w… │ -│ │ -│ 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__/ToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolMessage.test.tsx.snap index e5858f8cf0..599c9e68da 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 │ │ │ -│ MockAnsiOutput: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 e90c365951..4149cfbcc4 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/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap index 09a1cef39f..5d64da232b 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 │ -╰──────────────────────────────────────────────────────────────────────────────╯ +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ 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 9fa4d21ab9..58cb3697f3 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 │ +╰────────────────────────────────────────────────────────────────────────╯ █" `; diff --git a/packages/cli/src/ui/components/shared/Scrollable.test.tsx b/packages/cli/src/ui/components/shared/Scrollable.test.tsx index 22c2055f49..321d9b0ab0 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 16436be7c6..a4c5e6fedf 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,38 @@ export const Scrollable: React.FC = ({ useKeypress( (key: Key) => { - if (key.shift) { - if (key.name === 'up') { - scrollByWithAnimation(-1); + 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 (key.name === 'down') { - scrollByWithAnimation(1); + if (keyMatchers[Command.SCROLL_UP](key)) { + scrollByWithAnimation(-1); + 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 }, ); @@ -137,7 +162,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; } diff --git a/packages/cli/src/ui/contexts/ToolActionsContext.test.tsx b/packages/cli/src/ui/contexts/ToolActionsContext.test.tsx index 5ab9497106..3260ff3f0f 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)); }); diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx index 416b9d96f6..d262651590 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.test.tsx @@ -65,7 +65,6 @@ vi.mock('node:os', async (importOriginal) => { }; }); vi.mock('node:crypto'); -vi.mock('../utils/textUtils.js'); import { useShellCommandProcessor, diff --git a/packages/cli/src/ui/hooks/toolMapping.test.ts b/packages/cli/src/ui/hooks/toolMapping.test.ts index b40c3c7dea..16900f3ad7 100644 --- a/packages/cli/src/ui/hooks/toolMapping.test.ts +++ b/packages/cli/src/ui/hooks/toolMapping.test.ts @@ -245,5 +245,34 @@ describe('toolMapping', () => { expect(displayTool.status).toBe(ToolCallStatus.Canceled); expect(displayTool.resultDisplay).toBe('User cancelled'); }); + + it('propagates borderTop and borderBottom options correctly', () => { + const toolCall: ScheduledToolCall = { + status: 'scheduled', + request: mockRequest, + tool: mockTool, + invocation: mockInvocation, + }; + + const result = mapToDisplay(toolCall, { + borderTop: true, + borderBottom: false, + }); + expect(result.borderTop).toBe(true); + expect(result.borderBottom).toBe(false); + }); + + it('sets resultDisplay to undefined for pre-execution statuses', () => { + const toolCall: ScheduledToolCall = { + status: 'scheduled', + request: mockRequest, + tool: mockTool, + invocation: mockInvocation, + }; + + const result = mapToDisplay(toolCall); + expect(result.tools[0].resultDisplay).toBeUndefined(); + expect(result.tools[0].status).toBe(ToolCallStatus.Pending); + }); }); }); diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index e65fd4077c..3b7c14d896 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')], }, {