diff --git a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx index 40e5a7e781..b650ee4d9d 100644 --- a/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ShellToolMessage.test.tsx @@ -191,49 +191,63 @@ describe('', () => { 10, 8, false, + true, ], [ 'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large', 100, ACTIVE_SHELL_MAX_LINES - 3, false, + true, ], [ 'uses full availableTerminalHeight when focused in alternate buffer mode', 100, 98, // 100 - 2 true, + false, ], [ 'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined', undefined, ACTIVE_SHELL_MAX_LINES - 3, false, + false, ], - ])('%s', async (_, availableTerminalHeight, expectedMaxLines, focused) => { - const { lastFrame, waitUntilReady, unmount } = renderShell( - { - resultDisplay: LONG_OUTPUT, - renderOutputAsMarkdown: false, - availableTerminalHeight, - ptyId: 1, - status: CoreToolCallStatus.Executing, - }, - { - useAlternateBuffer: true, - uiState: { - activePtyId: focused ? 1 : 2, - embeddedShellFocused: focused, + ])( + '%s', + async ( + _, + availableTerminalHeight, + expectedMaxLines, + focused, + constrainHeight, + ) => { + const { lastFrame, waitUntilReady, unmount } = renderShell( + { + resultDisplay: LONG_OUTPUT, + renderOutputAsMarkdown: false, + availableTerminalHeight, + ptyId: 1, + status: CoreToolCallStatus.Executing, }, - }, - ); + { + useAlternateBuffer: true, + uiState: { + activePtyId: focused ? 1 : 2, + embeddedShellFocused: focused, + constrainHeight, + }, + }, + ); - await waitUntilReady(); - const frame = lastFrame(); - expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines); - expect(frame).toMatchSnapshot(); - unmount(); - }); + await waitUntilReady(); + const frame = lastFrame(); + expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines); + expect(frame).toMatchSnapshot(); + unmount(); + }, + ); it('fully expands in standard mode when availableTerminalHeight is undefined', async () => { const { lastFrame, unmount } = renderShell( diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index df4354b1c4..e3869b6e1b 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -9,7 +9,11 @@ import { ToolMessage, type ToolMessageProps } from './ToolMessage.js'; import { describe, it, expect, vi } from 'vitest'; import { StreamingState } from '../../types.js'; import { Text } from 'ink'; -import { type AnsiOutput, CoreToolCallStatus } from '@google/gemini-cli-core'; +import { + type AnsiOutput, + CoreToolCallStatus, + Kind, +} from '@google/gemini-cli-core'; import { renderWithProviders } from '../../../test-utils/render.js'; import { tryParseJSON } from '../../../utils/jsonoutput.js'; @@ -435,4 +439,99 @@ describe('', () => { expect(output).toMatchSnapshot(); unmount(); }); + + describe('Truncation', () => { + it('applies truncation for Kind.Agent when availableTerminalHeight is provided', async () => { + const multilineString = Array.from( + { length: 30 }, + (_, i) => `Line ${i + 1}`, + ).join('\n'); + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiActions, + uiState: { + streamingState: StreamingState.Idle, + constrainHeight: true, + }, + width: 80, + useAlternateBuffer: false, + }, + ); + await waitUntilReady(); + const output = lastFrame(); + + // Since kind=Kind.Agent and availableTerminalHeight is provided, it should truncate to SUBAGENT_MAX_LINES (15) + // and show the FIRST lines (overflowDirection='bottom') + expect(output).toContain('Line 1'); + expect(output).toContain('Line 14'); + expect(output).not.toContain('Line 16'); + expect(output).not.toContain('Line 30'); + unmount(); + }); + + it('does NOT apply truncation for Kind.Agent when availableTerminalHeight is undefined', async () => { + const multilineString = Array.from( + { length: 30 }, + (_, i) => `Line ${i + 1}`, + ).join('\n'); + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiActions, + uiState: { streamingState: StreamingState.Idle }, + width: 80, + useAlternateBuffer: false, + }, + ); + await waitUntilReady(); + const output = lastFrame(); + + expect(output).toContain('Line 1'); + expect(output).toContain('Line 30'); + unmount(); + }); + + it('does NOT apply truncation for Kind.Read', async () => { + const multilineString = Array.from( + { length: 30 }, + (_, i) => `Line ${i + 1}`, + ).join('\n'); + + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiActions, + uiState: { streamingState: StreamingState.Idle }, + width: 80, + useAlternateBuffer: false, + }, + ); + await waitUntilReady(); + const output = lastFrame(); + + expect(output).toContain('Line 1'); + expect(output).toContain('Line 30'); + unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 7c2277d4be..5747f7677f 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -21,8 +21,9 @@ import { useFocusHint, FocusHint, } from './ToolShared.js'; -import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core'; +import { type Config, CoreToolCallStatus, Kind } from '@google/gemini-cli-core'; import { ShellInputPrompt } from '../ShellInputPrompt.js'; +import { SUBAGENT_MAX_LINES } from '../../constants.js'; export type { TextEmphasis }; @@ -45,6 +46,7 @@ export const ToolMessage: React.FC = ({ description, resultDisplay, status, + kind, availableTerminalHeight, terminalWidth, emphasis = 'medium', @@ -133,6 +135,12 @@ export const ToolMessage: React.FC = ({ terminalWidth={terminalWidth} renderOutputAsMarkdown={renderOutputAsMarkdown} hasFocus={isThisShellFocused} + maxLines={ + kind === Kind.Agent && availableTerminalHeight !== undefined + ? SUBAGENT_MAX_LINES + : undefined + } + overflowDirection={kind === Kind.Agent ? 'bottom' : 'top'} /> {isThisShellFocused && config && ( diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx index f7d158d68c..02f466e72f 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.test.tsx @@ -6,35 +6,15 @@ import { renderWithProviders } from '../../../test-utils/render.js'; import { ToolResultDisplay } from './ToolResultDisplay.js'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import type { AnsiOutput } from '@google/gemini-cli-core'; -// Mock UIStateContext partially -const mockUseUIState = vi.fn(); -vi.mock('../../contexts/UIStateContext.js', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - useUIState: () => mockUseUIState(), - }; -}); - -// Mock useAlternateBuffer -const mockUseAlternateBuffer = vi.fn(); -vi.mock('../../hooks/useAlternateBuffer.js', () => ({ - useAlternateBuffer: () => mockUseAlternateBuffer(), -})); - describe('ToolResultDisplay', () => { beforeEach(() => { vi.clearAllMocks(); - mockUseUIState.mockReturnValue({ renderMarkdown: true }); - mockUseAlternateBuffer.mockReturnValue(false); }); it('uses ScrollableList for ANSI output in alternate buffer mode', async () => { - mockUseAlternateBuffer.mockReturnValue(true); const content = 'ansi content'; const ansiResult: AnsiOutput = [ [ @@ -56,6 +36,7 @@ describe('ToolResultDisplay', () => { terminalWidth={80} maxLines={10} />, + { useAlternateBuffer: true }, ); await waitUntilReady(); const output = lastFrame(); @@ -65,13 +46,13 @@ describe('ToolResultDisplay', () => { }); it('uses Scrollable for non-ANSI output in alternate buffer mode', async () => { - mockUseAlternateBuffer.mockReturnValue(true); const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , + { useAlternateBuffer: true }, ); await waitUntilReady(); const output = lastFrame(); @@ -82,13 +63,13 @@ describe('ToolResultDisplay', () => { }); it('passes hasFocus prop to scrollable components', async () => { - mockUseAlternateBuffer.mockReturnValue(true); const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , + { useAlternateBuffer: true }, ); await waitUntilReady(); @@ -99,6 +80,7 @@ describe('ToolResultDisplay', () => { it('renders string result as markdown by default', async () => { const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , + { useAlternateBuffer: false }, ); await waitUntilReady(); const output = lastFrame(); @@ -115,6 +97,10 @@ describe('ToolResultDisplay', () => { availableTerminalHeight={20} renderOutputAsMarkdown={false} />, + { + useAlternateBuffer: false, + uiState: { constrainHeight: true }, + }, ); await waitUntilReady(); const output = lastFrame(); @@ -131,6 +117,10 @@ describe('ToolResultDisplay', () => { terminalWidth={80} availableTerminalHeight={20} />, + { + useAlternateBuffer: false, + uiState: { constrainHeight: true }, + }, ); await waitUntilReady(); const output = lastFrame(); @@ -150,6 +140,7 @@ describe('ToolResultDisplay', () => { terminalWidth={80} availableTerminalHeight={20} />, + { useAlternateBuffer: false }, ); await waitUntilReady(); const output = lastFrame(); @@ -179,6 +170,7 @@ describe('ToolResultDisplay', () => { terminalWidth={80} availableTerminalHeight={20} />, + { useAlternateBuffer: false }, ); await waitUntilReady(); const output = lastFrame(); @@ -197,6 +189,7 @@ describe('ToolResultDisplay', () => { terminalWidth={80} availableTerminalHeight={20} />, + { useAlternateBuffer: false }, ); await waitUntilReady(); const output = lastFrame({ allowEmpty: true }); @@ -206,7 +199,6 @@ describe('ToolResultDisplay', () => { }); it('does not fall back to plain text if availableHeight is set and not in alternate buffer', async () => { - mockUseAlternateBuffer.mockReturnValue(false); // availableHeight calculation: 20 - 1 - 5 = 14 > 3 const { lastFrame, waitUntilReady, unmount } = renderWithProviders( { availableTerminalHeight={20} renderOutputAsMarkdown={true} />, + { + useAlternateBuffer: false, + uiState: { constrainHeight: true }, + }, ); await waitUntilReady(); const output = lastFrame(); @@ -223,7 +219,6 @@ describe('ToolResultDisplay', () => { }); it('keeps markdown if in alternate buffer even with availableHeight', async () => { - mockUseAlternateBuffer.mockReturnValue(true); const { lastFrame, waitUntilReady, unmount } = renderWithProviders( { availableTerminalHeight={20} renderOutputAsMarkdown={true} />, + { useAlternateBuffer: true }, ); await waitUntilReady(); const output = lastFrame(); @@ -309,6 +305,10 @@ describe('ToolResultDisplay', () => { availableTerminalHeight={20} maxLines={3} />, + { + useAlternateBuffer: false, + uiState: { constrainHeight: true }, + }, ); await waitUntilReady(); const output = lastFrame(); @@ -341,6 +341,10 @@ describe('ToolResultDisplay', () => { maxLines={25} availableTerminalHeight={undefined} />, + { + useAlternateBuffer: false, + uiState: { constrainHeight: true }, + }, ); await waitUntilReady(); const output = lastFrame(); diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 05b94442db..0bbe3446e0 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -9,7 +9,7 @@ import { Box, Text } from 'ink'; import { DiffRenderer } from './DiffRenderer.js'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js'; -import { MaxSizedBox } from '../shared/MaxSizedBox.js'; +import { SlicingMaxSizedBox } from '../shared/SlicingMaxSizedBox.js'; import { theme } from '../../semantic-colors.js'; import { type AnsiOutput, @@ -26,10 +26,6 @@ import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js'; import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js'; import { SubagentProgressDisplay } from './SubagentProgressDisplay.js'; -// Large threshold to ensure we don't cause performance issues for very large -// outputs that will get truncated further MaxSizedBox anyway. -const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 20000; - export interface ToolResultDisplayProps { resultDisplay: string | object | undefined; availableTerminalHeight?: number; @@ -37,6 +33,7 @@ export interface ToolResultDisplayProps { renderOutputAsMarkdown?: boolean; maxLines?: number; hasFocus?: boolean; + overflowDirection?: 'top' | 'bottom'; } interface FileDiffResult { @@ -51,6 +48,7 @@ export const ToolResultDisplay: React.FC = ({ renderOutputAsMarkdown = true, maxLines, hasFocus = false, + overflowDirection = 'top', }) => { const { renderMarkdown } = useUIState(); const isAlternateBuffer = useAlternateBuffer(); @@ -78,180 +76,147 @@ export const ToolResultDisplay: React.FC = ({ [], ); - const { truncatedResultDisplay, hiddenLinesCount } = React.useMemo(() => { - let hiddenLines = 0; - // Only truncate string output if not in alternate buffer mode to ensure - // we can scroll through the full output. - if (typeof resultDisplay === 'string' && !isAlternateBuffer) { - let text = resultDisplay; - if (text.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) { - text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS); - } - if (maxLines) { - const hasTrailingNewline = text.endsWith('\n'); - const contentText = hasTrailingNewline ? text.slice(0, -1) : text; - const lines = contentText.split('\n'); - if (lines.length > maxLines) { - // We will have a label from MaxSizedBox. Reserve space for it. - const targetLines = Math.max(1, maxLines - 1); - hiddenLines = lines.length - targetLines; - text = - lines.slice(-targetLines).join('\n') + - (hasTrailingNewline ? '\n' : ''); - } - } - return { truncatedResultDisplay: text, hiddenLinesCount: hiddenLines }; - } - - if (Array.isArray(resultDisplay) && !isAlternateBuffer && maxLines) { - if (resultDisplay.length > maxLines) { - // We will have a label from MaxSizedBox. Reserve space for it. - const targetLines = Math.max(1, maxLines - 1); - return { - truncatedResultDisplay: resultDisplay.slice(-targetLines), - hiddenLinesCount: resultDisplay.length - targetLines, - }; - } - } - - return { truncatedResultDisplay: resultDisplay, hiddenLinesCount: 0 }; - }, [resultDisplay, isAlternateBuffer, maxLines]); - - if (!truncatedResultDisplay) return null; + if (!resultDisplay) return null; // 1. Early return for background tools (Todos) - if ( - typeof truncatedResultDisplay === 'object' && - 'todos' in truncatedResultDisplay - ) { + if (typeof resultDisplay === 'object' && 'todos' in resultDisplay) { // 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( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (truncatedResultDisplay as AnsiOutput).length, - limit, - ); + const renderContent = (contentData: string | object | undefined) => { + // Check if string content is valid JSON and pretty-print it + const prettyJSON = + typeof contentData === 'string' ? tryParseJSON(contentData) : null; + const formattedJSON = prettyJSON + ? JSON.stringify(prettyJSON, null, 2) + : null; - return ( - - 1} - keyExtractor={keyExtractor} - initialScrollIndex={SCROLL_TO_ITEM_END} - hasFocus={hasFocus} + let content: React.ReactNode; + + if (formattedJSON) { + // Render pretty-printed JSON + content = ( + + {formattedJSON} + + ); + } else if (isSubagentProgress(contentData)) { + content = ; + } else if (typeof contentData === 'string' && renderOutputAsMarkdown) { + content = ( + + ); + } else if (typeof contentData === 'string' && !renderOutputAsMarkdown) { + content = ( + + {contentData} + + ); + } else if (typeof contentData === 'object' && 'fileDiff' in contentData) { + content = ( + + ); + } else { + const shouldDisableTruncation = + isAlternateBuffer || + (availableTerminalHeight === undefined && maxLines === undefined); + + content = ( + + ); + } + + // Final render based on session mode + if (isAlternateBuffer) { + return ( + + {content} + + ); + } + + return content; + }; + + // ASB Mode Handling (Interactive/Fullscreen) + if (isAlternateBuffer) { + // Virtualized path for large ANSI arrays + if (Array.isArray(resultDisplay)) { + const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES; + const listHeight = Math.min( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (resultDisplay as AnsiOutput).length, + limit, + ); + + return ( + + 1} + keyExtractor={keyExtractor} + initialScrollIndex={SCROLL_TO_ITEM_END} + hasFocus={hasFocus} + /> + + ); + } + + // Standard path for strings/diffs in ASB + return ( + + {renderContent(resultDisplay)} ); } - // 3. Compute content node for non-virtualized paths - // Check if string content is valid JSON and pretty-print it - const prettyJSON = - typeof truncatedResultDisplay === 'string' - ? tryParseJSON(truncatedResultDisplay) - : null; - const formattedJSON = prettyJSON ? JSON.stringify(prettyJSON, null, 2) : null; - - let content: React.ReactNode; - - if (formattedJSON) { - // Render pretty-printed JSON - content = ( - - {formattedJSON} - - ); - } else if (isSubagentProgress(truncatedResultDisplay)) { - content = ; - } else if ( - typeof truncatedResultDisplay === 'string' && - renderOutputAsMarkdown - ) { - content = ( - - ); - } else if ( - typeof truncatedResultDisplay === 'string' && - !renderOutputAsMarkdown - ) { - content = ( - - {truncatedResultDisplay} - - ); - } else if ( - typeof truncatedResultDisplay === 'object' && - 'fileDiff' in truncatedResultDisplay - ) { - content = ( - - ); - } else { - const shouldDisableTruncation = - isAlternateBuffer || - (availableTerminalHeight === undefined && maxLines === undefined); - - content = ( - - ); - } - - // 4. Final render based on session mode - if (isAlternateBuffer) { - return ( - - {content} - - ); - } - + // Standard Mode Handling (History/Scrollback) + // We use SlicingMaxSizedBox which includes MaxSizedBox for precision truncation + hidden labels return ( - - {content} - + {(truncatedResultDisplay) => renderContent(truncatedResultDisplay)} + ); }; diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx new file mode 100644 index 0000000000..b809e89748 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../../test-utils/render.js'; +import { ToolResultDisplay } from './ToolResultDisplay.js'; +import { describe, it, expect } from 'vitest'; +import { type AnsiOutput } from '@google/gemini-cli-core'; + +describe('ToolResultDisplay Overflow', () => { + it('shows the head of the content when overflowDirection is bottom (string)', async () => { + const content = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5'; + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + useAlternateBuffer: false, + uiState: { constrainHeight: true }, + }, + ); + await waitUntilReady(); + const output = lastFrame(); + + expect(output).toContain('Line 1'); + expect(output).toContain('Line 2'); + expect(output).not.toContain('Line 3'); // Line 3 is replaced by the "hidden" label + expect(output).not.toContain('Line 4'); + expect(output).not.toContain('Line 5'); + expect(output).toContain('hidden'); + unmount(); + }); + + it('shows the tail of the content when overflowDirection is top (string default)', async () => { + const content = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5'; + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + useAlternateBuffer: false, + uiState: { constrainHeight: true }, + }, + ); + await waitUntilReady(); + const output = lastFrame(); + + expect(output).not.toContain('Line 1'); + expect(output).not.toContain('Line 2'); + expect(output).not.toContain('Line 3'); + expect(output).toContain('Line 4'); + expect(output).toContain('Line 5'); + expect(output).toContain('hidden'); + unmount(); + }); + + it('shows the head of the content when overflowDirection is bottom (ANSI)', async () => { + const ansiResult: AnsiOutput = Array.from({ length: 5 }, (_, i) => [ + { + text: `Line ${i + 1}`, + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ]); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + useAlternateBuffer: false, + uiState: { constrainHeight: true }, + }, + ); + await waitUntilReady(); + const output = lastFrame(); + + expect(output).toContain('Line 1'); + expect(output).toContain('Line 2'); + expect(output).not.toContain('Line 3'); + expect(output).not.toContain('Line 4'); + expect(output).not.toContain('Line 5'); + expect(output).toContain('hidden'); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap deleted file mode 100644 index aab4b690a1..0000000000 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolResultDisplayOverflow.test.tsx.snap +++ /dev/null @@ -1,16 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`ToolResultDisplay Overflow > should display "press ctrl-o" hint when content overflows in ToolGroupMessage 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────╮ -│ ✓ test-tool a test tool │ -│ │ -│ line 45 │ -│ line 46 │ -│ line 47 │ -│ line 48 │ -│ line 49 │ -│ line 50 █ │ -╰──────────────────────────────────────────────────────────────────────────╯ - Press Ctrl+O to show more lines -" -`; diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx index ee91d34f57..e88dcd4b76 100644 --- a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx +++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx @@ -20,7 +20,7 @@ import { formatCommand } from '../../utils/keybindingUtils.js'; */ export const MINIMUM_MAX_HEIGHT = 2; -interface MaxSizedBoxProps { +export interface MaxSizedBoxProps { children?: React.ReactNode; maxWidth?: number; maxHeight?: number; diff --git a/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.test.tsx b/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.test.tsx new file mode 100644 index 0000000000..184c968836 --- /dev/null +++ b/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.test.tsx @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from '../../../test-utils/render.js'; +import { OverflowProvider } from '../../contexts/OverflowContext.js'; +import { SlicingMaxSizedBox } from './SlicingMaxSizedBox.js'; +import { Box, Text } from 'ink'; +import { describe, it, expect } from 'vitest'; + +describe('', () => { + it('renders string data without slicing when it fits', async () => { + const { lastFrame, waitUntilReady, unmount } = render( + + + {(truncatedData) => {truncatedData}} + + , + ); + await waitUntilReady(); + expect(lastFrame()).toContain('Hello World'); + unmount(); + }); + + it('slices string data by characters when very long', async () => { + const veryLongString = 'A'.repeat(25000); + const { lastFrame, waitUntilReady, unmount } = render( + + + {(truncatedData) => {truncatedData.length}} + + , + ); + await waitUntilReady(); + // 20000 characters + 3 for '...' + expect(lastFrame()).toContain('20003'); + unmount(); + }); + + it('slices string data by lines when maxLines is provided', async () => { + const multilineString = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5'; + const { lastFrame, waitUntilReady, unmount } = render( + + + {(truncatedData) => {truncatedData}} + + , + ); + await waitUntilReady(); + // maxLines=3, so it should keep 3-1 = 2 lines + expect(lastFrame()).toContain('Line 1'); + expect(lastFrame()).toContain('Line 2'); + expect(lastFrame()).not.toContain('Line 3'); + expect(lastFrame()).toContain( + '... last 3 lines hidden (Ctrl+O to show) ...', + ); + unmount(); + }); + + it('slices array data when maxLines is provided', async () => { + const dataArray = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5']; + const { lastFrame, waitUntilReady, unmount } = render( + + + {(truncatedData) => ( + + {truncatedData.map((item, i) => ( + {item} + ))} + + )} + + , + ); + await waitUntilReady(); + // maxLines=3, so it should keep 3-1 = 2 items + expect(lastFrame()).toContain('Item 1'); + expect(lastFrame()).toContain('Item 2'); + expect(lastFrame()).not.toContain('Item 3'); + expect(lastFrame()).toContain( + '... last 3 lines hidden (Ctrl+O to show) ...', + ); + unmount(); + }); + + it('does not slice when isAlternateBuffer is true', async () => { + const multilineString = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5'; + const { lastFrame, waitUntilReady, unmount } = render( + + + {(truncatedData) => {truncatedData}} + + , + ); + await waitUntilReady(); + expect(lastFrame()).toContain('Line 5'); + expect(lastFrame()).not.toContain('hidden'); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx b/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx new file mode 100644 index 0000000000..b756c40ee2 --- /dev/null +++ b/packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useMemo } from 'react'; +import { MaxSizedBox, type MaxSizedBoxProps } from './MaxSizedBox.js'; + +// Large threshold to ensure we don't cause performance issues for very large +// outputs that will get truncated further MaxSizedBox anyway. +const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 20000; + +export interface SlicingMaxSizedBoxProps + extends Omit { + data: T; + maxLines?: number; + isAlternateBuffer?: boolean; + children: (truncatedData: T) => React.ReactNode; +} + +/** + * An extension of MaxSizedBox that performs explicit slicing of the input data + * (string or array) before rendering. This is useful for performance and to + * ensure consistent truncation behavior for large outputs. + */ +export function SlicingMaxSizedBox({ + data, + maxLines, + isAlternateBuffer, + children, + ...boxProps +}: SlicingMaxSizedBoxProps) { + const { truncatedData, hiddenLinesCount } = useMemo(() => { + let hiddenLines = 0; + const overflowDirection = boxProps.overflowDirection ?? 'top'; + + // Only truncate string output if not in alternate buffer mode to ensure + // we can scroll through the full output. + if (typeof data === 'string' && !isAlternateBuffer) { + let text: string = data as string; + if (text.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) { + if (overflowDirection === 'bottom') { + text = text.slice(0, MAXIMUM_RESULT_DISPLAY_CHARACTERS) + '...'; + } else { + 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) { + // We will have a label from MaxSizedBox. Reserve space for it. + const targetLines = Math.max(1, maxLines - 1); + hiddenLines = lines.length - targetLines; + if (overflowDirection === 'bottom') { + text = + lines.slice(0, targetLines).join('\n') + + (hasTrailingNewline ? '\n' : ''); + } else { + text = + lines.slice(-targetLines).join('\n') + + (hasTrailingNewline ? '\n' : ''); + } + } + } + return { + truncatedData: text, + hiddenLinesCount: hiddenLines, + }; + } + + if (Array.isArray(data) && !isAlternateBuffer && maxLines) { + if (data.length > maxLines) { + // We will have a label from MaxSizedBox. Reserve space for it. + const targetLines = Math.max(1, maxLines - 1); + const hiddenCount = data.length - targetLines; + return { + truncatedData: + overflowDirection === 'bottom' + ? data.slice(0, targetLines) + : data.slice(-targetLines), + hiddenLinesCount: hiddenCount, + }; + } + } + + return { truncatedData: data, hiddenLinesCount: 0 }; + }, [data, isAlternateBuffer, maxLines, boxProps.overflowDirection]); + + return ( + + {/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */} + {children(truncatedData as unknown as T)} + + ); +} diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index 448dc37523..db52be1105 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -50,6 +50,9 @@ export const ACTIVE_SHELL_MAX_LINES = 15; // Max lines to preserve in history for completed shell commands export const COMPLETED_SHELL_MAX_LINES = 15; +// Max lines to show for subagent results before collapsing +export const SUBAGENT_MAX_LINES = 15; + /** Minimum terminal width required to show the full context used label */ export const MIN_TERMINAL_WIDTH_FOR_FULL_LABEL = 100; diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts index 1bc6d09903..e06ebf5bb5 100644 --- a/packages/cli/src/ui/hooks/toolMapping.ts +++ b/packages/cli/src/ui/hooks/toolMapping.ts @@ -103,6 +103,7 @@ export function mapToDisplay( ...baseDisplayProperties, status: call.status, isClientInitiated: !!call.request.isClientInitiated, + kind: call.tool?.kind, resultDisplay, confirmationDetails, outputFile, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index c9910179a5..3898461fb0 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -15,6 +15,7 @@ import { type SkillDefinition, type AgentDefinition, type ApprovalMode, + type Kind, CoreToolCallStatus, checkExhaustive, } from '@google/gemini-cli-core'; @@ -105,6 +106,7 @@ export interface IndividualToolCallDisplay { status: CoreToolCallStatus; // True when the tool was initiated directly by the user (slash/@/shell flows). isClientInitiated?: boolean; + kind?: Kind; confirmationDetails: SerializableConfirmationDetails | undefined; renderOutputAsMarkdown?: boolean; ptyId?: number; diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index 0200fbcb00..b3e88d9a01 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -414,6 +414,7 @@ const RenderListItemInternal: React.FC = ({ }) => { const prefix = type === 'ol' ? `${marker}. ` : `${marker} `; const prefixWidth = prefix.length; + // Account for leading whitespace (indentation level) plus the standard prefix padding const indentation = leadingWhitespace.length; const listResponseColor = theme.text.response ?? theme.text.primary; @@ -422,7 +423,7 @@ const RenderListItemInternal: React.FC = ({ paddingLeft={indentation + LIST_ITEM_PREFIX_PADDING} flexDirection="row" > - + {prefix} diff --git a/packages/cli/src/ui/utils/toolLayoutUtils.ts b/packages/cli/src/ui/utils/toolLayoutUtils.ts index 6ba1b85c5e..c91919cffa 100644 --- a/packages/cli/src/ui/utils/toolLayoutUtils.ts +++ b/packages/cli/src/ui/utils/toolLayoutUtils.ts @@ -53,7 +53,7 @@ export function calculateToolContentMaxLines(options: { ) : undefined; - if (maxLinesLimit) { + if (maxLinesLimit !== undefined) { contentHeight = contentHeight !== undefined ? Math.min(contentHeight, maxLinesLimit) diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 3bdb4fa2d5..a2ae2b9c9b 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -17,6 +17,7 @@ import { randomUUID } from 'node:crypto'; import type { Config } from '../../config/config.js'; import { LocalAgentExecutor } from '../local-executor.js'; +import { safeJsonToMarkdown } from '../../utils/markdownUtils.js'; import { BaseToolInvocation, type ToolResult, @@ -414,6 +415,8 @@ export class BrowserAgentInvocation extends BaseToolInvocation< const output = await executor.run(this.params, signal); + const displayResult = safeJsonToMarkdown(output.result); + const resultContent = `Browser agent finished. Termination Reason: ${output.terminate_reason} Result: @@ -425,7 +428,7 @@ Browser Agent Finished Termination Reason: ${output.terminate_reason} Result: -${output.result} +${displayResult} `; if (updateOutput) { diff --git a/packages/core/src/agents/local-invocation.ts b/packages/core/src/agents/local-invocation.ts index 4bd2bc171a..02bfb4efe0 100644 --- a/packages/core/src/agents/local-invocation.ts +++ b/packages/core/src/agents/local-invocation.ts @@ -6,6 +6,7 @@ import type { Config } from '../config/config.js'; import { LocalAgentExecutor } from './local-executor.js'; +import { safeJsonToMarkdown } from '../utils/markdownUtils.js'; import { BaseToolInvocation, type ToolResult, @@ -245,6 +246,8 @@ export class LocalSubagentInvocation extends BaseToolInvocation< throw cancelError; } + const displayResult = safeJsonToMarkdown(output.result); + const resultContent = `Subagent '${this.definition.name}' finished. Termination Reason: ${output.terminate_reason} Result: @@ -256,7 +259,7 @@ Subagent ${this.definition.name} Finished Termination Reason:\n ${output.terminate_reason} Result: -${output.result} +${displayResult} `; return { diff --git a/packages/core/src/agents/remote-invocation.ts b/packages/core/src/agents/remote-invocation.ts index a8c75ec51c..40dd142638 100644 --- a/packages/core/src/agents/remote-invocation.ts +++ b/packages/core/src/agents/remote-invocation.ts @@ -25,6 +25,7 @@ import { extractIdsFromResponse, A2AResultReassembler } from './a2aUtils.js'; import { GoogleAuth } from 'google-auth-library'; import type { AuthenticationHandler } from '@a2a-js/sdk/client'; import { debugLogger } from '../utils/debugLogger.js'; +import { safeJsonToMarkdown } from '../utils/markdownUtils.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; import { A2AAuthProviderFactory } from './auth-provider/factory.js'; @@ -222,7 +223,7 @@ export class RemoteAgentInvocation extends BaseToolInvocation< return { llmContent: [{ text: finalOutput }], - returnDisplay: finalOutput, + returnDisplay: safeJsonToMarkdown(finalOutput), }; } catch (error: unknown) { const partialOutput = reassembler.toString(); diff --git a/packages/core/src/services/keychainService.ts b/packages/core/src/services/keychainService.ts index ed28218c11..a43890f89b 100644 --- a/packages/core/src/services/keychainService.ts +++ b/packages/core/src/services/keychainService.ts @@ -13,6 +13,7 @@ import { KeychainSchema, KEYCHAIN_TEST_PREFIX, } from './keychainTypes.js'; +import { isRecord } from '../utils/markdownUtils.js'; /** * Service for interacting with OS-level secure storage (e.g. keytar). @@ -111,7 +112,7 @@ export class KeychainService { private async loadKeychainModule(): Promise { const moduleName = 'keytar'; const module: unknown = await import(moduleName); - const potential = (this.isRecord(module) && module['default']) || module; + const potential = (isRecord(module) && module['default']) || module; const result = KeychainSchema.safeParse(potential); if (result.success) { @@ -126,10 +127,6 @@ export class KeychainService { return null; } - private isRecord(obj: unknown): obj is Record { - return typeof obj === 'object' && obj !== null; - } - // Performs a set-get-delete cycle to verify keychain functionality. private async isKeychainFunctional(keychain: Keychain): Promise { const testAccount = `${KEYCHAIN_TEST_PREFIX}${crypto.randomBytes(8).toString('hex')}`; diff --git a/packages/core/src/utils/markdownUtils.test.ts b/packages/core/src/utils/markdownUtils.test.ts new file mode 100644 index 0000000000..246198c1d2 --- /dev/null +++ b/packages/core/src/utils/markdownUtils.test.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { jsonToMarkdown, safeJsonToMarkdown } from './markdownUtils.js'; + +describe('markdownUtils', () => { + describe('jsonToMarkdown', () => { + it('should handle primitives', () => { + expect(jsonToMarkdown('hello')).toBe('hello'); + expect(jsonToMarkdown(123)).toBe('123'); + expect(jsonToMarkdown(true)).toBe('true'); + expect(jsonToMarkdown(null)).toBe('null'); + expect(jsonToMarkdown(undefined)).toBe('undefined'); + }); + + it('should handle simple arrays', () => { + const data = ['a', 'b', 'c']; + expect(jsonToMarkdown(data)).toBe('- a\n- b\n- c'); + }); + + it('should handle simple objects and convert camelCase to Space Case', () => { + const data = { userName: 'Alice', userAge: 30 }; + expect(jsonToMarkdown(data)).toBe( + '- **User Name**: Alice\n- **User Age**: 30', + ); + }); + + it('should handle empty structures', () => { + expect(jsonToMarkdown([])).toBe('[]'); + expect(jsonToMarkdown({})).toBe('{}'); + }); + + it('should handle nested structures with proper indentation', () => { + const data = { + userInfo: { + fullName: 'Bob Smith', + userRoles: ['admin', 'user'], + }, + isActive: true, + }; + const result = jsonToMarkdown(data); + expect(result).toBe( + '- **User Info**:\n' + + ' - **Full Name**: Bob Smith\n' + + ' - **User Roles**:\n' + + ' - admin\n' + + ' - user\n' + + '- **Is Active**: true', + ); + }); + + it('should render tables for arrays of similar objects with Space Case keys', () => { + const data = [ + { userId: 1, userName: 'Item 1' }, + { userId: 2, userName: 'Item 2' }, + ]; + const result = jsonToMarkdown(data); + expect(result).toBe( + '| User Id | User Name |\n| --- | --- |\n| 1 | Item 1 |\n| 2 | Item 2 |', + ); + }); + + it('should handle pipe characters, backslashes, and newlines in table data', () => { + const data = [ + { colInfo: 'val|ue', otherInfo: 'line\nbreak', pathInfo: 'C:\\test' }, + ]; + const result = jsonToMarkdown(data); + expect(result).toBe( + '| Col Info | Other Info | Path Info |\n| --- | --- | --- |\n| val\\|ue | line break | C:\\\\test |', + ); + }); + + it('should fallback to lists for arrays with mixed objects', () => { + const data = [ + { userId: 1, userName: 'Item 1' }, + { userId: 2, somethingElse: 'Item 2' }, + ]; + const result = jsonToMarkdown(data); + expect(result).toContain('- **User Id**: 1'); + expect(result).toContain('- **Something Else**: Item 2'); + }); + + it('should properly indent nested tables', () => { + const data = { + items: [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + ], + }; + const result = jsonToMarkdown(data); + const lines = result.split('\n'); + expect(lines[0]).toBe('- **Items**:'); + expect(lines[1]).toBe(' | Id | Name |'); + expect(lines[2]).toBe(' | --- | --- |'); + expect(lines[3]).toBe(' | 1 | A |'); + expect(lines[4]).toBe(' | 2 | B |'); + }); + + it('should indent subsequent lines of multiline strings', () => { + const data = { + description: 'Line 1\nLine 2\nLine 3', + }; + const result = jsonToMarkdown(data); + expect(result).toBe('- **Description**: Line 1\n Line 2\n Line 3'); + }); + }); + + describe('safeJsonToMarkdown', () => { + it('should convert valid JSON', () => { + const json = JSON.stringify({ keyName: 'value' }); + expect(safeJsonToMarkdown(json)).toBe('- **Key Name**: value'); + }); + + it('should return original string for invalid JSON', () => { + const notJson = 'Not a JSON string'; + expect(safeJsonToMarkdown(notJson)).toBe(notJson); + }); + + it('should handle plain strings that look like numbers or booleans but are valid JSON', () => { + expect(safeJsonToMarkdown('123')).toBe('123'); + expect(safeJsonToMarkdown('true')).toBe('true'); + }); + }); +}); diff --git a/packages/core/src/utils/markdownUtils.ts b/packages/core/src/utils/markdownUtils.ts new file mode 100644 index 0000000000..ea0fee8eb8 --- /dev/null +++ b/packages/core/src/utils/markdownUtils.ts @@ -0,0 +1,147 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Converts a camelCase string to a Space Case string. + * e.g., "camelCaseString" -> "Camel Case String" + */ +function camelToSpace(text: string): string { + const result = text.replace(/([A-Z])/g, ' $1'); + return result.charAt(0).toUpperCase() + result.slice(1).trim(); +} + +/** + * Converts a JSON-compatible value into a readable Markdown representation. + * + * @param data The data to convert. + * @param indent The current indentation level (for internal recursion). + * @returns A Markdown string representing the data. + */ +export function jsonToMarkdown(data: unknown, indent = 0): string { + const spacing = ' '.repeat(indent); + + if (data === null) { + return 'null'; + } + + if (data === undefined) { + return 'undefined'; + } + + if (Array.isArray(data)) { + if (data.length === 0) { + return '[]'; + } + + if (isArrayOfSimilarObjects(data)) { + return renderTable(data, indent); + } + + return data + .map((item) => { + if ( + typeof item === 'object' && + item !== null && + Object.keys(item).length > 0 + ) { + const rendered = jsonToMarkdown(item, indent + 1); + return `${spacing}-\n${rendered}`; + } + const rendered = jsonToMarkdown(item, indent + 1).trimStart(); + return `${spacing}- ${rendered}`; + }) + .join('\n'); + } + + if (typeof data === 'object') { + const entries = Object.entries(data); + if (entries.length === 0) { + return '{}'; + } + + return entries + .map(([key, value]) => { + const displayKey = camelToSpace(key); + if ( + typeof value === 'object' && + value !== null && + Object.keys(value).length > 0 + ) { + const renderedValue = jsonToMarkdown(value, indent + 1); + return `${spacing}- **${displayKey}**:\n${renderedValue}`; + } + const renderedValue = jsonToMarkdown(value, indent + 1).trimStart(); + return `${spacing}- **${displayKey}**: ${renderedValue}`; + }) + .join('\n'); + } + + if (typeof data === 'string') { + return data + .split('\n') + .map((line, i) => (i === 0 ? line : spacing + line)) + .join('\n'); + } + + return String(data); +} + +/** + * Safely attempts to parse a string as JSON and convert it to Markdown. + * If parsing fails, returns the original string. + * + * @param text The text to potentially convert. + * @returns The Markdown representation or the original text. + */ +export function safeJsonToMarkdown(text: string): string { + try { + const parsed: unknown = JSON.parse(text); + return jsonToMarkdown(parsed); + } catch { + return text; + } +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isArrayOfSimilarObjects( + data: unknown[], +): data is Array> { + if (data.length === 0) { + return false; + } + if (!data.every(isRecord)) return false; + const firstKeys = Object.keys(data[0]).sort().join(','); + return data.every((item) => Object.keys(item).sort().join(',') === firstKeys); +} + +function renderTable(data: Array>, indent = 0): string { + const spacing = ' '.repeat(indent); + const keys = Object.keys(data[0]); + const displayKeys = keys.map(camelToSpace); + const header = `${spacing}| ${displayKeys.join(' | ')} |`; + const separator = `${spacing}| ${keys.map(() => '---').join(' | ')} |`; + const rows = data.map( + (item) => + `${spacing}| ${keys + .map((key) => { + const val = item[key]; + if (typeof val === 'object' && val !== null) { + return JSON.stringify(val) + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|'); + } + return String(val) + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|') + .replace(/\n/g, ' '); + }) + .join(' | ')} |`, + ); + return [header, separator, ...rows].join('\n'); +}