refactor(cli): decouple UI from live tool execution via ToolActionsContext (#17183)

This commit is contained in:
Abhi
2026-01-21 16:16:30 -05:00
committed by GitHub
parent dce450b1e8
commit c266b529ae
14 changed files with 561 additions and 79 deletions
+22 -15
View File
@@ -14,7 +14,6 @@ import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
import { SettingsContext } from '../ui/contexts/SettingsContext.js'; import { SettingsContext } from '../ui/contexts/SettingsContext.js';
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js'; import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
import { UIStateContext, type UIState } from '../ui/contexts/UIStateContext.js'; import { UIStateContext, type UIState } from '../ui/contexts/UIStateContext.js';
import { StreamingState } from '../ui/types.js';
import { ConfigContext } from '../ui/contexts/ConfigContext.js'; import { ConfigContext } from '../ui/contexts/ConfigContext.js';
import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js'; import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js';
import { VimModeProvider } from '../ui/contexts/VimModeContext.js'; import { VimModeProvider } from '../ui/contexts/VimModeContext.js';
@@ -25,6 +24,8 @@ import {
type UIActions, type UIActions,
UIActionsContext, UIActionsContext,
} from '../ui/contexts/UIActionsContext.js'; } from '../ui/contexts/UIActionsContext.js';
import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
import { type Config } from '@google/gemini-cli-core'; import { type Config } from '@google/gemini-cli-core';
@@ -239,6 +240,10 @@ export const renderWithProviders = (
const finalUIActions = { ...mockUIActions, ...uiActions }; const finalUIActions = { ...mockUIActions, ...uiActions };
const allToolCalls = (finalUiState.pendingHistoryItems || [])
.filter((item): item is HistoryItemToolGroup => item.type === 'tool_group')
.flatMap((item) => item.tools);
const renderResult = render( const renderResult = render(
<ConfigContext.Provider value={config}> <ConfigContext.Provider value={config}>
<SettingsContext.Provider value={finalSettings}> <SettingsContext.Provider value={finalSettings}>
@@ -247,20 +252,22 @@ export const renderWithProviders = (
<ShellFocusContext.Provider value={shellFocus}> <ShellFocusContext.Provider value={shellFocus}>
<StreamingContext.Provider value={finalUiState.streamingState}> <StreamingContext.Provider value={finalUiState.streamingState}>
<UIActionsContext.Provider value={finalUIActions}> <UIActionsContext.Provider value={finalUIActions}>
<KeypressProvider> <ToolActionsProvider config={config} toolCalls={allToolCalls}>
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}> <KeypressProvider>
<ScrollProvider> <MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
<Box <ScrollProvider>
width={terminalWidth} <Box
flexShrink={0} width={terminalWidth}
flexGrow={0} flexShrink={0}
flexDirection="column" flexGrow={0}
> flexDirection="column"
{component} >
</Box> {component}
</ScrollProvider> </Box>
</MouseProvider> </ScrollProvider>
</KeypressProvider> </MouseProvider>
</KeypressProvider>
</ToolActionsProvider>
</UIActionsContext.Provider> </UIActionsContext.Provider>
</StreamingContext.Provider> </StreamingContext.Provider>
</ShellFocusContext.Provider> </ShellFocusContext.Provider>
+17 -3
View File
@@ -25,9 +25,11 @@ import {
type HistoryItem, type HistoryItem,
ToolCallStatus, ToolCallStatus,
type HistoryItemWithoutId, type HistoryItemWithoutId,
type HistoryItemToolGroup,
AuthState, AuthState,
} from './types.js'; } from './types.js';
import { MessageType, StreamingState } from './types.js'; import { MessageType, StreamingState } from './types.js';
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
import { import {
type EditorType, type EditorType,
type Config, type Config,
@@ -1486,6 +1488,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems], [pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
); );
const allToolCalls = useMemo(
() =>
pendingHistoryItems
.filter(
(item): item is HistoryItemToolGroup => item.type === 'tool_group',
)
.flatMap((item) => item.tools),
[pendingHistoryItems],
);
const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>( const [geminiMdFileCount, setGeminiMdFileCount] = useState<number>(
config.getGeminiMdFileCount(), config.getGeminiMdFileCount(),
); );
@@ -1832,9 +1844,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
startupWarnings: props.startupWarnings || [], startupWarnings: props.startupWarnings || [],
}} }}
> >
<ShellFocusContext.Provider value={isFocused}> <ToolActionsProvider config={config} toolCalls={allToolCalls}>
<App /> <ShellFocusContext.Provider value={isFocused}>
</ShellFocusContext.Provider> <App />
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider> </AppContext.Provider>
</ConfigContext.Provider> </ConfigContext.Provider>
</UIActionsContext.Provider> </UIActionsContext.Provider>
@@ -88,6 +88,7 @@ const mockConfig = {
getModel: () => 'gemini-pro', getModel: () => 'gemini-pro',
getTargetDir: () => '/tmp', getTargetDir: () => '/tmp',
getDebugMode: () => false, getDebugMode: () => false,
getIdeMode: () => false,
getGeminiMdFileCount: () => 0, getGeminiMdFileCount: () => 0,
getExperiments: () => ({ getExperiments: () => ({
flags: {}, flags: {},
@@ -35,6 +35,7 @@ describe('ToolConfirmationMessage Redirection', () => {
const { lastFrame } = renderWithProviders( const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage <ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails} confirmationDetails={confirmationDetails}
config={mockConfig} config={mockConfig}
availableTerminalHeight={30} availableTerminalHeight={30}
@@ -14,8 +14,26 @@ import {
renderWithProviders, renderWithProviders,
createMockSettings, createMockSettings,
} from '../../../test-utils/render.js'; } from '../../../test-utils/render.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
vi.mock('../../contexts/ToolActionsContext.js', async (importOriginal) => {
const actual =
await importOriginal<
typeof import('../../contexts/ToolActionsContext.js')
>();
return {
...actual,
useToolActions: vi.fn(),
};
});
describe('ToolConfirmationMessage', () => { describe('ToolConfirmationMessage', () => {
const mockConfirm = vi.fn();
vi.mocked(useToolActions).mockReturnValue({
confirm: mockConfirm,
cancel: vi.fn(),
});
const mockConfig = { const mockConfig = {
isTrustedFolder: () => true, isTrustedFolder: () => true,
getIdeMode: () => false, getIdeMode: () => false,
@@ -32,6 +50,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders( const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage <ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails} confirmationDetails={confirmationDetails}
config={mockConfig} config={mockConfig}
availableTerminalHeight={30} availableTerminalHeight={30}
@@ -56,6 +75,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders( const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage <ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails} confirmationDetails={confirmationDetails}
config={mockConfig} config={mockConfig}
availableTerminalHeight={30} availableTerminalHeight={30}
@@ -79,6 +99,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders( const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage <ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails} confirmationDetails={confirmationDetails}
config={mockConfig} config={mockConfig}
availableTerminalHeight={30} availableTerminalHeight={30}
@@ -161,6 +182,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders( const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage <ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={details} confirmationDetails={details}
config={mockConfig} config={mockConfig}
availableTerminalHeight={30} availableTerminalHeight={30}
@@ -179,6 +201,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders( const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage <ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={details} confirmationDetails={details}
config={mockConfig} config={mockConfig}
availableTerminalHeight={30} availableTerminalHeight={30}
@@ -211,6 +234,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders( const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage <ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={editConfirmationDetails} confirmationDetails={editConfirmationDetails}
config={mockConfig} config={mockConfig}
availableTerminalHeight={30} availableTerminalHeight={30}
@@ -234,6 +258,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders( const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage <ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={editConfirmationDetails} confirmationDetails={editConfirmationDetails}
config={mockConfig} config={mockConfig}
availableTerminalHeight={30} availableTerminalHeight={30}
@@ -5,20 +5,20 @@
*/ */
import type React from 'react'; import type React from 'react';
import { useEffect, useState, useMemo } from 'react'; import { useMemo } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js'; import { DiffRenderer } from './DiffRenderer.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
import type {
ToolCallConfirmationDetails,
Config,
} from '@google/gemini-cli-core';
import { import {
IdeClient, type SerializableConfirmationDetails,
type ToolCallConfirmationDetails,
type Config,
ToolConfirmationOutcome, ToolConfirmationOutcome,
hasRedirection, hasRedirection,
debugLogger,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js'; import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js'; import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js'; import { useKeypress } from '../../hooks/useKeypress.js';
@@ -32,7 +32,10 @@ import {
} from '../../textConstants.js'; } from '../../textConstants.js';
export interface ToolConfirmationMessageProps { export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails; callId: string;
confirmationDetails:
| ToolCallConfirmationDetails
| SerializableConfirmationDetails;
config: Config; config: Config;
isFocused?: boolean; isFocused?: boolean;
availableTerminalHeight?: number; availableTerminalHeight?: number;
@@ -42,52 +45,26 @@ export interface ToolConfirmationMessageProps {
export const ToolConfirmationMessage: React.FC< export const ToolConfirmationMessage: React.FC<
ToolConfirmationMessageProps ToolConfirmationMessageProps
> = ({ > = ({
callId,
confirmationDetails, confirmationDetails,
config, config,
isFocused = true, isFocused = true,
availableTerminalHeight, availableTerminalHeight,
terminalWidth, terminalWidth,
}) => { }) => {
const { onConfirm } = confirmationDetails; const { confirm } = useToolActions();
const settings = useSettings(); const settings = useSettings();
const allowPermanentApproval = const allowPermanentApproval =
settings.merged.security.enablePermanentToolApproval; settings.merged.security.enablePermanentToolApproval;
const [ideClient, setIdeClient] = useState<IdeClient | null>(null); const handleConfirm = (outcome: ToolConfirmationOutcome) => {
const [isDiffingEnabled, setIsDiffingEnabled] = useState(false); void confirm(callId, outcome).catch((error) => {
debugLogger.error(
useEffect(() => { `Failed to handle tool confirmation for ${callId}:`,
let isMounted = true; error,
if (config.getIdeMode()) { );
const getIdeClient = async () => { });
const client = await IdeClient.getInstance();
if (isMounted) {
setIdeClient(client);
setIsDiffingEnabled(client?.isDiffingEnabled() ?? false);
}
};
// eslint-disable-next-line @typescript-eslint/no-floating-promises
getIdeClient();
}
return () => {
isMounted = false;
};
}, [config]);
const handleConfirm = async (outcome: ToolConfirmationOutcome) => {
if (confirmationDetails.type === 'edit') {
if (config.getIdeMode() && isDiffingEnabled) {
const cliOutcome =
outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted';
await ideClient?.resolveDiffFromCli(
confirmationDetails.filePath,
cliOutcome,
);
}
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
onConfirm(outcome);
}; };
const isTrustedFolder = config.isTrustedFolder(); const isTrustedFolder = config.isTrustedFolder();
@@ -96,7 +73,6 @@ export const ToolConfirmationMessage: React.FC<
(key) => { (key) => {
if (!isFocused) return; if (!isFocused) return;
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleConfirm(ToolConfirmationOutcome.Cancel); handleConfirm(ToolConfirmationOutcome.Cancel);
} }
}, },
@@ -132,7 +108,9 @@ export const ToolConfirmationMessage: React.FC<
}); });
} }
} }
if (!config.getIdeMode() || !isDiffingEnabled) { // We hide "Modify with external editor" if IDE mode is active, assuming
// the IDE provides a better interface (diff view) for this.
if (!config.getIdeMode()) {
options.push({ options.push({
label: 'Modify with external editor', label: 'Modify with external editor',
value: ToolConfirmationOutcome.ModifyWithEditor, value: ToolConfirmationOutcome.ModifyWithEditor,
@@ -400,7 +378,6 @@ export const ToolConfirmationMessage: React.FC<
confirmationDetails, confirmationDetails,
isTrustedFolder, isTrustedFolder,
config, config,
isDiffingEnabled,
availableTerminalHeight, availableTerminalHeight,
terminalWidth, terminalWidth,
allowPermanentApproval, allowPermanentApproval,
@@ -39,6 +39,11 @@ describe('<ToolGroupMessage />', () => {
const toolCalls = [createToolCall()]; const toolCalls = [createToolCall()];
const { lastFrame, unmount } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
unmount(); unmount();
@@ -67,6 +72,11 @@ describe('<ToolGroupMessage />', () => {
]; ];
const { lastFrame, unmount } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
unmount(); unmount();
@@ -89,6 +99,11 @@ describe('<ToolGroupMessage />', () => {
]; ];
const { lastFrame, unmount } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
unmount(); unmount();
@@ -105,6 +120,11 @@ describe('<ToolGroupMessage />', () => {
]; ];
const { lastFrame, unmount } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
unmount(); unmount();
@@ -133,6 +153,11 @@ describe('<ToolGroupMessage />', () => {
]; ];
const { lastFrame, unmount } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
unmount(); unmount();
@@ -160,6 +185,11 @@ describe('<ToolGroupMessage />', () => {
toolCalls={toolCalls} toolCalls={toolCalls}
availableTerminalHeight={10} availableTerminalHeight={10}
/>, />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
unmount(); unmount();
@@ -173,6 +203,11 @@ describe('<ToolGroupMessage />', () => {
toolCalls={toolCalls} toolCalls={toolCalls}
isFocused={false} isFocused={false}
/>, />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
unmount(); unmount();
@@ -192,6 +227,11 @@ describe('<ToolGroupMessage />', () => {
toolCalls={toolCalls} toolCalls={toolCalls}
terminalWidth={40} terminalWidth={40}
/>, />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
unmount(); unmount();
@@ -200,6 +240,11 @@ describe('<ToolGroupMessage />', () => {
it('renders empty tool calls array', () => { it('renders empty tool calls array', () => {
const { lastFrame, unmount } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={[]} />, <ToolGroupMessage {...baseProps} toolCalls={[]} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: [] }],
},
},
); );
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
unmount(); unmount();
@@ -225,6 +270,11 @@ describe('<ToolGroupMessage />', () => {
<Scrollable height={10} hasFocus={true} scrollToBottom={true}> <Scrollable height={10} hasFocus={true} scrollToBottom={true}>
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} /> <ToolGroupMessage {...baseProps} toolCalls={toolCalls} />
</Scrollable>, </Scrollable>,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
unmount(); unmount();
@@ -242,6 +292,11 @@ describe('<ToolGroupMessage />', () => {
]; ];
const { lastFrame, unmount } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
unmount(); unmount();
@@ -270,6 +325,14 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} toolCalls={toolCalls1} /> <ToolGroupMessage {...baseProps} toolCalls={toolCalls1} />
<ToolGroupMessage {...baseProps} toolCalls={toolCalls2} /> <ToolGroupMessage {...baseProps} toolCalls={toolCalls2} />
</Scrollable>, </Scrollable>,
{
uiState: {
pendingHistoryItems: [
{ type: 'tool_group', tools: toolCalls1 },
{ type: 'tool_group', tools: toolCalls2 },
],
},
},
); );
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
unmount(); unmount();
@@ -281,6 +344,11 @@ describe('<ToolGroupMessage />', () => {
const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })]; const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })];
const { lastFrame, unmount } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
// The snapshot will capture the visual appearance including border color // The snapshot will capture the visual appearance including border color
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
@@ -296,6 +364,11 @@ describe('<ToolGroupMessage />', () => {
]; ];
const { lastFrame, unmount } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
unmount(); unmount();
@@ -312,6 +385,11 @@ describe('<ToolGroupMessage />', () => {
]; ];
const { lastFrame, unmount } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
unmount(); unmount();
@@ -340,6 +418,11 @@ describe('<ToolGroupMessage />', () => {
toolCalls={toolCalls} toolCalls={toolCalls}
availableTerminalHeight={20} availableTerminalHeight={20}
/>, />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
unmount(); unmount();
@@ -374,6 +457,11 @@ describe('<ToolGroupMessage />', () => {
]; ];
const { lastFrame, unmount } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
// Should only show confirmation for the first tool // Should only show confirmation for the first tool
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
@@ -399,7 +487,12 @@ describe('<ToolGroupMessage />', () => {
}); });
const { lastFrame, unmount } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{ settings }, {
settings,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
expect(lastFrame()).toContain('Allow for all future sessions'); expect(lastFrame()).toContain('Allow for all future sessions');
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
@@ -425,7 +518,12 @@ describe('<ToolGroupMessage />', () => {
}); });
const { lastFrame, unmount } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />, <ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{ settings }, {
settings,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
); );
expect(lastFrame()).not.toContain('Allow for all future sessions'); expect(lastFrame()).not.toContain('Allow for all future sessions');
expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toMatchSnapshot();
@@ -157,6 +157,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
isConfirming && isConfirming &&
tool.confirmationDetails && ( tool.confirmationDetails && (
<ToolConfirmationMessage <ToolConfirmationMessage
callId={tool.callId}
confirmationDetails={tool.confirmationDetails} confirmationDetails={tool.confirmationDetails}
config={config} config={config}
isFocused={isFocused} isFocused={isFocused}
@@ -0,0 +1,180 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { act } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { ToolActionsProvider, useToolActions } from './ToolActionsContext.js';
import {
type Config,
ToolConfirmationOutcome,
MessageBusType,
IdeClient,
type ToolCallConfirmationDetails,
} from '@google/gemini-cli-core';
import { ToolCallStatus, type IndividualToolCallDisplay } from '../types.js';
// Mock IdeClient
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
IdeClient: {
getInstance: vi.fn(),
},
};
});
describe('ToolActionsContext', () => {
const mockMessageBus = {
publish: vi.fn(),
};
const mockConfig = {
getIdeMode: vi.fn().mockReturnValue(false),
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
} as unknown as Config;
const mockToolCalls: IndividualToolCallDisplay[] = [
{
callId: 'modern-call',
correlationId: 'corr-123',
name: 'test-tool',
description: 'desc',
status: ToolCallStatus.Confirming,
resultDisplay: undefined,
confirmationDetails: { type: 'info', title: 'title', prompt: 'prompt' },
},
{
callId: 'legacy-call',
name: 'legacy-tool',
description: 'desc',
status: ToolCallStatus.Confirming,
resultDisplay: undefined,
confirmationDetails: {
type: 'info',
title: 'legacy',
prompt: 'prompt',
onConfirm: vi.fn(),
} as ToolCallConfirmationDetails,
},
{
callId: 'edit-call',
name: 'edit-tool',
description: 'desc',
status: ToolCallStatus.Confirming,
resultDisplay: undefined,
confirmationDetails: {
type: 'edit',
title: 'edit',
fileName: 'f.txt',
filePath: '/f.txt',
fileDiff: 'diff',
originalContent: 'old',
newContent: 'new',
onConfirm: vi.fn(),
} as ToolCallConfirmationDetails,
},
];
beforeEach(() => {
vi.clearAllMocks();
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ToolActionsProvider config={mockConfig} toolCalls={mockToolCalls}>
{children}
</ToolActionsProvider>
);
it('publishes to MessageBus for tools with correlationId (Modern Path)', async () => {
const { result } = renderHook(() => useToolActions(), { wrapper });
await result.current.confirm(
'modern-call',
ToolConfirmationOutcome.ProceedOnce,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: 'corr-123',
confirmed: true,
requiresUserConfirmation: false,
outcome: ToolConfirmationOutcome.ProceedOnce,
payload: undefined,
});
});
it('calls onConfirm for legacy tools (Legacy Path)', async () => {
const { result } = renderHook(() => useToolActions(), { wrapper });
const legacyDetails = mockToolCalls[1]
.confirmationDetails as ToolCallConfirmationDetails;
await result.current.confirm(
'legacy-call',
ToolConfirmationOutcome.ProceedOnce,
);
if (legacyDetails && 'onConfirm' in legacyDetails) {
expect(legacyDetails.onConfirm).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
undefined,
);
} else {
throw new Error('Expected onConfirm to be present');
}
expect(mockMessageBus.publish).not.toHaveBeenCalled();
});
it('handles cancel by calling confirm with Cancel outcome', async () => {
const { result } = renderHook(() => useToolActions(), { wrapper });
await result.current.cancel('modern-call');
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
outcome: ToolConfirmationOutcome.Cancel,
confirmed: false,
}),
);
});
it('resolves IDE diffs for edit tools when in IDE mode', async () => {
const mockIdeClient = {
isDiffingEnabled: vi.fn().mockReturnValue(true),
resolveDiffFromCli: vi.fn(),
} as unknown as IdeClient;
vi.mocked(IdeClient.getInstance).mockResolvedValue(mockIdeClient);
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
const { result } = renderHook(() => useToolActions(), { wrapper });
// Wait for IdeClient initialization in useEffect
await act(async () => {
await vi.waitFor(() => expect(IdeClient.getInstance).toHaveBeenCalled());
// Give React a chance to update state
await new Promise((resolve) => setTimeout(resolve, 0));
});
await result.current.confirm(
'edit-call',
ToolConfirmationOutcome.ProceedOnce,
);
expect(mockIdeClient.resolveDiffFromCli).toHaveBeenCalledWith(
'/f.txt',
'accepted',
);
const editDetails = mockToolCalls[2]
.confirmationDetails as ToolCallConfirmationDetails;
if (editDetails && 'onConfirm' in editDetails) {
expect(editDetails.onConfirm).toHaveBeenCalled();
} else {
throw new Error('Expected onConfirm to be present');
}
});
});
@@ -0,0 +1,142 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import {
createContext,
useContext,
useCallback,
useState,
useEffect,
} from 'react';
import {
IdeClient,
ToolConfirmationOutcome,
MessageBusType,
type Config,
type ToolConfirmationPayload,
type ToolCallConfirmationDetails,
debugLogger,
} from '@google/gemini-cli-core';
import type { IndividualToolCallDisplay } from '../types.js';
interface ToolActionsContextValue {
confirm: (
callId: string,
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
cancel: (callId: string) => Promise<void>;
}
const ToolActionsContext = createContext<ToolActionsContextValue | null>(null);
export const useToolActions = () => {
const context = useContext(ToolActionsContext);
if (!context) {
throw new Error('useToolActions must be used within a ToolActionsProvider');
}
return context;
};
interface ToolActionsProviderProps {
children: React.ReactNode;
config: Config;
toolCalls: IndividualToolCallDisplay[];
}
export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
props: ToolActionsProviderProps,
) => {
const { children, config, toolCalls } = props;
// Hoist IdeClient logic here to keep UI pure
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
useEffect(() => {
let isMounted = true;
if (config.getIdeMode()) {
IdeClient.getInstance()
.then((client) => {
if (isMounted) setIdeClient(client);
})
.catch((error) => {
debugLogger.error('Failed to get IdeClient instance:', error);
});
}
return () => {
isMounted = false;
};
}, [config]);
const confirm = useCallback(
async (
callId: string,
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => {
const tool = toolCalls.find((t) => t.callId === callId);
if (!tool) {
debugLogger.warn(`ToolActions: Tool ${callId} not found`);
return;
}
const details = tool.confirmationDetails;
// 1. Handle Side Effects (IDE Diff)
if (
details?.type === 'edit' &&
ideClient?.isDiffingEnabled() &&
'filePath' in details // Check for safety
) {
const cliOutcome =
outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted';
await ideClient.resolveDiffFromCli(details.filePath, cliOutcome);
}
// 2. Dispatch
// PATH A: Event Bus (Modern)
if (tool.correlationId) {
await config.getMessageBus().publish({
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: tool.correlationId,
confirmed: outcome !== ToolConfirmationOutcome.Cancel,
requiresUserConfirmation: false,
outcome,
payload,
});
return;
}
// PATH B: Legacy Callback (Adapter or Old Scheduler)
if (
details &&
'onConfirm' in details &&
typeof details.onConfirm === 'function'
) {
await (details as ToolCallConfirmationDetails).onConfirm(
outcome,
payload,
);
return;
}
debugLogger.warn(`ToolActions: No confirmation mechanism for ${callId}`);
},
[config, toolCalls, ideClient],
);
const cancel = useCallback(
async (callId: string) => {
await confirm(callId, ToolConfirmationOutcome.Cancel);
},
[confirm],
);
return (
<ToolActionsContext.Provider value={{ confirm, cancel }}>
{children}
</ToolActionsContext.Provider>
);
};
@@ -195,6 +195,33 @@ describe('toolMapping', () => {
expect(displayTool.confirmationDetails).toEqual(confirmationDetails); expect(displayTool.confirmationDetails).toEqual(confirmationDetails);
}); });
it('maps correlationId and serializable confirmation details', () => {
const serializableDetails = {
type: 'edit' as const,
title: 'Confirm Edit',
fileName: 'file.txt',
filePath: '/path/file.txt',
fileDiff: 'diff',
originalContent: 'old',
newContent: 'new',
};
const toolCall: WaitingToolCall = {
status: 'awaiting_approval',
request: mockRequest,
tool: mockTool,
invocation: mockInvocation,
confirmationDetails: serializableDetails,
correlationId: 'corr-123',
};
const result = mapToDisplay(toolCall);
const displayTool = result.tools[0];
expect(displayTool.correlationId).toBe('corr-123');
expect(displayTool.confirmationDetails).toEqual(serializableDetails);
});
it('maps error tool call missing tool definition', () => { it('maps error tool call missing tool definition', () => {
// e.g. "TOOL_NOT_REGISTERED" errors // e.g. "TOOL_NOT_REGISTERED" errors
const toolCall: ToolCall = { const toolCall: ToolCall = {
+10 -12
View File
@@ -8,6 +8,7 @@ import {
type ToolCall, type ToolCall,
type Status as CoreStatus, type Status as CoreStatus,
type ToolCallConfirmationDetails, type ToolCallConfirmationDetails,
type SerializableConfirmationDetails,
type ToolResultDisplay, type ToolResultDisplay,
debugLogger, debugLogger,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
@@ -72,10 +73,13 @@ export function mapToDisplay(
}; };
let resultDisplay: ToolResultDisplay | undefined = undefined; let resultDisplay: ToolResultDisplay | undefined = undefined;
let confirmationDetails: ToolCallConfirmationDetails | undefined = let confirmationDetails:
undefined; | ToolCallConfirmationDetails
| SerializableConfirmationDetails
| undefined = undefined;
let outputFile: string | undefined = undefined; let outputFile: string | undefined = undefined;
let ptyId: number | undefined = undefined; let ptyId: number | undefined = undefined;
let correlationId: string | undefined = undefined;
switch (call.status) { switch (call.status) {
case 'success': case 'success':
@@ -87,16 +91,9 @@ export function mapToDisplay(
resultDisplay = call.response.resultDisplay; resultDisplay = call.response.resultDisplay;
break; break;
case 'awaiting_approval': case 'awaiting_approval':
// Only map if it's the legacy callback-based details. correlationId = call.correlationId;
// Serializable details will be handled in a later milestone. // Pass through details. Context handles dispatch (callback vs bus).
if ( confirmationDetails = call.confirmationDetails;
call.confirmationDetails &&
'onConfirm' in call.confirmationDetails &&
typeof call.confirmationDetails.onConfirm === 'function'
) {
confirmationDetails =
call.confirmationDetails as ToolCallConfirmationDetails;
}
break; break;
case 'executing': case 'executing':
resultDisplay = call.liveOutput; resultDisplay = call.liveOutput;
@@ -123,6 +120,7 @@ export function mapToDisplay(
confirmationDetails, confirmationDetails,
outputFile, outputFile,
ptyId, ptyId,
correlationId,
}; };
}); });
+11 -2
View File
@@ -10,6 +10,7 @@ import type {
MCPServerConfig, MCPServerConfig,
ThoughtSummary, ThoughtSummary,
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
SerializableConfirmationDetails,
ToolResultDisplay, ToolResultDisplay,
RetrieveUserQuotaResponse, RetrieveUserQuotaResponse,
SkillDefinition, SkillDefinition,
@@ -63,7 +64,11 @@ export interface ToolCallEvent {
name: string; name: string;
args: Record<string, never>; args: Record<string, never>;
resultDisplay: ToolResultDisplay | undefined; resultDisplay: ToolResultDisplay | undefined;
confirmationDetails: ToolCallConfirmationDetails | undefined; confirmationDetails:
| ToolCallConfirmationDetails
| SerializableConfirmationDetails
| undefined;
correlationId?: string;
} }
export interface IndividualToolCallDisplay { export interface IndividualToolCallDisplay {
@@ -72,10 +77,14 @@ export interface IndividualToolCallDisplay {
description: string; description: string;
resultDisplay: ToolResultDisplay | undefined; resultDisplay: ToolResultDisplay | undefined;
status: ToolCallStatus; status: ToolCallStatus;
confirmationDetails: ToolCallConfirmationDetails | undefined; confirmationDetails:
| ToolCallConfirmationDetails
| SerializableConfirmationDetails
| undefined;
renderOutputAsMarkdown?: boolean; renderOutputAsMarkdown?: boolean;
ptyId?: number; ptyId?: number;
outputFile?: string; outputFile?: string;
correlationId?: string;
} }
export interface CompressionProps { export interface CompressionProps {
@@ -74,6 +74,7 @@ export type SerializableConfirmationDetails =
fileDiff: string; fileDiff: string;
originalContent: string | null; originalContent: string | null;
newContent: string; newContent: string;
isModifying?: boolean;
} }
| { | {
type: 'exec'; type: 'exec';
@@ -81,6 +82,7 @@ export type SerializableConfirmationDetails =
command: string; command: string;
rootCommand: string; rootCommand: string;
rootCommands: string[]; rootCommands: string[];
commands?: string[];
} }
| { | {
type: 'mcp'; type: 'mcp';