mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-14 13:53:02 -07:00
feat(ux) Expandable (ctrl-O) and scrollable approvals in alternate buffer mode. (#17640)
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-1
@@ -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;
|
||||||
|
"
|
||||||
`;
|
`;
|
||||||
|
|||||||
-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
|
||||||
|
|||||||
-11
@@ -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
|
||||||
|
|||||||
+18
@@ -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 │
|
||||||
|
|||||||
+1
-2
@@ -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
|
||||||
|
|||||||
+14
@@ -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"
|
||||||
|
`;
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 & {
|
||||||
|
|||||||
Reference in New Issue
Block a user