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')],
},
{