feat(cli): truncate shell output in UI history and improve active shell display (#17438)

This commit is contained in:
Jarrod Whelan
2026-02-08 00:09:48 -08:00
committed by GitHub
parent 31522045cd
commit 4a48d7cf93
34 changed files with 1553 additions and 579 deletions

View File

@@ -68,8 +68,9 @@ describe('<AnsiOutputText />', () => {
const output = lastFrame();
expect(output).toBeDefined();
const lines = output!.split('\n');
expect(lines[0]).toBe('First line');
expect(lines[1]).toBe('Third line');
expect(lines[0].trim()).toBe('First line');
expect(lines[1].trim()).toBe('');
expect(lines[2].trim()).toBe('Third line');
});
it('respects the availableTerminalHeight prop and slices the lines correctly', () => {
@@ -89,6 +90,45 @@ describe('<AnsiOutputText />', () => {
expect(output).toContain('Line 4');
});
it('respects the maxLines prop and slices the lines correctly', () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'Line 1' })],
[createAnsiToken({ text: 'Line 2' })],
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
const { lastFrame } = render(
<AnsiOutputText data={data} maxLines={2} width={80} />,
);
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).toContain('Line 4');
});
it('prioritizes maxLines over availableTerminalHeight if maxLines is smaller', () => {
const data: AnsiOutput = [
[createAnsiToken({ text: 'Line 1' })],
[createAnsiToken({ text: 'Line 2' })],
[createAnsiToken({ text: 'Line 3' })],
[createAnsiToken({ text: 'Line 4' })],
];
// availableTerminalHeight=3, maxLines=2 => show 2 lines
const { lastFrame } = render(
<AnsiOutputText
data={data}
availableTerminalHeight={3}
maxLines={2}
width={80}
/>,
);
const output = lastFrame();
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).toContain('Line 4');
});
it('renders a large AnsiOutput object without crashing', () => {
const largeData: AnsiOutput = [];
for (let i = 0; i < 1000; i++) {

View File

@@ -14,40 +14,56 @@ interface AnsiOutputProps {
data: AnsiOutput;
availableTerminalHeight?: number;
width: number;
maxLines?: number;
disableTruncation?: boolean;
}
export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
data,
availableTerminalHeight,
width,
maxLines,
disableTruncation,
}) => {
const lastLines = data.slice(
-(availableTerminalHeight && availableTerminalHeight > 0
const availableHeightLimit =
availableTerminalHeight && availableTerminalHeight > 0
? availableTerminalHeight
: DEFAULT_HEIGHT),
);
: undefined;
const numLinesRetained =
availableHeightLimit !== undefined && maxLines !== undefined
? Math.min(availableHeightLimit, maxLines)
: (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT);
const lastLines = disableTruncation ? data : data.slice(-numLinesRetained);
return (
<Box flexDirection="column" width={width} flexShrink={0}>
<Box flexDirection="column" width={width} flexShrink={0} overflow="hidden">
{lastLines.map((line: AnsiLine, lineIndex: number) => (
<Text key={lineIndex} wrap="truncate">
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
<Box key={lineIndex} height={1} overflow="hidden">
<AnsiLineText line={line} />
</Box>
))}
</Box>
);
};
export const AnsiLineText: React.FC<{ line: AnsiLine }> = ({ line }) => (
<Text>
{line.length > 0
? line.map((token: AnsiToken, tokenIndex: number) => (
<Text
key={tokenIndex}
color={token.fg}
backgroundColor={token.bg}
inverse={token.inverse}
dimColor={token.dim}
bold={token.bold}
italic={token.italic}
underline={token.underline}
>
{token.text}
</Text>
))
: null}
</Text>
);

View File

@@ -10,6 +10,10 @@ import { MainContent } from './MainContent.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Box, Text } from 'ink';
import type React from 'react';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { ToolCallStatus } from '../types.js';
import { SHELL_COMMAND_NAME } from '../constants.js';
import type { UIState } from '../contexts/UIStateContext.js';
// Mock dependencies
vi.mock('../contexts/AppContext.js', async () => {
@@ -22,53 +26,10 @@ vi.mock('../contexts/AppContext.js', async () => {
};
});
vi.mock('../contexts/UIStateContext.js', async () => {
const actual = await vi.importActual('../contexts/UIStateContext.js');
return {
...actual,
useUIState: () => ({
history: [
{ id: 1, role: 'user', content: 'Hello' },
{ id: 2, role: 'model', content: 'Hi there' },
],
pendingHistoryItems: [],
mainAreaWidth: 80,
staticAreaMaxItemHeight: 20,
availableTerminalHeight: 24,
slashCommands: [],
constrainHeight: false,
isEditorDialogOpen: false,
activePtyId: undefined,
embeddedShellFocused: false,
historyRemountKey: 0,
}),
};
});
vi.mock('../hooks/useAlternateBuffer.js', () => ({
useAlternateBuffer: vi.fn(),
}));
vi.mock('./HistoryItemDisplay.js', () => ({
HistoryItemDisplay: ({
item,
availableTerminalHeight,
}: {
item: { content: string };
availableTerminalHeight?: number;
}) => (
<Box>
<Text>
HistoryItem: {item.content} (height:{' '}
{availableTerminalHeight === undefined
? 'undefined'
: availableTerminalHeight}
)
</Text>
</Box>
),
}));
vi.mock('./AppHeader.js', () => ({
AppHeader: () => <Text>AppHeader</Text>,
}));
@@ -95,39 +56,169 @@ vi.mock('./shared/ScrollableList.js', () => ({
SCROLL_TO_ITEM_END: 0,
}));
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
describe('MainContent', () => {
const defaultMockUiState = {
history: [
{ id: 1, type: 'user', text: 'Hello' },
{ id: 2, type: 'gemini', text: 'Hi there' },
],
pendingHistoryItems: [],
mainAreaWidth: 80,
staticAreaMaxItemHeight: 20,
availableTerminalHeight: 24,
slashCommands: [],
constrainHeight: false,
isEditorDialogOpen: false,
activePtyId: undefined,
embeddedShellFocused: false,
historyRemountKey: 0,
bannerData: { defaultText: '', warningText: '' },
bannerVisible: false,
};
beforeEach(() => {
vi.mocked(useAlternateBuffer).mockReturnValue(false);
});
it('renders in normal buffer mode', async () => {
const { lastFrame } = renderWithProviders(<MainContent />);
const { lastFrame } = renderWithProviders(<MainContent />, {
uiState: defaultMockUiState as Partial<UIState>,
});
await waitFor(() => expect(lastFrame()).toContain('AppHeader'));
const output = lastFrame();
expect(output).toContain('HistoryItem: Hello (height: 20)');
expect(output).toContain('HistoryItem: Hi there (height: 20)');
expect(output).toContain('Hello');
expect(output).toContain('Hi there');
});
it('renders in alternate buffer mode', async () => {
vi.mocked(useAlternateBuffer).mockReturnValue(true);
const { lastFrame } = renderWithProviders(<MainContent />);
const { lastFrame } = renderWithProviders(<MainContent />, {
uiState: defaultMockUiState as Partial<UIState>,
});
await waitFor(() => expect(lastFrame()).toContain('ScrollableList'));
const output = lastFrame();
expect(output).toContain('AppHeader');
expect(output).toContain('HistoryItem: Hello (height: undefined)');
expect(output).toContain('HistoryItem: Hi there (height: undefined)');
expect(output).toContain('Hello');
expect(output).toContain('Hi there');
});
it('does not constrain height in alternate buffer mode', async () => {
vi.mocked(useAlternateBuffer).mockReturnValue(true);
const { lastFrame } = renderWithProviders(<MainContent />);
await waitFor(() => expect(lastFrame()).toContain('HistoryItem: Hello'));
const { lastFrame } = renderWithProviders(<MainContent />, {
uiState: defaultMockUiState as Partial<UIState>,
});
await waitFor(() => expect(lastFrame()).toContain('Hello'));
const output = lastFrame();
expect(output).toMatchSnapshot();
});
describe('MainContent Tool Output Height Logic', () => {
const testCases = [
{
name: 'ASB mode - Focused shell should expand',
isAlternateBuffer: true,
embeddedShellFocused: true,
constrainHeight: true,
shouldShowLine1: true,
},
{
name: 'ASB mode - Unfocused shell',
isAlternateBuffer: true,
embeddedShellFocused: false,
constrainHeight: true,
shouldShowLine1: false,
},
{
name: 'Normal mode - Constrained height',
isAlternateBuffer: false,
embeddedShellFocused: false,
constrainHeight: true,
shouldShowLine1: false,
},
{
name: 'Normal mode - Unconstrained height',
isAlternateBuffer: false,
embeddedShellFocused: false,
constrainHeight: false,
shouldShowLine1: false,
},
];
it.each(testCases)(
'$name',
async ({
isAlternateBuffer,
embeddedShellFocused,
constrainHeight,
shouldShowLine1,
}) => {
vi.mocked(useAlternateBuffer).mockReturnValue(isAlternateBuffer);
const ptyId = 123;
const uiState = {
history: [],
pendingHistoryItems: [
{
type: 'tool_group' as const,
id: 1,
tools: [
{
callId: 'call_1',
name: SHELL_COMMAND_NAME,
status: ToolCallStatus.Executing,
description: 'Running a long command...',
// 20 lines of output.
// Default max is 15, so Line 1-5 will be truncated/scrolled out if not expanded.
resultDisplay: Array.from(
{ length: 20 },
(_, i) => `Line ${i + 1}`,
).join('\n'),
ptyId,
confirmationDetails: undefined,
},
],
},
],
availableTerminalHeight: 30, // In ASB mode, focused shell should get ~28 lines
terminalHeight: 50,
terminalWidth: 100,
mainAreaWidth: 100,
embeddedShellFocused,
activePtyId: embeddedShellFocused ? ptyId : undefined,
constrainHeight,
isEditorDialogOpen: false,
slashCommands: [],
historyRemountKey: 0,
bannerData: {
defaultText: '',
warningText: '',
},
bannerVisible: false,
};
const { lastFrame } = renderWithProviders(<MainContent />, {
uiState: uiState as Partial<UIState>,
useAlternateBuffer: isAlternateBuffer,
});
const output = lastFrame();
// Sanity checks - Use regex with word boundary to avoid matching "Line 10" etc.
const line1Regex = /\bLine 1\b/;
if (shouldShowLine1) {
expect(output).toMatch(line1Regex);
} else {
expect(output).not.toMatch(line1Regex);
}
// All cases should show the last line
expect(output).toContain('Line 20');
// Snapshots for visual verification
expect(output).toMatchSnapshot();
},
);
});
});

View File

@@ -81,7 +81,8 @@ export const MainContent = () => {
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight && !isAlternateBuffer
(uiState.constrainHeight && !isAlternateBuffer) ||
isAlternateBuffer
? availableTerminalHeight
: undefined
}
@@ -148,7 +149,7 @@ export const MainContent = () => {
return (
<ScrollableList
ref={scrollableListRef}
hasFocus={!uiState.isEditorDialogOpen}
hasFocus={!uiState.isEditorDialogOpen && !uiState.embeddedShellFocused}
width={uiState.terminalWidth}
data={virtualizedData}
renderItem={renderItem}

View File

@@ -5,6 +5,7 @@
*/
import { render, persistentStateMock } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { Notifications } from './Notifications.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAppContext, type AppState } from '../contexts/AppContext.js';
@@ -172,7 +173,7 @@ describe('Notifications', () => {
render(<Notifications />);
await act(async () => {
await vi.waitFor(() => {
await waitFor(() => {
expect(persistentStateMock.set).toHaveBeenCalledWith(
'hasSeenScreenReaderNudge',
true,

View File

@@ -95,16 +95,64 @@ describe('ShellInputPrompt', () => {
it.each([
['up', -1],
['down', 1],
])('handles scroll %s (Ctrl+Shift+%s)', (key, direction) => {
])('handles scroll %s (Command.SCROLL_%s)', (key, direction) => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
const handler = mockUseKeypress.mock.calls[0][0];
handler({ name: key, shift: true, alt: false, ctrl: true, cmd: false });
handler({ name: key, shift: true, alt: false, ctrl: false, cmd: false });
expect(mockScrollPty).toHaveBeenCalledWith(1, direction);
});
it.each([
['pageup', -15],
['pagedown', 15],
])(
'handles page scroll %s (Command.PAGE_%s) with default size',
(key, expectedScroll) => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
const handler = mockUseKeypress.mock.calls[0][0];
handler({ name: key, shift: false, alt: false, ctrl: false, cmd: false });
expect(mockScrollPty).toHaveBeenCalledWith(1, expectedScroll);
},
);
it('respects scrollPageSize prop', () => {
render(
<ShellInputPrompt
activeShellPtyId={1}
focus={true}
scrollPageSize={10}
/>,
);
const handler = mockUseKeypress.mock.calls[0][0];
// PageDown
handler({
name: 'pagedown',
shift: false,
alt: false,
ctrl: false,
cmd: false,
});
expect(mockScrollPty).toHaveBeenCalledWith(1, 10);
// PageUp
handler({
name: 'pageup',
shift: false,
alt: false,
ctrl: false,
cmd: false,
});
expect(mockScrollPty).toHaveBeenCalledWith(1, -10);
});
it('does not handle input when not focused', () => {
render(<ShellInputPrompt activeShellPtyId={1} focus={false} />);
@@ -138,4 +186,21 @@ describe('ShellInputPrompt', () => {
expect(mockWriteToPty).not.toHaveBeenCalled();
});
it('ignores Command.UNFOCUS_SHELL (Shift+Tab) to allow focus navigation', () => {
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
const handler = mockUseKeypress.mock.calls[0][0];
const result = handler({
name: 'tab',
shift: true,
alt: false,
ctrl: false,
cmd: false,
});
expect(result).toBe(false);
expect(mockWriteToPty).not.toHaveBeenCalled();
});
});

View File

@@ -9,16 +9,19 @@ import type React from 'react';
import { useKeypress } from '../hooks/useKeypress.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
import { ACTIVE_SHELL_MAX_LINES } from '../constants.js';
import { Command, keyMatchers } from '../keyMatchers.js';
export interface ShellInputPromptProps {
activeShellPtyId: number | null;
focus?: boolean;
scrollPageSize?: number;
}
export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
activeShellPtyId,
focus = true,
scrollPageSize = ACTIVE_SHELL_MAX_LINES,
}) => {
const handleShellInputSubmit = useCallback(
(input: string) => {
@@ -34,26 +37,33 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
if (!focus || !activeShellPtyId) {
return false;
}
// Allow background shell toggle to bubble up
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
return false;
}
// Allow unfocus to bubble up
// Allow Shift+Tab to bubble up for focus navigation
if (keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) {
return false;
}
if (key.ctrl && key.shift && key.name === 'up') {
if (keyMatchers[Command.SCROLL_UP](key)) {
ShellExecutionService.scrollPty(activeShellPtyId, -1);
return true;
}
if (key.ctrl && key.shift && key.name === 'down') {
if (keyMatchers[Command.SCROLL_DOWN](key)) {
ShellExecutionService.scrollPty(activeShellPtyId, 1);
return true;
}
// TODO: Check pty service actually scrolls (request)[https://github.com/google-gemini/gemini-cli/pull/17438/changes/c9fdaf8967da0036bfef43592fcab5a69537df35#r2776479023].
if (keyMatchers[Command.PAGE_UP](key)) {
ShellExecutionService.scrollPty(activeShellPtyId, -scrollPageSize);
return true;
}
if (keyMatchers[Command.PAGE_DOWN](key)) {
ShellExecutionService.scrollPty(activeShellPtyId, scrollPageSize);
return true;
}
const ansiSequence = keyToAnsi(key);
if (ansiSequence) {
@@ -63,7 +73,7 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
return false;
},
[focus, handleShellInputSubmit, activeShellPtyId],
[focus, handleShellInputSubmit, activeShellPtyId, scrollPageSize],
);
useKeypress(handleInput, { isActive: focus });

View File

@@ -39,14 +39,14 @@ Tips for getting started:
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
╭──────────────────────────────────────────────────────────────────────────────
│ ✓ tool1 Description for tool 1
╰──────────────────────────────────────────────────────────────────────────────
╭──────────────────────────────────────────────────────────────────────────────
│ ✓ tool2 Description for tool 2
╰──────────────────────────────────────────────────────────────────────────────╯"
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = `
@@ -83,14 +83,14 @@ Tips for getting started:
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
╭──────────────────────────────────────────────────────────────────────────────
│ ✓ tool1 Description for tool 1
╰──────────────────────────────────────────────────────────────────────────────
╭──────────────────────────────────────────────────────────────────────────────
│ ✓ tool2 Description for tool 2
╰──────────────────────────────────────────────────────────────────────────────╯"
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `

View File

@@ -1,8 +1,116 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Focused shell should expand' 1`] = `
"ScrollableList
AppHeader
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long 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 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
ShowMoreLines"
`;
exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Unfocused shell' 1`] = `
"ScrollableList
AppHeader
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
│ 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 █ │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
ShowMoreLines"
`;
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Constrained height' 1`] = `
"AppHeader
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
│ 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 │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
ShowMoreLines"
`;
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unconstrained height' 1`] = `
"AppHeader
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
│ 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 │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
ShowMoreLines"
`;
exports[`MainContent > does not constrain height in alternate buffer mode 1`] = `
"ScrollableList
AppHeader
HistoryItem: Hello (height: undefined)
HistoryItem: Hi there (height: undefined)"
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Hello
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
✦ Hi there
ShowMoreLines
"
`;

View File

@@ -4,55 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import React, { act } from 'react';
import {
ShellToolMessage,
type ShellToolMessageProps,
} from './ShellToolMessage.js';
import { StreamingState, ToolCallStatus } from '../../types.js';
import { Text } from 'ink';
import type { Config } from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SHELL_TOOL_NAME } from '@google/gemini-cli-core';
import { SHELL_COMMAND_NAME } from '../../constants.js';
import { StreamingContext } from '../../contexts/StreamingContext.js';
vi.mock('../TerminalOutput.js', () => ({
TerminalOutput: function MockTerminalOutput({
cursor,
}: {
cursor: { x: number; y: number } | null;
}) {
return (
<Text>
MockCursor:({cursor?.x},{cursor?.y})
</Text>
);
},
}));
// Mock child components or utilities if they are complex or have side effects
vi.mock('../GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: ({
nonRespondingDisplay,
}: {
nonRespondingDisplay?: string;
}) => {
const streamingState = React.useContext(StreamingContext)!;
if (streamingState === StreamingState.Responding) {
return <Text>MockRespondingSpinner</Text>;
}
return nonRespondingDisplay ? <Text>{nonRespondingDisplay}</Text> : null;
},
}));
vi.mock('../../utils/MarkdownDisplay.js', () => ({
MarkdownDisplay: function MockMarkdownDisplay({ text }: { text: string }) {
return <Text>MockMarkdown:{text}</Text>;
},
}));
import { SHELL_COMMAND_NAME, ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
describe('<ShellToolMessage />', () => {
const baseProps: ShellToolMessageProps = {
@@ -72,52 +35,36 @@ describe('<ShellToolMessage />', () => {
} as unknown as Config,
};
const LONG_OUTPUT = Array.from(
{ length: 100 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const mockSetEmbeddedShellFocused = vi.fn();
const uiActions = {
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
};
const renderShell = (
props: Partial<ShellToolMessageProps> = {},
options: Parameters<typeof renderWithProviders>[1] = {},
) =>
renderWithProviders(<ShellToolMessage {...baseProps} {...props} />, {
uiActions,
...options,
});
beforeEach(() => {
vi.clearAllMocks();
});
describe('interactive shell focus', () => {
const shellProps: ShellToolMessageProps = {
...baseProps,
};
it('clicks inside the shell area sets focus to true', async () => {
const { stdin, lastFrame, simulateClick } = renderWithProviders(
<ShellToolMessage {...shellProps} />,
{
mouseEventsEnabled: true,
uiActions,
},
);
await waitFor(() => {
expect(lastFrame()).toContain('A shell command'); // Wait for render
});
await simulateClick(stdin, 2, 2); // Click at column 2, row 2 (1-based)
await waitFor(() => {
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true);
});
});
it('handles focus for SHELL_TOOL_NAME (core shell tool)', async () => {
const coreShellProps: ShellToolMessageProps = {
...shellProps,
name: SHELL_TOOL_NAME,
};
const { stdin, lastFrame, simulateClick } = renderWithProviders(
<ShellToolMessage {...coreShellProps} />,
{
mouseEventsEnabled: true,
uiActions,
},
it.each([
['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME],
['SHELL_TOOL_NAME', SHELL_TOOL_NAME],
])('clicks inside the shell area sets focus for %s', async (_, name) => {
const { stdin, lastFrame, simulateClick } = renderShell(
{ name },
{ mouseEventsEnabled: true },
);
await waitFor(() => {
@@ -130,5 +77,136 @@ describe('<ShellToolMessage />', () => {
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true);
});
});
it('resets focus when shell finishes', async () => {
let updateStatus: (s: ToolCallStatus) => void = () => {};
const Wrapper = () => {
const [status, setStatus] = React.useState(ToolCallStatus.Executing);
updateStatus = setStatus;
return (
<ShellToolMessage
{...baseProps}
status={status}
embeddedShellFocused={true}
activeShellPtyId={1}
ptyId={1}
/>
);
};
const { lastFrame } = renderWithProviders(<Wrapper />, {
uiActions,
uiState: { streamingState: StreamingState.Idle },
});
// Verify it is initially focused
await waitFor(() => {
expect(lastFrame()).toContain('(Shift+Tab to unfocus)');
});
// Now update status to Success
await act(async () => {
updateStatus(ToolCallStatus.Success);
});
// Should call setEmbeddedShellFocused(false) because isThisShellFocused became false
await waitFor(() => {
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
expect(lastFrame()).not.toContain('(Shift+Tab to unfocus)');
});
});
});
describe('Snapshots', () => {
it.each([
[
'renders in Executing state',
{ status: ToolCallStatus.Executing },
undefined,
],
[
'renders in Success state (history mode)',
{ status: ToolCallStatus.Success },
undefined,
],
[
'renders in Error state',
{ status: ToolCallStatus.Error, resultDisplay: 'Error output' },
undefined,
],
[
'renders in Alternate Buffer mode while focused',
{
status: ToolCallStatus.Executing,
embeddedShellFocused: true,
activeShellPtyId: 1,
ptyId: 1,
},
{ useAlternateBuffer: true },
],
[
'renders in Alternate Buffer mode while unfocused',
{
status: ToolCallStatus.Executing,
embeddedShellFocused: false,
activeShellPtyId: 1,
ptyId: 1,
},
{ useAlternateBuffer: true },
],
])('%s', async (_, props, options) => {
const { lastFrame } = renderShell(props, options);
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
});
});
describe('Height Constraints', () => {
it.each([
[
'respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES',
10,
8,
false,
],
[
'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large',
100,
ACTIVE_SHELL_MAX_LINES,
false,
],
[
'uses full availableTerminalHeight when focused in alternate buffer mode',
100,
98, // 100 - 2
true,
],
[
'defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined',
undefined,
ACTIVE_SHELL_MAX_LINES,
false,
],
])('%s', async (_, availableTerminalHeight, expectedMaxLines, focused) => {
const { lastFrame } = renderShell(
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight,
activeShellPtyId: 1,
ptyId: focused ? 1 : 2,
status: ToolCallStatus.Executing,
embeddedShellFocused: focused,
},
{ useAlternateBuffer: true },
);
await waitFor(() => {
const frame = lastFrame();
expect(frame!.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
expect(frame).toMatchSnapshot();
});
});
});
});

View File

@@ -22,6 +22,12 @@ import {
FocusHint,
} from './ToolShared.js';
import type { ToolMessageProps } from './ToolMessage.js';
import { ToolCallStatus } from '../../types.js';
import {
ACTIVE_SHELL_MAX_LINES,
COMPLETED_SHELL_MAX_LINES,
} from '../../constants.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import type { Config } from '@google/gemini-cli-core';
export interface ShellToolMessageProps extends ToolMessageProps {
@@ -61,6 +67,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
borderDimColor,
}) => {
const isAlternateBuffer = useAlternateBuffer();
const isThisShellFocused = checkIsShellFocused(
name,
status,
@@ -70,6 +77,18 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
);
const { setEmbeddedShellFocused } = useUIActions();
const wasFocusedRef = React.useRef(false);
React.useEffect(() => {
if (isThisShellFocused) {
wasFocusedRef.current = true;
} else if (wasFocusedRef.current) {
if (embeddedShellFocused) {
setEmbeddedShellFocused(false);
}
wasFocusedRef.current = false;
}
}, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
const headerRef = React.useRef<DOMElement>(null);
@@ -139,12 +158,20 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
renderOutputAsMarkdown={renderOutputAsMarkdown}
hasFocus={isThisShellFocused}
maxLines={getShellMaxLines(
status,
isAlternateBuffer,
isThisShellFocused,
availableTerminalHeight,
)}
/>
{isThisShellFocused && config && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
<ShellInputPrompt
activeShellPtyId={activeShellPtyId ?? null}
focus={embeddedShellFocused}
scrollPageSize={availableTerminalHeight ?? ACTIVE_SHELL_MAX_LINES}
/>
</Box>
)}
@@ -152,3 +179,39 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
</>
);
};
/**
* 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: ToolCallStatus,
isAlternateBuffer: boolean,
isThisShellFocused: boolean,
availableTerminalHeight: number | undefined,
): number {
if (
status === ToolCallStatus.Success ||
status === ToolCallStatus.Error ||
status === ToolCallStatus.Canceled
) {
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);
}

View File

@@ -42,6 +42,9 @@ const isAskUserInProgress = (t: IndividualToolCallDisplay): boolean =>
].includes(t.status);
// Main component renders the border and maps the tools using ToolMessage
const TOOL_MESSAGE_HORIZONTAL_MARGIN = 4;
const TOOL_CONFIRMATION_INTERNAL_PADDING = 4;
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
toolCalls: allToolCalls,
availableTerminalHeight,
@@ -142,6 +145,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
)
: undefined;
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
@@ -155,6 +160,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
cause tearing.
*/
width={terminalWidth}
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
>
{visibleToolCalls.map((tool, index) => {
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
@@ -164,7 +170,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const commonProps = {
...tool,
availableTerminalHeight: availableTerminalHeightPerToolMessage,
terminalWidth,
terminalWidth: contentWidth,
emphasis: isConfirming
? ('high' as const)
: toolAwaitingApproval
@@ -183,7 +189,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
key={tool.callId}
flexDirection="column"
minHeight={1}
width={terminalWidth}
width={contentWidth}
>
{isShellToolCall ? (
<ShellToolMessage
@@ -218,7 +224,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
availableTerminalHeight={
availableTerminalHeightPerToolMessage
}
terminalWidth={terminalWidth - 4}
terminalWidth={
contentWidth - TOOL_CONFIRMATION_INTERNAL_PADDING
}
/>
)}
{tool.outputFile && (
@@ -240,7 +248,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
<Box
height={0}
width={terminalWidth}
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}

View File

@@ -4,13 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import type { ToolMessageProps } from './ToolMessage.js';
import type React from 'react';
import { ToolMessage, type ToolMessageProps } from './ToolMessage.js';
import { describe, it, expect, vi } from 'vitest';
import { ToolMessage } from './ToolMessage.js';
import { StreamingState, ToolCallStatus } from '../../types.js';
import { Text } from 'ink';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import type { AnsiOutput } from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
@@ -29,45 +27,6 @@ vi.mock('../TerminalOutput.js', () => ({
},
}));
vi.mock('../AnsiOutput.js', () => ({
AnsiOutputText: function MockAnsiOutputText({ data }: { data: AnsiOutput }) {
// Simple serialization for snapshot stability
const serialized = data
.map((line) => line.map((token) => token.text || '').join(''))
.join('\n');
return <Text>MockAnsiOutput:{serialized}</Text>;
},
}));
// Mock child components or utilities if they are complex or have side effects
vi.mock('../GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: ({
nonRespondingDisplay,
}: {
nonRespondingDisplay?: string;
}) => {
const streamingState = React.useContext(StreamingContext)!;
if (streamingState === StreamingState.Responding) {
return <Text>MockRespondingSpinner</Text>;
}
return nonRespondingDisplay ? <Text>{nonRespondingDisplay}</Text> : null;
},
}));
vi.mock('./DiffRenderer.js', () => ({
DiffRenderer: function MockDiffRenderer({
diffContent,
}: {
diffContent: string;
}) {
return <Text>MockDiff:{diffContent}</Text>;
},
}));
vi.mock('../../utils/MarkdownDisplay.js', () => ({
MarkdownDisplay: function MockMarkdownDisplay({ text }: { text: string }) {
return <Text>MockMarkdown:{text}</Text>;
},
}));
describe('<ToolMessage />', () => {
const baseProps: ToolMessageProps = {
callId: 'tool-123',
@@ -131,7 +90,6 @@ describe('<ToolMessage />', () => {
expect(output).toContain('"a": 1');
expect(output).toContain('"b": [');
// Should not use markdown renderer for JSON
expect(output).not.toContain('MockMarkdown:');
});
it('renders pretty JSON in ink frame', () => {
@@ -143,9 +101,6 @@ describe('<ToolMessage />', () => {
const frame = lastFrame();
expect(frame).toMatchSnapshot();
expect(frame).not.toContain('MockMarkdown:');
expect(frame).not.toContain('MockAnsiOutput:');
expect(frame).not.toMatch(/MockDiff:/);
});
it('uses JSON renderer even when renderOutputAsMarkdown=true is true', () => {
@@ -167,7 +122,6 @@ describe('<ToolMessage />', () => {
expect(output).toContain('"a": 1');
expect(output).toContain('"b": [');
// Should not use markdown renderer for JSON even when renderOutputAsMarkdown=true
expect(output).not.toContain('MockMarkdown:');
});
it('falls back to plain text for malformed JSON', () => {
const testJSONstring = 'a": 1, "b": [2, 3]}';

View File

@@ -113,6 +113,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth}
renderOutputAsMarkdown={renderOutputAsMarkdown}
hasFocus={isThisShellFocused}
/>
{isThisShellFocused && config && (
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>

View File

@@ -4,34 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../../test-utils/render.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { ToolResultDisplay } from './ToolResultDisplay.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Box, Text } from 'ink';
import type { AnsiOutput } from '@google/gemini-cli-core';
// Mock child components to simplify testing
vi.mock('./DiffRenderer.js', () => ({
DiffRenderer: ({
diffContent,
filename,
}: {
diffContent: string;
filename: string;
}) => (
<Box>
<Text>
DiffRenderer: {filename} - {diffContent}
</Text>
</Box>
),
}));
// Mock UIStateContext
// Mock UIStateContext partially
const mockUseUIState = vi.fn();
vi.mock('../../contexts/UIStateContext.js', () => ({
useUIState: () => mockUseUIState(),
}));
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();
@@ -39,28 +26,6 @@ vi.mock('../../hooks/useAlternateBuffer.js', () => ({
useAlternateBuffer: () => mockUseAlternateBuffer(),
}));
// Mock useSettings
vi.mock('../../contexts/SettingsContext.js', () => ({
useSettings: () => ({
merged: {
ui: {
useAlternateBuffer: false,
},
},
}),
}));
// Mock useOverflowActions
vi.mock('../../contexts/OverflowContext.js', () => ({
useOverflowActions: () => ({
addOverflowingId: vi.fn(),
removeOverflowingId: vi.fn(),
}),
useOverflowState: () => ({
overflowingIds: new Set(),
}),
}));
describe('ToolResultDisplay', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -68,6 +33,66 @@ describe('ToolResultDisplay', () => {
mockUseAlternateBuffer.mockReturnValue(false);
});
// Helper to use renderWithProviders
const render = (ui: React.ReactElement) => renderWithProviders(ui);
it('uses ScrollableList for ANSI output in alternate buffer mode', () => {
mockUseAlternateBuffer.mockReturnValue(true);
const content = 'ansi content';
const ansiResult: AnsiOutput = [
[
{
text: content,
fg: 'red',
bg: 'black',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
},
],
];
const { lastFrame } = render(
<ToolResultDisplay
resultDisplay={ansiResult}
terminalWidth={80}
maxLines={10}
/>,
);
const output = lastFrame();
expect(output).toContain(content);
});
it('uses Scrollable for non-ANSI output in alternate buffer mode', () => {
mockUseAlternateBuffer.mockReturnValue(true);
const { lastFrame } = render(
<ToolResultDisplay
resultDisplay="**Markdown content**"
terminalWidth={80}
maxLines={10}
/>,
);
const output = lastFrame();
// With real components, we check for the content itself
expect(output).toContain('Markdown content');
});
it('passes hasFocus prop to scrollable components', () => {
mockUseAlternateBuffer.mockReturnValue(true);
const { lastFrame } = render(
<ToolResultDisplay
resultDisplay="Some result"
terminalWidth={80}
hasFocus={true}
/>,
);
expect(lastFrame()).toContain('Some result');
});
it('renders string result as markdown by default', () => {
const { lastFrame } = render(
<ToolResultDisplay resultDisplay="**Some result**" terminalWidth={80} />,
@@ -194,4 +219,86 @@ describe('ToolResultDisplay', () => {
expect(output).toMatchSnapshot();
});
it('truncates ANSI output when maxLines is provided', () => {
const ansiResult: AnsiOutput = [
[
{
text: 'Line 1',
fg: '',
bg: '',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
},
],
[
{
text: 'Line 2',
fg: '',
bg: '',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
},
],
[
{
text: 'Line 3',
fg: '',
bg: '',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
},
],
];
const { lastFrame } = render(
<ToolResultDisplay
resultDisplay={ansiResult}
terminalWidth={80}
availableTerminalHeight={20}
maxLines={2}
/>,
);
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).toContain('Line 2');
expect(output).toContain('Line 3');
});
it('truncates ANSI output when maxLines is provided, even if availableTerminalHeight is undefined', () => {
const ansiResult: AnsiOutput = Array.from({ length: 50 }, (_, i) => [
{
text: `Line ${i + 1}`,
fg: '',
bg: '',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
},
]);
const { lastFrame } = render(
<ToolResultDisplay
resultDisplay={ansiResult}
terminalWidth={80}
maxLines={25}
availableTerminalHeight={undefined}
/>,
);
const output = lastFrame();
// It SHOULD truncate to 25 lines because maxLines is provided
expect(output).not.toContain('Line 1');
expect(output).toContain('Line 50');
});
});

View File

@@ -8,12 +8,17 @@ import React from 'react';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { AnsiOutputText } from '../AnsiOutput.js';
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme } from '../../semantic-colors.js';
import type { AnsiOutput } from '@google/gemini-cli-core';
import type { AnsiOutput, AnsiLine } from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
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
@@ -28,6 +33,8 @@ export interface ToolResultDisplayProps {
availableTerminalHeight?: number;
terminalWidth: number;
renderOutputAsMarkdown?: boolean;
maxLines?: number;
hasFocus?: boolean;
}
interface FileDiffResult {
@@ -40,30 +47,100 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
availableTerminalHeight,
terminalWidth,
renderOutputAsMarkdown = true,
maxLines,
hasFocus = false,
}) => {
const { renderMarkdown } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const availableHeight = availableTerminalHeight
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 combinedPaddingAndBorderWidth = 4;
const childWidth = terminalWidth - combinedPaddingAndBorderWidth;
const keyExtractor = React.useCallback(
(_: AnsiLine, index: number) => index.toString(),
[],
);
const renderVirtualizedAnsiLine = React.useCallback(
({ item }: { item: AnsiLine }) => (
<Box height={1} overflow="hidden">
<AnsiLineText line={item} />
</Box>
),
[],
);
const truncatedResultDisplay = React.useMemo(() => {
if (typeof resultDisplay === 'string') {
if (resultDisplay.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
return '...' + resultDisplay.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
// 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) {
text =
lines.slice(-maxLines).join('\n') +
(hasTrailingNewline ? '\n' : '');
}
}
return text;
}
return resultDisplay;
}, [resultDisplay]);
}, [resultDisplay, isAlternateBuffer, maxLines]);
if (!truncatedResultDisplay) return null;
// 1. Early return for background tools (Todos)
if (
typeof truncatedResultDisplay === 'object' &&
'todos' in truncatedResultDisplay
) {
// 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(
(truncatedResultDisplay as AnsiOutput).length,
limit,
);
return (
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
<ScrollableList
width={childWidth}
data={truncatedResultDisplay as AnsiOutput}
renderItem={renderVirtualizedAnsiLine}
estimatedItemHeight={() => 1}
keyExtractor={keyExtractor}
initialScrollIndex={SCROLL_TO_ITEM_END}
hasFocus={hasFocus}
/>
</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'
@@ -113,22 +190,38 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
terminalWidth={childWidth}
/>
);
} else if (
typeof truncatedResultDisplay === 'object' &&
'todos' in truncatedResultDisplay
) {
// display nothing, as the TodoTray will handle rendering todos
return null;
} else {
const shouldDisableTruncation =
isAlternateBuffer ||
(availableTerminalHeight === undefined && maxLines === undefined);
content = (
<AnsiOutputText
data={truncatedResultDisplay as AnsiOutput}
availableTerminalHeight={availableHeight}
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}
>
{content}
</Scrollable>
);
}
return (
<Box width={childWidth} flexDirection="column">
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>

View File

@@ -49,6 +49,7 @@ describe('ToolResultDisplay Overflow', () => {
streamingState: StreamingState.Idle,
constrainHeight: true,
},
useAlternateBuffer: false,
},
);

View File

@@ -0,0 +1,198 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined 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 > respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command │
│ │
│ 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 │
│ │
│ 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 full availableTerminalHeight when focused in alternate buffer mode 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │
│ │
│ 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 /> > Snapshots > renders in Alternate Buffer mode while focused 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │
│ │
│ Test result │
│ │"
`;
exports[`<ShellToolMessage /> > Snapshots > renders in Alternate Buffer mode while unfocused 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command │
│ │
│ Test result │"
`;
exports[`<ShellToolMessage /> > Snapshots > renders in Error state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ x Shell Command A shell command │
│ │
│ Error output │"
`;
exports[`<ShellToolMessage /> > Snapshots > renders in Executing state 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command A shell command │
│ │
│ Test result │"
`;
exports[`<ShellToolMessage /> > Snapshots > renders in Success state (history mode) 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command A shell command │
│ │
│ Test result │"
`;

View File

@@ -1,18 +1,18 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToolConfirmationMessage Overflow > should display "press ctrl-o" hint when content overflows in ToolGroupMessage 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ? test-tool a test tool ← │
│ ... first 49 lines hidden ...
│ 50 line 50
│ Apply this change?
│ ● 1. Allow once
│ 2. Allow for this session
│ 3. Modify with external editor
│ 4. No, suggest changes (esc)
╰──────────────────────────────────────────────────────────────────────────────
"╭──────────────────────────────────────────────────────────────────────────╮
│ ? test-tool a test tool ← │
│ │
│ ... first 49 lines hidden ... │
│ 50 line 50 │
│ Apply this change? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. Modify with external editor │
│ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
Press ctrl-o to show more lines"
`;

View File

@@ -1,19 +1,19 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolGroupMessage /> > Ask User Filtering > does NOT filter out ask_user when status is Error 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ x Ask User
│ Test result
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ x Ask User │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Ask User Filtering > does NOT filter out ask_user when status is Success 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ✓ Ask User
│ Test result
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ Ask User │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Ask User Filtering > filters out ask_user when status is Confirming 1`] = `""`;
@@ -23,89 +23,89 @@ exports[`<ToolGroupMessage /> > Ask User Filtering > filters out ask_user when s
exports[`<ToolGroupMessage /> > Ask User Filtering > filters out ask_user when status is Pending 1`] = `""`;
exports[`<ToolGroupMessage /> > Ask User Filtering > shows other tools when ask_user is filtered out 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ✓ other-tool A tool for testing
│ Test result
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ other-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses gray border when all tools are successful and no shell commands 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ✓ test-tool A tool for testing
│ Test result
│ ✓ another-tool A tool for testing
│ Test result
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
│ Test result │
│ │
│ ✓ another-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border for shell commands even when successful 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ✓ run_shell_command A tool for testing
│ Test result
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ run_shell_command A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border when tools are pending 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ o test-tool A tool for testing
│ Test result
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ o test-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation with permanent approval disabled 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ? confirm-tool A tool for testing ← │
│ Test result
│ Do you want to proceed?
│ Do you want to proceed?
│ ● 1. Allow once
│ 2. Allow for this session
│ 3. No, suggest changes (esc)
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ? confirm-tool A tool for testing ← │
│ │
│ Test result │
│ Do you want to proceed? │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation with permanent approval enabled 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ? confirm-tool A tool for testing ← │
│ Test result
│ Do you want to proceed?
│ Do you want to proceed?
│ ● 1. Allow once
│ 2. Allow for this session
│ 3. Allow for all future sessions
│ 4. No, suggest changes (esc)
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ? confirm-tool A tool for testing ← │
│ │
│ Test result │
│ Do you want to proceed? │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. Allow for all future sessions │
│ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialog for first confirming tool only 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ? first-confirm A tool for testing ← │
│ Test result
│ Confirm first tool
│ Do you want to proceed?
│ ● 1. Allow once
│ 2. Allow for this session
│ 3. No, suggest changes (esc)
│ ? second-confirm A tool for testing
│ Test result
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ? first-confirm A tool for testing ← │
│ │
│ Test result │
│ Confirm first tool │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. No, suggest changes (esc) │
│ │
│ │
│ ? second-confirm A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Event-Driven Scheduler > hides confirming tools when event-driven scheduler is enabled 1`] = `""`;
@@ -113,148 +113,148 @@ exports[`<ToolGroupMessage /> > Event-Driven Scheduler > hides confirming tools
exports[`<ToolGroupMessage /> > Event-Driven Scheduler > renders nothing when only tool is in-progress AskUser with borderBottom=false 1`] = `""`;
exports[`<ToolGroupMessage /> > Event-Driven Scheduler > shows only successful tools when mixed with confirming tools 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ✓ success-tool A tool for testing
│ Test result
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ success-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `""`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ✓ tool-1 Description 1. This is a long description that will need to be tr… │
│──────────────────────────────────────────────────────────────────────────────
│ line5
│ ✓ tool-2 Description 2
│ line1
│ line2
╰──────────────────────────────────────────────────────────────────────────────╯ █"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-1 Description 1. This is a long description that will need to b… │
│──────────────────────────────────────────────────────────────────────────│
│ line5
│ ✓ tool-2 Description 2
│ line1
│ line2
╰──────────────────────────────────────────────────────────────────────────╯ █"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including shell command 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ✓ read_file Read a file
│ Test result
│ ⊷ run_shell_command Run command
│ Test result
│ o write_file Write to file
│ Test result
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ read_file Read a file │
│ │
│ Test result │
│ │
│ ⊷ run_shell_command Run command │
│ │
│ Test result │
│ │
│ o write_file Write to file │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders multiple tool calls with different statuses 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ✓ successful-tool This tool succeeded
│ Test result
│ o pending-tool This tool is pending
│ Test result
│ x error-tool This tool failed
│ Test result
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ successful-tool This tool succeeded │
│ │
│ Test result │
│ │
│ o pending-tool This tool is pending │
│ │
│ Test result │
│ │
│ x error-tool This tool failed │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders shell command with yellow border 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ✓ run_shell_command Execute shell command
│ Test result
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ run_shell_command Execute shell command │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders single successful tool call 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ✓ test-tool A tool for testing
│ Test result
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call awaiting confirmation 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ? confirmation-tool This tool needs confirmation ← │
│ Test result
│ Are you sure you want to proceed?
│ Do you want to proceed?
│ ● 1. Allow once
│ 2. Allow for this session
│ 3. No, suggest changes (esc)
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ? confirmation-tool This tool needs confirmation ← │
│ │
│ Test result │
│ Are you sure you want to proceed? │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with outputFile 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ✓ tool-with-file Tool that saved output to file
│ Test result
│ Output too long and was saved to: /path/to/output.txt
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-with-file Tool that saved output to file │
│ │
│ Test result │
│ Output too long and was saved to: /path/to/output.txt │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
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
╰──────────────────────────────────────────────────────────────────────────────╯ █"
"╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-2 Description 2 │
│ line1
╰──────────────────────────────────────────────────────────────────────────╯ █"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders when not focused 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ✓ test-tool A tool for testing
│ Test result
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ✓ tool-with-result Tool with output
│ This is a long result that might need height constraints
│ ✓ another-tool Another tool
│ More output here
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-with-result Tool with output │
│ │
│ This is a long result that might need height constraints │
│ │
│ ✓ another-tool Another tool │
│ │
│ More output here │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with narrow terminal width 1`] = `
"╭──────────────────────────────────────
│ ✓ very-long-tool-name-that-might-w… │
│ Test result
╰──────────────────────────────────────╯"
"╭──────────────────────────────────╮
│ ✓ very-long-tool-name-that-mig… │
│ │
│ Test result │
╰──────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Height Calculation > calculates available height correctly with multiple tools with results 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────
│ ✓ test-tool A tool for testing
│ Result 1
│ ✓ test-tool A tool for testing
│ Result 2
│ ✓ test-tool A tool for testing
╰──────────────────────────────────────────────────────────────────────────────╯"
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
│ Result 1 │
│ │
│ ✓ test-tool A tool for testing │
│ │
│ Result 2 │
│ │
│ ✓ test-tool A tool for testing │
│ │
╰──────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -14,93 +14,90 @@ exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows ? for Confirmin
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ? test-tool A tool for testing │
│ │
MockMarkdown:Test result │"
Test result │"
`;
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows - for Canceled status 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ - test-tool A tool for testing │
│ │
MockMarkdown:Test result │"
Test result │"
`;
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows MockRespondingSpinner for Executing status when streamingState is Responding 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
MockRespondingSpinnertest-tool A tool for testing │
test-tool A tool for testing
│ │
MockMarkdown:Test result │"
Test result │"
`;
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows o for Pending status 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ o test-tool A tool for testing │
│ │
MockMarkdown:Test result │"
Test result │"
`;
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is Idle 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ test-tool A tool for testing │
│ │
MockMarkdown:Test result │"
Test result │"
`;
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows paused spinner for Executing status when streamingState is WaitingForConfirmation 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊷ test-tool A tool for testing │
│ │
MockMarkdown:Test result │"
Test result │"
`;
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows x for Error status 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ x test-tool A tool for testing │
│ │
MockMarkdown:Test result │"
Test result │"
`;
exports[`<ToolMessage /> > ToolStatusIndicator rendering > shows ✓ for Success status 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
MockMarkdown:Test result │"
Test result │"
`;
exports[`<ToolMessage /> > renders AnsiOutputText for AnsiOutput results 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
MockAnsiOutput:hello │"
hello │"
`;
exports[`<ToolMessage /> > renders DiffRenderer for diff results 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
MockDiff:--- a/file.txt
+++ b/file.txt
│ @@ -1 +1 @@ │
│ -old │
│ +new │"
1 - old
1 + new "
`;
exports[`<ToolMessage /> > renders basic tool information 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
MockMarkdown:Test result │"
Test result │"
`;
exports[`<ToolMessage /> > renders emphasis correctly 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing ← │
│ │
MockMarkdown:Test result │"
Test result │"
`;
exports[`<ToolMessage /> > renders emphasis correctly 2`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool A tool for testing │
│ │
MockMarkdown:Test result │"
Test result │"
`;

View File

@@ -6,7 +6,13 @@ exports[`ToolResultDisplay > keeps markdown if in alternate buffer even with ava
exports[`ToolResultDisplay > renders ANSI output result 1`] = `"ansi content"`;
exports[`ToolResultDisplay > renders file diff result 1`] = `"DiffRenderer: test.ts - diff content"`;
exports[`ToolResultDisplay > renders file diff result 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ │
│ No changes detected. │
│ │
╰──────────────────────────────────────────────────────────────────────────╯"
`;
exports[`ToolResultDisplay > renders nothing for todos result 1`] = `""`;

View File

@@ -1,14 +1,14 @@
// 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
│ ... first 46 lines hidden ...
│ line 47
│ line 48
│ line 49
│ line 50
╰──────────────────────────────────────────────────────────────────────────────
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ test-tool a test tool │
│ │
│ ... first 46 lines hidden ... │
│ line 47 │
│ line 48 │
│ line 49 │
│ line 50 │
╰──────────────────────────────────────────────────────────────────────────╯
Press ctrl-o to show more lines"
`;

View File

@@ -1,41 +1,41 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
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-01
│ shell-02 │"
"╭────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command Description for Shell Command
│ │
│ shell-01 │
│ shell-02 │"
`;
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-06
│ shell-07 │"
"╭────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command Description for Shell Command
│────────────────────────────────────────────────────────────────────────│
│ shell-06
│ shell-07 │"
`;
exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 1`] = `
"╭────────────────────────────────────────────────────────────────────────────╮ █
│ ✓ tool-1 Description for tool-1
│ c1-01
│ c1-02 │"
"╭────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-1 Description for tool-1 │
│ │
│ c1-01 │
│ c1-02 │"
`;
exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 2`] = `
"╭────────────────────────────────────────────────────────────────────────────
│ ✓ tool-1 Description for tool-1
│────────────────────────────────────────────────────────────────────────────
│ c1-06
│ c1-07 │"
"╭────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-1 Description for tool-1
│────────────────────────────────────────────────────────────────────────│
│ c1-06 │
│ c1-07 │"
`;
exports[`ToolMessage Sticky Header Regression > verifies that multiple ToolMessages in a ToolGroupMessage in a ScrollableList have sticky headers 3`] = `
"│
│ ✓ tool-2 Description for tool-2
│────────────────────────────────────────────────────────────────────────────
│ c2-10
╰────────────────────────────────────────────────────────────────────────────╯ █"
"│ │
│ ✓ tool-2 Description for tool-2 │
│────────────────────────────────────────────────────────────────────────│
│ c2-10 │
╰────────────────────────────────────────────────────────────────────────╯ █"
`;

View File

@@ -117,4 +117,91 @@ describe('<Scrollable />', () => {
});
expect(capturedEntry.getScrollState().scrollTop).toBe(1);
});
describe('keypress handling', () => {
it.each([
{
name: 'scrolls down when overflow exists and not at bottom',
initialScrollTop: 0,
scrollHeight: 10,
keySequence: '\u001B[1;2B', // Shift+Down
expectedScrollTop: 1,
},
{
name: 'scrolls up when overflow exists and not at top',
initialScrollTop: 2,
scrollHeight: 10,
keySequence: '\u001B[1;2A', // Shift+Up
expectedScrollTop: 1,
},
{
name: 'does not scroll up when at top (allows event to bubble)',
initialScrollTop: 0,
scrollHeight: 10,
keySequence: '\u001B[1;2A', // Shift+Up
expectedScrollTop: 0,
},
{
name: 'does not scroll down when at bottom (allows event to bubble)',
initialScrollTop: 5, // maxScroll = 10 - 5 = 5
scrollHeight: 10,
keySequence: '\u001B[1;2B', // Shift+Down
expectedScrollTop: 5,
},
{
name: 'does not scroll when content fits (allows event to bubble)',
initialScrollTop: 0,
scrollHeight: 5, // Same as innerHeight (5)
keySequence: '\u001B[1;2B', // Shift+Down
expectedScrollTop: 0,
},
])(
'$name',
async ({
initialScrollTop,
scrollHeight,
keySequence,
expectedScrollTop,
}) => {
// Dynamically import ink to mock getScrollHeight
const ink = await import('ink');
vi.mocked(ink.getScrollHeight).mockReturnValue(scrollHeight);
let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined;
vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation(
(entry, isActive) => {
if (isActive) {
capturedEntry = entry as ScrollProviderModule.ScrollableEntry;
}
},
);
const { stdin } = renderWithProviders(
<Scrollable hasFocus={true} height={5}>
<Text>Content</Text>
</Scrollable>,
);
// Ensure initial state using existing scrollBy method
act(() => {
// Reset to top first, then scroll to desired start position
capturedEntry!.scrollBy(-100);
if (initialScrollTop > 0) {
capturedEntry!.scrollBy(initialScrollTop);
}
});
expect(capturedEntry!.getScrollState().scrollTop).toBe(
initialScrollTop,
);
act(() => {
stdin.write(keySequence);
});
expect(capturedEntry!.getScrollState().scrollTop).toBe(
expectedScrollTop,
);
},
);
});
});

View File

@@ -17,6 +17,7 @@ import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
interface ScrollableProps {
children?: React.ReactNode;
@@ -103,14 +104,38 @@ export const Scrollable: React.FC<ScrollableProps> = ({
useKeypress(
(key: Key) => {
if (key.shift) {
if (key.name === 'up') {
scrollByWithAnimation(-1);
const { scrollHeight, innerHeight } = sizeRef.current;
const scrollTop = getScrollTop();
const maxScroll = Math.max(0, scrollHeight - innerHeight);
// Only capture scroll-up events if there's room;
// otherwise allow events to bubble.
if (scrollTop > 0) {
if (keyMatchers[Command.PAGE_UP](key)) {
scrollByWithAnimation(-innerHeight);
return true;
}
if (key.name === 'down') {
scrollByWithAnimation(1);
if (keyMatchers[Command.SCROLL_UP](key)) {
scrollByWithAnimation(-1);
return true;
}
}
// Only capture scroll-down events if there's room;
// otherwise allow events to bubble.
if (scrollTop < maxScroll) {
if (keyMatchers[Command.PAGE_DOWN](key)) {
scrollByWithAnimation(innerHeight);
return true;
}
if (keyMatchers[Command.SCROLL_DOWN](key)) {
scrollByWithAnimation(1);
return true;
}
}
// bubble keypress
return false;
},
{ isActive: hasFocus },
);
@@ -137,7 +162,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
[getScrollState, scrollByWithAnimation, hasFocusCallback, flashScrollbar],
);
useScrollable(scrollableEntry, hasFocus && ref.current !== null);
useScrollable(scrollableEntry, true);
return (
<Box

View File

@@ -186,9 +186,11 @@ function ScrollableList<T>(
if (keyMatchers[Command.SCROLL_UP](key)) {
stopSmoothScroll();
scrollByWithAnimation(-1);
return true;
} else if (keyMatchers[Command.SCROLL_DOWN](key)) {
stopSmoothScroll();
scrollByWithAnimation(1);
return true;
} else if (
keyMatchers[Command.PAGE_UP](key) ||
keyMatchers[Command.PAGE_DOWN](key)
@@ -200,11 +202,15 @@ function ScrollableList<T>(
: scrollState.scrollTop;
const innerHeight = scrollState.innerHeight;
smoothScrollTo(current + direction * innerHeight);
return true;
} else if (keyMatchers[Command.SCROLL_HOME](key)) {
smoothScrollTo(0);
return true;
} else if (keyMatchers[Command.SCROLL_END](key)) {
smoothScrollTo(SCROLL_TO_ITEM_END);
return true;
}
return false;
},
{ isActive: hasFocus },
);
@@ -229,7 +235,7 @@ function ScrollableList<T>(
],
);
useScrollable(scrollableEntry, hasFocus);
useScrollable(scrollableEntry, true);
return (
<Box

View File

@@ -39,3 +39,9 @@ export const DEFAULT_BACKGROUND_OPACITY = 0.08;
export const KEYBOARD_SHORTCUTS_URL =
'https://geminicli.com/docs/cli/keyboard-shortcuts/';
export const LRU_BUFFER_PERF_CACHE_LIMIT = 20000;
// Max lines to show for active shell output when not focused
export const ACTIVE_SHELL_MAX_LINES = 15;
// Max lines to preserve in history for completed shell commands
export const COMPLETED_SHELL_MAX_LINES = 15;

View File

@@ -47,7 +47,7 @@ const findScrollableCandidates = (
const candidates: Array<ScrollableEntry & { area: number }> = [];
for (const entry of scrollables.values()) {
if (!entry.ref.current || !entry.hasFocus()) {
if (!entry.ref.current) {
continue;
}

View File

@@ -7,6 +7,7 @@
import { act } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { ToolActionsProvider, useToolActions } from './ToolActionsContext.js';
import {
type Config,
@@ -155,7 +156,7 @@ describe('ToolActionsContext', () => {
// Wait for IdeClient initialization in useEffect
await act(async () => {
await vi.waitFor(() => expect(IdeClient.getInstance).toHaveBeenCalled());
await waitFor(() => expect(IdeClient.getInstance).toHaveBeenCalled());
// Give React a chance to update state
await new Promise((resolve) => setTimeout(resolve, 0));
});
@@ -195,7 +196,7 @@ describe('ToolActionsContext', () => {
// Wait for initialization
await act(async () => {
await vi.waitFor(() => expect(IdeClient.getInstance).toHaveBeenCalled());
await waitFor(() => expect(IdeClient.getInstance).toHaveBeenCalled());
await new Promise((resolve) => setTimeout(resolve, 0));
});

View File

@@ -65,7 +65,6 @@ vi.mock('node:os', async (importOriginal) => {
};
});
vi.mock('node:crypto');
vi.mock('../utils/textUtils.js');
import {
useShellCommandProcessor,

View File

@@ -245,5 +245,34 @@ describe('toolMapping', () => {
expect(displayTool.status).toBe(ToolCallStatus.Canceled);
expect(displayTool.resultDisplay).toBe('User cancelled');
});
it('propagates borderTop and borderBottom options correctly', () => {
const toolCall: ScheduledToolCall = {
status: 'scheduled',
request: mockRequest,
tool: mockTool,
invocation: mockInvocation,
};
const result = mapToDisplay(toolCall, {
borderTop: true,
borderBottom: false,
});
expect(result.borderTop).toBe(true);
expect(result.borderBottom).toBe(false);
});
it('sets resultDisplay to undefined for pre-execution statuses', () => {
const toolCall: ScheduledToolCall = {
status: 'scheduled',
request: mockRequest,
tool: mockTool,
invocation: mockInvocation,
};
const result = mapToDisplay(toolCall);
expect(result.tools[0].resultDisplay).toBeUndefined();
expect(result.tools[0].status).toBe(ToolCallStatus.Pending);
});
});
});

View File

@@ -166,21 +166,27 @@ describe('keyMatchers', () => {
{
command: Command.SCROLL_UP,
positive: [createKey('up', { shift: true })],
negative: [createKey('up'), createKey('up', { ctrl: true })],
negative: [createKey('up')],
},
{
command: Command.SCROLL_DOWN,
positive: [createKey('down', { shift: true })],
negative: [createKey('down'), createKey('down', { ctrl: true })],
negative: [createKey('down')],
},
{
command: Command.SCROLL_HOME,
positive: [createKey('home', { ctrl: true })],
positive: [
createKey('home', { ctrl: true }),
createKey('home', { shift: true }),
],
negative: [createKey('end'), createKey('home')],
},
{
command: Command.SCROLL_END,
positive: [createKey('end', { ctrl: true })],
positive: [
createKey('end', { ctrl: true }),
createKey('end', { shift: true }),
],
negative: [createKey('home'), createKey('end')],
},
{