mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
refactor(cli): decouple UI from live tool execution via ToolActionsContext (#17183)
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user