feat(cli): truncate shell output in UI history and improve active shell display (#17438)

This commit is contained in:
Jarrod Whelan
2026-02-08 00:09:48 -08:00
committed by GitHub
parent 31522045cd
commit 4a48d7cf93
34 changed files with 1553 additions and 579 deletions
+1 -24
View File
@@ -2253,7 +2253,6 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@octokit/auth-token": "^6.0.0", "@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2", "@octokit/graphql": "^9.0.2",
@@ -2434,7 +2433,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"engines": { "engines": {
"node": ">=8.0.0" "node": ">=8.0.0"
} }
@@ -2468,7 +2466,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0" "@opentelemetry/semantic-conventions": "^1.29.0"
}, },
@@ -2837,7 +2834,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@opentelemetry/core": "2.0.1", "@opentelemetry/core": "2.0.1",
"@opentelemetry/semantic-conventions": "^1.29.0" "@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2871,7 +2867,6 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@opentelemetry/core": "2.0.1", "@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "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", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@opentelemetry/core": "2.0.1", "@opentelemetry/core": "2.0.1",
"@opentelemetry/resources": "2.0.1", "@opentelemetry/resources": "2.0.1",
@@ -4140,7 +4134,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -4435,7 +4428,6 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0", "@typescript-eslint/types": "8.35.0",
@@ -5428,7 +5420,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -8438,7 +8429,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -8979,7 +8969,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "^2.0.0", "accepts": "^2.0.0",
"body-parser": "^2.2.1", "body-parser": "^2.2.1",
@@ -10581,7 +10570,6 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz", "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==", "integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1", "@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0", "ansi-escapes": "^7.0.0",
@@ -14366,7 +14354,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -14377,7 +14364,6 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"shell-quote": "^1.6.1", "shell-quote": "^1.6.1",
"ws": "^7" "ws": "^7"
@@ -16614,7 +16600,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -16838,8 +16823,7 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "dev": true,
"license": "0BSD", "license": "0BSD"
"peer": true
}, },
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.20.3", "version": "4.20.3",
@@ -16847,7 +16831,6 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "~0.25.0", "esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5" "get-tsconfig": "^4.7.5"
@@ -17020,7 +17003,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -17228,7 +17210,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -17342,7 +17323,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -17355,7 +17335,6 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/chai": "^5.2.2", "@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4", "@vitest/expect": "3.2.4",
@@ -18060,7 +18039,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@@ -18357,7 +18335,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -68,8 +68,9 @@ describe('<AnsiOutputText />', () => {
const output = lastFrame(); const output = lastFrame();
expect(output).toBeDefined(); expect(output).toBeDefined();
const lines = output!.split('\n'); const lines = output!.split('\n');
expect(lines[0]).toBe('First line'); expect(lines[0].trim()).toBe('First line');
expect(lines[1]).toBe('Third line'); expect(lines[1].trim()).toBe('');
expect(lines[2].trim()).toBe('Third line');
}); });
it('respects the availableTerminalHeight prop and slices the lines correctly', () => { it('respects the availableTerminalHeight prop and slices the lines correctly', () => {
@@ -89,6 +90,45 @@ describe('<AnsiOutputText />', () => {
expect(output).toContain('Line 4'); 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(
<AnsiOutputText data={data} maxLines={2} width={80} />,
);
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(
<AnsiOutputText
data={data}
availableTerminalHeight={3}
maxLines={2}
width={80}
/>,
);
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', () => { it('renders a large AnsiOutput object without crashing', () => {
const largeData: AnsiOutput = []; const largeData: AnsiOutput = [];
for (let i = 0; i < 1000; i++) { for (let i = 0; i < 1000; i++) {
+39 -23
View File
@@ -14,40 +14,56 @@ interface AnsiOutputProps {
data: AnsiOutput; data: AnsiOutput;
availableTerminalHeight?: number; availableTerminalHeight?: number;
width: number; width: number;
maxLines?: number;
disableTruncation?: boolean;
} }
export const AnsiOutputText: React.FC<AnsiOutputProps> = ({ export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
data, data,
availableTerminalHeight, availableTerminalHeight,
width, width,
maxLines,
disableTruncation,
}) => { }) => {
const lastLines = data.slice( const availableHeightLimit =
-(availableTerminalHeight && availableTerminalHeight > 0 availableTerminalHeight && availableTerminalHeight > 0
? availableTerminalHeight ? 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 ( return (
<Box flexDirection="column" width={width} flexShrink={0}> <Box flexDirection="column" width={width} flexShrink={0} overflow="hidden">
{lastLines.map((line: AnsiLine, lineIndex: number) => ( {lastLines.map((line: AnsiLine, lineIndex: number) => (
<Text key={lineIndex} wrap="truncate"> <Box key={lineIndex} height={1} overflow="hidden">
{line.length > 0 <AnsiLineText line={line} />
? line.map((token: AnsiToken, tokenIndex: number) => ( </Box>
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
))} ))}
</Box> </Box>
); );
}; };
export const AnsiLineText: React.FC<{ line: AnsiLine }> = ({ line }) => (
<Text>
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
);
@@ -10,6 +10,10 @@ import { MainContent } from './MainContent.js';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import type React from 'react'; 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 // Mock dependencies
vi.mock('../contexts/AppContext.js', async () => { 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', () => ({ vi.mock('../hooks/useAlternateBuffer.js', () => ({
useAlternateBuffer: vi.fn(), useAlternateBuffer: vi.fn(),
})); }));
vi.mock('./HistoryItemDisplay.js', () => ({
HistoryItemDisplay: ({
item,
availableTerminalHeight,
}: {
item: { content: string };
availableTerminalHeight?: number;
}) => (
<Box>
<Text>
HistoryItem: {item.content} (height:{' '}
{availableTerminalHeight === undefined
? 'undefined'
: availableTerminalHeight}
)
</Text>
</Box>
),
}));
vi.mock('./AppHeader.js', () => ({ vi.mock('./AppHeader.js', () => ({
AppHeader: () => <Text>AppHeader</Text>, AppHeader: () => <Text>AppHeader</Text>,
})); }));
@@ -95,39 +56,169 @@ vi.mock('./shared/ScrollableList.js', () => ({
SCROLL_TO_ITEM_END: 0, SCROLL_TO_ITEM_END: 0,
})); }));
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
describe('MainContent', () => { 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(() => { beforeEach(() => {
vi.mocked(useAlternateBuffer).mockReturnValue(false); vi.mocked(useAlternateBuffer).mockReturnValue(false);
}); });
it('renders in normal buffer mode', async () => { it('renders in normal buffer mode', async () => {
const { lastFrame } = renderWithProviders(<MainContent />); const { lastFrame } = renderWithProviders(<MainContent />, {
uiState: defaultMockUiState as Partial<UIState>,
});
await waitFor(() => expect(lastFrame()).toContain('AppHeader')); await waitFor(() => expect(lastFrame()).toContain('AppHeader'));
const output = lastFrame(); const output = lastFrame();
expect(output).toContain('HistoryItem: Hello (height: 20)'); expect(output).toContain('Hello');
expect(output).toContain('HistoryItem: Hi there (height: 20)'); expect(output).toContain('Hi there');
}); });
it('renders in alternate buffer mode', async () => { it('renders in alternate buffer mode', async () => {
vi.mocked(useAlternateBuffer).mockReturnValue(true); vi.mocked(useAlternateBuffer).mockReturnValue(true);
const { lastFrame } = renderWithProviders(<MainContent />); const { lastFrame } = renderWithProviders(<MainContent />, {
uiState: defaultMockUiState as Partial<UIState>,
});
await waitFor(() => expect(lastFrame()).toContain('ScrollableList')); await waitFor(() => expect(lastFrame()).toContain('ScrollableList'));
const output = lastFrame(); const output = lastFrame();
expect(output).toContain('AppHeader'); expect(output).toContain('AppHeader');
expect(output).toContain('HistoryItem: Hello (height: undefined)'); expect(output).toContain('Hello');
expect(output).toContain('HistoryItem: Hi there (height: undefined)'); expect(output).toContain('Hi there');
}); });
it('does not constrain height in alternate buffer mode', async () => { it('does not constrain height in alternate buffer mode', async () => {
vi.mocked(useAlternateBuffer).mockReturnValue(true); vi.mocked(useAlternateBuffer).mockReturnValue(true);
const { lastFrame } = renderWithProviders(<MainContent />); const { lastFrame } = renderWithProviders(<MainContent />, {
await waitFor(() => expect(lastFrame()).toContain('HistoryItem: Hello')); uiState: defaultMockUiState as Partial<UIState>,
});
await waitFor(() => expect(lastFrame()).toContain('Hello'));
const output = lastFrame(); const output = lastFrame();
expect(output).toMatchSnapshot(); 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(<MainContent />, {
uiState: uiState as Partial<UIState>,
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();
},
);
});
}); });
@@ -81,7 +81,8 @@ export const MainContent = () => {
<HistoryItemDisplay <HistoryItemDisplay
key={i} key={i}
availableTerminalHeight={ availableTerminalHeight={
uiState.constrainHeight && !isAlternateBuffer (uiState.constrainHeight && !isAlternateBuffer) ||
isAlternateBuffer
? availableTerminalHeight ? availableTerminalHeight
: undefined : undefined
} }
@@ -148,7 +149,7 @@ export const MainContent = () => {
return ( return (
<ScrollableList <ScrollableList
ref={scrollableListRef} ref={scrollableListRef}
hasFocus={!uiState.isEditorDialogOpen} hasFocus={!uiState.isEditorDialogOpen && !uiState.embeddedShellFocused}
width={uiState.terminalWidth} width={uiState.terminalWidth}
data={virtualizedData} data={virtualizedData}
renderItem={renderItem} renderItem={renderItem}
@@ -5,6 +5,7 @@
*/ */
import { render, persistentStateMock } from '../../test-utils/render.js'; import { render, persistentStateMock } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { Notifications } from './Notifications.js'; import { Notifications } from './Notifications.js';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAppContext, type AppState } from '../contexts/AppContext.js'; import { useAppContext, type AppState } from '../contexts/AppContext.js';
@@ -172,7 +173,7 @@ describe('Notifications', () => {
render(<Notifications />); render(<Notifications />);
await act(async () => { await act(async () => {
await vi.waitFor(() => { await waitFor(() => {
expect(persistentStateMock.set).toHaveBeenCalledWith( expect(persistentStateMock.set).toHaveBeenCalledWith(
'hasSeenScreenReaderNudge', 'hasSeenScreenReaderNudge',
true, true,
@@ -95,16 +95,64 @@ describe('ShellInputPrompt', () => {
it.each([ it.each([
['up', -1], ['up', -1],
['down', 1], ['down', 1],
])('handles scroll %s (Ctrl+Shift+%s)', (key, direction) => { ])('handles scroll %s (Command.SCROLL_%s)', (key, direction) => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />); render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
const handler = mockUseKeypress.mock.calls[0][0]; 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); expect(mockScrollPty).toHaveBeenCalledWith(1, direction);
}); });
it.each([
['pageup', -15],
['pagedown', 15],
])(
'handles page scroll %s (Command.PAGE_%s) with default size',
(key, expectedScroll) => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
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(
<ShellInputPrompt
activeShellPtyId={1}
focus={true}
scrollPageSize={10}
/>,
);
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', () => { it('does not handle input when not focused', () => {
render(<ShellInputPrompt activeShellPtyId={1} focus={false} />); render(<ShellInputPrompt activeShellPtyId={1} focus={false} />);
@@ -138,4 +186,21 @@ describe('ShellInputPrompt', () => {
expect(mockWriteToPty).not.toHaveBeenCalled(); expect(mockWriteToPty).not.toHaveBeenCalled();
}); });
it('ignores Command.UNFOCUS_SHELL (Shift+Tab) to allow focus navigation', () => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
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();
});
}); });
@@ -9,16 +9,19 @@ import type React from 'react';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import { ShellExecutionService } from '@google/gemini-cli-core'; import { ShellExecutionService } from '@google/gemini-cli-core';
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js'; import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
import { ACTIVE_SHELL_MAX_LINES } from '../constants.js';
import { Command, keyMatchers } from '../keyMatchers.js'; import { Command, keyMatchers } from '../keyMatchers.js';
export interface ShellInputPromptProps { export interface ShellInputPromptProps {
activeShellPtyId: number | null; activeShellPtyId: number | null;
focus?: boolean; focus?: boolean;
scrollPageSize?: number;
} }
export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
activeShellPtyId, activeShellPtyId,
focus = true, focus = true,
scrollPageSize = ACTIVE_SHELL_MAX_LINES,
}) => { }) => {
const handleShellInputSubmit = useCallback( const handleShellInputSubmit = useCallback(
(input: string) => { (input: string) => {
@@ -34,26 +37,33 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
if (!focus || !activeShellPtyId) { if (!focus || !activeShellPtyId) {
return false; return false;
} }
// Allow background shell toggle to bubble up // Allow background shell toggle to bubble up
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) { if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
return false; return false;
} }
// Allow unfocus to bubble up // Allow Shift+Tab to bubble up for focus navigation
if (keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) { if (keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) {
return false; return false;
} }
if (key.ctrl && key.shift && key.name === 'up') { if (keyMatchers[Command.SCROLL_UP](key)) {
ShellExecutionService.scrollPty(activeShellPtyId, -1); ShellExecutionService.scrollPty(activeShellPtyId, -1);
return true; return true;
} }
if (keyMatchers[Command.SCROLL_DOWN](key)) {
if (key.ctrl && key.shift && key.name === 'down') {
ShellExecutionService.scrollPty(activeShellPtyId, 1); ShellExecutionService.scrollPty(activeShellPtyId, 1);
return true; 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); const ansiSequence = keyToAnsi(key);
if (ansiSequence) { if (ansiSequence) {
@@ -63,7 +73,7 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
return false; return false;
}, },
[focus, handleShellInputSubmit, activeShellPtyId], [focus, handleShellInputSubmit, activeShellPtyId, scrollPageSize],
); );
useKeypress(handleInput, { isActive: focus }); useKeypress(handleInput, { isActive: focus });
@@ -39,14 +39,14 @@ Tips for getting started:
2. Be specific for the best results. 2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini. 3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information. 4. /help for more information.
╭────────────────────────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool1 Description for tool 1 │ ✓ tool1 Description for tool 1 │
│ │
╰────────────────────────────────────────────────────────────────────────────── ╰──────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │ ✓ tool2 Description for tool 2 │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = ` 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. 2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini. 3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information. 4. /help for more information.
╭────────────────────────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool1 Description for tool 1 │ ✓ tool1 Description for tool 1 │
│ │
╰────────────────────────────────────────────────────────────────────────────── ╰──────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │ ✓ tool2 Description for tool 2 │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = ` exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `
@@ -1,8 +1,116 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // 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`] = ` exports[`MainContent > does not constrain height in alternate buffer mode 1`] = `
"ScrollableList "ScrollableList
AppHeader AppHeader
HistoryItem: Hello (height: undefined) ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
HistoryItem: Hi there (height: undefined)" > Hello
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
✦ Hi there
ShowMoreLines
"
`; `;
@@ -4,55 +4,18 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import React from 'react'; import React, { act } from 'react';
import { import {
ShellToolMessage, ShellToolMessage,
type ShellToolMessageProps, type ShellToolMessageProps,
} from './ShellToolMessage.js'; } from './ShellToolMessage.js';
import { StreamingState, ToolCallStatus } from '../../types.js'; import { StreamingState, ToolCallStatus } from '../../types.js';
import { Text } from 'ink';
import type { Config } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js'; import { renderWithProviders } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js'; import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SHELL_TOOL_NAME } from '@google/gemini-cli-core'; import { SHELL_TOOL_NAME } from '@google/gemini-cli-core';
import { SHELL_COMMAND_NAME } from '../../constants.js'; import { SHELL_COMMAND_NAME, ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import { StreamingContext } from '../../contexts/StreamingContext.js';
vi.mock('../TerminalOutput.js', () => ({
TerminalOutput: function MockTerminalOutput({
cursor,
}: {
cursor: { x: number; y: number } | null;
}) {
return (
<Text>
MockCursor:({cursor?.x},{cursor?.y})
</Text>
);
},
}));
// 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 <Text>MockRespondingSpinner</Text>;
}
return nonRespondingDisplay ? <Text>{nonRespondingDisplay}</Text> : null;
},
}));
vi.mock('../../utils/MarkdownDisplay.js', () => ({
MarkdownDisplay: function MockMarkdownDisplay({ text }: { text: string }) {
return <Text>MockMarkdown:{text}</Text>;
},
}));
describe('<ShellToolMessage />', () => { describe('<ShellToolMessage />', () => {
const baseProps: ShellToolMessageProps = { const baseProps: ShellToolMessageProps = {
@@ -72,52 +35,36 @@ describe('<ShellToolMessage />', () => {
} as unknown as Config, } as unknown as Config,
}; };
const LONG_OUTPUT = Array.from(
{ length: 100 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const mockSetEmbeddedShellFocused = vi.fn(); const mockSetEmbeddedShellFocused = vi.fn();
const uiActions = { const uiActions = {
setEmbeddedShellFocused: mockSetEmbeddedShellFocused, setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
}; };
const renderShell = (
props: Partial<ShellToolMessageProps> = {},
options: Parameters<typeof renderWithProviders>[1] = {},
) =>
renderWithProviders(<ShellToolMessage {...baseProps} {...props} />, {
uiActions,
...options,
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe('interactive shell focus', () => { describe('interactive shell focus', () => {
const shellProps: ShellToolMessageProps = { it.each([
...baseProps, ['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME],
}; ['SHELL_TOOL_NAME', SHELL_TOOL_NAME],
])('clicks inside the shell area sets focus for %s', async (_, name) => {
it('clicks inside the shell area sets focus to true', async () => { const { stdin, lastFrame, simulateClick } = renderShell(
const { stdin, lastFrame, simulateClick } = renderWithProviders( { name },
<ShellToolMessage {...shellProps} />, { mouseEventsEnabled: true },
{
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(
<ShellToolMessage {...coreShellProps} />,
{
mouseEventsEnabled: true,
uiActions,
},
); );
await waitFor(() => { await waitFor(() => {
@@ -130,5 +77,136 @@ describe('<ShellToolMessage />', () => {
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true); 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 (
<ShellToolMessage
{...baseProps}
status={status}
embeddedShellFocused={true}
activeShellPtyId={1}
ptyId={1}
/>
);
};
const { lastFrame } = renderWithProviders(<Wrapper />, {
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();
});
});
}); });
}); });
@@ -22,6 +22,12 @@ import {
FocusHint, FocusHint,
} from './ToolShared.js'; } from './ToolShared.js';
import type { ToolMessageProps } from './ToolMessage.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'; import type { Config } from '@google/gemini-cli-core';
export interface ShellToolMessageProps extends ToolMessageProps { export interface ShellToolMessageProps extends ToolMessageProps {
@@ -61,6 +67,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
borderDimColor, borderDimColor,
}) => { }) => {
const isAlternateBuffer = useAlternateBuffer();
const isThisShellFocused = checkIsShellFocused( const isThisShellFocused = checkIsShellFocused(
name, name,
status, status,
@@ -70,6 +77,18 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
); );
const { setEmbeddedShellFocused } = useUIActions(); 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<DOMElement>(null); const headerRef = React.useRef<DOMElement>(null);
@@ -139,12 +158,20 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
availableTerminalHeight={availableTerminalHeight} availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth} terminalWidth={terminalWidth}
renderOutputAsMarkdown={renderOutputAsMarkdown} renderOutputAsMarkdown={renderOutputAsMarkdown}
hasFocus={isThisShellFocused}
maxLines={getShellMaxLines(
status,
isAlternateBuffer,
isThisShellFocused,
availableTerminalHeight,
)}
/> />
{isThisShellFocused && config && ( {isThisShellFocused && config && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}> <Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
<ShellInputPrompt <ShellInputPrompt
activeShellPtyId={activeShellPtyId ?? null} activeShellPtyId={activeShellPtyId ?? null}
focus={embeddedShellFocused} focus={embeddedShellFocused}
scrollPageSize={availableTerminalHeight ?? ACTIVE_SHELL_MAX_LINES}
/> />
</Box> </Box>
)} )}
@@ -152,3 +179,39 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
</> </>
); );
}; };
/**
* 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);
}
@@ -42,6 +42,9 @@ const isAskUserInProgress = (t: IndividualToolCallDisplay): boolean =>
].includes(t.status); ].includes(t.status);
// Main component renders the border and maps the tools using ToolMessage // 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<ToolGroupMessageProps> = ({ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
toolCalls: allToolCalls, toolCalls: allToolCalls,
availableTerminalHeight, availableTerminalHeight,
@@ -142,6 +145,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
) )
: undefined; : undefined;
const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN;
return ( return (
// This box doesn't have a border even though it conceptually does because // 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 // we need to allow the sticky headers to render the borders themselves so
@@ -155,6 +160,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
cause tearing. cause tearing.
*/ */
width={terminalWidth} width={terminalWidth}
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
> >
{visibleToolCalls.map((tool, index) => { {visibleToolCalls.map((tool, index) => {
const isConfirming = toolAwaitingApproval?.callId === tool.callId; const isConfirming = toolAwaitingApproval?.callId === tool.callId;
@@ -164,7 +170,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const commonProps = { const commonProps = {
...tool, ...tool,
availableTerminalHeight: availableTerminalHeightPerToolMessage, availableTerminalHeight: availableTerminalHeightPerToolMessage,
terminalWidth, terminalWidth: contentWidth,
emphasis: isConfirming emphasis: isConfirming
? ('high' as const) ? ('high' as const)
: toolAwaitingApproval : toolAwaitingApproval
@@ -183,7 +189,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
key={tool.callId} key={tool.callId}
flexDirection="column" flexDirection="column"
minHeight={1} minHeight={1}
width={terminalWidth} width={contentWidth}
> >
{isShellToolCall ? ( {isShellToolCall ? (
<ShellToolMessage <ShellToolMessage
@@ -218,7 +224,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
availableTerminalHeight={ availableTerminalHeight={
availableTerminalHeightPerToolMessage availableTerminalHeightPerToolMessage
} }
terminalWidth={terminalWidth - 4} terminalWidth={
contentWidth - TOOL_CONFIRMATION_INTERNAL_PADDING
}
/> />
)} )}
{tool.outputFile && ( {tool.outputFile && (
@@ -240,7 +248,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && ( (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
<Box <Box
height={0} height={0}
width={terminalWidth} width={contentWidth}
borderLeft={true} borderLeft={true}
borderRight={true} borderRight={true}
borderTop={false} borderTop={false}
@@ -4,13 +4,11 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import React from 'react'; import type React from 'react';
import type { ToolMessageProps } from './ToolMessage.js'; import { ToolMessage, type ToolMessageProps } from './ToolMessage.js';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { ToolMessage } from './ToolMessage.js';
import { StreamingState, ToolCallStatus } from '../../types.js'; import { StreamingState, ToolCallStatus } from '../../types.js';
import { Text } from 'ink'; import { Text } from 'ink';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import type { AnsiOutput } from '@google/gemini-cli-core'; import type { AnsiOutput } from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js'; import { renderWithProviders } from '../../../test-utils/render.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js'; import { tryParseJSON } from '../../../utils/jsonoutput.js';
@@ -29,45 +27,6 @@ vi.mock('../TerminalOutput.js', () => ({
}, },
})); }));
vi.mock('../AnsiOutput.js', () => ({
AnsiOutputText: function MockAnsiOutputText({ data }: { data: AnsiOutput }) {
// Simple serialization for snapshot stability
const serialized = data
.map((line) => line.map((token) => token.text || '').join(''))
.join('\n');
return <Text>MockAnsiOutput:{serialized}</Text>;
},
}));
// 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 <Text>MockRespondingSpinner</Text>;
}
return nonRespondingDisplay ? <Text>{nonRespondingDisplay}</Text> : null;
},
}));
vi.mock('./DiffRenderer.js', () => ({
DiffRenderer: function MockDiffRenderer({
diffContent,
}: {
diffContent: string;
}) {
return <Text>MockDiff:{diffContent}</Text>;
},
}));
vi.mock('../../utils/MarkdownDisplay.js', () => ({
MarkdownDisplay: function MockMarkdownDisplay({ text }: { text: string }) {
return <Text>MockMarkdown:{text}</Text>;
},
}));
describe('<ToolMessage />', () => { describe('<ToolMessage />', () => {
const baseProps: ToolMessageProps = { const baseProps: ToolMessageProps = {
callId: 'tool-123', callId: 'tool-123',
@@ -131,7 +90,6 @@ describe('<ToolMessage />', () => {
expect(output).toContain('"a": 1'); expect(output).toContain('"a": 1');
expect(output).toContain('"b": ['); expect(output).toContain('"b": [');
// Should not use markdown renderer for JSON // Should not use markdown renderer for JSON
expect(output).not.toContain('MockMarkdown:');
}); });
it('renders pretty JSON in ink frame', () => { it('renders pretty JSON in ink frame', () => {
@@ -143,9 +101,6 @@ describe('<ToolMessage />', () => {
const frame = lastFrame(); const frame = lastFrame();
expect(frame).toMatchSnapshot(); 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', () => { it('uses JSON renderer even when renderOutputAsMarkdown=true is true', () => {
@@ -167,7 +122,6 @@ describe('<ToolMessage />', () => {
expect(output).toContain('"a": 1'); expect(output).toContain('"a": 1');
expect(output).toContain('"b": ['); expect(output).toContain('"b": [');
// Should not use markdown renderer for JSON even when renderOutputAsMarkdown=true // 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', () => { it('falls back to plain text for malformed JSON', () => {
const testJSONstring = 'a": 1, "b": [2, 3]}'; const testJSONstring = 'a": 1, "b": [2, 3]}';
@@ -113,6 +113,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
availableTerminalHeight={availableTerminalHeight} availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth} terminalWidth={terminalWidth}
renderOutputAsMarkdown={renderOutputAsMarkdown} renderOutputAsMarkdown={renderOutputAsMarkdown}
hasFocus={isThisShellFocused}
/> />
{isThisShellFocused && config && ( {isThisShellFocused && config && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}> <Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
@@ -4,34 +4,21 @@
* SPDX-License-Identifier: Apache-2.0 * 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 { ToolResultDisplay } from './ToolResultDisplay.js';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Box, Text } from 'ink';
import type { AnsiOutput } from '@google/gemini-cli-core'; import type { AnsiOutput } from '@google/gemini-cli-core';
// Mock child components to simplify testing // Mock UIStateContext partially
vi.mock('./DiffRenderer.js', () => ({
DiffRenderer: ({
diffContent,
filename,
}: {
diffContent: string;
filename: string;
}) => (
<Box>
<Text>
DiffRenderer: {filename} - {diffContent}
</Text>
</Box>
),
}));
// Mock UIStateContext
const mockUseUIState = vi.fn(); const mockUseUIState = vi.fn();
vi.mock('../../contexts/UIStateContext.js', () => ({ vi.mock('../../contexts/UIStateContext.js', async (importOriginal) => {
useUIState: () => mockUseUIState(), const actual =
})); await importOriginal<typeof import('../../contexts/UIStateContext.js')>();
return {
...actual,
useUIState: () => mockUseUIState(),
};
});
// Mock useAlternateBuffer // Mock useAlternateBuffer
const mockUseAlternateBuffer = vi.fn(); const mockUseAlternateBuffer = vi.fn();
@@ -39,28 +26,6 @@ vi.mock('../../hooks/useAlternateBuffer.js', () => ({
useAlternateBuffer: () => mockUseAlternateBuffer(), 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', () => { describe('ToolResultDisplay', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -68,6 +33,66 @@ describe('ToolResultDisplay', () => {
mockUseAlternateBuffer.mockReturnValue(false); 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(
<ToolResultDisplay
resultDisplay={ansiResult}
terminalWidth={80}
maxLines={10}
/>,
);
const output = lastFrame();
expect(output).toContain(content);
});
it('uses Scrollable for non-ANSI output in alternate buffer mode', () => {
mockUseAlternateBuffer.mockReturnValue(true);
const { lastFrame } = render(
<ToolResultDisplay
resultDisplay="**Markdown content**"
terminalWidth={80}
maxLines={10}
/>,
);
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(
<ToolResultDisplay
resultDisplay="Some result"
terminalWidth={80}
hasFocus={true}
/>,
);
expect(lastFrame()).toContain('Some result');
});
it('renders string result as markdown by default', () => { it('renders string result as markdown by default', () => {
const { lastFrame } = render( const { lastFrame } = render(
<ToolResultDisplay resultDisplay="**Some result**" terminalWidth={80} />, <ToolResultDisplay resultDisplay="**Some result**" terminalWidth={80} />,
@@ -194,4 +219,86 @@ describe('ToolResultDisplay', () => {
expect(output).toMatchSnapshot(); 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(
<ToolResultDisplay
resultDisplay={ansiResult}
terminalWidth={80}
availableTerminalHeight={20}
maxLines={2}
/>,
);
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(
<ToolResultDisplay
resultDisplay={ansiResult}
terminalWidth={80}
maxLines={25}
availableTerminalHeight={undefined}
/>,
);
const output = lastFrame();
// It SHOULD truncate to 25 lines because maxLines is provided
expect(output).not.toContain('Line 1');
expect(output).toContain('Line 50');
});
}); });
@@ -8,12 +8,17 @@ import React from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js'; import { DiffRenderer } from './DiffRenderer.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.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 { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme } from '../../semantic-colors.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 { useUIState } from '../../contexts/UIStateContext.js';
import { tryParseJSON } from '../../../utils/jsonoutput.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 STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint
@@ -28,6 +33,8 @@ export interface ToolResultDisplayProps {
availableTerminalHeight?: number; availableTerminalHeight?: number;
terminalWidth: number; terminalWidth: number;
renderOutputAsMarkdown?: boolean; renderOutputAsMarkdown?: boolean;
maxLines?: number;
hasFocus?: boolean;
} }
interface FileDiffResult { interface FileDiffResult {
@@ -40,30 +47,100 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
availableTerminalHeight, availableTerminalHeight,
terminalWidth, terminalWidth,
renderOutputAsMarkdown = true, renderOutputAsMarkdown = true,
maxLines,
hasFocus = false,
}) => { }) => {
const { renderMarkdown } = useUIState(); const { renderMarkdown } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const availableHeight = availableTerminalHeight let availableHeight = availableTerminalHeight
? Math.max( ? Math.max(
availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT, availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
MIN_LINES_SHOWN + 1, // enforce minimum lines shown MIN_LINES_SHOWN + 1, // enforce minimum lines shown
) )
: undefined; : undefined;
if (maxLines && availableHeight) {
availableHeight = Math.min(availableHeight, maxLines);
}
const combinedPaddingAndBorderWidth = 4; const combinedPaddingAndBorderWidth = 4;
const childWidth = terminalWidth - combinedPaddingAndBorderWidth; const childWidth = terminalWidth - combinedPaddingAndBorderWidth;
const keyExtractor = React.useCallback(
(_: AnsiLine, index: number) => index.toString(),
[],
);
const renderVirtualizedAnsiLine = React.useCallback(
({ item }: { item: AnsiLine }) => (
<Box height={1} overflow="hidden">
<AnsiLineText line={item} />
</Box>
),
[],
);
const truncatedResultDisplay = React.useMemo(() => { const truncatedResultDisplay = React.useMemo(() => {
if (typeof resultDisplay === 'string') { // Only truncate string output if not in alternate buffer mode to ensure
if (resultDisplay.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) { // we can scroll through the full output.
return '...' + resultDisplay.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS); 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; return resultDisplay;
}, [resultDisplay]); }, [resultDisplay, isAlternateBuffer, maxLines]);
if (!truncatedResultDisplay) return null; 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 (
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
<ScrollableList
width={childWidth}
data={truncatedResultDisplay as AnsiOutput}
renderItem={renderVirtualizedAnsiLine}
estimatedItemHeight={() => 1}
keyExtractor={keyExtractor}
initialScrollIndex={SCROLL_TO_ITEM_END}
hasFocus={hasFocus}
/>
</Box>
);
}
// 3. Compute content node for non-virtualized paths
// Check if string content is valid JSON and pretty-print it // Check if string content is valid JSON and pretty-print it
const prettyJSON = const prettyJSON =
typeof truncatedResultDisplay === 'string' typeof truncatedResultDisplay === 'string'
@@ -113,22 +190,38 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
terminalWidth={childWidth} terminalWidth={childWidth}
/> />
); );
} else if (
typeof truncatedResultDisplay === 'object' &&
'todos' in truncatedResultDisplay
) {
// display nothing, as the TodoTray will handle rendering todos
return null;
} else { } else {
const shouldDisableTruncation =
isAlternateBuffer ||
(availableTerminalHeight === undefined && maxLines === undefined);
content = ( content = (
<AnsiOutputText <AnsiOutputText
data={truncatedResultDisplay as AnsiOutput} data={truncatedResultDisplay as AnsiOutput}
availableTerminalHeight={availableHeight} availableTerminalHeight={
isAlternateBuffer ? undefined : availableHeight
}
width={childWidth} width={childWidth}
maxLines={isAlternateBuffer ? undefined : maxLines}
disableTruncation={shouldDisableTruncation}
/> />
); );
} }
// 4. Final render based on session mode
if (isAlternateBuffer) {
return (
<Scrollable
width={childWidth}
maxHeight={maxLines ?? availableHeight}
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
scrollToBottom={true}
>
{content}
</Scrollable>
);
}
return ( return (
<Box width={childWidth} flexDirection="column"> <Box width={childWidth} flexDirection="column">
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}> <MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
@@ -49,6 +49,7 @@ describe('ToolResultDisplay Overflow', () => {
streamingState: StreamingState.Idle, streamingState: StreamingState.Idle,
constrainHeight: true, constrainHeight: true,
}, },
useAlternateBuffer: false,
}, },
); );
@@ -0,0 +1,198 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ShellToolMessage /> > 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[`<ShellToolMessage /> > 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[`<ShellToolMessage /> > 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[`<ShellToolMessage /> > 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[`<ShellToolMessage /> > Snapshots > renders in Alternate Buffer mode while focused 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │
│ │
│ Test result │
│ │"
`;
exports[`<ShellToolMessage /> > Snapshots > renders in Alternate Buffer mode while unfocused 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command │
│ │
│ Test result │"
`;
exports[`<ShellToolMessage /> > Snapshots > renders in Error state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ x Shell Command A shell command │
│ │
│ Error output │"
`;
exports[`<ShellToolMessage /> > Snapshots > renders in Executing state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command │
│ │
│ Test result │"
`;
exports[`<ShellToolMessage /> > Snapshots > renders in Success state (history mode) 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command A shell command │
│ │
│ Test result │"
`;
@@ -1,18 +1,18 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToolConfirmationMessage Overflow > should display "press ctrl-o" hint when content overflows in ToolGroupMessage 1`] = ` exports[`ToolConfirmationMessage Overflow > should display "press ctrl-o" hint when content overflows in ToolGroupMessage 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ? test-tool a test tool ← │ │ ? test-tool a test tool ← │
│ │
│ ... first 49 lines hidden ... │ ... first 49 lines hidden ... │
│ 50 line 50 │ 50 line 50 │
│ Apply this change? │ Apply this change? │
│ │
│ ● 1. Allow once │ ● 1. Allow once │
│ 2. Allow for this session │ 2. Allow for this session │
│ 3. Modify with external editor │ 3. Modify with external editor │
│ 4. No, suggest changes (esc) │ 4. No, suggest changes (esc) │
│ │
╰────────────────────────────────────────────────────────────────────────────── ╰──────────────────────────────────────────────────────────────────────────╯
Press ctrl-o to show more lines" Press ctrl-o to show more lines"
`; `;
@@ -1,19 +1,19 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolGroupMessage /> > Ask User Filtering > does NOT filter out ask_user when status is Error 1`] = ` exports[`<ToolGroupMessage /> > Ask User Filtering > does NOT filter out ask_user when status is Error 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ x Ask User │ x Ask User │
│ │
│ Test result │ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Ask User Filtering > does NOT filter out ask_user when status is Success 1`] = ` exports[`<ToolGroupMessage /> > Ask User Filtering > does NOT filter out ask_user when status is Success 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ Ask User │ ✓ Ask User │
│ │
│ Test result │ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Ask User Filtering > filters out ask_user when status is Confirming 1`] = `""`; exports[`<ToolGroupMessage /> > Ask User Filtering > filters out ask_user when status is Confirming 1`] = `""`;
@@ -23,89 +23,89 @@ exports[`<ToolGroupMessage /> > Ask User Filtering > filters out ask_user when s
exports[`<ToolGroupMessage /> > Ask User Filtering > filters out ask_user when status is Pending 1`] = `""`; exports[`<ToolGroupMessage /> > Ask User Filtering > filters out ask_user when status is Pending 1`] = `""`;
exports[`<ToolGroupMessage /> > Ask User Filtering > shows other tools when ask_user is filtered out 1`] = ` exports[`<ToolGroupMessage /> > Ask User Filtering > shows other tools when ask_user is filtered out 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ other-tool A tool for testing │ ✓ other-tool A tool for testing │
│ │
│ Test result │ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Border Color Logic > uses gray border when all tools are successful and no shell commands 1`] = ` exports[`<ToolGroupMessage /> > Border Color Logic > uses gray border when all tools are successful and no shell commands 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │ ✓ test-tool A tool for testing │
│ │
│ Test result │ Test result │
│ │
│ ✓ another-tool A tool for testing │ ✓ another-tool A tool for testing │
│ │
│ Test result │ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shell commands even when successful 1`] = ` exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shell commands even when successful 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ run_shell_command A tool for testing │ ✓ run_shell_command A tool for testing │
│ │
│ Test result │ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border when tools are pending 1`] = ` exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border when tools are pending 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ o test-tool A tool for testing │ o test-tool A tool for testing │
│ │
│ Test result │ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation with permanent approval disabled 1`] = ` exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation with permanent approval disabled 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ? confirm-tool A tool for testing ← │ │ ? confirm-tool A tool for testing ← │
│ │
│ Test result │ Test result │
│ Do you want to proceed? │ Do you want to proceed? │
│ Do you want to proceed? │ Do you want to proceed? │
│ │
│ ● 1. Allow once │ ● 1. Allow once │
│ 2. Allow for this session │ 2. Allow for this session │
│ 3. No, suggest changes (esc) │ 3. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation with permanent approval enabled 1`] = ` exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation with permanent approval enabled 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ? confirm-tool A tool for testing ← │ │ ? confirm-tool A tool for testing ← │
│ │
│ Test result │ Test result │
│ Do you want to proceed? │ Do you want to proceed? │
│ Do you want to proceed? │ Do you want to proceed? │
│ │
│ ● 1. Allow once │ ● 1. Allow once │
│ 2. Allow for this session │ 2. Allow for this session │
│ 3. Allow for all future sessions │ 3. Allow for all future sessions │
│ 4. No, suggest changes (esc) │ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialog for first confirming tool only 1`] = ` exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialog for first confirming tool only 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ? first-confirm A tool for testing ← │ │ ? first-confirm A tool for testing ← │
│ │
│ Test result │ Test result │
│ Confirm first tool │ Confirm first tool │
│ Do you want to proceed? │ Do you want to proceed? │
│ │
│ ● 1. Allow once │ ● 1. Allow once │
│ 2. Allow for this session │ 2. Allow for this session │
│ 3. No, suggest changes (esc) │ 3. No, suggest changes (esc) │
│ │
│ │
│ ? second-confirm A tool for testing │ ? second-confirm A tool for testing │
│ │
│ Test result │ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Event-Driven Scheduler > hides confirming tools when event-driven scheduler is enabled 1`] = `""`; exports[`<ToolGroupMessage /> > Event-Driven Scheduler > hides confirming tools when event-driven scheduler is enabled 1`] = `""`;
@@ -113,148 +113,148 @@ exports[`<ToolGroupMessage /> > Event-Driven Scheduler > hides confirming tools
exports[`<ToolGroupMessage /> > Event-Driven Scheduler > renders nothing when only tool is in-progress AskUser with borderBottom=false 1`] = `""`; exports[`<ToolGroupMessage /> > Event-Driven Scheduler > renders nothing when only tool is in-progress AskUser with borderBottom=false 1`] = `""`;
exports[`<ToolGroupMessage /> > Event-Driven Scheduler > shows only successful tools when mixed with confirming tools 1`] = ` exports[`<ToolGroupMessage /> > Event-Driven Scheduler > shows only successful tools when mixed with confirming tools 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ success-tool A tool for testing │ ✓ success-tool A tool for testing │
│ │
│ Test result │ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `""`; exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `""`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled 1`] = ` exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-1 Description 1. This is a long description that will need to be tr… │ │ ✓ tool-1 Description 1. This is a long description that will need to b… │
│────────────────────────────────────────────────────────────────────────────── │──────────────────────────────────────────────────────────────────────────│
│ line5 │ line5
│ ✓ tool-2 Description 2 │ ✓ tool-2 Description 2
│ line1 │ line1
│ line2 │ line2
╰──────────────────────────────────────────────────────────────────────────────╯ █" ╰──────────────────────────────────────────────────────────────────────────╯ █"
`; `;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = ` exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ read_file Read a file │ ✓ read_file Read a file │
│ │
│ Test result │ Test result │
│ │
│ ⊷ run_shell_command Run command │ ⊷ run_shell_command Run command │
│ │
│ Test result │ Test result │
│ │
│ o write_file Write to file │ o write_file Write to file │
│ │
│ Test result │ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls with different statuses 1`] = ` exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls with different statuses 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ successful-tool This tool succeeded │ ✓ successful-tool This tool succeeded │
│ │
│ Test result │ Test result │
│ │
│ o pending-tool This tool is pending │ o pending-tool This tool is pending │
│ │
│ Test result │ Test result │
│ │
│ x error-tool This tool failed │ x error-tool This tool failed │
│ │
│ Test result │ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders shell command with yellow border 1`] = ` exports[`<ToolGroupMessage /> > Golden Snapshots > renders shell command with yellow border 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ run_shell_command Execute shell command │ ✓ run_shell_command Execute shell command │
│ │
│ Test result │ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful tool call 1`] = ` exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful tool call 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │ ✓ test-tool A tool for testing │
│ │
│ Test result │ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call awaiting confirmation 1`] = ` exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call awaiting confirmation 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ? confirmation-tool This tool needs confirmation ← │ │ ? confirmation-tool This tool needs confirmation ← │
│ │
│ Test result │ Test result │
│ Are you sure you want to proceed? │ Are you sure you want to proceed? │
│ Do you want to proceed? │ Do you want to proceed? │
│ │
│ ● 1. Allow once │ ● 1. Allow once │
│ 2. Allow for this session │ 2. Allow for this session │
│ 3. No, suggest changes (esc) │ 3. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with outputFile 1`] = ` exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with outputFile 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-with-file Tool that saved output to file │ ✓ tool-with-file Tool that saved output to file │
│ │
│ Test result │ Test result │
│ Output too long and was saved to: /path/to/output.txt │ Output too long and was saved to: /path/to/output.txt │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = ` exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = `
"╰────────────────────────────────────────────────────────────────────────────── "╰──────────────────────────────────────────────────────────────────────────╯
╭────────────────────────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-2 Description 2 │ ✓ tool-2 Description 2 │
│ line1 │ line1
╰──────────────────────────────────────────────────────────────────────────────╯ █" ╰──────────────────────────────────────────────────────────────────────────╯ █"
`; `;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders when not focused 1`] = ` exports[`<ToolGroupMessage /> > Golden Snapshots > renders when not focused 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │ ✓ test-tool A tool for testing │
│ │
│ Test result │ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = ` exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-with-result Tool with output │ ✓ tool-with-result Tool with output │
│ │
│ This is a long result that might need height constraints │ This is a long result that might need height constraints │
│ │
│ ✓ another-tool Another tool │ ✓ another-tool Another tool │
│ │
│ More output here │ More output here │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with narrow terminal width 1`] = ` exports[`<ToolGroupMessage /> > Golden Snapshots > renders with narrow terminal width 1`] = `
"╭────────────────────────────────────── "╭──────────────────────────────────╮
│ ✓ very-long-tool-name-that-might-w… │ │ ✓ very-long-tool-name-that-mig… │
│ │
│ Test result │ Test result │
╰──────────────────────────────────────╯" ╰──────────────────────────────────╯"
`; `;
exports[`<ToolGroupMessage /> > Height Calculation > calculates available height correctly with multiple tools with results 1`] = ` exports[`<ToolGroupMessage /> > Height Calculation > calculates available height correctly with multiple tools with results 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │ ✓ test-tool A tool for testing │
│ │
│ Result 1 │ Result 1 │
│ │
│ ✓ test-tool A tool for testing │ ✓ test-tool A tool for testing │
│ │
│ Result 2 │ Result 2 │
│ │
│ ✓ test-tool A tool for testing │ ✓ test-tool A tool for testing │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────╯"
`; `;
@@ -14,93 +14,90 @@ exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows ? for Confirmin
"╭──────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────╮
│ ? test-tool A tool for testing │ │ ? test-tool A tool for testing │
│ │ │ │
MockMarkdown:Test result │" Test result │"
`; `;
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows - for Canceled status 1`] = ` exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows - for Canceled status 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────╮
│ - test-tool A tool for testing │ │ - test-tool A tool for testing │
│ │ │ │
MockMarkdown:Test result │" Test result │"
`; `;
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows MockRespondingSpinner for Executing status when streamingState is Responding 1`] = ` exports[`<ToolMessage /> > 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[`<ToolMessage /> > ToolStatusIndicator rendering > shows o for Pending status 1`] = ` exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows o for Pending status 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────╮
│ o test-tool A tool for testing │ │ o test-tool A tool for testing │
│ │ │ │
MockMarkdown:Test result │" Test result │"
`; `;
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is Idle 1`] = ` exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is Idle 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ test-tool A tool for testing │ │ ⊷ test-tool A tool for testing │
│ │ │ │
MockMarkdown:Test result │" Test result │"
`; `;
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is WaitingForConfirmation 1`] = ` exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is WaitingForConfirmation 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ test-tool A tool for testing │ │ ⊷ test-tool A tool for testing │
│ │ │ │
MockMarkdown:Test result │" Test result │"
`; `;
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows x for Error status 1`] = ` exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows x for Error status 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────╮
│ x test-tool A tool for testing │ │ x test-tool A tool for testing │
│ │ │ │
MockMarkdown:Test result │" Test result │"
`; `;
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows ✓ for Success status 1`] = ` exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows ✓ for Success status 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │ │ ✓ test-tool A tool for testing │
│ │ │ │
MockMarkdown:Test result │" Test result │"
`; `;
exports[`<ToolMessage /> > renders AnsiOutputText for AnsiOutput results 1`] = ` exports[`<ToolMessage /> > renders AnsiOutputText for AnsiOutput results 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │ │ ✓ test-tool A tool for testing │
│ │ │ │
MockAnsiOutput:hello │" hello │"
`; `;
exports[`<ToolMessage /> > renders DiffRenderer for diff results 1`] = ` exports[`<ToolMessage /> > renders DiffRenderer for diff results 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │ │ ✓ test-tool A tool for testing │
│ │ │ │
MockDiff:--- a/file.txt 1 - old
+++ b/file.txt 1 + new "
│ @@ -1 +1 @@ │
│ -old │
│ +new │"
`; `;
exports[`<ToolMessage /> > renders basic tool information 1`] = ` exports[`<ToolMessage /> > renders basic tool information 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │ │ ✓ test-tool A tool for testing │
│ │ │ │
MockMarkdown:Test result │" Test result │"
`; `;
exports[`<ToolMessage /> > renders emphasis correctly 1`] = ` exports[`<ToolMessage /> > renders emphasis correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing ← │ │ ✓ test-tool A tool for testing ← │
│ │ │ │
MockMarkdown:Test result │" Test result │"
`; `;
exports[`<ToolMessage /> > renders emphasis correctly 2`] = ` exports[`<ToolMessage /> > renders emphasis correctly 2`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │ │ ✓ test-tool A tool for testing │
│ │ │ │
MockMarkdown:Test result │" Test result │"
`; `;
@@ -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 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`] = `""`; exports[`ToolResultDisplay > renders nothing for todos result 1`] = `""`;
@@ -1,14 +1,14 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToolResultDisplay Overflow > should display "press ctrl-o" hint when content overflows in ToolGroupMessage 1`] = ` exports[`ToolResultDisplay Overflow > should display "press ctrl-o" hint when content overflows in ToolGroupMessage 1`] = `
"╭────────────────────────────────────────────────────────────────────────────── "╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool a test tool │ ✓ test-tool a test tool │
│ │
│ ... first 46 lines hidden ... │ ... first 46 lines hidden ... │
│ line 47 │ line 47 │
│ line 48 │ line 48 │
│ line 49 │ line 49 │
│ line 50 │ line 50 │
╰────────────────────────────────────────────────────────────────────────────── ╰──────────────────────────────────────────────────────────────────────────╯
Press ctrl-o to show more lines" Press ctrl-o to show more lines"
`; `;
@@ -1,41 +1,41 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // 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`] = ` exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = `
"╭────────────────────────────────────────────────────────────────────────────╮ █ "╭────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command Description for Shell Command │ ✓ Shell Command Description for Shell Command
│ │
│ shell-01 │ shell-01 │
│ shell-02 │" │ shell-02 │"
`; `;
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = ` 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 Command Description for Shell Command
│────────────────────────────────────────────────────────────────────────────│ █ │────────────────────────────────────────────────────────────────────────│
│ shell-06 │ shell-06
│ shell-07 │" │ shell-07 │"
`; `;
exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 1`] = ` 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 │ ✓ tool-1 Description for tool-1 │
│ │
│ c1-01 │ c1-01 │
│ c1-02 │" │ c1-02 │"
`; `;
exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 2`] = ` 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 │ ✓ tool-1 Description for tool-1
│──────────────────────────────────────────────────────────────────────────── │────────────────────────────────────────────────────────────────────────│
│ c1-06 │ c1-06 │
│ c1-07 │" │ c1-07 │"
`; `;
exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 3`] = ` 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 │ ✓ tool-2 Description for tool-2 │
│──────────────────────────────────────────────────────────────────────────── │────────────────────────────────────────────────────────────────────────│
│ c2-10 │ c2-10 │
╰────────────────────────────────────────────────────────────────────────────╯ █" ╰────────────────────────────────────────────────────────────────────────╯ █"
`; `;
@@ -117,4 +117,91 @@ describe('<Scrollable />', () => {
}); });
expect(capturedEntry.getScrollState().scrollTop).toBe(1); 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(
<Scrollable hasFocus={true} height={5}>
<Text>Content</Text>
</Scrollable>,
);
// 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,
);
},
);
});
}); });
@@ -17,6 +17,7 @@ import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useScrollable } from '../../contexts/ScrollProvider.js'; import { useScrollable } from '../../contexts/ScrollProvider.js';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js'; import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js'; import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
interface ScrollableProps { interface ScrollableProps {
children?: React.ReactNode; children?: React.ReactNode;
@@ -103,14 +104,38 @@ export const Scrollable: React.FC<ScrollableProps> = ({
useKeypress( useKeypress(
(key: Key) => { (key: Key) => {
if (key.shift) { const { scrollHeight, innerHeight } = sizeRef.current;
if (key.name === 'up') { const scrollTop = getScrollTop();
scrollByWithAnimation(-1); 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') { if (keyMatchers[Command.SCROLL_UP](key)) {
scrollByWithAnimation(1); 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 }, { isActive: hasFocus },
); );
@@ -137,7 +162,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
[getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar], [getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar],
); );
useScrollable(scrollableEntry, hasFocus && ref.current !== null); useScrollable(scrollableEntry, true);
return ( return (
<Box <Box
@@ -186,9 +186,11 @@ function ScrollableList<T>(
if (keyMatchers[Command.SCROLL_UP](key)) { if (keyMatchers[Command.SCROLL_UP](key)) {
stopSmoothScroll(); stopSmoothScroll();
scrollByWithAnimation(-1); scrollByWithAnimation(-1);
return true;
} else if (keyMatchers[Command.SCROLL_DOWN](key)) { } else if (keyMatchers[Command.SCROLL_DOWN](key)) {
stopSmoothScroll(); stopSmoothScroll();
scrollByWithAnimation(1); scrollByWithAnimation(1);
return true;
} else if ( } else if (
keyMatchers[Command.PAGE_UP](key) || keyMatchers[Command.PAGE_UP](key) ||
keyMatchers[Command.PAGE_DOWN](key) keyMatchers[Command.PAGE_DOWN](key)
@@ -200,11 +202,15 @@ function ScrollableList<T>(
: scrollState.scrollTop; : scrollState.scrollTop;
const innerHeight = scrollState.innerHeight; const innerHeight = scrollState.innerHeight;
smoothScrollTo(current + direction * innerHeight); smoothScrollTo(current + direction * innerHeight);
return true;
} else if (keyMatchers[Command.SCROLL_HOME](key)) { } else if (keyMatchers[Command.SCROLL_HOME](key)) {
smoothScrollTo(0); smoothScrollTo(0);
return true;
} else if (keyMatchers[Command.SCROLL_END](key)) { } else if (keyMatchers[Command.SCROLL_END](key)) {
smoothScrollTo(SCROLL_TO_ITEM_END); smoothScrollTo(SCROLL_TO_ITEM_END);
return true;
} }
return false;
}, },
{ isActive: hasFocus }, { isActive: hasFocus },
); );
@@ -229,7 +235,7 @@ function ScrollableList<T>(
], ],
); );
useScrollable(scrollableEntry, hasFocus); useScrollable(scrollableEntry, true);
return ( return (
<Box <Box
+6
View File
@@ -39,3 +39,9 @@ export const DEFAULT_BACKGROUND_OPACITY = 0.08;
export const KEYBOARD_SHORTCUTS_URL = export const KEYBOARD_SHORTCUTS_URL =
'https://geminicli.com/docs/cli/keyboard-shortcuts/'; 'https://geminicli.com/docs/cli/keyboard-shortcuts/';
export const LRU_BUFFER_PERF_CACHE_LIMIT = 20000; export const LRU_BUFFER_PERF_CACHE_LIMIT = 20000;
// Max lines to show for active shell output when not focused
export const ACTIVE_SHELL_MAX_LINES = 15;
// Max lines to preserve in history for completed shell commands
export const COMPLETED_SHELL_MAX_LINES = 15;
@@ -47,7 +47,7 @@ const findScrollableCandidates = (
const candidates: Array<ScrollableEntry & { area: number }> = []; const candidates: Array<ScrollableEntry & { area: number }> = [];
for (const entry of scrollables.values()) { for (const entry of scrollables.values()) {
if (!entry.ref.current || !entry.hasFocus()) { if (!entry.ref.current) {
continue; continue;
} }
@@ -7,6 +7,7 @@
import { act } from 'react'; import { act } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '../../test-utils/render.js'; import { renderHook } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { ToolActionsProvider, useToolActions } from './ToolActionsContext.js'; import { ToolActionsProvider, useToolActions } from './ToolActionsContext.js';
import { import {
type Config, type Config,
@@ -155,7 +156,7 @@ describe('ToolActionsContext', () => {
// Wait for IdeClient initialization in useEffect // Wait for IdeClient initialization in useEffect
await act(async () => { await act(async () => {
await vi.waitFor(() => expect(IdeClient.getInstance).toHaveBeenCalled()); await waitFor(() => expect(IdeClient.getInstance).toHaveBeenCalled());
// Give React a chance to update state // Give React a chance to update state
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
}); });
@@ -195,7 +196,7 @@ describe('ToolActionsContext', () => {
// Wait for initialization // Wait for initialization
await act(async () => { await act(async () => {
await vi.waitFor(() => expect(IdeClient.getInstance).toHaveBeenCalled()); await waitFor(() => expect(IdeClient.getInstance).toHaveBeenCalled());
await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0));
}); });
@@ -65,7 +65,6 @@ vi.mock('node:os', async (importOriginal) => {
}; };
}); });
vi.mock('node:crypto'); vi.mock('node:crypto');
vi.mock('../utils/textUtils.js');
import { import {
useShellCommandProcessor, useShellCommandProcessor,
@@ -245,5 +245,34 @@ describe('toolMapping', () => {
expect(displayTool.status).toBe(ToolCallStatus.Canceled); expect(displayTool.status).toBe(ToolCallStatus.Canceled);
expect(displayTool.resultDisplay).toBe('User cancelled'); 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);
});
}); });
}); });
+10 -4
View File
@@ -166,21 +166,27 @@ describe('keyMatchers', () => {
{ {
command: Command.SCROLL_UP, command: Command.SCROLL_UP,
positive: [createKey('up', { shift: true })], positive: [createKey('up', { shift: true })],
negative: [createKey('up'), createKey('up', { ctrl: true })], negative: [createKey('up')],
}, },
{ {
command: Command.SCROLL_DOWN, command: Command.SCROLL_DOWN,
positive: [createKey('down', { shift: true })], positive: [createKey('down', { shift: true })],
negative: [createKey('down'), createKey('down', { ctrl: true })], negative: [createKey('down')],
}, },
{ {
command: Command.SCROLL_HOME, command: Command.SCROLL_HOME,
positive: [createKey('home', { ctrl: true })], positive: [
createKey('home', { ctrl: true }),
createKey('home', { shift: true }),
],
negative: [createKey('end'), createKey('home')], negative: [createKey('end'), createKey('home')],
}, },
{ {
command: Command.SCROLL_END, command: Command.SCROLL_END,
positive: [createKey('end', { ctrl: true })], positive: [
createKey('end', { ctrl: true }),
createKey('end', { shift: true }),
],
negative: [createKey('home'), createKey('end')], negative: [createKey('home'), createKey('end')],
}, },
{ {