feat(cli): improve CTRL+O experience for both standard and alternate screen buffer (ASB) modes (#19010)

Co-authored-by: jacob314 <jacob314@gmail.com>
This commit is contained in:
Jarrod Whelan
2026-02-20 16:26:11 -08:00
committed by GitHub
parent 547f5d45f5
commit 727f9b67b1
39 changed files with 1622 additions and 428 deletions
@@ -12,6 +12,7 @@ import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
interface GeminiMessageProps {
text: string;
@@ -31,7 +32,7 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
const prefixWidth = prefix.length;
const isAlternateBuffer = useAlternateBuffer();
return (
const content = (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.text.accent} aria-label={SCREEN_READER_MODEL_PREFIX}>
@@ -61,4 +62,11 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
</Box>
</Box>
);
return isAlternateBuffer ? (
/* Shadow the global provider to maintain isolation in ASB mode. */
<OverflowProvider>{content}</OverflowProvider>
) : (
content
);
};
@@ -191,7 +191,7 @@ describe('<ShellToolMessage />', () => {
true,
],
[
'defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined',
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
undefined,
ACTIVE_SHELL_MAX_LINES,
false,
@@ -219,5 +219,75 @@ describe('<ShellToolMessage />', () => {
expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
expect(frame).toMatchSnapshot();
});
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
const { lastFrame } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight: undefined,
status: CoreToolCallStatus.Executing,
},
{ useAlternateBuffer: false },
);
await waitFor(() => {
const frame = lastFrame();
// Should show all 100 lines
expect(frame.match(/Line \d+/g)?.length).toBe(100);
});
});
it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => {
const { lastFrame, waitUntilReady } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight: undefined,
status: CoreToolCallStatus.Success,
isExpandable: true,
},
{
useAlternateBuffer: true,
uiState: {
constrainHeight: false,
},
},
);
await waitUntilReady();
await waitFor(() => {
const frame = lastFrame();
// Should show all 100 lines because constrainHeight is false and isExpandable is true
expect(frame.match(/Line \d+/g)?.length).toBe(100);
});
expect(lastFrame()).toMatchSnapshot();
});
it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => {
const { lastFrame, waitUntilReady } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight: undefined,
status: CoreToolCallStatus.Success,
isExpandable: false,
},
{
useAlternateBuffer: true,
uiState: {
constrainHeight: false,
},
},
);
await waitUntilReady();
await waitFor(() => {
const frame = lastFrame();
// Should still be constrained to ACTIVE_SHELL_MAX_LINES (15) because isExpandable is false
expect(frame.match(/Line \d+/g)?.length).toBe(15);
});
expect(lastFrame()).toMatchSnapshot();
});
});
});
@@ -15,24 +15,21 @@ import {
ToolStatusIndicator,
ToolInfo,
TrailingIndicator,
STATUS_INDICATOR_WIDTH,
isThisShellFocusable as checkIsShellFocusable,
isThisShellFocused as checkIsShellFocused,
useFocusHint,
FocusHint,
} from './ToolShared.js';
import type { ToolMessageProps } from './ToolMessage.js';
import {
ACTIVE_SHELL_MAX_LINES,
COMPLETED_SHELL_MAX_LINES,
} from '../../constants.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { type Config } from '@google/gemini-cli-core';
import { calculateShellMaxLines } from '../../utils/toolLayoutUtils.js';
export interface ShellToolMessageProps extends ToolMessageProps {
config?: Config;
isExpandable?: boolean;
}
export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
@@ -61,9 +58,15 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
borderColor,
borderDimColor,
isExpandable,
}) => {
const { activePtyId: activeShellPtyId, embeddedShellFocused } = useUIState();
const {
activePtyId: activeShellPtyId,
embeddedShellFocused,
constrainHeight,
} = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const isThisShellFocused = checkIsShellFocused(
name,
status,
@@ -155,59 +158,23 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
terminalWidth={terminalWidth}
renderOutputAsMarkdown={renderOutputAsMarkdown}
hasFocus={isThisShellFocused}
maxLines={getShellMaxLines(
maxLines={calculateShellMaxLines({
status,
isAlternateBuffer,
isThisShellFocused,
availableTerminalHeight,
)}
constrainHeight,
isExpandable,
})}
/>
{isThisShellFocused && config && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
<ShellInputPrompt
activeShellPtyId={activeShellPtyId ?? null}
focus={embeddedShellFocused}
scrollPageSize={availableTerminalHeight ?? ACTIVE_SHELL_MAX_LINES}
/>
</Box>
<ShellInputPrompt
activeShellPtyId={activeShellPtyId ?? null}
focus={embeddedShellFocused}
scrollPageSize={availableTerminalHeight ?? ACTIVE_SHELL_MAX_LINES}
/>
)}
</Box>
</>
);
};
/**
* Calculates the maximum number of lines to display for shell output.
*
* For completed processes (Success, Error, Canceled), it returns COMPLETED_SHELL_MAX_LINES.
* For active processes, it returns the available terminal height if in alternate buffer mode
* and focused. Otherwise, it returns ACTIVE_SHELL_MAX_LINES.
*
* This function ensures a finite number of lines is always returned to prevent performance issues.
*/
function getShellMaxLines(
status: CoreToolCallStatus,
isAlternateBuffer: boolean,
isThisShellFocused: boolean,
availableTerminalHeight: number | undefined,
): number {
if (
status === CoreToolCallStatus.Success ||
status === CoreToolCallStatus.Error ||
status === CoreToolCallStatus.Cancelled
) {
return COMPLETED_SHELL_MAX_LINES;
}
if (availableTerminalHeight === undefined) {
return ACTIVE_SHELL_MAX_LINES;
}
const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2);
if (isAlternateBuffer && isThisShellFocused) {
return maxLinesBasedOnHeight;
}
return Math.min(maxLinesBasedOnHeight, ACTIVE_SHELL_MAX_LINES);
}
@@ -6,6 +6,7 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { act } from 'react';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type {
HistoryItem,
@@ -678,4 +679,194 @@ describe('<ToolGroupMessage />', () => {
},
);
});
describe('Manual Overflow Detection', () => {
it('detects overflow for string results exceeding available height', async () => {
const toolCalls = [
createToolCall({
resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5',
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={6} // Very small height
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
},
},
);
await waitUntilReady();
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it('detects overflow for array results exceeding available height', async () => {
// resultDisplay when array is expected to be AnsiLine[]
// AnsiLine is AnsiToken[]
const toolCalls = [
createToolCall({
resultDisplay: Array(5).fill([{ text: 'line', fg: 'default' }]),
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={6}
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
},
},
);
await waitUntilReady();
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it('respects ACTIVE_SHELL_MAX_LINES for focused shell tools', async () => {
const toolCalls = [
createToolCall({
name: 'run_shell_command',
status: CoreToolCallStatus.Executing,
ptyId: 1,
resultDisplay: Array(20).fill('line').join('\n'), // 20 lines > 15 (limit)
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={100} // Plenty of terminal height
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
activePtyId: 1,
embeddedShellFocused: true,
},
},
);
await waitUntilReady();
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it('does not show expansion hint when content is within limits', async () => {
const toolCalls = [
createToolCall({
resultDisplay: 'small result',
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={20}
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
},
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
unmount();
});
it('hides expansion hint when constrainHeight is false', async () => {
const toolCalls = [
createToolCall({
resultDisplay: 'line 1\nline 2\nline 3\nline 4\nline 5',
}),
];
const { lastFrame, unmount, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={6}
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: false,
},
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
unmount();
});
it('isolates overflow hint in ASB mode (ignores global overflow state)', async () => {
// In this test, the tool output is SHORT (no local overflow).
// We will inject a dummy ID into the global overflow state.
// ToolGroupMessage should still NOT show the hint because it calculates
// overflow locally and passes it as a prop.
const toolCalls = [
createToolCall({
resultDisplay: 'short result',
}),
];
const { lastFrame, unmount, waitUntilReady, capturedOverflowActions } =
renderWithProviders(
<ToolGroupMessage
{...baseProps}
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={100}
isExpandable={true}
/>,
{
config: baseMockConfig,
useAlternateBuffer: true,
uiState: {
constrainHeight: true,
},
},
);
await waitUntilReady();
// Manually trigger a global overflow
act(() => {
expect(capturedOverflowActions).toBeDefined();
capturedOverflowActions!.addOverflowingId('unrelated-global-id');
});
// The hint should NOT appear because ToolGroupMessage is isolated by its prop logic
expect(lastFrame()).not.toContain('Press Ctrl+O to show more lines');
unmount();
});
});
});
@@ -17,10 +17,15 @@ import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool } from './ToolShared.js';
import { isShellTool, isThisShellFocused } from './ToolShared.js';
import { shouldHideToolCall } from '@google/gemini-cli-core';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import {
calculateShellMaxLines,
calculateToolContentMaxLines,
} from '../../utils/toolLayoutUtils.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
interface ToolGroupMessageProps {
@@ -31,6 +36,7 @@ interface ToolGroupMessageProps {
onShellInputSubmit?: (input: string) => void;
borderTop?: boolean;
borderBottom?: boolean;
isExpandable?: boolean;
}
// Main component renders the border and maps the tools using ToolMessage
@@ -43,6 +49,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
terminalWidth,
borderTop: borderTopOverride,
borderBottom: borderBottomOverride,
isExpandable,
}) => {
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
const toolCalls = useMemo(
@@ -67,6 +74,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
backgroundShells,
pendingHistoryItems,
} = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const { borderColor, borderDimColor } = useMemo(
() =>
@@ -106,14 +114,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const staticHeight = /* border */ 2;
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
// only render if we need to close a border from previous
// tool groups. borderBottomOverride=true means we must render the closing border;
// undefined or false means there's nothing to display.
if (visibleToolCalls.length === 0 && borderBottomOverride !== true) {
return null;
}
let countToolCallsWithResults = 0;
for (const tool of visibleToolCalls) {
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
@@ -134,21 +134,91 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN;
return (
// This box doesn't have a border even though it conceptually does because
// we need to allow the sticky headers to render the borders themselves so
// that the top border can be sticky.
/*
* ToolGroupMessage calculates its own overflow state locally and passes
* it as a prop to ShowMoreLines. This isolates it from global overflow
* reports in ASB mode, while allowing it to contribute to the global
* 'Toast' hint in Standard mode.
*
* Because of this prop-based isolation and the explicit mode-checks in
* AppContainer, we do not need to shadow the OverflowProvider here.
*/
const hasOverflow = useMemo(() => {
if (!availableTerminalHeightPerToolMessage) return false;
return visibleToolCalls.some((tool) => {
const isShellToolCall = isShellTool(tool.name);
const isFocused = isThisShellFocused(
tool.name,
tool.status,
tool.ptyId,
activePtyId,
embeddedShellFocused,
);
let maxLines: number | undefined;
if (isShellToolCall) {
maxLines = calculateShellMaxLines({
status: tool.status,
isAlternateBuffer,
isThisShellFocused: isFocused,
availableTerminalHeight: availableTerminalHeightPerToolMessage,
constrainHeight,
isExpandable,
});
}
// Standard tools and Shell tools both eventually use ToolResultDisplay's logic.
// ToolResultDisplay uses calculateToolContentMaxLines to find the final line budget.
const contentMaxLines = calculateToolContentMaxLines({
availableTerminalHeight: availableTerminalHeightPerToolMessage,
isAlternateBuffer,
maxLinesLimit: maxLines,
});
if (!contentMaxLines) return false;
if (typeof tool.resultDisplay === 'string') {
const text = tool.resultDisplay;
const hasTrailingNewline = text.endsWith('\n');
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
const lineCount = contentText.split('\n').length;
return lineCount > contentMaxLines;
}
if (Array.isArray(tool.resultDisplay)) {
return tool.resultDisplay.length > contentMaxLines;
}
return false;
});
}, [
visibleToolCalls,
availableTerminalHeightPerToolMessage,
activePtyId,
embeddedShellFocused,
isAlternateBuffer,
constrainHeight,
isExpandable,
]);
// If all tools are filtered out (e.g., in-progress AskUser tools, confirming tools),
// only render if we need to close a border from previous
// tool groups. borderBottomOverride=true means we must render the closing border;
// undefined or false means there's nothing to display.
if (visibleToolCalls.length === 0 && borderBottomOverride !== true) {
return null;
}
const content = (
<Box
flexDirection="column"
/*
This width constraint is highly important and protects us from an Ink rendering bug.
Since the ToolGroup can typically change rendering states frequently, it can cause
Ink to render the border of the box incorrectly and span multiple lines and even
cause tearing.
*/
This width constraint is highly important and protects us from an Ink rendering bug.
Since the ToolGroup can typically change rendering states frequently, it can cause
Ink to render the border of the box incorrectly and span multiple lines and even
cause tearing.
*/
width={terminalWidth}
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
marginBottom={borderBottomOverride === false ? 0 : 1}
>
{visibleToolCalls.map((tool, index) => {
const isFirst = index === 0;
@@ -165,6 +235,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
: isFirst,
borderColor,
borderDimColor,
isExpandable,
};
return (
@@ -179,34 +250,34 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
) : (
<ToolMessage {...commonProps} />
)}
<Box
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={false}
borderColor={borderColor}
borderDimColor={borderDimColor}
flexDirection="column"
borderStyle="round"
paddingLeft={1}
paddingRight={1}
>
{tool.outputFile && (
{tool.outputFile && (
<Box
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={false}
borderColor={borderColor}
borderDimColor={borderDimColor}
flexDirection="column"
borderStyle="round"
paddingLeft={1}
paddingRight={1}
>
<Box>
<Text color={theme.text.primary}>
Output too long and was saved to: {tool.outputFile}
</Text>
</Box>
)}
</Box>
</Box>
)}
</Box>
);
})}
{
/*
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
<Box
height={0}
@@ -222,8 +293,13 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
)
}
{(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && (
<ShowMoreLines constrainHeight={constrainHeight} />
<ShowMoreLines
constrainHeight={constrainHeight && !!isExpandable}
isOverflowing={hasOverflow}
/>
)}
</Box>
);
return content;
};
@@ -0,0 +1,115 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { StreamingState, type IndividualToolCallDisplay } from '../../types.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { waitFor } from '../../../test-utils/async.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay synchronization', () => {
it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Alternate Buffer (ASB) mode', async () => {
/**
* Logic:
* 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool.
* 2. ASB mode reserves 1 + 6 = 7 lines.
* 3. Line budget = 10 - 7 = 3 lines.
* 4. 5 lines of output > 3 lines budget => hasOverflow should be TRUE.
*/
const lines = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`);
const resultDisplay = lines.join('\n');
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call-1',
name: 'test-tool',
description: 'a test tool',
status: CoreToolCallStatus.Success,
resultDisplay,
confirmationDetails: undefined,
},
];
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<ToolGroupMessage
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={13}
terminalWidth={80}
isExpandable={true}
/>
</OverflowProvider>,
{
uiState: {
streamingState: StreamingState.Idle,
constrainHeight: true,
},
useAlternateBuffer: true,
},
);
// In ASB mode, the hint should appear because hasOverflow is now correctly calculated.
await waitFor(() =>
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
),
);
});
it('should ensure explicit hasOverflow calculation is consistent with ToolResultDisplay truncation in Standard mode', async () => {
/**
* Logic:
* 1. availableTerminalHeight(13) - staticHeight(3) = 10 lines per tool.
* 2. Standard mode reserves 1 + 2 = 3 lines.
* 3. Line budget = 10 - 3 = 7 lines.
* 4. 9 lines of output > 7 lines budget => hasOverflow should be TRUE.
*/
const lines = Array.from({ length: 9 }, (_, i) => `line ${i + 1}`);
const resultDisplay = lines.join('\n');
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call-1',
name: 'test-tool',
description: 'a test tool',
status: CoreToolCallStatus.Success,
resultDisplay,
confirmationDetails: undefined,
},
];
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<ToolGroupMessage
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={13}
terminalWidth={80}
isExpandable={true}
/>
</OverflowProvider>,
{
uiState: {
streamingState: StreamingState.Idle,
constrainHeight: true,
},
useAlternateBuffer: false,
},
);
// Verify truncation is occurring (standard mode uses MaxSizedBox)
await waitFor(() => expect(lastFrame()).toContain('hidden ...'));
// In Standard mode, ToolGroupMessage calculates hasOverflow correctly now.
// While Standard mode doesn't render the inline hint (ShowMoreLines returns null),
// the logic inside ToolGroupMessage is now synchronized.
});
});
@@ -277,21 +277,47 @@ describe('ToolResultDisplay', () => {
inverse: false,
},
],
[
{
text: 'Line 4',
fg: '',
bg: '',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
},
],
[
{
text: 'Line 5',
fg: '',
bg: '',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
},
],
];
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolResultDisplay
resultDisplay={ansiResult}
terminalWidth={80}
availableTerminalHeight={20}
maxLines={2}
maxLines={3}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).not.toContain('Line 2');
expect(output).not.toContain('Line 3');
expect(output).toContain('Line 4');
expect(output).toContain('Line 5');
unmount();
});
@@ -19,10 +19,7 @@ import { Scrollable } from '../shared/Scrollable.js';
import { ScrollableList } from '../shared/ScrollableList.js';
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint
const MIN_LINES_SHOWN = 2; // show at least this many lines
import { calculateToolContentMaxLines } from '../../utils/toolLayoutUtils.js';
// Large threshold to ensure we don't cause performance issues for very large
// outputs that will get truncated further MaxSizedBox anyway.
@@ -53,16 +50,11 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
const { renderMarkdown } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
let availableHeight = availableTerminalHeight
? Math.max(
availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
MIN_LINES_SHOWN + 1, // enforce minimum lines shown
)
: undefined;
if (maxLines && availableHeight) {
availableHeight = Math.min(availableHeight, maxLines);
}
const availableHeight = calculateToolContentMaxLines({
availableTerminalHeight,
isAlternateBuffer,
maxLinesLimit: maxLines,
});
const combinedPaddingAndBorderWidth = 4;
const childWidth = terminalWidth - combinedPaddingAndBorderWidth;
@@ -81,7 +73,8 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
[],
);
const truncatedResultDisplay = React.useMemo(() => {
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) {
@@ -94,14 +87,29 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
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(-maxLines).join('\n') +
lines.slice(-targetLines).join('\n') +
(hasTrailingNewline ? '\n' : '');
}
}
return text;
return { truncatedResultDisplay: text, hiddenLinesCount: hiddenLines };
}
return resultDisplay;
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;
@@ -229,7 +237,11 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
return (
<Box width={childWidth} flexDirection="column">
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
<MaxSizedBox
maxHeight={availableHeight}
maxWidth={childWidth}
additionalHiddenLinesCount={hiddenLinesCount}
>
{content}
</MaxSizedBox>
</Box>
@@ -39,6 +39,7 @@ describe('ToolResultDisplay Overflow', () => {
toolCalls={toolCalls}
availableTerminalHeight={15} // Small height to force overflow
terminalWidth={80}
isExpandable={true}
/>
</OverflowProvider>,
{
@@ -46,26 +47,28 @@ describe('ToolResultDisplay Overflow', () => {
streamingState: StreamingState.Idle,
constrainHeight: true,
},
useAlternateBuffer: false,
useAlternateBuffer: true,
},
);
// ResizeObserver might take a tick
await waitFor(() =>
expect(lastFrame()).toContain('Press ctrl-o to show more lines'),
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
),
);
const frame = lastFrame();
expect(frame).toBeDefined();
if (frame) {
expect(frame).toContain('Press ctrl-o to show more lines');
expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines');
// Ensure it's AFTER the bottom border
const linesOfOutput = frame.split('\n');
const bottomBorderIndex = linesOfOutput.findLastIndex((l) =>
l.includes('╰─'),
);
const hintIndex = linesOfOutput.findIndex((l) =>
l.includes('Press ctrl-o to show more lines'),
l.toLowerCase().includes('press ctrl+o to show more lines'),
);
expect(hintIndex).toBeGreaterThan(bottomBorderIndex);
expect(frame).toMatchSnapshot();
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined 1`] = `
exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command │
│ │
@@ -22,6 +22,113 @@ exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MA
"
`;
exports[`<ShellToolMessage /> > Height Constraints > fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command A shell command │
│ │
│ Line 1 │
│ Line 2 │
│ Line 3 │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ Line 7 │
│ Line 8 │
│ Line 9 │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14 │
│ Line 15 │
│ Line 16 │
│ Line 17 │
│ Line 18 │
│ Line 19 │
│ Line 20 │
│ Line 21 │
│ Line 22 │
│ Line 23 │
│ Line 24 │
│ Line 25 │
│ Line 26 │
│ Line 27 │
│ Line 28 │
│ Line 29 │
│ Line 30 │
│ Line 31 │
│ Line 32 │
│ Line 33 │
│ Line 34 │
│ Line 35 │
│ Line 36 │
│ Line 37 │
│ Line 38 │
│ Line 39 │
│ Line 40 │
│ Line 41 │
│ Line 42 │
│ Line 43 │
│ Line 44 │
│ Line 45 │
│ Line 46 │
│ Line 47 │
│ Line 48 │
│ Line 49 │
│ Line 50 │
│ Line 51 │
│ Line 52 │
│ Line 53 │
│ Line 54 │
│ Line 55 │
│ Line 56 │
│ Line 57 │
│ Line 58 │
│ Line 59 │
│ Line 60 │
│ Line 61 │
│ Line 62 │
│ Line 63 │
│ Line 64 │
│ Line 65 │
│ Line 66 │
│ Line 67 │
│ Line 68 │
│ Line 69 │
│ Line 70 │
│ Line 71 │
│ Line 72 │
│ Line 73 │
│ Line 74 │
│ Line 75 │
│ Line 76 │
│ Line 77 │
│ Line 78 │
│ Line 79 │
│ Line 80 │
│ Line 81 │
│ Line 82 │
│ Line 83 │
│ Line 84 │
│ Line 85 │
│ Line 86 │
│ Line 87 │
│ Line 88 │
│ Line 89 │
│ Line 90 │
│ Line 91 │
│ Line 92 │
│ Line 93 │
│ Line 94 │
│ Line 95 │
│ Line 96 │
│ Line 97 │
│ Line 98 │
│ Line 99 │
│ Line 100 │
"
`;
exports[`<ShellToolMessage /> > Height Constraints > respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command │
@@ -37,6 +144,28 @@ exports[`<ShellToolMessage /> > Height Constraints > respects availableTerminalH
"
`;
exports[`<ShellToolMessage /> > Height Constraints > stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command A shell command │
│ │
│ Line 86 │
│ Line 87 │
│ Line 88 │
│ Line 89 │
│ Line 90 │
│ Line 91 │
│ Line 92 │
│ Line 93 │
│ Line 94 │
│ Line 95 │
│ Line 96 │
│ Line 97 │
│ Line 98 ▄ │
│ Line 99 █ │
│ Line 100 █ │
"
`;
exports[`<ShellToolMessage /> > Height Constraints > uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command │
@@ -161,7 +290,6 @@ exports[`<ShellToolMessage /> > Height Constraints > uses full availableTerminal
│ Line 98 █ │
│ Line 99 █ │
│ Line 100 █ │
│ │
"
`;
@@ -170,7 +298,6 @@ exports[`<ShellToolMessage /> > Snapshots > renders in Alternate Buffer mode whi
│ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │
│ │
│ Test result │
│ │
"
`;
@@ -55,13 +55,13 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-1 Description 1. This is a long description that will need to b… │
│──────────────────────────────────────────────────────────────────────────│
line5
│ │ █
│ ✓ tool-2 Description 2 │ █
│ │ █
│ line1 │ █
│ line2 │ █
╰──────────────────────────────────────────────────────────────────────────╯ █
"
`;
@@ -111,12 +111,12 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with output
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = `
"──────────────────────────────────────────────────────────────────────────
"──────────────────────────────────────────────────────────────────────────
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-2 Description 2 │
│ │
│ line1 │
│ │
│ line1 │
╰──────────────────────────────────────────────────────────────────────────╯ █
"
`;
@@ -37,7 +37,11 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp
`;
exports[`ToolResultDisplay > truncates very long string results 1`] = `
"... first 252 lines hidden ...
"... first 248 lines hidden ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
@@ -4,13 +4,13 @@ exports[`ToolResultDisplay Overflow > should display "press ctrl-o" hint when co
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool a test tool │
│ │
... first 45 lines hidden ...
line 45
│ line 46 │
│ line 47 │
│ line 48 │
│ line 49 │
│ line 50
│ line 50
╰──────────────────────────────────────────────────────────────────────────╯
Press ctrl-o to show more lines
Press Ctrl+O to show more lines
"
`;
@@ -2,7 +2,7 @@
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = `
"╭────────────────────────────────────────────────────────────────────────╮ █
│ ✓ Shell Command Description for Shell Command │
│ ✓ Shell Command Description for Shell Command │
│ │
│ shell-01 │
│ shell-02 │
@@ -11,7 +11,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = `
"╭────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command Description for Shell Command │
│ ✓ Shell Command Description for Shell Command │
│────────────────────────────────────────────────────────────────────────│ █
│ shell-06 │ ▀
│ shell-07 │