feat(ux) Expandable (ctrl-O) and scrollable approvals in alternate buffer mode. (#17640)

This commit is contained in:
Jacob Richman
2026-01-27 16:06:24 -08:00
committed by GitHub
parent ff6547857e
commit d165b6d4e7
34 changed files with 1177 additions and 496 deletions

View File

@@ -7,6 +7,7 @@
import type React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
import { useUIState } from '../../contexts/UIStateContext.js';
@@ -42,11 +43,18 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
text={text}
isPending={isPending}
availableTerminalHeight={
isAlternateBuffer ? undefined : availableTerminalHeight
isAlternateBuffer || availableTerminalHeight === undefined
? undefined
: Math.max(availableTerminalHeight - 1, 1)
}
terminalWidth={terminalWidth}
renderMarkdown={renderMarkdown}
/>
<Box marginBottom={1}>
<ShowMoreLines
constrainHeight={availableTerminalHeight !== undefined}
/>
</Box>
</Box>
</Box>
);

View File

@@ -7,6 +7,7 @@
import type React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
@@ -40,11 +41,18 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
text={text}
isPending={isPending}
availableTerminalHeight={
isAlternateBuffer ? undefined : availableTerminalHeight
isAlternateBuffer || availableTerminalHeight === undefined
? undefined
: Math.max(availableTerminalHeight - 1, 1)
}
terminalWidth={terminalWidth}
renderMarkdown={renderMarkdown}
/>
<Box marginBottom={1}>
<ShowMoreLines
constrainHeight={availableTerminalHeight !== undefined}
/>
</Box>
</Box>
);
};

View File

@@ -61,7 +61,7 @@ export const ToolConfirmationMessage: React.FC<
const handleConfirm = useCallback(
(outcome: ToolConfirmationOutcome) => {
void confirm(callId, outcome).catch((error) => {
void confirm(callId, outcome).catch((error: unknown) => {
debugLogger.error(
`Failed to handle tool confirmation for ${callId}:`,
error,
@@ -240,7 +240,8 @@ export const ToolConfirmationMessage: React.FC<
MARGIN_BODY_BOTTOM +
HEIGHT_QUESTION +
MARGIN_QUESTION_BOTTOM +
optionsCount;
optionsCount +
1; // Reserve one line for 'ShowMoreLines' hint
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
}, [availableTerminalHeight, getOptions]);
@@ -431,7 +432,7 @@ export const ToolConfirmationMessage: React.FC<
<Box flexDirection="column" paddingTop={0} paddingBottom={1}>
{/* Body Content (Diff Renderer or Command Info) */}
{/* No separate context display here anymore for edits */}
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
<Box flexGrow={1} flexShrink={1} overflow="hidden">
<MaxSizedBox
maxHeight={availableBodyContentHeight()}
maxWidth={terminalWidth}

View File

@@ -0,0 +1,128 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type {
ToolCallConfirmationDetails,
Config,
} from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
import {
StreamingState,
ToolCallStatus,
type IndividualToolCallDisplay,
} from '../../types.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { waitFor } from '../../../test-utils/async.js';
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
const actual =
await importOriginal<
typeof import('../../contexts/ToolActionsContext.js')
>();
return {
...actual,
useToolActions: vi.fn(),
};
});
describe('ToolConfirmationMessage Overflow', () => {
const mockConfirm = vi.fn();
vi.mocked(useToolActions).mockReturnValue({
confirm: mockConfirm,
cancel: vi.fn(),
isDiffingEnabled: false,
});
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getMessageBus: () => ({
subscribe: vi.fn(),
unsubscribe: vi.fn(),
publish: vi.fn(),
}),
isEventDrivenSchedulerEnabled: () => false,
getTheme: () => ({
status: { warning: 'yellow' },
text: { primary: 'white', secondary: 'gray', link: 'blue' },
border: { default: 'gray' },
ui: { symbol: 'cyan' },
}),
} as unknown as Config;
it('should display "press ctrl-o" hint when content overflows in ToolGroupMessage', async () => {
// Large diff that will definitely overflow
const diffLines = ['--- a/test.txt', '+++ b/test.txt', '@@ -1,20 +1,20 @@'];
for (let i = 0; i < 50; i++) {
diffLines.push(`+ line ${i + 1}`);
}
const fileDiff = diffLines.join('\n');
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'edit',
title: 'Confirm Edit',
fileName: 'test.txt',
filePath: '/test.txt',
fileDiff,
originalContent: '',
newContent: 'lots of lines',
onConfirm: vi.fn(),
};
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'test-call-id',
name: 'test-tool',
description: 'a test tool',
status: ToolCallStatus.Confirming,
confirmationDetails,
resultDisplay: undefined,
},
];
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<ToolGroupMessage
groupId={1}
toolCalls={toolCalls}
availableTerminalHeight={15} // Small height to force overflow
terminalWidth={80}
/>
</OverflowProvider>,
{
config: mockConfig,
uiState: {
streamingState: StreamingState.WaitingForConfirmation,
constrainHeight: true,
},
},
);
// ResizeObserver might take a tick
await waitFor(() =>
expect(lastFrame()).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');
// 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'),
);
expect(hintIndex).toBeGreaterThan(bottomBorderIndex);
expect(frame).toMatchSnapshot();
}
});
});

View File

@@ -16,6 +16,8 @@ import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool, isThisShellFocused } from './ToolShared.js';
import { ASK_USER_DISPLAY_NAME } from '@google/gemini-cli-core';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js';
interface ToolGroupMessageProps {
groupId: number;
@@ -26,6 +28,8 @@ interface ToolGroupMessageProps {
activeShellPtyId?: number | null;
embeddedShellFocused?: boolean;
onShellInputSubmit?: (input: string) => void;
borderTop?: boolean;
borderBottom?: boolean;
}
// Helper to identify Ask User tools that are in progress (have their own dialog UI)
@@ -45,6 +49,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
isFocused = true,
activeShellPtyId,
embeddedShellFocused,
borderTop: borderTopOverride,
borderBottom: borderBottomOverride,
}) => {
// Filter out in-progress Ask User tools (they have their own AskUserDialog UI)
const toolCalls = useMemo(
@@ -53,6 +59,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
);
const config = useConfig();
const { constrainHeight } = useUIState();
const isEventDriven = config.isEventDrivenSchedulerEnabled();
@@ -110,8 +117,12 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
);
// If all tools are hidden (e.g. group only contains confirming or pending tools),
// render nothing in the history log.
if (visibleToolCalls.length === 0) {
// render nothing in the history log unless we have a border override.
if (
visibleToolCalls.length === 0 &&
borderTopOverride === undefined &&
borderBottomOverride === undefined
) {
return null;
}
@@ -161,7 +172,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
: toolAwaitingApproval
? ('low' as const)
: ('medium' as const),
isFirst,
isFirst:
borderTopOverride !== undefined
? borderTopOverride && isFirst
: isFirst,
borderColor,
borderDimColor,
};
@@ -225,20 +239,25 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
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 && (
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
<Box
height={0}
width={terminalWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={true}
borderBottom={borderBottomOverride ?? true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)
}
{(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && (
<Box paddingX={1}>
<ShowMoreLines constrainHeight={constrainHeight} />
</Box>
)}
</Box>
);
};

View File

@@ -56,6 +56,9 @@ vi.mock('../../contexts/OverflowContext.js', () => ({
addOverflowingId: vi.fn(),
removeOverflowingId: vi.fn(),
}),
useOverflowState: () => ({
overflowingIds: new Set(),
}),
}));
describe('ToolResultDisplay', () => {

View File

@@ -16,7 +16,7 @@ import { useUIState } from '../../contexts/UIStateContext.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint
const MIN_LINES_SHOWN = 2; // show at least this many lines
// Large threshold to ensure we don't cause performance issues for very large

View File

@@ -0,0 +1,76 @@
/**
* @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,
ToolCallStatus,
type IndividualToolCallDisplay,
} from '../../types.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { waitFor } from '../../../test-utils/async.js';
describe('ToolResultDisplay Overflow', () => {
it('should display "press ctrl-o" hint when content overflows in ToolGroupMessage', async () => {
// Large output that will definitely overflow
const lines = [];
for (let i = 0; i < 50; i++) {
lines.push(`line ${i + 1}`);
}
const resultDisplay = lines.join('\n');
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call-1',
name: 'test-tool',
description: 'a test tool',
status: ToolCallStatus.Success,
resultDisplay,
confirmationDetails: undefined,
},
];
const { lastFrame } = renderWithProviders(
<OverflowProvider>
<ToolGroupMessage
groupId={1}
toolCalls={toolCalls}
availableTerminalHeight={15} // Small height to force overflow
terminalWidth={80}
/>
</OverflowProvider>,
{
uiState: {
streamingState: StreamingState.Idle,
constrainHeight: true,
},
},
);
// ResizeObserver might take a tick
await waitFor(() =>
expect(lastFrame()).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');
// 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'),
);
expect(hintIndex).toBeGreaterThan(bottomBorderIndex);
expect(frame).toMatchSnapshot();
}
});
});

View File

@@ -5,13 +5,15 @@ exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders pending st
\`\`\`javascript
const x = 1;
\`\`\`"
\`\`\`
"
`;
exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders pending state with renderMarkdown=true 1`] = `
"✦ Test bold and code markdown
1 const x = 1;"
1 const x = 1;
"
`;
exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=false '(raw markdown with syntax highlightin…' 1`] = `
@@ -19,11 +21,13 @@ exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders with rende
\`\`\`javascript
const x = 1;
\`\`\`"
\`\`\`
"
`;
exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true '(default)' 1`] = `
"✦ Test bold and code markdown
1 const x = 1;"
1 const x = 1;
"
`;

View File

@@ -5,7 +5,6 @@ exports[`ToolConfirmationMessage Redirection > should display redirection warnin
Note: Command contains redirection which can be undesirable.
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
Allow execution of: 'echo, redirection (>)'?
● 1. Allow once

View File

@@ -4,7 +4,6 @@ exports[`ToolConfirmationMessage > should display multiple commands for exec typ
"echo "hello"
ls -la
whoami
Allow execution of 3 commands?
● 1. Allow once
@@ -18,7 +17,6 @@ exports[`ToolConfirmationMessage > should display urls if prompt and url are dif
URLs to fetch:
- https://raw.githubusercontent.com/google/gemini-react/main/README.md
Do you want to proceed?
● 1. Allow once
@@ -29,7 +27,6 @@ Do you want to proceed?
exports[`ToolConfirmationMessage > should not display urls if prompt and url are the same 1`] = `
"https://example.com
Do you want to proceed?
● 1. Allow once
@@ -44,7 +41,6 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
│ No changes detected. │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
● 1. Allow once
@@ -59,7 +55,6 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
│ No changes detected. │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
● 1. Allow once
@@ -71,7 +66,6 @@ Apply this change?
exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
"echo "hello"
Allow execution of: 'echo'?
● 1. Allow once
@@ -81,7 +75,6 @@ Allow execution of: 'echo'?
exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations' > should show "allow always" when folder is trusted 1`] = `
"echo "hello"
Allow execution of: 'echo'?
● 1. Allow once
@@ -92,7 +85,6 @@ Allow execution of: 'echo'?
exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
"https://example.com
Do you want to proceed?
● 1. Allow once
@@ -102,7 +94,6 @@ Do you want to proceed?
exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations' > should show "allow always" when folder is trusted 1`] = `
"https://example.com
Do you want to proceed?
● 1. Allow once
@@ -114,7 +105,6 @@ Do you want to proceed?
exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
"MCP Server: test-server
Tool: test-tool
Allow execution of MCP tool "test-tool" from server "test-server"?
● 1. Allow once
@@ -125,7 +115,6 @@ Allow execution of MCP tool "test-tool" from server "test-server"?
exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' > should show "allow always" when folder is trusted 1`] = `
"MCP Server: test-server
Tool: test-tool
Allow execution of MCP tool "test-tool" from server "test-server"?
● 1. Allow once

View File

@@ -0,0 +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) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Press ctrl-o to show more lines"
`;

View File

@@ -34,7 +34,6 @@ exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation wit
│ │
│ Test result │
│ Do you want to proceed? │
│ │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once │
@@ -50,7 +49,6 @@ exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation wit
│ │
│ Test result │
│ Do you want to proceed? │
│ │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once │
@@ -67,7 +65,6 @@ exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialo
│ │
│ Test result │
│ Confirm first tool │
│ │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once │
@@ -160,7 +157,6 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call awaiting co
│ │
│ Test result │
│ Are you sure you want to proceed? │
│ │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once │

View File

@@ -15,8 +15,7 @@ exports[`ToolResultDisplay > renders string result as markdown by default 1`] =
exports[`ToolResultDisplay > renders string result as plain text when renderOutputAsMarkdown is false 1`] = `"**Some result**"`;
exports[`ToolResultDisplay > truncates very long string results 1`] = `
"... first 251 lines hidden ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
"... first 252 lines hidden ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

View File

@@ -0,0 +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 │
╰──────────────────────────────────────────────────────────────────────────────╯
Press ctrl-o to show more lines"
`;