From 09a6667c354d50a81064de6aa4e2d371f2cf86e7 Mon Sep 17 00:00:00 2001 From: Jarrod Whelan <150866123+jwhelangoog@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:07:59 -0700 Subject: [PATCH] refactor(cli): address nuanced snapshot rendering scenarios through layout and padding refinements - Refined height calculation logic in ToolGroupMessage to ensure consistent spacing between compact and standard tools. - Adjusted padding and margins in StickyHeader, ToolConfirmationQueue, ShellToolMessage, and ToolMessage for visual alignment. - Updated TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT to account for internal layout changes. - Improved ToolResultDisplay height handling in alternate buffer mode. - Updated test snapshots to reflect layout and spacing corrections. refactor(cli): cleanup and simplify UI components - Reduced UI refresh delay in AppContainer.tsx for a more responsive user experience. - Reorder imports and hook definitions within AppContainer.tsx to reduce diff 'noise'. refactor(cli): enhance compact output robustness and visual regression testing Addressing automated review feedback to improve code maintainability and layout stability. 1. Robust File Extension Parsing: - Introduced getFileExtension utility in packages/cli/src/ui/utils/fileUtils.ts using node:path for reliable extension extraction. - Updated DenseToolMessage and DiffRenderer to use the new utility, replacing fragile string splitting. 2. Visual Regression Coverage: - Added SVG snapshot tests to DenseToolMessage.test.tsx to verify semantic color rendering and layout integrity in compact mode. fix(cli): resolve dense tool output code quality issues - Replaced manual string truncation with Ink's `wrap="truncate-end"` to adhere to UI guidelines. - Added `isReadManyFilesResult` type guard to `packages/core/src/tools/tools.ts` to improve typing for structured tool results. - Fixed an incomplete test case in `DenseToolMessage.test.tsx` to properly simulate expansion via context instead of missing mouse events. --- packages/cli/src/ui/AppContainer.tsx | 27 ++++---- .../cli/src/ui/components/MainContent.tsx | 1 - .../cli/src/ui/components/StickyHeader.tsx | 1 - .../ui/components/ToolConfirmationQueue.tsx | 11 ++-- .../__snapshots__/MainContent.test.tsx.snap | 9 +-- .../messages/DenseToolMessage.test.tsx | 65 +++++++++++++++++-- .../components/messages/DenseToolMessage.tsx | 22 +++---- .../ui/components/messages/DiffRenderer.tsx | 5 +- .../components/messages/ShellToolMessage.tsx | 1 + .../messages/ToolGroupMessage.test.tsx | 2 +- .../components/messages/ToolGroupMessage.tsx | 57 ++++++++-------- .../ui/components/messages/ToolMessage.tsx | 1 + .../components/messages/ToolResultDisplay.tsx | 8 ++- ...snapshot-for-a-Rejected-tool-call.snap.svg | 11 ++++ ...ccepted-file-edit-with-diff-stats.snap.svg | 33 ++++++++++ .../DenseToolMessage.test.tsx.snap | 14 +++- .../ShellToolMessage.test.tsx.snap | 9 +-- .../ToolGroupMessage.test.tsx.snap | 18 ++--- .../ToolResultDisplay.test.tsx.snap | 3 +- .../ToolStickyHeaderRegression.test.tsx.snap | 4 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 5 +- packages/cli/src/ui/utils/fileUtils.ts | 19 ++++++ packages/cli/src/ui/utils/toolLayoutUtils.ts | 2 +- packages/core/src/tools/tools.ts | 4 ++ 24 files changed, 224 insertions(+), 108 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage-DenseToolMessage-Visual-Regression-matches-SVG-snapshot-for-a-Rejected-tool-call.snap.svg create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage-DenseToolMessage-Visual-Regression-matches-SVG-snapshot-for-an-Accepted-file-edit-with-diff-stats.snap.svg create mode 100644 packages/cli/src/ui/utils/fileUtils.ts diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c52dda7b74..6ab47b8537 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -167,6 +167,12 @@ import { useIsHelpDismissKey } from './utils/shortcutsHelp.js'; import { useSuspend } from './hooks/useSuspend.js'; import { useRunEventNotifications } from './hooks/useRunEventNotifications.js'; import { isNotificationsEnabled } from '../utils/terminalNotifications.js'; +import { + getLastTurnToolCallIds, + isToolExecuting, + isToolAwaitingConfirmation, + getAllToolCalls, +} from './utils/historyUtils.js'; interface AppContainerProps { config: Config; @@ -182,12 +188,6 @@ import { APPROVAL_MODE_REVEAL_DURATION_MS, } from './hooks/useVisibilityToggle.js'; import { useKeyMatchers } from './hooks/useKeyMatchers.js'; -import { - getLastTurnToolCallIds, - isToolExecuting, - isToolAwaitingConfirmation, - getAllToolCalls, -} from './utils/historyUtils.js'; /** * The fraction of the terminal width to allocate to the shell. @@ -1166,6 +1166,11 @@ Logging in with Google... Restarting Gemini CLI to continue. consumePendingHints, ); + const pendingHistoryItems = useMemo( + () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], + [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], + ); + toggleBackgroundShellRef.current = toggleBackgroundShell; isBackgroundShellVisibleRef.current = isBackgroundShellVisible; backgroundShellsRef.current = backgroundShells; @@ -1188,11 +1193,6 @@ Logging in with Google... Restarting Gemini CLI to continue. setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen; - const pendingHistoryItems = useMemo( - () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], - [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], - ); - const lastOutputTimeRef = useRef(0); useEffect(() => { @@ -1826,11 +1826,10 @@ Logging in with Google... Restarting Gemini CLI to continue. toggleLastTurnTools(); // Force layout refresh after a short delay to allow the terminal layout to settle. - // This prevents the "blank screen" issue by ensuring Ink re-measures after - // any async subview updates are complete. + // Minimize "blank screen" issue after any async subview updates are complete. setTimeout(() => { refreshStatic(); - }, 500); + }, 250); return true; } else if ( diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 98b99d384f..d8656a879c 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -88,7 +88,6 @@ export const MainContent = () => { () => augmentedHistory.map( ({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ( - // ({ item, isExpandable, isFirstThinking, /* isFirstAfterThinking */ }) => ( = ({ borderLeft={true} borderRight={true} paddingX={1} - paddingBottom={1} paddingTop={isFirst ? 0 : 1} > {children} diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx index e5294e9614..1fa34a6641 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx @@ -66,9 +66,9 @@ export const ToolConfirmationQueue: React.FC = ({ // ToolConfirmationMessage needs to know the height available for its OWN content. // We subtract the lines used by the Queue wrapper: - // - 2 lines for the rounded border + // - 2 lines for the rounded border (top/bottom) // - 2 lines for the Header (text + margin) - // - 2 lines for Tool Identity (text + margin) + // - 2 lines for Tool Identity (text + margin) if shown const availableContentHeight = constrainHeight ? Math.max(maxHeight - (hideToolIdentity ? 4 : 6), 4) : undefined; @@ -83,10 +83,7 @@ export const ToolConfirmationQueue: React.FC = ({ > {/* Header */} - + {getConfirmationHeader(tool.confirmationDetails)} @@ -98,7 +95,7 @@ export const ToolConfirmationQueue: React.FC = ({ {!hideToolIdentity && ( - + MainContent Tool Output Height Logic > 'Normal mode - Con ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │ ⊶ Shell Command Running a long command... │ │ │ -│ ... first 10 lines hidden (Ctrl+O to show) ... │ -│ Line 11 │ +│ ... first 11 lines hidden (Ctrl+O to show) ... │ │ Line 12 │ │ Line 13 │ │ Line 14 │ diff --git a/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx b/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx index a4b3120b3f..d28e8ed468 100644 --- a/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/DenseToolMessage.test.tsx @@ -54,9 +54,8 @@ describe('DenseToolMessage', () => { />, ); await waitUntilReady(); - // Remove all whitespace to check the continuous string content truncation - const output = lastFrame()?.replace(/\s/g, ''); - expect(output).toContain('A'.repeat(117) + '...'); + const output = lastFrame(); + expect(output).toContain('…'); expect(lastFrame()).toMatchSnapshot(); }); @@ -503,7 +502,7 @@ describe('DenseToolMessage', () => { expect(output).toMatchSnapshot(); }); - it('shows diff content after clicking summary', async () => { + it('shows diff content when expanded via ToolActionsContext', async () => { const { lastFrame, waitUntilReady } = await renderWithProviders( { { config: makeFakeConfig({ useAlternateBuffer: true }), settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - mouseEventsEnabled: true, + toolActions: { + isExpanded: () => true, + }, }, ); await waitUntilReady(); - // Verify it's hidden initially - expect(lastFrame()).not.toContain('new line'); + // Verify it shows the diff when expanded + expect(lastFrame()).toContain('new line'); + }); + }); + + describe('Visual Regression', () => { + it('matches SVG snapshot for an Accepted file edit with diff stats', async () => { + const diffResult: FileDiff = { + fileName: 'test.ts', + filePath: '/mock/test.ts', + fileDiff: '--- a/test.ts\n+++ b/test.ts\n@@ -1 +1 @@\n-old\n+new', + originalContent: 'old', + newContent: 'new', + diffStat: { + model_added_lines: 1, + model_removed_lines: 1, + model_added_chars: 3, + model_removed_chars: 3, + user_added_lines: 0, + user_removed_lines: 0, + user_added_chars: 0, + user_removed_chars: 0, + }, + }; + + const renderResult = await renderWithProviders( + , + ); + + await renderResult.waitUntilReady(); + await expect(renderResult).toMatchSvgSnapshot(); + }); + + it('matches SVG snapshot for a Rejected tool call', async () => { + const renderResult = await renderWithProviders( + , + ); + + await renderResult.waitUntilReady(); + await expect(renderResult).toMatchSvgSnapshot(); }); }); }); diff --git a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx index 6dc9497d25..60355c72a7 100644 --- a/packages/cli/src/ui/components/messages/DenseToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/DenseToolMessage.tsx @@ -16,6 +16,7 @@ import { hasSummary, isGrepResult, isListResult, + isReadManyFilesResult, } from '@google/gemini-cli-core'; import { type IndividualToolCallDisplay, isTodoList } from '../../types.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; @@ -33,6 +34,7 @@ import { COMPACT_TOOL_SUBVIEW_MAX_LINES } from '../../constants.js'; import { useSettings } from '../../contexts/SettingsContext.js'; import { colorizeCode } from '../../utils/CodeColorizer.js'; import { useToolActions } from '../../contexts/ToolActionsContext.js'; +import { getFileExtension } from '../../utils/fileUtils.js'; interface DenseToolMessageProps extends IndividualToolCallDisplay { terminalWidth?: number; @@ -242,14 +244,10 @@ function getListResultData( result: ListDirectoryResult | ReadManyFilesResult, originalDescription?: string, ): ViewParts { - // Use 'include' to determine if this is a ReadManyFilesResult - if ('include' in result) { + if (isReadManyFilesResult(result)) { return getReadManyFilesData(result); } - return getListDirectoryData( - result as ListDirectoryResult, - originalDescription, - ); + return getListDirectoryData(result, originalDescription); } function getGenericSuccessData( @@ -268,8 +266,8 @@ function getGenericSuccessData( if (typeof resultDisplay === 'string') { const flattened = resultDisplay.replace(/\n/g, ' ').trim(); summary = ( - - → {flattened.length > 120 ? flattened.slice(0, 117) + '...' : flattened} + + → {flattened} ); } else if (isGrepResult(resultDisplay)) { @@ -406,8 +404,8 @@ export const DenseToolMessage: React.FC = (props) => { ? resultDisplay.replace(/\n/g, ' ') : 'Failed'; const errorSummary = ( - - → {text.length > 120 ? text.slice(0, 117) + '...' : text} + + → {text} ); const descriptionText = originalDescription ? ( @@ -455,7 +453,9 @@ export const DenseToolMessage: React.FC = (props) => { .filter((line) => line.type === 'add') .map((line) => line.content) .join('\n'); - const fileExtension = diff.fileName?.split('.').pop() || null; + + const fileExtension = getFileExtension(diff.fileName); + return colorizeCode({ code: addedContent, language: fileExtension, diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.tsx index fec44bc19a..2a0d5b39c4 100644 --- a/packages/cli/src/ui/components/messages/DiffRenderer.tsx +++ b/packages/cli/src/ui/components/messages/DiffRenderer.tsx @@ -13,6 +13,7 @@ import { MaxSizedBox } from '../shared/MaxSizedBox.js'; import { theme as semanticTheme } from '../../semantic-colors.js'; import type { Theme } from '../../themes/theme.js'; import { useSettings } from '../../contexts/SettingsContext.js'; +import { getFileExtension } from '../../utils/fileUtils.js'; export interface DiffLine { type: 'add' | 'del' | 'context' | 'hunk' | 'other'; @@ -150,7 +151,7 @@ export const DiffRenderer: React.FC = ({ .map((line) => line.content) .join('\n'); // Attempt to infer language from filename, default to plain text if no filename - const fileExtension = filename?.split('.').pop() || null; + const fileExtension = getFileExtension(filename); const language = fileExtension ? getLanguageFromExtension(fileExtension) : null; @@ -259,7 +260,7 @@ export const renderDiffLines = ({ ); const gutterWidth = Math.max(1, maxLineNumber.toString().length); - const fileExtension = filename?.split('.').pop() || null; + const fileExtension = getFileExtension(filename); const language = fileExtension ? getLanguageFromExtension(fileExtension) : null; diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx index f3694f3490..533850f6d5 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.tsx @@ -190,6 +190,7 @@ export const ShellToolMessage: React.FC = ({ borderLeft={true} borderRight={true} paddingX={1} + paddingTop={1} flexDirection="column" > ', () => { ]; const item = createItem(toolCalls); const { lastFrame, unmount } = await renderWithProviders( - + , { diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index c4018a599d..b0c8ae4ea7 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -222,38 +222,41 @@ export const ToolGroupMessage: React.FC = ({ !Array.isArray(prevGroup) && isCompactTool(prevGroup, isCompactModeEnabled); - if (Array.isArray(group)) { + const isAgentGroup = Array.isArray(group); + const isCompact = + !isAgentGroup && isCompactTool(group, isCompactModeEnabled); + + if (isFirst) { + height += (borderTopOverride ?? false) ? 1 : 0; + } else if (isCompact && prevIsCompact) { + height += 0; + } else if (isCompact || prevIsCompact) { + height += 1; + } else { + // Gap is provided by StickyHeader's paddingTop=1 + height += 0; + } + + const isFirstProp = !!(isFirst + ? (borderTopOverride ?? true) + : prevIsCompact); + + if (isAgentGroup) { // Agent group height += 1; // Header height += group.length; // 1 line per agent - const isFirstProp = isFirst - ? (borderTopOverride ?? true) - : prevIsCompact; if (isFirstProp) height += 1; // Top border - - // Spacing logic - if (isFirst) { - height += (borderTopOverride ?? true) ? 1 : 0; - } else { - height += 1; // marginTop - } } else { - const isCompact = isCompactTool(group, isCompactModeEnabled); if (isCompact) { height += 1; // Base height for compact tool - // Spacing logic (matching marginTop) - if (isFirst) { - height += (borderTopOverride ?? true) ? 1 : 0; - } else if (!prevIsCompact) { - height += 1; - } } else { - height += 3; // Static overhead for standard tool - if (isFirst) { - height += (borderTopOverride ?? true) ? 1 : 0; - } else { - height += 1; // marginTop is always 1 for non-compact tools (not first) - } + // Static overhead for standard tool header: + // 1 line for header text + // 1 line for dark separator + // 1 line for tool body internal paddingTop=1 + // 1 line for top border (if isFirstProp) OR StickyHeader paddingTop=1 (if !isFirstProp) + height += 3; + height += isFirstProp ? 1 : 1; // Either top border or paddingTop } } } @@ -352,12 +355,14 @@ export const ToolGroupMessage: React.FC = ({ let marginTop = 0; if (isFirst) { - // marginTop = (borderTopOverride ?? true) ? 1 : 0; marginTop = (borderTopOverride ?? false) ? 1 : 0; } else if (isCompact && prevIsCompact) { marginTop = 0; - } else { + } else if (isCompact || prevIsCompact) { marginTop = 1; + } else { + // Subsequent standard tools: StickyHeader's paddingTop=1 provides the gap. + marginTop = 0; } const isFirstProp = !!(isFirst diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 5747f7677f..3f3f01778f 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -119,6 +119,7 @@ export const ToolMessage: React.FC = ({ borderLeft={true} borderRight={true} paddingX={1} + paddingTop={1} flexDirection="column" > {status === CoreToolCallStatus.Executing && progress !== undefined && ( diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 3a2ca1b2ed..2b05eb453b 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -179,10 +179,16 @@ export const ToolResultDisplay: React.FC = ({ // Final render based on session mode if (isAlternateBuffer) { + // If availableTerminalHeight is undefined, we don't have a fixed budget, + // so if maxLines is also undefined, we shouldn't cap the height at all. + const effectiveMaxHeight = + maxLines ?? + (availableTerminalHeight !== undefined ? availableHeight : undefined); + return ( + + + + - + read_file + Reading important.txt + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage-DenseToolMessage-Visual-Regression-matches-SVG-snapshot-for-an-Accepted-file-edit-with-diff-stats.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage-DenseToolMessage-Visual-Regression-matches-SVG-snapshot-for-an-Accepted-file-edit-with-diff-stats.snap.svg new file mode 100644 index 0000000000..7b21bd65a0 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage-DenseToolMessage-Visual-Regression-matches-SVG-snapshot-for-an-Accepted-file-edit-with-diff-stats.snap.svg @@ -0,0 +1,33 @@ + + + + + + edit + test.ts + → Accepted + ( + +1 + , + -1 + ) + + 1 + + + - + + + old + + 1 + + + + + + + new + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage.test.tsx.snap index e7cb9cb195..d08b84c1a9 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/DenseToolMessage.test.tsx.snap @@ -13,6 +13,16 @@ exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > shows diff " `; +exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for a Rejected tool call 1`] = `" - read_file Reading important.txt"`; + +exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for an Accepted file edit with diff stats 1`] = ` +" ✓ edit test.ts → Accepted (+1, -1) + + 1 - old + 1 + new +" +`; + exports[`DenseToolMessage > does not render result arrow if resultDisplay is missing 1`] = ` " o test-tool Test description " @@ -128,8 +138,6 @@ exports[`DenseToolMessage > renders generic output message for unknown object re exports[`DenseToolMessage > truncates long string results 1`] = ` " ✓ test-tool Test description - → - AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA - AAAAAAAAAAAAAAAAAAAA... + → AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… " `; diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap index 967ea81e14..38700b92de 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ShellToolMessage.test.tsx.snap @@ -4,7 +4,6 @@ exports[` > Height Constraints > defaults to ACTIVE_SHELL_MA "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊶ Shell Command A shell command │ │ │ -│ Line 90 │ │ Line 91 │ │ Line 92 │ │ Line 93 │ @@ -129,7 +128,6 @@ exports[` > Height Constraints > respects availableTerminalH "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊶ Shell Command A shell command │ │ │ -│ Line 94 │ │ Line 95 │ │ Line 96 │ │ Line 97 │ @@ -143,7 +141,6 @@ exports[` > Height Constraints > stays constrained in altern "╭──────────────────────────────────────────────────────────────────────────────╮ │ ✓ Shell Command A shell command │ │ │ -│ Line 90 │ │ Line 91 │ │ Line 92 │ │ Line 93 │ @@ -161,7 +158,6 @@ exports[` > Height Constraints > uses ACTIVE_SHELL_MAX_LINES "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊶ Shell Command A shell command │ │ │ -│ Line 90 │ │ Line 91 │ │ Line 92 │ │ Line 93 │ @@ -179,11 +175,10 @@ exports[` > Height Constraints > uses full availableTerminal "╭──────────────────────────────────────────────────────────────────────────────╮ │ ⊶ Shell Command A shell command (Shift+Tab to unfocus) │ │ │ -│ Line 4 │ │ Line 5 │ │ Line 6 │ -│ Line 7 █ │ -│ Line 8 █ │ +│ Line 7 │ +│ Line 8 │ │ Line 9 █ │ │ Line 10 █ │ │ Line 11 █ │ diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap index 6980837725..6fe4e28b70 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolGroupMessage.test.tsx.snap @@ -32,7 +32,6 @@ exports[` > Border Color Logic > uses gray border when all t │ ✓ test-tool A tool for testing │ │ │ │ Test result │ - │ │ │ ✓ another-tool A tool for testing │ │ │ @@ -62,10 +61,12 @@ exports[` > Golden Snapshots > renders canceled tool calls > exports[` > Golden Snapshots > renders empty tool calls array 1`] = `""`; exports[` > Golden Snapshots > renders header when scrolled 1`] = ` -"│ ✓ tool-1 Description 1. This is a long description that will need to b… │ -│──────────────────────────────────────────────────────────────────────────│ - -│ │ ▄ +"╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ tool-1 Description 1. This is a long description that will need to b… │ +│──────────────────────────────────────────────────────────────────────────│ ▄ +│ line4 │ █ +│ line5 │ █ +│ │ █ │ ✓ tool-2 Description 2 │ █ │ │ █ │ line1 │ █ @@ -80,12 +81,10 @@ exports[` > Golden Snapshots > renders mixed tool calls incl │ ✓ read_file Read a file │ │ │ │ Test result │ - │ │ │ ⊶ run_shell_command Run command │ │ │ │ Test result │ - │ │ │ o write_file Write to file │ │ │ @@ -99,12 +98,10 @@ exports[` > Golden Snapshots > renders multiple tool calls w │ ✓ successful-tool This tool succeeded │ │ │ │ Test result │ - │ │ │ o pending-tool This tool is pending │ │ │ │ Test result │ - │ │ │ x error-tool This tool failed │ │ │ @@ -147,7 +144,6 @@ exports[` > Golden Snapshots > renders with limited terminal │ ✓ tool-with-result Tool with output │ │ │ │ This is a long result that might need height constraints │ - │ │ │ ✓ another-tool Another tool │ │ │ @@ -170,12 +166,10 @@ exports[` > Height Calculation > calculates available height │ ✓ test-tool A tool for testing │ │ │ │ Result 1 │ - │ │ │ ✓ test-tool A tool for testing │ │ │ │ Result 2 │ - │ │ │ ✓ test-tool A tool for testing │ │ │ diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap index e34e66cc48..f4b3a35884 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplay.test.tsx.snap @@ -37,8 +37,7 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp `; exports[`ToolResultDisplay > truncates very long string results 1`] = ` -"... 249 hidden (Ctrl+O) ... -aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +"... 250 hidden (Ctrl+O) ... aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap index 16627ec220..66ca527b4b 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolStickyHeaderRegression.test.tsx.snap @@ -40,7 +40,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessa "│ │ │ ✓ tool-2 Description for tool-2 │ │────────────────────────────────────────────────────────────────────────│ -│ c2-09 │ ▄ -│ c2-10 │ ▀ +│ c2-10 │ +╰────────────────────────────────────────────────────────────────────────╯ █ " `; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index bddb41c163..0b63aaccce 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -665,9 +665,6 @@ export const useGeminiStream = ( return true; }; - const anyVisibleInHistory = pushedToolCallIds.size > 0; - const anyVisibleInPending = remainingTools.some(isToolVisible); - let lastVisibleIsCompact = false; const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true; for (let i = toolCalls.length - 1; i >= 0; i--) { @@ -683,7 +680,7 @@ export const useGeminiStream = ( if ( toolCalls.length > 0 && !(allTerminal && allPushed) && - (anyVisibleInHistory || anyVisibleInPending) && + toolCalls.some(isToolVisible) && !lastVisibleIsCompact ) { items.push({ diff --git a/packages/cli/src/ui/utils/fileUtils.ts b/packages/cli/src/ui/utils/fileUtils.ts new file mode 100644 index 0000000000..a1f3472aa4 --- /dev/null +++ b/packages/cli/src/ui/utils/fileUtils.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; + +/** + * Gets the file extension from a filename or path, excluding the leading dot. + * Returns null if no extension is found. + */ +export function getFileExtension( + filename: string | null | undefined, +): string | null { + if (!filename) return null; + const ext = path.extname(filename); + return ext ? ext.slice(1) : null; +} diff --git a/packages/cli/src/ui/utils/toolLayoutUtils.ts b/packages/cli/src/ui/utils/toolLayoutUtils.ts index 1f140b9bc9..e45be2c840 100644 --- a/packages/cli/src/ui/utils/toolLayoutUtils.ts +++ b/packages/cli/src/ui/utils/toolLayoutUtils.ts @@ -17,7 +17,7 @@ import { CoreToolCallStatus } from '@google/gemini-cli-core'; */ export const TOOL_RESULT_STATIC_HEIGHT = 1; export const TOOL_RESULT_ASB_RESERVED_LINE_COUNT = 6; -export const TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT = 3; +export const TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT = 4; export const TOOL_RESULT_MIN_LINES_SHOWN = 2; /** diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 444e4836e5..e89ef1b9e6 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -920,6 +920,10 @@ export const isListResult = ( res: unknown, ): res is ListDirectoryResult | ReadManyFilesResult => isStructuredToolResult(res) && 'files' in res && Array.isArray(res.files); + +export const isReadManyFilesResult = ( + res: unknown, +): res is ReadManyFilesResult => isListResult(res) && 'include' in res; export type ToolResultDisplay = | string | FileDiff