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
+7 -2
View File
@@ -416,10 +416,13 @@ export function renderHookWithProviders<Result, Props>(
const result = { current: undefined as unknown as Result }; const result = { current: undefined as unknown as Result };
let setPropsFn: ((props: Props) => void) | undefined; let setPropsFn: ((props: Props) => void) | undefined;
let forceUpdateFn: (() => void) | undefined;
function TestComponent({ initialProps }: { initialProps: Props }) { function TestComponent({ initialProps }: { initialProps: Props }) {
const [props, setProps] = useState(initialProps); const [props, setProps] = useState(initialProps);
const [, forceUpdate] = useState(0);
setPropsFn = setProps; setPropsFn = setProps;
forceUpdateFn = () => forceUpdate((n) => n + 1);
result.current = renderCallback(props); result.current = renderCallback(props);
return null; return null;
} }
@@ -439,8 +442,10 @@ export function renderHookWithProviders<Result, Props>(
function rerender(newProps?: Props) { function rerender(newProps?: Props) {
act(() => { act(() => {
if (setPropsFn && newProps) { if (arguments.length > 0 && setPropsFn) {
setPropsFn(newProps); setPropsFn(newProps as Props);
} else if (forceUpdateFn) {
forceUpdateFn();
} }
}); });
} }
+37 -27
View File
@@ -8,7 +8,6 @@ import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
import type React from 'react'; import type React from 'react';
import { renderWithProviders } from '../test-utils/render.js'; import { renderWithProviders } from '../test-utils/render.js';
import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink'; import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink';
import { type Config } from '@google/gemini-cli-core';
import { App } from './App.js'; import { App } from './App.js';
import { type UIState } from './contexts/UIStateContext.js'; import { type UIState } from './contexts/UIStateContext.js';
import { StreamingState, ToolCallStatus } from './types.js'; import { StreamingState, ToolCallStatus } from './types.js';
@@ -22,10 +21,6 @@ vi.mock('ink', async (importOriginal) => {
}; };
}); });
vi.mock('./components/MainContent.js', () => ({
MainContent: () => <Text>MainContent</Text>,
}));
vi.mock('./components/DialogManager.js', () => ({ vi.mock('./components/DialogManager.js', () => ({
DialogManager: () => <Text>DialogManager</Text>, DialogManager: () => <Text>DialogManager</Text>,
})); }));
@@ -34,9 +29,16 @@ vi.mock('./components/Composer.js', () => ({
Composer: () => <Text>Composer</Text>, Composer: () => <Text>Composer</Text>,
})); }));
vi.mock('./components/Notifications.js', () => ({ vi.mock('./components/Notifications.js', async () => {
Notifications: () => <Text>Notifications</Text>, const { Text, Box } = await import('ink');
})); return {
Notifications: () => (
<Box>
<Text>Notifications</Text>
</Box>
),
};
});
vi.mock('./components/QuittingDisplay.js', () => ({ vi.mock('./components/QuittingDisplay.js', () => ({
QuittingDisplay: () => <Text>Quitting...</Text>, QuittingDisplay: () => <Text>Quitting...</Text>,
@@ -46,9 +48,16 @@ vi.mock('./components/HistoryItemDisplay.js', () => ({
HistoryItemDisplay: () => <Text>HistoryItemDisplay</Text>, HistoryItemDisplay: () => <Text>HistoryItemDisplay</Text>,
})); }));
vi.mock('./components/Footer.js', () => ({ vi.mock('./components/Footer.js', async () => {
Footer: () => <Text>Footer</Text>, const { Text, Box } = await import('ink');
})); return {
Footer: () => (
<Box>
<Text>Footer</Text>
</Box>
),
};
});
describe('App', () => { describe('App', () => {
beforeEach(() => { beforeEach(() => {
@@ -87,7 +96,7 @@ describe('App', () => {
useAlternateBuffer: false, useAlternateBuffer: false,
}); });
expect(lastFrame()).toContain('MainContent'); expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Composer'); expect(lastFrame()).toContain('Composer');
}); });
@@ -133,7 +142,7 @@ describe('App', () => {
uiState: dialogUIState, uiState: dialogUIState,
}); });
expect(lastFrame()).toContain('MainContent'); expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('DialogManager'); expect(lastFrame()).toContain('DialogManager');
}); });
@@ -165,10 +174,10 @@ describe('App', () => {
uiState: mockUIState, uiState: mockUIState,
}); });
expect(lastFrame()).toContain(`Notifications expect(lastFrame()).toContain('Notifications');
Footer expect(lastFrame()).toContain('Footer');
MainContent expect(lastFrame()).toContain('Tips for getting started');
Composer`); expect(lastFrame()).toContain('Composer');
}); });
it('should render DefaultAppLayout when screen reader is not enabled', () => { it('should render DefaultAppLayout when screen reader is not enabled', () => {
@@ -178,9 +187,9 @@ Composer`);
uiState: mockUIState, uiState: mockUIState,
}); });
expect(lastFrame()).toContain(`MainContent expect(lastFrame()).toContain('Tips for getting started');
Notifications expect(lastFrame()).toContain('Notifications');
Composer`); expect(lastFrame()).toContain('Composer');
}); });
it('should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on', () => { it('should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on', () => {
@@ -209,19 +218,20 @@ Composer`);
pendingGeminiHistoryItems: [{ type: 'tool_group', tools: toolCalls }], pendingGeminiHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
} as UIState; } as UIState;
const configWithExperiment = { const configWithExperiment = makeFakeConfig();
...makeFakeConfig(), vi.spyOn(
isEventDrivenSchedulerEnabled: () => true, configWithExperiment,
isTrustedFolder: () => true, 'isEventDrivenSchedulerEnabled',
getIdeMode: () => false, ).mockReturnValue(true);
} as unknown as Config; vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true);
vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false);
const { lastFrame } = renderWithProviders(<App />, { const { lastFrame } = renderWithProviders(<App />, {
uiState: stateWithConfirmingTool, uiState: stateWithConfirmingTool,
config: configWithExperiment, config: configWithExperiment,
}); });
expect(lastFrame()).toContain('MainContent'); expect(lastFrame()).toContain('Tips for getting started');
expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue
expect(lastFrame()).toContain('1 of 1'); expect(lastFrame()).toContain('1 of 1');
@@ -1,108 +1,135 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`App > Snapshots > renders default layout correctly 1`] = ` exports[`App > Snapshots > renders default layout correctly 1`] = `
"MainContent "
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
Notifications Notifications
Composer Composer
" "
`; `;
exports[`App > Snapshots > renders screen reader layout correctly 1`] = ` exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
"Notifications "Notifications
Footer Footer
MainContent
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
Composer" Composer"
`; `;
exports[`App > Snapshots > renders with dialogs visible 1`] = ` exports[`App > Snapshots > renders with dialogs visible 1`] = `
"MainContent "
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Notifications Notifications
DialogManager DialogManager
" "
`; `;
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = ` exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
"MainContent "
Notifications ███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
HistoryItemDisplay
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ ╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 1 │ │ Action Required 1 of 1 │
│ │ │ │
│ ? ls list directory │ │ ? ls list directory │
│ │ │ │
│ ls │ │ ls │
│ │
│ Allow execution of: 'ls'? │ │ Allow execution of: 'ls'? │
│ │ │ │
│ ● 1. Allow once │ │ ● 1. Allow once │
@@ -110,28 +137,15 @@ Notifications
│ 3. No, suggest changes (esc) │ │ 3. No, suggest changes (esc) │
│ │ │ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Notifications
Composer Composer
" "
`; `;
@@ -141,6 +141,8 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
isFocused={isFocused} isFocused={isFocused}
activeShellPtyId={activeShellPtyId} activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused} embeddedShellFocused={embeddedShellFocused}
borderTop={itemForDisplay.borderTop}
borderBottom={itemForDisplay.borderBottom}
/> />
)} )}
{itemForDisplay.type === 'compression' && ( {itemForDisplay.type === 'compression' && (
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { render } from '../../test-utils/render.js'; import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js'; import { waitFor } from '../../test-utils/async.js';
import { MainContent } from './MainContent.js'; import { MainContent } from './MainContent.js';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
@@ -12,30 +12,38 @@ import { Box, Text } from 'ink';
import type React from 'react'; import type React from 'react';
// Mock dependencies // Mock dependencies
vi.mock('../contexts/AppContext.js', () => ({ vi.mock('../contexts/AppContext.js', async () => {
useAppContext: () => ({ const actual = await vi.importActual('../contexts/AppContext.js');
version: '1.0.0', return {
}), ...actual,
})); useAppContext: () => ({
version: '1.0.0',
}),
};
});
vi.mock('../contexts/UIStateContext.js', () => ({ vi.mock('../contexts/UIStateContext.js', async () => {
useUIState: () => ({ const actual = await vi.importActual('../contexts/UIStateContext.js');
history: [ return {
{ id: 1, role: 'user', content: 'Hello' }, ...actual,
{ id: 2, role: 'model', content: 'Hi there' }, useUIState: () => ({
], history: [
pendingHistoryItems: [], { id: 1, role: 'user', content: 'Hello' },
mainAreaWidth: 80, { id: 2, role: 'model', content: 'Hi there' },
staticAreaMaxItemHeight: 20, ],
availableTerminalHeight: 24, pendingHistoryItems: [],
slashCommands: [], mainAreaWidth: 80,
constrainHeight: false, staticAreaMaxItemHeight: 20,
isEditorDialogOpen: false, availableTerminalHeight: 24,
activePtyId: undefined, slashCommands: [],
embeddedShellFocused: false, constrainHeight: false,
historyRemountKey: 0, isEditorDialogOpen: false,
}), activePtyId: undefined,
})); embeddedShellFocused: false,
historyRemountKey: 0,
}),
};
});
vi.mock('../hooks/useAlternateBuffer.js', () => ({ vi.mock('../hooks/useAlternateBuffer.js', () => ({
useAlternateBuffer: vi.fn(), useAlternateBuffer: vi.fn(),
@@ -95,7 +103,7 @@ describe('MainContent', () => {
}); });
it('renders in normal buffer mode', async () => { it('renders in normal buffer mode', async () => {
const { lastFrame } = render(<MainContent />); const { lastFrame } = renderWithProviders(<MainContent />);
await waitFor(() => expect(lastFrame()).toContain('AppHeader')); await waitFor(() => expect(lastFrame()).toContain('AppHeader'));
const output = lastFrame(); const output = lastFrame();
@@ -105,7 +113,7 @@ describe('MainContent', () => {
it('renders in alternate buffer mode', async () => { it('renders in alternate buffer mode', async () => {
vi.mocked(useAlternateBuffer).mockReturnValue(true); vi.mocked(useAlternateBuffer).mockReturnValue(true);
const { lastFrame } = render(<MainContent />); const { lastFrame } = renderWithProviders(<MainContent />);
await waitFor(() => expect(lastFrame()).toContain('ScrollableList')); await waitFor(() => expect(lastFrame()).toContain('ScrollableList'));
const output = lastFrame(); const output = lastFrame();
@@ -116,7 +124,7 @@ describe('MainContent', () => {
it('does not constrain height in alternate buffer mode', async () => { it('does not constrain height in alternate buffer mode', async () => {
vi.mocked(useAlternateBuffer).mockReturnValue(true); vi.mocked(useAlternateBuffer).mockReturnValue(true);
const { lastFrame } = render(<MainContent />); const { lastFrame } = renderWithProviders(<MainContent />);
await waitFor(() => expect(lastFrame()).toContain('HistoryItem: Hello')); await waitFor(() => expect(lastFrame()).toContain('HistoryItem: Hello'));
const output = lastFrame(); const output = lastFrame();
+45 -25
View File
@@ -6,16 +6,20 @@
import { Box, Static } from 'ink'; import { Box, Static } from 'ink';
import { HistoryItemDisplay } from './HistoryItemDisplay.js'; import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { useUIState } from '../contexts/UIStateContext.js'; import { useUIState } from '../contexts/UIStateContext.js';
import { useAppContext } from '../contexts/AppContext.js'; import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js'; import { AppHeader } from './AppHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { SCROLL_TO_ITEM_END } from './shared/VirtualizedList.js'; import {
SCROLL_TO_ITEM_END,
type VirtualizedListRef,
} from './shared/VirtualizedList.js';
import { ScrollableList } from './shared/ScrollableList.js'; import { ScrollableList } from './shared/ScrollableList.js';
import { useMemo, memo, useCallback } from 'react'; import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js'; import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { useConfig } from '../contexts/ConfigContext.js';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay); const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
const MemoizedAppHeader = memo(AppHeader); const MemoizedAppHeader = memo(AppHeader);
@@ -27,8 +31,21 @@ const MemoizedAppHeader = memo(AppHeader);
export const MainContent = () => { export const MainContent = () => {
const { version } = useAppContext(); const { version } = useAppContext();
const uiState = useUIState(); const uiState = useUIState();
const config = useConfig();
const isAlternateBuffer = useAlternateBuffer(); const isAlternateBuffer = useAlternateBuffer();
const confirmingTool = useConfirmingTool();
const showConfirmationQueue =
config.isEventDrivenSchedulerEnabled() && confirmingTool !== null;
const scrollableListRef = useRef<VirtualizedListRef<unknown>>(null);
useEffect(() => {
if (showConfirmationQueue) {
scrollableListRef.current?.scrollToEnd();
}
}, [showConfirmationQueue, confirmingTool]);
const { const {
pendingHistoryItems, pendingHistoryItems,
mainAreaWidth, mainAreaWidth,
@@ -59,27 +76,27 @@ export const MainContent = () => {
const pendingItems = useMemo( const pendingItems = useMemo(
() => ( () => (
<OverflowProvider> <Box flexDirection="column">
<Box flexDirection="column"> {pendingHistoryItems.map((item, i) => (
{pendingHistoryItems.map((item, i) => ( <HistoryItemDisplay
<HistoryItemDisplay key={i}
key={i} availableTerminalHeight={
availableTerminalHeight={ uiState.constrainHeight && !isAlternateBuffer
uiState.constrainHeight && !isAlternateBuffer ? availableTerminalHeight
? availableTerminalHeight : undefined
: undefined }
} terminalWidth={mainAreaWidth}
terminalWidth={mainAreaWidth} item={{ ...item, id: 0 }}
item={{ ...item, id: 0 }} isPending={true}
isPending={true} isFocused={!uiState.isEditorDialogOpen}
isFocused={!uiState.isEditorDialogOpen} activeShellPtyId={uiState.activePtyId}
activeShellPtyId={uiState.activePtyId} embeddedShellFocused={uiState.embeddedShellFocused}
embeddedShellFocused={uiState.embeddedShellFocused} />
/> ))}
))} {showConfirmationQueue && confirmingTool && (
<ShowMoreLines constrainHeight={uiState.constrainHeight} /> <ToolConfirmationQueue confirmingTool={confirmingTool} />
</Box> )}
</OverflowProvider> </Box>
), ),
[ [
pendingHistoryItems, pendingHistoryItems,
@@ -90,6 +107,8 @@ export const MainContent = () => {
uiState.isEditorDialogOpen, uiState.isEditorDialogOpen,
uiState.activePtyId, uiState.activePtyId,
uiState.embeddedShellFocused, uiState.embeddedShellFocused,
showConfirmationQueue,
confirmingTool,
], ],
); );
@@ -128,6 +147,7 @@ export const MainContent = () => {
if (isAlternateBuffer) { if (isAlternateBuffer) {
return ( return (
<ScrollableList <ScrollableList
ref={scrollableListRef}
hasFocus={!uiState.isEditorDialogOpen} hasFocus={!uiState.isEditorDialogOpen}
width={uiState.terminalWidth} width={uiState.terminalWidth}
data={virtualizedData} data={virtualizedData}
@@ -5,9 +5,11 @@
*/ */
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { Box } from 'ink';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js'; import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { ToolCallStatus } from '../types.js'; import { ToolCallStatus, StreamingState } from '../types.js';
import { renderWithProviders } from '../../test-utils/render.js'; import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import type { Config } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core';
import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js'; import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js';
@@ -86,4 +88,95 @@ describe('ToolConfirmationQueue', () => {
expect(lastFrame()).toBe(''); expect(lastFrame()).toBe('');
}); });
it('renders expansion hint when content is long and constrained', async () => {
const longDiff = '@@ -1,1 +1,50 @@\n' + '+line\n'.repeat(50);
const confirmingTool = {
tool: {
callId: 'call-1',
name: 'replace',
description: 'edit file',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'edit' as const,
title: 'Confirm edit',
fileName: 'test.ts',
filePath: '/test.ts',
fileDiff: longDiff,
originalContent: 'old',
newContent: 'new',
onConfirm: vi.fn(),
},
},
index: 1,
total: 1,
};
const { lastFrame } = renderWithProviders(
<Box flexDirection="column" height={30}>
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>
</Box>,
{
config: mockConfig,
useAlternateBuffer: false,
uiState: {
terminalWidth: 80,
terminalHeight: 20,
constrainHeight: true,
streamingState: StreamingState.WaitingForConfirmation,
},
},
);
await waitFor(() =>
expect(lastFrame()).toContain('Press ctrl-o to show more lines'),
);
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).toContain('Press ctrl-o to show more lines');
});
it('does not render expansion hint when constrainHeight is false', () => {
const longDiff = 'line\n'.repeat(50);
const confirmingTool = {
tool: {
callId: 'call-1',
name: 'replace',
description: 'edit file',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'edit' as const,
title: 'Confirm edit',
fileName: 'test.ts',
filePath: '/test.ts',
fileDiff: longDiff,
originalContent: 'old',
newContent: 'new',
onConfirm: vi.fn(),
},
},
index: 1,
total: 1,
};
const { lastFrame } = renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
{
config: mockConfig,
uiState: {
terminalWidth: 80,
terminalHeight: 40,
constrainHeight: false,
streamingState: StreamingState.WaitingForConfirmation,
},
},
);
const output = lastFrame();
expect(output).not.toContain('Press ctrl-o to show more lines');
expect(output).toMatchSnapshot();
});
}); });
@@ -12,6 +12,10 @@ import { ToolConfirmationMessage } from './messages/ToolConfirmationMessage.js';
import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js'; import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js';
import { useUIState } from '../contexts/UIStateContext.js'; import { useUIState } from '../contexts/UIStateContext.js';
import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js'; import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { StickyHeader } from './StickyHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
interface ToolConfirmationQueueProps { interface ToolConfirmationQueueProps {
confirmingTool: ConfirmingToolState; confirmingTool: ConfirmingToolState;
@@ -21,7 +25,8 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
confirmingTool, confirmingTool,
}) => { }) => {
const config = useConfig(); const config = useConfig();
const { terminalWidth, terminalHeight } = useUIState(); const isAlternateBuffer = useAlternateBuffer();
const { mainAreaWidth, terminalHeight, constrainHeight } = useUIState();
const { tool, index, total } = confirmingTool; const { tool, index, total } = confirmingTool;
// Safety check: ToolConfirmationMessage requires confirmationDetails // Safety check: ToolConfirmationMessage requires confirmationDetails
@@ -38,52 +43,85 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
// - 2 lines for the rounded border // - 2 lines for the rounded border
// - 2 lines for the Header (text + margin) // - 2 lines for the Header (text + margin)
// - 2 lines for Tool Identity (text + margin) // - 2 lines for Tool Identity (text + margin)
const availableContentHeight = Math.max(maxHeight - 6, 4); const availableContentHeight =
constrainHeight && !isAlternateBuffer
? Math.max(maxHeight - 6, 4)
: undefined;
const borderColor = theme.status.warning;
return ( return (
<Box <OverflowProvider>
flexDirection="column" <Box flexDirection="column" width={mainAreaWidth} flexShrink={0}>
borderStyle="round" <StickyHeader
borderColor={theme.status.warning} width={mainAreaWidth}
paddingX={1} isFirst={true}
// Matches existing layout spacing borderColor={borderColor}
width={terminalWidth} borderDimColor={false}
flexShrink={0} >
> <Box flexDirection="column" width={mainAreaWidth - 4}>
{/* Header */} {/* Header */}
<Box marginBottom={1} justifyContent="space-between"> <Box marginBottom={1} justifyContent="space-between">
<Text color={theme.status.warning} bold> <Text color={theme.status.warning} bold>
Action Required Action Required
</Text> </Text>
<Text color={theme.text.secondary}> <Text color={theme.text.secondary}>
{index} of {total} {index} of {total}
</Text> </Text>
</Box> </Box>
{/* Tool Identity (Context) */} {/* Tool Identity (Context) */}
<Box marginBottom={1}> <Box>
<ToolStatusIndicator status={tool.status} name={tool.name} /> <ToolStatusIndicator status={tool.status} name={tool.name} />
<ToolInfo <ToolInfo
name={tool.name} name={tool.name}
status={tool.status} status={tool.status}
description={tool.description} description={tool.description}
emphasis="high" emphasis="high"
/>
</Box>
</Box>
</StickyHeader>
<Box
width={mainAreaWidth}
borderStyle="round"
borderColor={borderColor}
borderTop={false}
borderBottom={false}
borderLeft={true}
borderRight={true}
paddingX={1}
flexDirection="column"
>
{/* Interactive Area */}
{/*
Note: We force isFocused={true} because if this component is rendered,
it effectively acts as a modal over the shell/composer.
*/}
<ToolConfirmationMessage
callId={tool.callId}
confirmationDetails={tool.confirmationDetails}
config={config}
terminalWidth={mainAreaWidth - 4} // Adjust for parent border/padding
availableTerminalHeight={availableContentHeight}
isFocused={true}
/>
</Box>
<Box
height={0}
width={mainAreaWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={true}
borderColor={borderColor}
borderStyle="round"
/> />
</Box> </Box>
<Box paddingX={2} marginBottom={1}>
{/* Interactive Area */} <ShowMoreLines constrainHeight={constrainHeight} />
{/* </Box>
Note: We force isFocused={true} because if this component is rendered, </OverflowProvider>
it effectively acts as a modal over the shell/composer.
*/}
<ToolConfirmationMessage
callId={tool.callId}
confirmationDetails={tool.confirmationDetails}
config={config}
terminalWidth={terminalWidth - 4} // Adjust for parent border/padding
availableTerminalHeight={availableContentHeight}
isFocused={true}
/>
</Box>
); );
}; };
@@ -130,5 +130,6 @@ Tips for getting started:
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Hello Gemini > Hello Gemini
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
✦ Hello User!" ✦ Hello User!
"
`; `;
@@ -51,7 +51,8 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
47 Line 47 47 Line 47
48 Line 48 48 Line 48
49 Line 49 49 Line 49
50 Line 50" 50 Line 50
"
`; `;
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = ` exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = `
@@ -105,13 +106,13 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
47 Line 47 47 Line 47
48 Line 48 48 Line 48
49 Line 49 49 Line 49
50 Line 50" 50 Line 50
"
`; `;
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini item 1`] = ` exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini item 1`] = `
"✦ Example code block: "✦ Example code block:
... first 41 lines hidden ... ... first 42 lines hidden ...
42 Line 42
43 Line 43 43 Line 43
44 Line 44 44 Line 44
45 Line 45 45 Line 45
@@ -119,13 +120,13 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
47 Line 47 47 Line 47
48 Line 48 48 Line 48
49 Line 49 49 Line 49
50 Line 50" 50 Line 50
"
`; `;
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini_content item 1`] = ` exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini_content item 1`] = `
" Example code block: " Example code block:
... first 41 lines hidden ... ... first 42 lines hidden ...
42 Line 42
43 Line 43 43 Line 43
44 Line 44 44 Line 44
45 Line 45 45 Line 45
@@ -133,7 +134,8 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
47 Line 47 47 Line 47
48 Line 48 48 Line 48
49 Line 49 49 Line 49
50 Line 50" 50 Line 50
"
`; `;
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should render a full gemini item when using availableTerminalHeightGemini 1`] = ` exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should render a full gemini item when using availableTerminalHeightGemini 1`] = `
@@ -187,7 +189,8 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should r
47 Line 47 47 Line 47
48 Line 48 48 Line 48
49 Line 49 49 Line 49
50 Line 50" 50 Line 50
"
`; `;
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = ` exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should render a full gemini_content item when using availableTerminalHeightGemini 1`] = `
@@ -241,7 +244,8 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should r
47 Line 47 47 Line 47
48 Line 48 48 Line 48
49 Line 49 49 Line 49
50 Line 50" 50 Line 50
"
`; `;
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should render a truncated gemini item 1`] = ` exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should render a truncated gemini item 1`] = `
@@ -295,7 +299,8 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should r
47 Line 47 47 Line 47
48 Line 48 48 Line 48
49 Line 49 49 Line 49
50 Line 50" 50 Line 50
"
`; `;
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should render a truncated gemini_content item 1`] = ` exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should render a truncated gemini_content item 1`] = `
@@ -349,7 +354,8 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=true) > should r
47 Line 47 47 Line 47
48 Line 48 48 Line 48
49 Line 49 49 Line 49
50 Line 50" 50 Line 50
"
`; `;
exports[`<HistoryItemDisplay /> > renders AgentsStatus for "agents_list" type 1`] = ` exports[`<HistoryItemDisplay /> > renders AgentsStatus for "agents_list" type 1`] = `
@@ -4,6 +4,5 @@ exports[`MainContent > does not constrain height in alternate buffer mode 1`] =
"ScrollableList "ScrollableList
AppHeader AppHeader
HistoryItem: Hello (height: undefined) HistoryItem: Hello (height: undefined)
HistoryItem: Hi there (height: undefined) HistoryItem: Hi there (height: undefined)"
ShowMoreLines"
`; `;
@@ -1,5 +1,60 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToolConfirmationQueue > does not render expansion hint when constrainHeight is false 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 1 │
│ │
│ ? replace edit file │
│ │
│ ╭──────────────────────────────────────────────────────────────────────────╮ │
│ │ │ │
│ │ No changes detected. │ │
│ │ │ │
│ ╰──────────────────────────────────────────────────────────────────────────╯ │
│ Apply this change? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. Modify with external editor │
│ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`ToolConfirmationQueue > renders expansion hint when content is long and constrained 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 1 │
│ │
│ ? replace edit file │
│ │
│ ... first 49 lines hidden ... │
│ 50 line │
│ 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
"
`;
exports[`ToolConfirmationQueue > renders the confirming tool with progress indicator 1`] = ` exports[`ToolConfirmationQueue > renders the confirming tool with progress indicator 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮ "╭──────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 3 │ │ Action Required 1 of 3 │
@@ -7,12 +62,12 @@ exports[`ToolConfirmationQueue > renders the confirming tool with progress indic
│ ? ls list files │ │ ? ls list files │
│ │ │ │
│ ls │ │ ls │
│ │
│ Allow execution of: 'ls'? │ │ Allow execution of: 'ls'? │
│ │ │ │
│ ● 1. Allow once │ │ ● 1. Allow once │
│ 2. Allow for this session │ │ 2. Allow for this session │
│ 3. No, suggest changes (esc) │ │ 3. No, suggest changes (esc) │
│ │ │ │
╰──────────────────────────────────────────────────────────────────────────────╯" ╰──────────────────────────────────────────────────────────────────────────────╯
"
`; `;
@@ -7,6 +7,7 @@
import type React from 'react'; import type React from 'react';
import { Text, Box } from 'ink'; import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { theme } from '../../semantic-colors.js'; import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js'; import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
import { useUIState } from '../../contexts/UIStateContext.js'; import { useUIState } from '../../contexts/UIStateContext.js';
@@ -42,11 +43,18 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
text={text} text={text}
isPending={isPending} isPending={isPending}
availableTerminalHeight={ availableTerminalHeight={
isAlternateBuffer ? undefined : availableTerminalHeight isAlternateBuffer || availableTerminalHeight === undefined
? undefined
: Math.max(availableTerminalHeight - 1, 1)
} }
terminalWidth={terminalWidth} terminalWidth={terminalWidth}
renderMarkdown={renderMarkdown} renderMarkdown={renderMarkdown}
/> />
<Box marginBottom={1}>
<ShowMoreLines
constrainHeight={availableTerminalHeight !== undefined}
/>
</Box>
</Box> </Box>
</Box> </Box>
); );
@@ -7,6 +7,7 @@
import type React from 'react'; import type React from 'react';
import { Box } from 'ink'; import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js'; import { useUIState } from '../../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js'; import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
@@ -40,11 +41,18 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
text={text} text={text}
isPending={isPending} isPending={isPending}
availableTerminalHeight={ availableTerminalHeight={
isAlternateBuffer ? undefined : availableTerminalHeight isAlternateBuffer || availableTerminalHeight === undefined
? undefined
: Math.max(availableTerminalHeight - 1, 1)
} }
terminalWidth={terminalWidth} terminalWidth={terminalWidth}
renderMarkdown={renderMarkdown} renderMarkdown={renderMarkdown}
/> />
<Box marginBottom={1}>
<ShowMoreLines
constrainHeight={availableTerminalHeight !== undefined}
/>
</Box>
</Box> </Box>
); );
}; };
@@ -61,7 +61,7 @@ export const ToolConfirmationMessage: React.FC<
const handleConfirm = useCallback( const handleConfirm = useCallback(
(outcome: ToolConfirmationOutcome) => { (outcome: ToolConfirmationOutcome) => {
void confirm(callId, outcome).catch((error) => { void confirm(callId, outcome).catch((error: unknown) => {
debugLogger.error( debugLogger.error(
`Failed to handle tool confirmation for ${callId}:`, `Failed to handle tool confirmation for ${callId}:`,
error, error,
@@ -240,7 +240,8 @@ export const ToolConfirmationMessage: React.FC<
MARGIN_BODY_BOTTOM + MARGIN_BODY_BOTTOM +
HEIGHT_QUESTION + HEIGHT_QUESTION +
MARGIN_QUESTION_BOTTOM + MARGIN_QUESTION_BOTTOM +
optionsCount; optionsCount +
1; // Reserve one line for 'ShowMoreLines' hint
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1); return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
}, [availableTerminalHeight, getOptions]); }, [availableTerminalHeight, getOptions]);
@@ -431,7 +432,7 @@ export const ToolConfirmationMessage: React.FC<
<Box flexDirection="column" paddingTop={0} paddingBottom={1}> <Box flexDirection="column" paddingTop={0} paddingBottom={1}>
{/* Body Content (Diff Renderer or Command Info) */} {/* Body Content (Diff Renderer or Command Info) */}
{/* No separate context display here anymore for edits */} {/* 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 <MaxSizedBox
maxHeight={availableBodyContentHeight()} maxHeight={availableBodyContentHeight()}
maxWidth={terminalWidth} maxWidth={terminalWidth}
@@ -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();
}
});
});
@@ -16,6 +16,8 @@ import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js'; import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool, isThisShellFocused } from './ToolShared.js'; import { isShellTool, isThisShellFocused } from './ToolShared.js';
import { ASK_USER_DISPLAY_NAME } from '@google/gemini-cli-core'; import { ASK_USER_DISPLAY_NAME } from '@google/gemini-cli-core';
import { ShowMoreLines } from '../ShowMoreLines.js';
import { useUIState } from '../../contexts/UIStateContext.js';
interface ToolGroupMessageProps { interface ToolGroupMessageProps {
groupId: number; groupId: number;
@@ -26,6 +28,8 @@ interface ToolGroupMessageProps {
activeShellPtyId?: number | null; activeShellPtyId?: number | null;
embeddedShellFocused?: boolean; embeddedShellFocused?: boolean;
onShellInputSubmit?: (input: string) => void; onShellInputSubmit?: (input: string) => void;
borderTop?: boolean;
borderBottom?: boolean;
} }
// Helper to identify Ask User tools that are in progress (have their own dialog UI) // 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, isFocused = true,
activeShellPtyId, activeShellPtyId,
embeddedShellFocused, embeddedShellFocused,
borderTop: borderTopOverride,
borderBottom: borderBottomOverride,
}) => { }) => {
// Filter out in-progress Ask User tools (they have their own AskUserDialog UI) // Filter out in-progress Ask User tools (they have their own AskUserDialog UI)
const toolCalls = useMemo( const toolCalls = useMemo(
@@ -53,6 +59,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
); );
const config = useConfig(); const config = useConfig();
const { constrainHeight } = useUIState();
const isEventDriven = config.isEventDrivenSchedulerEnabled(); 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), // If all tools are hidden (e.g. group only contains confirming or pending tools),
// render nothing in the history log. // render nothing in the history log unless we have a border override.
if (visibleToolCalls.length === 0) { if (
visibleToolCalls.length === 0 &&
borderTopOverride === undefined &&
borderBottomOverride === undefined
) {
return null; return null;
} }
@@ -161,7 +172,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
: toolAwaitingApproval : toolAwaitingApproval
? ('low' as const) ? ('low' as const)
: ('medium' as const), : ('medium' as const),
isFirst, isFirst:
borderTopOverride !== undefined
? borderTopOverride && isFirst
: isFirst,
borderColor, borderColor,
borderDimColor, borderDimColor,
}; };
@@ -225,20 +239,25 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
We have to keep the bottom border separate so it doesn't get We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it. drawn over by the sticky header directly inside it.
*/ */
visibleToolCalls.length > 0 && ( (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
<Box <Box
height={0} height={0}
width={terminalWidth} width={terminalWidth}
borderLeft={true} borderLeft={true}
borderRight={true} borderRight={true}
borderTop={false} borderTop={false}
borderBottom={true} borderBottom={borderBottomOverride ?? true}
borderColor={borderColor} borderColor={borderColor}
borderDimColor={borderDimColor} borderDimColor={borderDimColor}
borderStyle="round" borderStyle="round"
/> />
) )
} }
{(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && (
<Box paddingX={1}>
<ShowMoreLines constrainHeight={constrainHeight} />
</Box>
)}
</Box> </Box>
); );
}; };
@@ -56,6 +56,9 @@ vi.mock('../../contexts/OverflowContext.js', () => ({
addOverflowingId: vi.fn(), addOverflowingId: vi.fn(),
removeOverflowingId: vi.fn(), removeOverflowingId: vi.fn(),
}), }),
useOverflowState: () => ({
overflowingIds: new Set(),
}),
})); }));
describe('ToolResultDisplay', () => { describe('ToolResultDisplay', () => {
@@ -16,7 +16,7 @@ import { useUIState } from '../../contexts/UIStateContext.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js'; import { tryParseJSON } from '../../../utils/jsonoutput.js';
const STATIC_HEIGHT = 1; 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 const MIN_LINES_SHOWN = 2; // show at least this many lines
// Large threshold to ensure we don't cause performance issues for very large // Large threshold to ensure we don't cause performance issues for very large
@@ -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();
}
});
});
@@ -5,13 +5,15 @@ exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders pending st
\`\`\`javascript \`\`\`javascript
const x = 1; const x = 1;
\`\`\`" \`\`\`
"
`; `;
exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders pending state with renderMarkdown=true 1`] = ` exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders pending state with renderMarkdown=true 1`] = `
"✦ Test bold and code markdown "✦ 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`] = ` 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 \`\`\`javascript
const x = 1; const x = 1;
\`\`\`" \`\`\`
"
`; `;
exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true '(default)' 1`] = ` exports[`<GeminiMessage /> - Raw Markdown Display Snapshots > renders with renderMarkdown=true '(default)' 1`] = `
"✦ Test bold and code markdown "✦ Test bold and code markdown
1 const x = 1;" 1 const x = 1;
"
`; `;
@@ -5,7 +5,6 @@ exports[`ToolConfirmationMessage Redirection > should display redirection warnin
Note: Command contains redirection which can be undesirable. Note: Command contains redirection which can be undesirable.
Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future. Tip: Toggle auto-edit (Shift+Tab) to allow redirection in the future.
Allow execution of: 'echo, redirection (>)'? Allow execution of: 'echo, redirection (>)'?
● 1. Allow once ● 1. Allow once
@@ -4,7 +4,6 @@ exports[`ToolConfirmationMessage > should display multiple commands for exec typ
"echo "hello" "echo "hello"
ls -la ls -la
whoami whoami
Allow execution of 3 commands? Allow execution of 3 commands?
● 1. Allow once ● 1. Allow once
@@ -18,7 +17,6 @@ exports[`ToolConfirmationMessage > should display urls if prompt and url are dif
URLs to fetch: URLs to fetch:
- https://raw.githubusercontent.com/google/gemini-react/main/README.md - https://raw.githubusercontent.com/google/gemini-react/main/README.md
Do you want to proceed? Do you want to proceed?
● 1. Allow once ● 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`] = ` exports[`ToolConfirmationMessage > should not display urls if prompt and url are the same 1`] = `
"https://example.com "https://example.com
Do you want to proceed? Do you want to proceed?
● 1. Allow once ● 1. Allow once
@@ -44,7 +41,6 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
│ No changes detected. │ │ No changes detected. │
│ │ │ │
╰──────────────────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change? Apply this change?
● 1. Allow once ● 1. Allow once
@@ -59,7 +55,6 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
│ No changes detected. │ │ No changes detected. │
│ │ │ │
╰──────────────────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change? Apply this change?
● 1. Allow once ● 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`] = ` exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
"echo "hello" "echo "hello"
Allow execution of: 'echo'? Allow execution of: 'echo'?
● 1. Allow once ● 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`] = ` exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations' > should show "allow always" when folder is trusted 1`] = `
"echo "hello" "echo "hello"
Allow execution of: 'echo'? Allow execution of: 'echo'?
● 1. Allow once ● 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`] = ` exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
"https://example.com "https://example.com
Do you want to proceed? Do you want to proceed?
● 1. Allow once ● 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`] = ` exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations' > should show "allow always" when folder is trusted 1`] = `
"https://example.com "https://example.com
Do you want to proceed? Do you want to proceed?
● 1. Allow once ● 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`] = ` exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
"MCP Server: test-server "MCP Server: test-server
Tool: test-tool Tool: test-tool
Allow execution of MCP tool "test-tool" from server "test-server"? Allow execution of MCP tool "test-tool" from server "test-server"?
● 1. Allow once ● 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`] = ` exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' > should show "allow always" when folder is trusted 1`] = `
"MCP Server: test-server "MCP Server: test-server
Tool: test-tool Tool: test-tool
Allow execution of MCP tool "test-tool" from server "test-server"? Allow execution of MCP tool "test-tool" from server "test-server"?
● 1. Allow once ● 1. Allow once
@@ -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"
`;
@@ -34,7 +34,6 @@ exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation wit
│ │ │ │
│ Test result │ │ Test result │
│ Do you want to proceed? │ │ Do you want to proceed? │
│ │
│ Do you want to proceed? │ │ Do you want to proceed? │
│ │ │ │
│ ● 1. Allow once │ │ ● 1. Allow once │
@@ -50,7 +49,6 @@ exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation wit
│ │ │ │
│ Test result │ │ Test result │
│ Do you want to proceed? │ │ Do you want to proceed? │
│ │
│ Do you want to proceed? │ │ Do you want to proceed? │
│ │ │ │
│ ● 1. Allow once │ │ ● 1. Allow once │
@@ -67,7 +65,6 @@ exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialo
│ │ │ │
│ Test result │ │ Test result │
│ Confirm first tool │ │ Confirm first tool │
│ │
│ Do you want to proceed? │ │ Do you want to proceed? │
│ │ │ │
│ ● 1. Allow once │ │ ● 1. Allow once │
@@ -160,7 +157,6 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call awaiting co
│ │ │ │
│ Test result │ │ Test result │
│ Are you sure you want to proceed? │ │ Are you sure you want to proceed? │
│ │
│ Do you want to proceed? │ │ Do you want to proceed? │
│ │ │ │
│ ● 1. Allow once │ │ ● 1. Allow once │
@@ -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 > renders string result as plain text when renderOutputAsMarkdown is false 1`] = `"**Some result**"`;
exports[`ToolResultDisplay > truncates very long string results 1`] = ` exports[`ToolResultDisplay > truncates very long string results 1`] = `
"... first 251 lines hidden ... "... first 252 lines hidden ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
@@ -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"
`;
+4
View File
@@ -50,8 +50,10 @@ export function mapCoreStatusToDisplayStatus(
*/ */
export function mapToDisplay( export function mapToDisplay(
toolOrTools: ToolCall[] | ToolCall, toolOrTools: ToolCall[] | ToolCall,
options: { borderTop?: boolean; borderBottom?: boolean } = {},
): HistoryItemToolGroup { ): HistoryItemToolGroup {
const toolCalls = Array.isArray(toolOrTools) ? toolOrTools : [toolOrTools]; const toolCalls = Array.isArray(toolOrTools) ? toolOrTools : [toolOrTools];
const { borderTop, borderBottom } = options;
const toolDisplays = toolCalls.map((call): IndividualToolCallDisplay => { const toolDisplays = toolCalls.map((call): IndividualToolCallDisplay => {
let description: string; let description: string;
@@ -128,5 +130,7 @@ export function mapToDisplay(
return { return {
type: 'tool_group', type: 'tool_group',
tools: toolDisplays, tools: toolDisplays,
borderTop,
borderBottom,
}; };
} }
+187 -169
View File
@@ -8,7 +8,7 @@
import type { Mock, MockInstance } from 'vitest'; import type { Mock, MockInstance } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react'; import { act } from 'react';
import { renderHook } from '../../test-utils/render.js'; import { renderHookWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js'; import { waitFor } from '../../test-utils/async.js';
import { useGeminiStream } from './useGeminiStream.js'; import { useGeminiStream } from './useGeminiStream.js';
import { useKeypress } from './useKeypress.js'; import { useKeypress } from './useKeypress.js';
@@ -56,7 +56,7 @@ const MockedGeminiClientClass = vi.hoisted(() =>
this.startChat = mockStartChat; this.startChat = mockStartChat;
this.sendMessageStream = mockSendMessageStream; this.sendMessageStream = mockSendMessageStream;
this.addHistory = vi.fn(); this.addHistory = vi.fn();
this.getCurrentSequenceModel = vi.fn(); this.getCurrentSequenceModel = vi.fn().mockReturnValue('test-model');
this.getChat = vi.fn().mockReturnValue({ this.getChat = vi.fn().mockReturnValue({
recordCompletedToolCalls: vi.fn(), recordCompletedToolCalls: vi.fn(),
}); });
@@ -93,6 +93,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
ValidationRequiredError: MockValidationRequiredError, ValidationRequiredError: MockValidationRequiredError,
parseAndFormatApiError: mockParseAndFormatApiError, parseAndFormatApiError: mockParseAndFormatApiError,
tokenLimit: vi.fn().mockReturnValue(100), // Mock tokenLimit tokenLimit: vi.fn().mockReturnValue(100), // Mock tokenLimit
recordToolCallInteractions: vi.fn().mockResolvedValue(undefined),
getCodeAssistServer: vi.fn().mockReturnValue(undefined),
}; };
}); });
@@ -167,84 +169,92 @@ vi.mock('./useAlternateBuffer.js', () => ({
// --- Tests for useGeminiStream Hook --- // --- Tests for useGeminiStream Hook ---
describe('useGeminiStream', () => { describe('useGeminiStream', () => {
let mockAddItem: Mock; let mockAddItem = vi.fn();
let mockConfig: Config; let mockOnDebugMessage = vi.fn();
let mockOnDebugMessage: Mock; let mockHandleSlashCommand = vi.fn().mockResolvedValue(false);
let mockHandleSlashCommand: Mock;
let mockScheduleToolCalls: Mock; let mockScheduleToolCalls: Mock;
let mockCancelAllToolCalls: Mock; let mockCancelAllToolCalls: Mock;
let mockMarkToolsAsSubmitted: Mock; let mockMarkToolsAsSubmitted: Mock;
let handleAtCommandSpy: MockInstance; let handleAtCommandSpy: MockInstance;
beforeEach(() => { const emptyHistory: any[] = [];
vi.clearAllMocks(); // Clear mocks before each test let capturedOnComplete: any = null;
const mockGetPreferredEditor = vi.fn(() => 'vscode' as EditorType);
const mockOnAuthError = vi.fn();
const mockPerformMemoryRefresh = vi.fn(() => Promise.resolve());
const mockSetModelSwitchedFromQuotaError = vi.fn();
const mockOnCancelSubmit = vi.fn();
const mockSetShellInputFocused = vi.fn();
mockAddItem = vi.fn(); const mockGetGeminiClient = vi.fn().mockImplementation(() => {
// Define the mock for getGeminiClient const clientInstance = new MockedGeminiClientClass(mockConfig);
const mockGetGeminiClient = vi.fn().mockImplementation(() => { return clientInstance;
// MockedGeminiClientClass is defined in the module scope by the previous change. });
// It will use the mockStartChat and mockSendMessageStream that are managed within beforeEach.
const clientInstance = new MockedGeminiClientClass(mockConfig);
return clientInstance;
});
const mockMcpClientManager = { const mockMcpClientManager = {
getDiscoveryState: vi.fn().mockReturnValue(MCPDiscoveryState.COMPLETED), getDiscoveryState: vi.fn().mockReturnValue(MCPDiscoveryState.COMPLETED),
getMcpServerCount: vi.fn().mockReturnValue(0), getMcpServerCount: vi.fn().mockReturnValue(0),
}; };
const contentGeneratorConfig = { const mockConfig: Config = {
apiKey: 'test-api-key',
model: 'gemini-pro',
sandbox: false,
targetDir: '/test/dir',
debugMode: false,
question: undefined,
coreTools: [],
toolDiscoveryCommand: undefined,
toolCallCommand: undefined,
mcpServerCommand: undefined,
mcpServers: undefined,
userAgent: 'test-agent',
userMemory: '',
geminiMdFileCount: 0,
alwaysSkipModificationConfirmation: false,
vertexai: false,
showMemoryUsage: false,
contextFileName: undefined,
storage: {
getProjectTempDir: vi.fn(() => '/test/temp'),
getProjectTempCheckpointsDir: vi.fn(() => '/test/temp/checkpoints'),
} as any,
getToolRegistry: vi.fn(
() => ({ getToolSchemaList: vi.fn(() => []) }) as any,
),
getProjectRoot: vi.fn(() => '/test/dir'),
getCheckpointingEnabled: vi.fn(() => false),
getGeminiClient: mockGetGeminiClient,
getMcpClientManager: () => mockMcpClientManager as any,
getApprovalMode: vi.fn(() => ApprovalMode.DEFAULT),
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
addHistory: vi.fn(),
getSessionId: vi.fn(() => 'test-session-id'),
setQuotaErrorOccurred: vi.fn(),
getQuotaErrorOccurred: vi.fn(() => false),
getModel: vi.fn(() => 'gemini-2.5-pro'),
getContentGeneratorConfig: vi.fn(() => ({
model: 'test-model', model: 'test-model',
apiKey: 'test-key', apiKey: 'test-key',
vertexai: false, vertexai: false,
authType: AuthType.USE_GEMINI, authType: AuthType.USE_GEMINI,
}; })),
getContentGenerator: vi.fn(),
isInteractive: () => false,
getExperiments: () => {},
isEventDrivenSchedulerEnabled: vi.fn(() => false),
getMaxSessionTurns: vi.fn(() => 100),
isJitContextEnabled: vi.fn(() => false),
getGlobalMemory: vi.fn(() => ''),
getUserMemory: vi.fn(() => ''),
getIdeMode: vi.fn(() => false),
getEnableHooks: vi.fn(() => false),
} as unknown as Config;
mockConfig = { beforeEach(() => {
apiKey: 'test-api-key', vi.clearAllMocks(); // Clear mocks before each test
model: 'gemini-pro', mockAddItem = vi.fn();
sandbox: false,
targetDir: '/test/dir',
debugMode: false,
question: undefined,
coreTools: [],
toolDiscoveryCommand: undefined,
toolCallCommand: undefined,
mcpServerCommand: undefined,
mcpServers: undefined,
userAgent: 'test-agent',
userMemory: '',
geminiMdFileCount: 0,
alwaysSkipModificationConfirmation: false,
vertexai: false,
showMemoryUsage: false,
contextFileName: undefined,
getToolRegistry: vi.fn(
() => ({ getToolSchemaList: vi.fn(() => []) }) as any,
),
getProjectRoot: vi.fn(() => '/test/dir'),
getCheckpointingEnabled: vi.fn(() => false),
getGeminiClient: mockGetGeminiClient,
getMcpClientManager: () => mockMcpClientManager as any,
getApprovalMode: () => ApprovalMode.DEFAULT,
getUsageStatisticsEnabled: () => true,
getDebugMode: () => false,
getWorkingDir: () => '/working/dir',
addHistory: vi.fn(),
getSessionId() {
return 'test-session-id';
},
setQuotaErrorOccurred: vi.fn(),
getQuotaErrorOccurred: vi.fn(() => false),
getModel: vi.fn(() => 'gemini-2.5-pro'),
getContentGenerator: vi.fn(),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue(contentGeneratorConfig),
isInteractive: () => false,
getExperiments: () => {},
} as unknown as Config;
mockOnDebugMessage = vi.fn(); mockOnDebugMessage = vi.fn();
mockHandleSlashCommand = vi.fn().mockResolvedValue(false); mockHandleSlashCommand = vi.fn().mockResolvedValue(false);
@@ -253,6 +263,10 @@ describe('useGeminiStream', () => {
mockCancelAllToolCalls = vi.fn(); mockCancelAllToolCalls = vi.fn();
mockMarkToolsAsSubmitted = vi.fn(); mockMarkToolsAsSubmitted = vi.fn();
// Reset properties of mockConfig if needed
(mockConfig.getCheckpointingEnabled as Mock).mockReturnValue(false);
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.DEFAULT);
// Default mock for useReactToolScheduler to prevent toolCalls being undefined initially // Default mock for useReactToolScheduler to prevent toolCalls being undefined initially
mockUseToolScheduler.mockReturnValue([ mockUseToolScheduler.mockReturnValue([
[], // Default to empty array for toolCalls [], // Default to empty array for toolCalls
@@ -289,10 +303,11 @@ describe('useGeminiStream', () => {
geminiClient?: any, geminiClient?: any,
) => { ) => {
const client = geminiClient || mockConfig.getGeminiClient(); const client = geminiClient || mockConfig.getGeminiClient();
let lastToolCalls = initialToolCalls;
const initialProps = { const initialProps = {
client, client,
history: [], history: emptyHistory,
addItem: mockAddItem as unknown as UseHistoryManagerReturn['addItem'], addItem: mockAddItem as unknown as UseHistoryManagerReturn['addItem'],
config: mockConfig, config: mockConfig,
onDebugMessage: mockOnDebugMessage, onDebugMessage: mockOnDebugMessage,
@@ -304,31 +319,26 @@ describe('useGeminiStream', () => {
toolCalls: initialToolCalls, toolCalls: initialToolCalls,
}; };
const { result, rerender } = renderHook( mockUseToolScheduler.mockImplementation((onComplete) => {
(props: typeof initialProps) => { capturedOnComplete = onComplete;
// This mock needs to be stateful. When setToolCallsForDisplay is called, return [
// it should trigger a rerender with the new state. lastToolCalls,
const mockSetToolCallsForDisplay = vi.fn((updater) => { mockScheduleToolCalls,
const newToolCalls = mockMarkToolsAsSubmitted,
typeof updater === 'function' ? updater(props.toolCalls) : updater; (updater: any) => {
rerender({ ...props, toolCalls: newToolCalls }); lastToolCalls =
}); typeof updater === 'function' ? updater(lastToolCalls) : updater;
rerender({ ...initialProps, toolCalls: lastToolCalls });
// Create a stateful mock for cancellation that updates the toolCalls state. },
const statefulCancelAllToolCalls = vi.fn((...args) => { (...args: any[]) => {
// Call the original spy so `toHaveBeenCalled` checks still work.
mockCancelAllToolCalls(...args); mockCancelAllToolCalls(...args);
lastToolCalls = lastToolCalls.map((tc) => {
const newToolCalls = props.toolCalls.map((tc) => {
// Only cancel tools that are in a cancellable state.
if ( if (
tc.status === 'awaiting_approval' || tc.status === 'awaiting_approval' ||
tc.status === 'executing' || tc.status === 'executing' ||
tc.status === 'scheduled' || tc.status === 'scheduled' ||
tc.status === 'validating' tc.status === 'validating'
) { ) {
// A real cancelled tool call has a response object.
// We need to simulate this to avoid type errors downstream.
return { return {
...tc, ...tc,
status: 'cancelled', status: 'cancelled',
@@ -337,23 +347,20 @@ describe('useGeminiStream', () => {
responseParts: [], responseParts: [],
resultDisplay: 'Request cancelled.', resultDisplay: 'Request cancelled.',
}, },
responseSubmittedToGemini: true, // Mark as "processed" responseSubmittedToGemini: true,
} as any as TrackedCancelledToolCall; } as any as TrackedCancelledToolCall;
} }
return tc; return tc;
}); });
rerender({ ...props, toolCalls: newToolCalls }); rerender({ ...initialProps, toolCalls: lastToolCalls });
}); },
0,
];
});
mockUseToolScheduler.mockImplementation(() => [ const { result, rerender } = renderHookWithProviders(
props.toolCalls, (props: typeof initialProps) =>
mockScheduleToolCalls, useGeminiStream(
mockMarkToolsAsSubmitted,
mockSetToolCallsForDisplay,
statefulCancelAllToolCalls, // Use the stateful mock
]);
return useGeminiStream(
props.client, props.client,
props.history, props.history,
props.addItem, props.addItem,
@@ -362,17 +369,16 @@ describe('useGeminiStream', () => {
props.onDebugMessage, props.onDebugMessage,
props.handleSlashCommand, props.handleSlashCommand,
props.shellModeActive, props.shellModeActive,
() => 'vscode' as EditorType, mockGetPreferredEditor,
() => {}, mockOnAuthError,
() => Promise.resolve(), mockPerformMemoryRefresh,
false, false,
() => {}, mockSetModelSwitchedFromQuotaError,
() => {}, mockOnCancelSubmit,
() => {}, mockSetShellInputFocused,
80, 80,
24, 24,
); ),
},
{ {
initialProps, initialProps,
}, },
@@ -454,7 +460,7 @@ describe('useGeminiStream', () => {
modelSwitched = false, modelSwitched = false,
} = options; } = options;
return renderHook(() => return renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
new MockedGeminiClientClass(mockConfig), new MockedGeminiClientClass(mockConfig),
[], [],
@@ -592,10 +598,17 @@ describe('useGeminiStream', () => {
mockUseToolScheduler.mockImplementation((onComplete) => { mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete; capturedOnComplete = onComplete;
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted, vi.fn()]; return [
[],
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
0,
];
}); });
renderHook(() => renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
new MockedGeminiClientClass(mockConfig), new MockedGeminiClientClass(mockConfig),
[], [],
@@ -620,6 +633,8 @@ describe('useGeminiStream', () => {
// Trigger the onComplete callback with completed tools // Trigger the onComplete callback with completed tools
await act(async () => { await act(async () => {
if (capturedOnComplete) { if (capturedOnComplete) {
// Wait a tick for refs to be set up
await new Promise((resolve) => setTimeout(resolve, 0));
await capturedOnComplete(completedToolCalls); await capturedOnComplete(completedToolCalls);
} }
}); });
@@ -674,10 +689,17 @@ describe('useGeminiStream', () => {
mockUseToolScheduler.mockImplementation((onComplete) => { mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete; capturedOnComplete = onComplete;
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted, vi.fn()]; return [
[],
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
0,
];
}); });
renderHook(() => renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
client, client,
[], [],
@@ -702,6 +724,8 @@ describe('useGeminiStream', () => {
// Trigger the onComplete callback with cancelled tools // Trigger the onComplete callback with cancelled tools
await act(async () => { await act(async () => {
if (capturedOnComplete) { if (capturedOnComplete) {
// Wait a tick for refs to be set up
await new Promise((resolve) => setTimeout(resolve, 0));
await capturedOnComplete(cancelledToolCalls); await capturedOnComplete(cancelledToolCalls);
} }
}); });
@@ -746,48 +770,12 @@ describe('useGeminiStream', () => {
]; ];
const client = new MockedGeminiClientClass(mockConfig); const client = new MockedGeminiClientClass(mockConfig);
// Capture the onComplete callback const { result } = renderTestHook([], client);
let capturedOnComplete:
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
[],
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
];
});
const { result } = renderHook(() =>
useGeminiStream(
client,
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
80,
24,
),
);
// Trigger the onComplete callback with STOP_EXECUTION tool // Trigger the onComplete callback with STOP_EXECUTION tool
await act(async () => { await act(async () => {
if (capturedOnComplete) { if (capturedOnComplete) {
await (capturedOnComplete as any)(stopExecutionToolCalls); await capturedOnComplete(stopExecutionToolCalls);
} }
}); });
@@ -877,10 +865,17 @@ describe('useGeminiStream', () => {
mockUseToolScheduler.mockImplementation((onComplete) => { mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete; capturedOnComplete = onComplete;
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted, vi.fn()]; return [
[],
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
0,
];
}); });
renderHook(() => renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
client, client,
[], [],
@@ -905,6 +900,8 @@ describe('useGeminiStream', () => {
// Trigger the onComplete callback with multiple cancelled tools // Trigger the onComplete callback with multiple cancelled tools
await act(async () => { await act(async () => {
if (capturedOnComplete) { if (capturedOnComplete) {
// Wait a tick for refs to be set up
await new Promise((resolve) => setTimeout(resolve, 0));
await capturedOnComplete(allCancelledTools); await capturedOnComplete(allCancelledTools);
} }
}); });
@@ -990,10 +987,12 @@ describe('useGeminiStream', () => {
mockScheduleToolCalls, mockScheduleToolCalls,
mockMarkToolsAsSubmitted, mockMarkToolsAsSubmitted,
vi.fn(), // setToolCallsForDisplay vi.fn(), // setToolCallsForDisplay
mockCancelAllToolCalls,
0,
]; ];
}); });
const { result, rerender } = renderHook(() => const { result, rerender } = renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
new MockedGeminiClientClass(mockConfig), new MockedGeminiClientClass(mockConfig),
[], [],
@@ -1027,6 +1026,8 @@ describe('useGeminiStream', () => {
mockScheduleToolCalls, mockScheduleToolCalls,
mockMarkToolsAsSubmitted, mockMarkToolsAsSubmitted,
vi.fn(), // setToolCallsForDisplay vi.fn(), // setToolCallsForDisplay
mockCancelAllToolCalls,
0,
]; ];
}); });
@@ -1041,6 +1042,8 @@ describe('useGeminiStream', () => {
// 4. Trigger the onComplete callback to simulate tool completion // 4. Trigger the onComplete callback to simulate tool completion
await act(async () => { await act(async () => {
if (capturedOnComplete) { if (capturedOnComplete) {
// Wait a tick for refs to be set up
await new Promise((resolve) => setTimeout(resolve, 0));
await capturedOnComplete(completedToolCalls); await capturedOnComplete(completedToolCalls);
} }
}); });
@@ -1124,7 +1127,7 @@ describe('useGeminiStream', () => {
})(); })();
mockSendMessageStream.mockReturnValue(mockStream); mockSendMessageStream.mockReturnValue(mockStream);
const { result } = renderHook(() => const { result } = renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
mockConfig.getGeminiClient(), mockConfig.getGeminiClient(),
[], [],
@@ -1165,7 +1168,7 @@ describe('useGeminiStream', () => {
})(); })();
mockSendMessageStream.mockReturnValue(mockStream); mockSendMessageStream.mockReturnValue(mockStream);
const { result } = renderHook(() => const { result } = renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
mockConfig.getGeminiClient(), mockConfig.getGeminiClient(),
[], [],
@@ -1559,7 +1562,7 @@ describe('useGeminiStream', () => {
}); });
it('should not call handleSlashCommand is shell mode is active', async () => { it('should not call handleSlashCommand is shell mode is active', async () => {
const { result } = renderHook(() => const { result } = renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
new MockedGeminiClientClass(mockConfig), new MockedGeminiClientClass(mockConfig),
[], [],
@@ -1629,10 +1632,17 @@ describe('useGeminiStream', () => {
mockUseToolScheduler.mockImplementation((onComplete) => { mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete; capturedOnComplete = onComplete;
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted, vi.fn()]; return [
[],
mockScheduleToolCalls,
mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
0,
];
}); });
renderHook(() => renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
new MockedGeminiClientClass(mockConfig), new MockedGeminiClientClass(mockConfig),
[], [],
@@ -1657,6 +1667,8 @@ describe('useGeminiStream', () => {
// Trigger the onComplete callback with the completed save_memory tool // Trigger the onComplete callback with the completed save_memory tool
await act(async () => { await act(async () => {
if (capturedOnComplete) { if (capturedOnComplete) {
// Wait a tick for refs to be set up
await new Promise((resolve) => setTimeout(resolve, 0));
await capturedOnComplete([completedToolCall]); await capturedOnComplete([completedToolCall]);
} }
}); });
@@ -1689,7 +1701,7 @@ describe('useGeminiStream', () => {
getModel: vi.fn(() => 'gemini-2.5-pro'), getModel: vi.fn(() => 'gemini-2.5-pro'),
} as unknown as Config; } as unknown as Config;
const { result } = renderHook(() => const { result } = renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
new MockedGeminiClientClass(testConfig), new MockedGeminiClientClass(testConfig),
[], [],
@@ -1990,7 +2002,7 @@ describe('useGeminiStream', () => {
})(), })(),
); );
const { result } = renderHook(() => const { result } = renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
new MockedGeminiClientClass(mockConfig), new MockedGeminiClientClass(mockConfig),
[], [],
@@ -2095,7 +2107,7 @@ describe('useGeminiStream', () => {
})(), })(),
); );
const { result } = renderHook(() => const { result } = renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
new MockedGeminiClientClass(mockConfig), new MockedGeminiClientClass(mockConfig),
[], [],
@@ -2243,6 +2255,8 @@ describe('useGeminiStream', () => {
startTime: Date.now(), startTime: Date.now(),
endTime: Date.now(), endTime: Date.now(),
})); }));
// Wait a tick for refs to be set up
await new Promise((resolve) => setTimeout(resolve, 0));
await capturedOnComplete(tools); await capturedOnComplete(tools);
addItemOrder.push('scheduleToolCalls_END'); addItemOrder.push('scheduleToolCalls_END');
}); });
@@ -2264,7 +2278,7 @@ describe('useGeminiStream', () => {
]; ];
}); });
const { result } = renderHook(() => const { result } = renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
new MockedGeminiClientClass(mockConfig), new MockedGeminiClientClass(mockConfig),
[], [],
@@ -2330,7 +2344,7 @@ describe('useGeminiStream', () => {
shouldProceed: true, shouldProceed: true,
}); });
const { result } = renderHook(() => const { result } = renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
mockConfig.getGeminiClient(), mockConfig.getGeminiClient(),
[], [],
@@ -2489,7 +2503,7 @@ describe('useGeminiStream', () => {
})(), })(),
); );
const { result } = renderHook(() => const { result } = renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
new MockedGeminiClientClass(mockConfig), new MockedGeminiClientClass(mockConfig),
[], [],
@@ -2565,11 +2579,13 @@ describe('useGeminiStream', () => {
mockUseToolScheduler.mockReturnValue([ mockUseToolScheduler.mockReturnValue([
[], [],
mockScheduleToolCalls, mockScheduleToolCalls,
mockCancelAllToolCalls,
mockMarkToolsAsSubmitted, mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
0,
]); ]);
const { result, rerender } = renderHook(() => const { result, rerender } = renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
mockConfig.getGeminiClient(), mockConfig.getGeminiClient(),
[], [],
@@ -2616,8 +2632,10 @@ describe('useGeminiStream', () => {
mockUseToolScheduler.mockReturnValue([ mockUseToolScheduler.mockReturnValue([
newToolCalls, newToolCalls,
mockScheduleToolCalls, mockScheduleToolCalls,
mockCancelAllToolCalls,
mockMarkToolsAsSubmitted, mockMarkToolsAsSubmitted,
vi.fn(),
mockCancelAllToolCalls,
0,
]); ]);
rerender(); rerender();
@@ -2638,7 +2656,7 @@ describe('useGeminiStream', () => {
})(), })(),
); );
const { result } = renderHook(() => const { result } = renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
new MockedGeminiClientClass(mockConfig), new MockedGeminiClientClass(mockConfig),
[], [],
@@ -2695,7 +2713,7 @@ describe('useGeminiStream', () => {
})(), })(),
); );
const { result } = renderHook(() => const { result } = renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
new MockedGeminiClientClass(mockConfig), new MockedGeminiClientClass(mockConfig),
[], [],
@@ -2763,7 +2781,7 @@ describe('useGeminiStream', () => {
})(), })(),
); );
const { result } = renderHook(() => const { result } = renderHookWithProviders(() =>
useGeminiStream( useGeminiStream(
new MockedGeminiClientClass(mockConfig), new MockedGeminiClientClass(mockConfig),
[], [],
+192 -35
View File
@@ -51,6 +51,7 @@ import type {
HistoryItem, HistoryItem,
HistoryItemWithoutId, HistoryItemWithoutId,
HistoryItemToolGroup, HistoryItemToolGroup,
IndividualToolCallDisplay,
SlashCommandProcessorResult, SlashCommandProcessorResult,
HistoryItemModel, HistoryItemModel,
} from '../types.js'; } from '../types.js';
@@ -91,6 +92,48 @@ function showCitations(settings: LoadedSettings): boolean {
return true; return true;
} }
/**
* Calculates the current streaming state based on tool call status and responding flag.
*/
function calculateStreamingState(
isResponding: boolean,
toolCalls: TrackedToolCall[],
): StreamingState {
if (toolCalls.some((tc) => tc.status === 'awaiting_approval')) {
return StreamingState.WaitingForConfirmation;
}
const isAnyToolActive = toolCalls.some((tc) => {
// These statuses indicate active processing
if (
tc.status === 'executing' ||
tc.status === 'scheduled' ||
tc.status === 'validating'
) {
return true;
}
// Terminal statuses (success, error, cancelled) still count as "Responding"
// if the result hasn't been submitted back to Gemini yet.
if (
tc.status === 'success' ||
tc.status === 'error' ||
tc.status === 'cancelled'
) {
return !(tc as TrackedCompletedToolCall | TrackedCancelledToolCall)
.responseSubmittedToGemini;
}
return false;
});
if (isResponding || isAnyToolActive) {
return StreamingState.Responding;
}
return StreamingState.Idle;
}
/** /**
* Manages the Gemini stream, including user input, command processing, * Manages the Gemini stream, including user input, command processing,
* API interaction, and tool call lifecycle. * API interaction, and tool call lifecycle.
@@ -130,6 +173,10 @@ export const useGeminiStream = (
useStateAndRef<HistoryItemWithoutId | null>(null); useStateAndRef<HistoryItemWithoutId | null>(null);
const [lastGeminiActivityTime, setLastGeminiActivityTime] = const [lastGeminiActivityTime, setLastGeminiActivityTime] =
useState<number>(0); useState<number>(0);
const [pushedToolCallIds, pushedToolCallIdsRef, setPushedToolCallIds] =
useStateAndRef<Set<string>>(new Set());
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
useStateAndRef<boolean>(true);
const processedMemoryToolsRef = useRef<Set<string>>(new Set()); const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const { startNewPrompt, getPromptCount } = useSessionStats(); const { startNewPrompt, getPromptCount } = useSessionStats();
const storage = config.storage; const storage = config.storage;
@@ -162,12 +209,18 @@ export const useGeminiStream = (
async (completedToolCallsFromScheduler) => { async (completedToolCallsFromScheduler) => {
// This onComplete is called when ALL scheduled tools for a given batch are done. // This onComplete is called when ALL scheduled tools for a given batch are done.
if (completedToolCallsFromScheduler.length > 0) { if (completedToolCallsFromScheduler.length > 0) {
// Add the final state of these tools to the history for display. // Add only the tools that haven't been pushed to history yet.
addItem( const toolsToPush = completedToolCallsFromScheduler.filter(
mapTrackedToolCallsToDisplay( (tc) => !pushedToolCallIdsRef.current.has(tc.request.callId),
completedToolCallsFromScheduler as TrackedToolCall[],
),
); );
if (toolsToPush.length > 0) {
addItem(
mapTrackedToolCallsToDisplay(toolsToPush as TrackedToolCall[], {
borderTop: isFirstToolInGroupRef.current,
borderBottom: true,
}),
);
}
// Clear the live-updating display now that the final state is in history. // Clear the live-updating display now that the final state is in history.
setToolCallsForDisplay([]); setToolCallsForDisplay([]);
@@ -205,12 +258,139 @@ export const useGeminiStream = (
getPreferredEditor, getPreferredEditor,
); );
const pendingToolCallGroupDisplay = useMemo( const streamingState = useMemo(
() => () => calculateStreamingState(isResponding, toolCalls),
toolCalls.length ? mapTrackedToolCallsToDisplay(toolCalls) : undefined, [isResponding, toolCalls],
[toolCalls],
); );
// Reset tracking when a new batch of tools starts
useEffect(() => {
if (toolCalls.length > 0) {
const isNewBatch = !toolCalls.some((tc) =>
pushedToolCallIdsRef.current.has(tc.request.callId),
);
if (isNewBatch) {
setPushedToolCallIds(new Set());
setIsFirstToolInGroup(true);
}
} else if (streamingState === StreamingState.Idle) {
// Clear when idle to be ready for next turn
setPushedToolCallIds(new Set());
setIsFirstToolInGroup(true);
}
}, [
toolCalls,
pushedToolCallIdsRef,
setPushedToolCallIds,
setIsFirstToolInGroup,
streamingState,
]);
// Push completed tools to history as they finish
useEffect(() => {
const toolsToPush: TrackedToolCall[] = [];
for (const tc of toolCalls) {
if (pushedToolCallIdsRef.current.has(tc.request.callId)) continue;
if (
tc.status === 'success' ||
tc.status === 'error' ||
tc.status === 'cancelled'
) {
toolsToPush.push(tc);
} else {
// Stop at first non-terminal tool to preserve order
break;
}
}
if (toolsToPush.length > 0) {
const newPushed = new Set(pushedToolCallIdsRef.current);
let isFirst = isFirstToolInGroupRef.current;
for (const tc of toolsToPush) {
newPushed.add(tc.request.callId);
const isLastInBatch = tc === toolCalls[toolCalls.length - 1];
const historyItem = mapTrackedToolCallsToDisplay(tc, {
borderTop: isFirst,
borderBottom: isLastInBatch,
});
addItem(historyItem);
isFirst = false;
}
setPushedToolCallIds(newPushed);
setIsFirstToolInGroup(false);
}
}, [
toolCalls,
pushedToolCallIdsRef,
isFirstToolInGroupRef,
setPushedToolCallIds,
setIsFirstToolInGroup,
addItem,
]);
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
const remainingTools = toolCalls.filter(
(tc) => !pushedToolCallIds.has(tc.request.callId),
);
const items: HistoryItemWithoutId[] = [];
if (remainingTools.length > 0) {
items.push(
mapTrackedToolCallsToDisplay(remainingTools, {
borderTop: pushedToolCallIds.size === 0,
borderBottom: false, // Stay open to connect with the slice below
}),
);
}
// Always show a bottom border slice if we have ANY tools in the batch
// and we haven't finished pushing the whole batch to history yet.
// Once all tools are terminal and pushed, the last history item handles the closing border.
const allTerminal =
toolCalls.length > 0 &&
toolCalls.every(
(tc) =>
tc.status === 'success' ||
tc.status === 'error' ||
tc.status === 'cancelled',
);
const allPushed =
toolCalls.length > 0 &&
toolCalls.every((tc) => pushedToolCallIds.has(tc.request.callId));
const isEventDriven = config.isEventDrivenSchedulerEnabled();
const anyVisibleInHistory = pushedToolCallIds.size > 0;
const anyVisibleInPending = remainingTools.some((tc) => {
if (!isEventDriven) return true;
return (
tc.status !== 'scheduled' &&
tc.status !== 'validating' &&
tc.status !== 'awaiting_approval'
);
});
if (
toolCalls.length > 0 &&
!(allTerminal && allPushed) &&
(anyVisibleInHistory || anyVisibleInPending)
) {
items.push({
type: 'tool_group' as const,
tools: [] as IndividualToolCallDisplay[],
borderTop: false,
borderBottom: true,
});
}
return items;
}, [toolCalls, pushedToolCallIds, config]);
const activeToolPtyId = useMemo(() => { const activeToolPtyId = useMemo(() => {
const executingShellTool = toolCalls?.find( const executingShellTool = toolCalls?.find(
(tc) => (tc) =>
@@ -271,29 +451,6 @@ export const useGeminiStream = (
prevActiveShellPtyIdRef.current = activeShellPtyId; prevActiveShellPtyIdRef.current = activeShellPtyId;
}, [activeShellPtyId, addItem]); }, [activeShellPtyId, addItem]);
const streamingState = useMemo(() => {
if (toolCalls.some((tc) => tc.status === 'awaiting_approval')) {
return StreamingState.WaitingForConfirmation;
}
if (
isResponding ||
toolCalls.some(
(tc) =>
tc.status === 'executing' ||
tc.status === 'scheduled' ||
tc.status === 'validating' ||
((tc.status === 'success' ||
tc.status === 'error' ||
tc.status === 'cancelled') &&
!(tc as TrackedCompletedToolCall | TrackedCancelledToolCall)
.responseSubmittedToGemini),
)
) {
return StreamingState.Responding;
}
return StreamingState.Idle;
}, [isResponding, toolCalls]);
useEffect(() => { useEffect(() => {
if ( if (
config.getApprovalMode() === ApprovalMode.YOLO && config.getApprovalMode() === ApprovalMode.YOLO &&
@@ -1349,10 +1506,10 @@ export const useGeminiStream = (
const pendingHistoryItems = useMemo( const pendingHistoryItems = useMemo(
() => () =>
[pendingHistoryItem, pendingToolCallGroupDisplay].filter( [pendingHistoryItem, ...pendingToolGroupItems].filter(
(i) => i !== undefined && i !== null, (i): i is HistoryItemWithoutId => i !== undefined && i !== null,
), ),
[pendingHistoryItem, pendingToolCallGroupDisplay], [pendingHistoryItem, pendingToolGroupItems],
); );
useEffect(() => { useEffect(() => {
+1 -1
View File
@@ -11,7 +11,7 @@ import React from 'react';
// times in the same function. // times in the same function.
export const useStateAndRef = < export const useStateAndRef = <
// Everything but function. // Everything but function.
T extends object | null | undefined | number | string, T extends object | null | undefined | number | string | boolean,
>( >(
initialValue: T, initialValue: T,
) => { ) => {
@@ -36,6 +36,8 @@ export type {
TrackedExecutingToolCall, TrackedExecutingToolCall,
TrackedCompletedToolCall, TrackedCompletedToolCall,
TrackedCancelledToolCall, TrackedCancelledToolCall,
MarkToolsAsSubmittedFn,
CancelAllFn,
}; };
// Unified type that covers both implementations // Unified type that covers both implementations
@@ -15,21 +15,11 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useFlickerDetector } from '../hooks/useFlickerDetector.js'; import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { CopyModeWarning } from '../components/CopyModeWarning.js'; import { CopyModeWarning } from '../components/CopyModeWarning.js';
import { ToolConfirmationQueue } from '../components/ToolConfirmationQueue.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { useConfig } from '../contexts/ConfigContext.js';
export const DefaultAppLayout: React.FC = () => { export const DefaultAppLayout: React.FC = () => {
const uiState = useUIState(); const uiState = useUIState();
const config = useConfig();
const isAlternateBuffer = useAlternateBuffer(); const isAlternateBuffer = useAlternateBuffer();
// If the event-driven scheduler is enabled AND we have a tool waiting,
// we switch the footer mode to "Queue".
const confirmingTool = useConfirmingTool();
const showConfirmationQueue =
config.isEventDrivenSchedulerEnabled() && confirmingTool !== null;
const { rootUiRef, terminalHeight } = uiState; const { rootUiRef, terminalHeight } = uiState;
useFlickerDetector(rootUiRef, terminalHeight); useFlickerDetector(rootUiRef, terminalHeight);
// If in alternate buffer mode, need to leave room to draw the scrollbar on // If in alternate buffer mode, need to leave room to draw the scrollbar on
@@ -65,12 +55,7 @@ export const DefaultAppLayout: React.FC = () => {
addItem={uiState.historyManager.addItem} addItem={uiState.historyManager.addItem}
/> />
) : ( ) : (
<> <Composer isFocused={true} />
{showConfirmationQueue && confirmingTool && (
<ToolConfirmationQueue confirmingTool={confirmingTool} />
)}
<Composer isFocused={!showConfirmationQueue} />
</>
)} )}
<ExitWarning /> <ExitWarning />
+2
View File
@@ -180,6 +180,8 @@ export type HistoryItemQuit = HistoryItemBase & {
export type HistoryItemToolGroup = HistoryItemBase & { export type HistoryItemToolGroup = HistoryItemBase & {
type: 'tool_group'; type: 'tool_group';
tools: IndividualToolCallDisplay[]; tools: IndividualToolCallDisplay[];
borderTop?: boolean;
borderBottom?: boolean;
}; };
export type HistoryItemUserShell = HistoryItemBase & { export type HistoryItemUserShell = HistoryItemBase & {