From 34f271504a20d47c6dc7309d5e16b74da64dee83 Mon Sep 17 00:00:00 2001 From: Dev Randalpura Date: Wed, 18 Mar 2026 17:28:21 -0400 Subject: [PATCH] fix(ui): fix flickering on small terminal heights (#21416) Co-authored-by: Jacob Richman --- packages/cli/src/ui/components/AnsiOutput.tsx | 6 +- .../cli/src/ui/components/MainContent.tsx | 5 +- .../__snapshots__/MainContent.test.tsx.snap | 10 +- .../messages/ShellToolMessage.test.tsx | 2 +- .../components/shared/SlicingMaxSizedBox.tsx | 4 +- .../cli/src/ui/utils/toolLayoutUtils.test.ts | 208 ++++++++++++++++++ packages/cli/src/ui/utils/toolLayoutUtils.ts | 18 +- 7 files changed, 237 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/ui/utils/toolLayoutUtils.test.ts diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index cc17b6b6b0..a1b30b0856 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -35,7 +35,11 @@ export const AnsiOutputText: React.FC = ({ ? Math.min(availableHeightLimit, maxLines) : (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT); - const lastLines = disableTruncation ? data : data.slice(-numLinesRetained); + const lastLines = disableTruncation + ? data + : numLinesRetained === 0 + ? [] + : data.slice(-numLinesRetained); return ( {lastLines.map((line: AnsiLine, lineIndex: number) => ( diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index d7e04bd351..0530e171b8 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -48,6 +48,7 @@ export const MainContent = () => { pendingHistoryItems, mainAreaWidth, staticAreaMaxItemHeight, + availableTerminalHeight, cleanUiDetailsVisible, } = uiState; const showHeaderDetails = cleanUiDetailsVisible; @@ -141,7 +142,7 @@ export const MainContent = () => { { [ pendingHistoryItems, uiState.constrainHeight, - staticAreaMaxItemHeight, + availableTerminalHeight, mainAreaWidth, showConfirmationQueue, confirmingTool, diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap index c0043bf6f9..785dc6b6f0 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -6,11 +6,12 @@ AppHeader(full) ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊶ Shell Command Running a long command... │ │ │ +│ Line 9 │ │ Line 10 │ │ Line 11 │ │ Line 12 │ │ Line 13 │ -│ Line 14 │ +│ Line 14 █ │ │ Line 15 █ │ │ Line 16 █ │ │ Line 17 █ │ @@ -27,11 +28,12 @@ AppHeader(full) ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊶ Shell Command Running a long command... │ │ │ +│ Line 9 │ │ Line 10 │ │ Line 11 │ │ Line 12 │ │ Line 13 │ -│ Line 14 │ +│ Line 14 █ │ │ Line 15 █ │ │ Line 16 █ │ │ Line 17 █ │ @@ -47,7 +49,9 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊶ Shell Command Running a long command... │ │ │ -│ ... first 11 lines hidden (Ctrl+O to show) ... │ +│ ... first 9 lines hidden (Ctrl+O to show) ... │ +│ Line 10 │ +│ Line 11 │ │ Line 12 │ │ Line 13 │ │ Line 14 │ diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 2aa285003f..7ee726a609 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -199,7 +199,7 @@ describe('', () => { [ 'uses full availableTerminalHeight when focused in alternate buffer mode', 100, - 98, // 100 - 2 + 98, true, false, ], diff --git a/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx b/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx index b756c40ee2..f8f851aed3 100644 --- a/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx +++ b/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx @@ -46,7 +46,7 @@ export function SlicingMaxSizedBox({ text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS); } } - if (maxLines) { + if (maxLines !== undefined) { const hasTrailingNewline = text.endsWith('\n'); const contentText = hasTrailingNewline ? text.slice(0, -1) : text; const lines = contentText.split('\n'); @@ -71,7 +71,7 @@ export function SlicingMaxSizedBox({ }; } - if (Array.isArray(data) && !isAlternateBuffer && maxLines) { + if (Array.isArray(data) && !isAlternateBuffer && maxLines !== undefined) { if (data.length > maxLines) { // We will have a label from MaxSizedBox. Reserve space for it. const targetLines = Math.max(1, maxLines - 1); diff --git a/packages/cli/src/ui/utils/toolLayoutUtils.test.ts b/packages/cli/src/ui/utils/toolLayoutUtils.test.ts new file mode 100644 index 0000000000..57e1e3f190 --- /dev/null +++ b/packages/cli/src/ui/utils/toolLayoutUtils.test.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + calculateToolContentMaxLines, + calculateShellMaxLines, + SHELL_CONTENT_OVERHEAD, +} from './toolLayoutUtils.js'; +import { CoreToolCallStatus } from '@google/gemini-cli-core'; +import { + ACTIVE_SHELL_MAX_LINES, + COMPLETED_SHELL_MAX_LINES, +} from '../constants.js'; + +describe('toolLayoutUtils', () => { + describe('calculateToolContentMaxLines', () => { + interface CalculateToolContentMaxLinesTestCase { + desc: string; + options: Parameters[0]; + expected: number | undefined; + } + + const testCases: CalculateToolContentMaxLinesTestCase[] = [ + { + desc: 'returns undefined if availableTerminalHeight is undefined', + options: { + availableTerminalHeight: undefined, + isAlternateBuffer: false, + }, + expected: undefined, + }, + { + desc: 'returns maxLinesLimit if maxLinesLimit applies but availableTerminalHeight is undefined', + options: { + availableTerminalHeight: undefined, + isAlternateBuffer: false, + maxLinesLimit: 10, + }, + expected: 10, + }, + { + desc: 'returns available space directly in constrained terminal (Standard mode)', + options: { + availableTerminalHeight: 2, + isAlternateBuffer: false, + }, + expected: 3, + }, + { + desc: 'returns available space directly in constrained terminal (ASB mode)', + options: { + availableTerminalHeight: 4, + isAlternateBuffer: true, + }, + expected: 3, + }, + { + desc: 'returns remaining space if sufficient space exists (Standard mode)', + options: { + availableTerminalHeight: 20, + isAlternateBuffer: false, + }, + expected: 17, + }, + { + desc: 'returns remaining space if sufficient space exists (ASB mode)', + options: { + availableTerminalHeight: 20, + isAlternateBuffer: true, + }, + expected: 13, + }, + ]; + + it.each(testCases)('$desc', ({ options, expected }) => { + const result = calculateToolContentMaxLines(options); + expect(result).toBe(expected); + }); + }); + + describe('calculateShellMaxLines', () => { + interface CalculateShellMaxLinesTestCase { + desc: string; + options: Parameters[0]; + expected: number | undefined; + } + + const testCases: CalculateShellMaxLinesTestCase[] = [ + { + desc: 'returns undefined when not constrained and is expandable', + options: { + status: CoreToolCallStatus.Executing, + isAlternateBuffer: false, + isThisShellFocused: false, + availableTerminalHeight: 20, + constrainHeight: false, + isExpandable: true, + }, + expected: undefined, + }, + { + desc: 'returns ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD for ASB mode when availableTerminalHeight is undefined', + options: { + status: CoreToolCallStatus.Executing, + isAlternateBuffer: true, + isThisShellFocused: false, + availableTerminalHeight: undefined, + constrainHeight: true, + isExpandable: false, + }, + expected: ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, + }, + { + desc: 'returns undefined for Standard mode when availableTerminalHeight is undefined', + options: { + status: CoreToolCallStatus.Executing, + isAlternateBuffer: false, + isThisShellFocused: false, + availableTerminalHeight: undefined, + constrainHeight: true, + isExpandable: false, + }, + expected: undefined, + }, + { + desc: 'handles small availableTerminalHeight gracefully without overflow in Standard mode', + options: { + status: CoreToolCallStatus.Executing, + isAlternateBuffer: false, + isThisShellFocused: false, + availableTerminalHeight: 2, + constrainHeight: true, + isExpandable: false, + }, + expected: 1, + }, + { + desc: 'handles small availableTerminalHeight gracefully without overflow in ASB mode', + options: { + status: CoreToolCallStatus.Executing, + isAlternateBuffer: true, + isThisShellFocused: false, + availableTerminalHeight: 6, + constrainHeight: true, + isExpandable: false, + }, + expected: 4, + }, + { + desc: 'handles negative availableTerminalHeight gracefully', + options: { + status: CoreToolCallStatus.Executing, + isAlternateBuffer: false, + isThisShellFocused: false, + availableTerminalHeight: -5, + constrainHeight: true, + isExpandable: false, + }, + expected: 1, + }, + { + desc: 'returns maxLinesBasedOnHeight for focused ASB shells', + options: { + status: CoreToolCallStatus.Executing, + isAlternateBuffer: true, + isThisShellFocused: true, + availableTerminalHeight: 30, + constrainHeight: false, + isExpandable: false, + }, + expected: 28, + }, + { + desc: 'falls back to COMPLETED_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD for completed shells if space allows', + options: { + status: CoreToolCallStatus.Success, + isAlternateBuffer: false, + isThisShellFocused: false, + availableTerminalHeight: 100, + constrainHeight: true, + isExpandable: false, + }, + expected: COMPLETED_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, + }, + { + desc: 'falls back to ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD for executing shells if space allows', + options: { + status: CoreToolCallStatus.Executing, + isAlternateBuffer: false, + isThisShellFocused: false, + availableTerminalHeight: 100, + constrainHeight: true, + isExpandable: false, + }, + expected: ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, + }, + ]; + + it.each(testCases)('$desc', ({ options, expected }) => { + const result = calculateShellMaxLines(options); + expect(result).toBe(expected); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/toolLayoutUtils.ts b/packages/cli/src/ui/utils/toolLayoutUtils.ts index c91919cffa..9f391dca4e 100644 --- a/packages/cli/src/ui/utils/toolLayoutUtils.ts +++ b/packages/cli/src/ui/utils/toolLayoutUtils.ts @@ -46,12 +46,13 @@ export function calculateToolContentMaxLines(options: { ? TOOL_RESULT_ASB_RESERVED_LINE_COUNT : TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT; - let contentHeight = availableTerminalHeight - ? Math.max( - availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines, - TOOL_RESULT_MIN_LINES_SHOWN + 1, - ) - : undefined; + let contentHeight = + availableTerminalHeight !== undefined + ? Math.max( + availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines, + TOOL_RESULT_MIN_LINES_SHOWN + 1, + ) + : undefined; if (maxLinesLimit !== undefined) { contentHeight = @@ -100,7 +101,10 @@ export function calculateShellMaxLines(options: { : undefined; } - const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2); + const maxLinesBasedOnHeight = Math.max( + 1, + availableTerminalHeight - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT, + ); // 3. Handle ASB mode focus expansion. // We allow a focused shell in ASB mode to take up the full available height,