feat(core): improve subagent result display (#20378)

This commit is contained in:
joshualitt
2026-03-09 12:20:15 -07:00
committed by GitHub
parent d246315cea
commit a17691f0fc
21 changed files with 925 additions and 238 deletions

View File

@@ -191,49 +191,63 @@ describe('<ShellToolMessage />', () => {
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(

View File

@@ -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('<ToolMessage />', () => {
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(
<ToolMessage
{...baseProps}
kind={Kind.Agent}
resultDisplay={multilineString}
renderOutputAsMarkdown={false}
availableTerminalHeight={40}
/>,
{
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(
<ToolMessage
{...baseProps}
kind={Kind.Agent}
resultDisplay={multilineString}
renderOutputAsMarkdown={false}
availableTerminalHeight={undefined}
/>,
{
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(
<ToolMessage
{...baseProps}
kind={Kind.Read}
resultDisplay={multilineString}
renderOutputAsMarkdown={false}
/>,
{
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();
});
});
});

View File

@@ -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<ToolMessageProps> = ({
description,
resultDisplay,
status,
kind,
availableTerminalHeight,
terminalWidth,
emphasis = 'medium',
@@ -133,6 +135,12 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
terminalWidth={terminalWidth}
renderOutputAsMarkdown={renderOutputAsMarkdown}
hasFocus={isThisShellFocused}
maxLines={
kind === Kind.Agent && availableTerminalHeight !== undefined
? SUBAGENT_MAX_LINES
: undefined
}
overflowDirection={kind === Kind.Agent ? 'bottom' : 'top'}
/>
{isThisShellFocused && config && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>

View File

@@ -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<typeof import('../../contexts/UIStateContext.js')>();
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(
<ToolResultDisplay
resultDisplay="**Markdown content**"
terminalWidth={80}
maxLines={10}
/>,
{ 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(
<ToolResultDisplay
resultDisplay="Some result"
terminalWidth={80}
hasFocus={true}
/>,
{ useAlternateBuffer: true },
);
await waitUntilReady();
@@ -99,6 +80,7 @@ describe('ToolResultDisplay', () => {
it('renders string result as markdown by default', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolResultDisplay resultDisplay="**Some result**" terminalWidth={80} />,
{ 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(
<ToolResultDisplay
@@ -215,6 +207,10 @@ describe('ToolResultDisplay', () => {
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(
<ToolResultDisplay
resultDisplay="**Some result**"
@@ -231,6 +226,7 @@ describe('ToolResultDisplay', () => {
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();

View File

@@ -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<ToolResultDisplayProps> = ({
renderOutputAsMarkdown = true,
maxLines,
hasFocus = false,
overflowDirection = 'top',
}) => {
const { renderMarkdown } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
@@ -78,180 +76,147 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
[],
);
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 (
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
<ScrollableList
width={childWidth}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
data={truncatedResultDisplay as AnsiOutput}
renderItem={renderVirtualizedAnsiLine}
estimatedItemHeight={() => 1}
keyExtractor={keyExtractor}
initialScrollIndex={SCROLL_TO_ITEM_END}
hasFocus={hasFocus}
let content: React.ReactNode;
if (formattedJSON) {
// Render pretty-printed JSON
content = (
<Text wrap="wrap" color={theme.text.primary}>
{formattedJSON}
</Text>
);
} else if (isSubagentProgress(contentData)) {
content = <SubagentProgressDisplay progress={contentData} />;
} else if (typeof contentData === 'string' && renderOutputAsMarkdown) {
content = (
<MarkdownDisplay
text={contentData}
terminalWidth={childWidth}
renderMarkdown={renderMarkdown}
isPending={false}
/>
);
} else if (typeof contentData === 'string' && !renderOutputAsMarkdown) {
content = (
<Text wrap="wrap" color={theme.text.primary}>
{contentData}
</Text>
);
} else if (typeof contentData === 'object' && 'fileDiff' in contentData) {
content = (
<DiffRenderer
diffContent={
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(contentData as FileDiffResult).fileDiff
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
filename={(contentData as FileDiffResult).fileName}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
);
} else {
const shouldDisableTruncation =
isAlternateBuffer ||
(availableTerminalHeight === undefined && maxLines === undefined);
content = (
<AnsiOutputText
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
data={contentData as AnsiOutput}
availableTerminalHeight={
isAlternateBuffer ? undefined : availableHeight
}
width={childWidth}
maxLines={isAlternateBuffer ? undefined : maxLines}
disableTruncation={shouldDisableTruncation}
/>
);
}
// Final render based on session mode
if (isAlternateBuffer) {
return (
<Scrollable
width={childWidth}
maxHeight={maxLines ?? availableHeight}
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
scrollToBottom={true}
reportOverflow={true}
>
{content}
</Scrollable>
);
}
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 (
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
<ScrollableList
width={childWidth}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
data={resultDisplay as AnsiOutput}
renderItem={renderVirtualizedAnsiLine}
estimatedItemHeight={() => 1}
keyExtractor={keyExtractor}
initialScrollIndex={SCROLL_TO_ITEM_END}
hasFocus={hasFocus}
/>
</Box>
);
}
// Standard path for strings/diffs in ASB
return (
<Box width={childWidth} flexDirection="column">
{renderContent(resultDisplay)}
</Box>
);
}
// 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 = (
<Text wrap="wrap" color={theme.text.primary}>
{formattedJSON}
</Text>
);
} else if (isSubagentProgress(truncatedResultDisplay)) {
content = <SubagentProgressDisplay progress={truncatedResultDisplay} />;
} else if (
typeof truncatedResultDisplay === 'string' &&
renderOutputAsMarkdown
) {
content = (
<MarkdownDisplay
text={truncatedResultDisplay}
terminalWidth={childWidth}
renderMarkdown={renderMarkdown}
isPending={false}
/>
);
} else if (
typeof truncatedResultDisplay === 'string' &&
!renderOutputAsMarkdown
) {
content = (
<Text wrap="wrap" color={theme.text.primary}>
{truncatedResultDisplay}
</Text>
);
} else if (
typeof truncatedResultDisplay === 'object' &&
'fileDiff' in truncatedResultDisplay
) {
content = (
<DiffRenderer
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
diffContent={(truncatedResultDisplay as FileDiffResult).fileDiff}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
filename={(truncatedResultDisplay as FileDiffResult).fileName}
availableTerminalHeight={availableHeight}
terminalWidth={childWidth}
/>
);
} else {
const shouldDisableTruncation =
isAlternateBuffer ||
(availableTerminalHeight === undefined && maxLines === undefined);
content = (
<AnsiOutputText
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
data={truncatedResultDisplay as AnsiOutput}
availableTerminalHeight={
isAlternateBuffer ? undefined : availableHeight
}
width={childWidth}
maxLines={isAlternateBuffer ? undefined : maxLines}
disableTruncation={shouldDisableTruncation}
/>
);
}
// 4. Final render based on session mode
if (isAlternateBuffer) {
return (
<Scrollable
width={childWidth}
maxHeight={maxLines ?? availableHeight}
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
scrollToBottom={true}
reportOverflow={true}
>
{content}
</Scrollable>
);
}
// Standard Mode Handling (History/Scrollback)
// We use SlicingMaxSizedBox which includes MaxSizedBox for precision truncation + hidden labels
return (
<Box width={childWidth} flexDirection="column">
<MaxSizedBox
<SlicingMaxSizedBox
data={resultDisplay}
maxLines={maxLines}
isAlternateBuffer={isAlternateBuffer}
maxHeight={availableHeight}
maxWidth={childWidth}
additionalHiddenLinesCount={hiddenLinesCount}
overflowDirection={overflowDirection}
>
{content}
</MaxSizedBox>
{(truncatedResultDisplay) => renderContent(truncatedResultDisplay)}
</SlicingMaxSizedBox>
</Box>
);
};

View File

@@ -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(
<ToolResultDisplay
resultDisplay={content}
terminalWidth={80}
maxLines={3}
overflowDirection="bottom"
/>,
{
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(
<ToolResultDisplay
resultDisplay={content}
terminalWidth={80}
maxLines={3}
overflowDirection="top"
/>,
{
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(
<ToolResultDisplay
resultDisplay={ansiResult}
terminalWidth={80}
maxLines={3}
overflowDirection="bottom"
/>,
{
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();
});
});

View File

@@ -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
"
`;

View File

@@ -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;

View File

@@ -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('<SlicingMaxSizedBox />', () => {
it('renders string data without slicing when it fits', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<SlicingMaxSizedBox data="Hello World" maxWidth={80}>
{(truncatedData) => <Text>{truncatedData}</Text>}
</SlicingMaxSizedBox>
</OverflowProvider>,
);
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(
<OverflowProvider>
<SlicingMaxSizedBox
data={veryLongString}
maxWidth={80}
overflowDirection="bottom"
>
{(truncatedData) => <Text>{truncatedData.length}</Text>}
</SlicingMaxSizedBox>
</OverflowProvider>,
);
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(
<OverflowProvider>
<SlicingMaxSizedBox
data={multilineString}
maxLines={3}
maxWidth={80}
maxHeight={10}
overflowDirection="bottom"
>
{(truncatedData) => <Text>{truncatedData}</Text>}
</SlicingMaxSizedBox>
</OverflowProvider>,
);
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(
<OverflowProvider>
<SlicingMaxSizedBox
data={dataArray}
maxLines={3}
maxWidth={80}
maxHeight={10}
overflowDirection="bottom"
>
{(truncatedData) => (
<Box flexDirection="column">
{truncatedData.map((item, i) => (
<Text key={i}>{item}</Text>
))}
</Box>
)}
</SlicingMaxSizedBox>
</OverflowProvider>,
);
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(
<OverflowProvider>
<SlicingMaxSizedBox
data={multilineString}
maxLines={3}
maxWidth={80}
isAlternateBuffer={true}
>
{(truncatedData) => <Text>{truncatedData}</Text>}
</SlicingMaxSizedBox>
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Line 5');
expect(lastFrame()).not.toContain('hidden');
unmount();
});
});

View File

@@ -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<T>
extends Omit<MaxSizedBoxProps, 'children'> {
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<T>({
data,
maxLines,
isAlternateBuffer,
children,
...boxProps
}: SlicingMaxSizedBoxProps<T>) {
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 (
<MaxSizedBox
{...boxProps}
additionalHiddenLinesCount={
(boxProps.additionalHiddenLinesCount ?? 0) + hiddenLinesCount
}
>
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */}
{children(truncatedData as unknown as T)}
</MaxSizedBox>
);
}

View File

@@ -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;

View File

@@ -103,6 +103,7 @@ export function mapToDisplay(
...baseDisplayProperties,
status: call.status,
isClientInitiated: !!call.request.isClientInitiated,
kind: call.tool?.kind,
resultDisplay,
confirmationDetails,
outputFile,

View File

@@ -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;

View File

@@ -414,6 +414,7 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
}) => {
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<RenderListItemProps> = ({
paddingLeft={indentation + LIST_ITEM_PREFIX_PADDING}
flexDirection="row"
>
<Box width={prefixWidth}>
<Box width={prefixWidth} flexShrink={0}>
<Text color={listResponseColor}>{prefix}</Text>
</Box>
<Box flexGrow={LIST_ITEM_TEXT_FLEX_GROW}>

View File

@@ -53,7 +53,7 @@ export function calculateToolContentMaxLines(options: {
)
: undefined;
if (maxLinesLimit) {
if (maxLinesLimit !== undefined) {
contentHeight =
contentHeight !== undefined
? Math.min(contentHeight, maxLinesLimit)