Skip to content

Commit ff611c5

Browse files
devr0306jacob314
authored andcommitted
fix(ui): fix flickering on small terminal heights (google-gemini#21416)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
1 parent f1bc66e commit ff611c5

File tree

7 files changed

+237
-16
lines changed

7 files changed

+237
-16
lines changed

packages/cli/src/ui/components/AnsiOutput.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
3535
? Math.min(availableHeightLimit, maxLines)
3636
: (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT);
3737

38-
const lastLines = disableTruncation ? data : data.slice(-numLinesRetained);
38+
const lastLines = disableTruncation
39+
? data
40+
: numLinesRetained === 0
41+
? []
42+
: data.slice(-numLinesRetained);
3943
return (
4044
<Box flexDirection="column" width={width} flexShrink={0} overflow="hidden">
4145
{lastLines.map((line: AnsiLine, lineIndex: number) => (

packages/cli/src/ui/components/MainContent.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const MainContent = () => {
4848
pendingHistoryItems,
4949
mainAreaWidth,
5050
staticAreaMaxItemHeight,
51+
availableTerminalHeight,
5152
cleanUiDetailsVisible,
5253
} = uiState;
5354
const showHeaderDetails = cleanUiDetailsVisible;
@@ -141,7 +142,7 @@ export const MainContent = () => {
141142
<HistoryItemDisplay
142143
key={i}
143144
availableTerminalHeight={
144-
uiState.constrainHeight ? staticAreaMaxItemHeight : undefined
145+
uiState.constrainHeight ? availableTerminalHeight : undefined
145146
}
146147
terminalWidth={mainAreaWidth}
147148
item={{ ...item, id: 0 }}
@@ -160,7 +161,7 @@ export const MainContent = () => {
160161
[
161162
pendingHistoryItems,
162163
uiState.constrainHeight,
163-
staticAreaMaxItemHeight,
164+
availableTerminalHeight,
164165
mainAreaWidth,
165166
showConfirmationQueue,
166167
confirmingTool,

packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ AppHeader(full)
66
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
77
│ ⊶ Shell Command Running a long command... │
88
│ │
9+
│ Line 9 │
910
│ Line 10 │
1011
│ Line 11 │
1112
│ Line 12 │
1213
│ Line 13 │
13-
│ Line 14
14+
│ Line 14
1415
│ Line 15 █ │
1516
│ Line 16 █ │
1617
│ Line 17 █ │
@@ -27,11 +28,12 @@ AppHeader(full)
2728
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
2829
│ ⊶ Shell Command Running a long command... │
2930
│ │
31+
│ Line 9 │
3032
│ Line 10 │
3133
│ Line 11 │
3234
│ Line 12 │
3335
│ Line 13 │
34-
│ Line 14
36+
│ Line 14
3537
│ Line 15 █ │
3638
│ Line 16 █ │
3739
│ Line 17 █ │
@@ -47,7 +49,9 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
4749
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
4850
│ ⊶ Shell Command Running a long command... │
4951
│ │
50-
│ ... first 11 lines hidden (Ctrl+O to show) ... │
52+
│ ... first 9 lines hidden (Ctrl+O to show) ... │
53+
│ Line 10 │
54+
│ Line 11 │
5155
│ Line 12 │
5256
│ Line 13 │
5357
│ Line 14 │

packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ describe('<ShellToolMessage />', () => {
199199
[
200200
'uses full availableTerminalHeight when focused in alternate buffer mode',
201201
100,
202-
98, // 100 - 2
202+
98,
203203
true,
204204
false,
205205
],

packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export function SlicingMaxSizedBox<T>({
4646
text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
4747
}
4848
}
49-
if (maxLines) {
49+
if (maxLines !== undefined) {
5050
const hasTrailingNewline = text.endsWith('\n');
5151
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
5252
const lines = contentText.split('\n');
@@ -71,7 +71,7 @@ export function SlicingMaxSizedBox<T>({
7171
};
7272
}
7373

74-
if (Array.isArray(data) && !isAlternateBuffer && maxLines) {
74+
if (Array.isArray(data) && !isAlternateBuffer && maxLines !== undefined) {
7575
if (data.length > maxLines) {
7676
// We will have a label from MaxSizedBox. Reserve space for it.
7777
const targetLines = Math.max(1, maxLines - 1);
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect } from 'vitest';
8+
import {
9+
calculateToolContentMaxLines,
10+
calculateShellMaxLines,
11+
SHELL_CONTENT_OVERHEAD,
12+
} from './toolLayoutUtils.js';
13+
import { CoreToolCallStatus } from '@google/gemini-cli-core';
14+
import {
15+
ACTIVE_SHELL_MAX_LINES,
16+
COMPLETED_SHELL_MAX_LINES,
17+
} from '../constants.js';
18+
19+
describe('toolLayoutUtils', () => {
20+
describe('calculateToolContentMaxLines', () => {
21+
interface CalculateToolContentMaxLinesTestCase {
22+
desc: string;
23+
options: Parameters<typeof calculateToolContentMaxLines>[0];
24+
expected: number | undefined;
25+
}
26+
27+
const testCases: CalculateToolContentMaxLinesTestCase[] = [
28+
{
29+
desc: 'returns undefined if availableTerminalHeight is undefined',
30+
options: {
31+
availableTerminalHeight: undefined,
32+
isAlternateBuffer: false,
33+
},
34+
expected: undefined,
35+
},
36+
{
37+
desc: 'returns maxLinesLimit if maxLinesLimit applies but availableTerminalHeight is undefined',
38+
options: {
39+
availableTerminalHeight: undefined,
40+
isAlternateBuffer: false,
41+
maxLinesLimit: 10,
42+
},
43+
expected: 10,
44+
},
45+
{
46+
desc: 'returns available space directly in constrained terminal (Standard mode)',
47+
options: {
48+
availableTerminalHeight: 2,
49+
isAlternateBuffer: false,
50+
},
51+
expected: 3,
52+
},
53+
{
54+
desc: 'returns available space directly in constrained terminal (ASB mode)',
55+
options: {
56+
availableTerminalHeight: 4,
57+
isAlternateBuffer: true,
58+
},
59+
expected: 3,
60+
},
61+
{
62+
desc: 'returns remaining space if sufficient space exists (Standard mode)',
63+
options: {
64+
availableTerminalHeight: 20,
65+
isAlternateBuffer: false,
66+
},
67+
expected: 17,
68+
},
69+
{
70+
desc: 'returns remaining space if sufficient space exists (ASB mode)',
71+
options: {
72+
availableTerminalHeight: 20,
73+
isAlternateBuffer: true,
74+
},
75+
expected: 13,
76+
},
77+
];
78+
79+
it.each(testCases)('$desc', ({ options, expected }) => {
80+
const result = calculateToolContentMaxLines(options);
81+
expect(result).toBe(expected);
82+
});
83+
});
84+
85+
describe('calculateShellMaxLines', () => {
86+
interface CalculateShellMaxLinesTestCase {
87+
desc: string;
88+
options: Parameters<typeof calculateShellMaxLines>[0];
89+
expected: number | undefined;
90+
}
91+
92+
const testCases: CalculateShellMaxLinesTestCase[] = [
93+
{
94+
desc: 'returns undefined when not constrained and is expandable',
95+
options: {
96+
status: CoreToolCallStatus.Executing,
97+
isAlternateBuffer: false,
98+
isThisShellFocused: false,
99+
availableTerminalHeight: 20,
100+
constrainHeight: false,
101+
isExpandable: true,
102+
},
103+
expected: undefined,
104+
},
105+
{
106+
desc: 'returns ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD for ASB mode when availableTerminalHeight is undefined',
107+
options: {
108+
status: CoreToolCallStatus.Executing,
109+
isAlternateBuffer: true,
110+
isThisShellFocused: false,
111+
availableTerminalHeight: undefined,
112+
constrainHeight: true,
113+
isExpandable: false,
114+
},
115+
expected: ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD,
116+
},
117+
{
118+
desc: 'returns undefined for Standard mode when availableTerminalHeight is undefined',
119+
options: {
120+
status: CoreToolCallStatus.Executing,
121+
isAlternateBuffer: false,
122+
isThisShellFocused: false,
123+
availableTerminalHeight: undefined,
124+
constrainHeight: true,
125+
isExpandable: false,
126+
},
127+
expected: undefined,
128+
},
129+
{
130+
desc: 'handles small availableTerminalHeight gracefully without overflow in Standard mode',
131+
options: {
132+
status: CoreToolCallStatus.Executing,
133+
isAlternateBuffer: false,
134+
isThisShellFocused: false,
135+
availableTerminalHeight: 2,
136+
constrainHeight: true,
137+
isExpandable: false,
138+
},
139+
expected: 1,
140+
},
141+
{
142+
desc: 'handles small availableTerminalHeight gracefully without overflow in ASB mode',
143+
options: {
144+
status: CoreToolCallStatus.Executing,
145+
isAlternateBuffer: true,
146+
isThisShellFocused: false,
147+
availableTerminalHeight: 6,
148+
constrainHeight: true,
149+
isExpandable: false,
150+
},
151+
expected: 4,
152+
},
153+
{
154+
desc: 'handles negative availableTerminalHeight gracefully',
155+
options: {
156+
status: CoreToolCallStatus.Executing,
157+
isAlternateBuffer: false,
158+
isThisShellFocused: false,
159+
availableTerminalHeight: -5,
160+
constrainHeight: true,
161+
isExpandable: false,
162+
},
163+
expected: 1,
164+
},
165+
{
166+
desc: 'returns maxLinesBasedOnHeight for focused ASB shells',
167+
options: {
168+
status: CoreToolCallStatus.Executing,
169+
isAlternateBuffer: true,
170+
isThisShellFocused: true,
171+
availableTerminalHeight: 30,
172+
constrainHeight: false,
173+
isExpandable: false,
174+
},
175+
expected: 28,
176+
},
177+
{
178+
desc: 'falls back to COMPLETED_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD for completed shells if space allows',
179+
options: {
180+
status: CoreToolCallStatus.Success,
181+
isAlternateBuffer: false,
182+
isThisShellFocused: false,
183+
availableTerminalHeight: 100,
184+
constrainHeight: true,
185+
isExpandable: false,
186+
},
187+
expected: COMPLETED_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD,
188+
},
189+
{
190+
desc: 'falls back to ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD for executing shells if space allows',
191+
options: {
192+
status: CoreToolCallStatus.Executing,
193+
isAlternateBuffer: false,
194+
isThisShellFocused: false,
195+
availableTerminalHeight: 100,
196+
constrainHeight: true,
197+
isExpandable: false,
198+
},
199+
expected: ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD,
200+
},
201+
];
202+
203+
it.each(testCases)('$desc', ({ options, expected }) => {
204+
const result = calculateShellMaxLines(options);
205+
expect(result).toBe(expected);
206+
});
207+
});
208+
});

packages/cli/src/ui/utils/toolLayoutUtils.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,13 @@ export function calculateToolContentMaxLines(options: {
4646
? TOOL_RESULT_ASB_RESERVED_LINE_COUNT
4747
: TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT;
4848

49-
let contentHeight = availableTerminalHeight
50-
? Math.max(
51-
availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines,
52-
TOOL_RESULT_MIN_LINES_SHOWN + 1,
53-
)
54-
: undefined;
49+
let contentHeight =
50+
availableTerminalHeight !== undefined
51+
? Math.max(
52+
availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines,
53+
TOOL_RESULT_MIN_LINES_SHOWN + 1,
54+
)
55+
: undefined;
5556

5657
if (maxLinesLimit !== undefined) {
5758
contentHeight =
@@ -100,7 +101,10 @@ export function calculateShellMaxLines(options: {
100101
: undefined;
101102
}
102103

103-
const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2);
104+
const maxLinesBasedOnHeight = Math.max(
105+
1,
106+
availableTerminalHeight - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
107+
);
104108

105109
// 3. Handle ASB mode focus expansion.
106110
// We allow a focused shell in ASB mode to take up the full available height,

0 commit comments

Comments
 (0)