From c266b529ae88097f14a33d43829e46ca9664a622 Mon Sep 17 00:00:00 2001
From: Abhi <43648792+abhipatel12@users.noreply.github.com>
Date: Wed, 21 Jan 2026 16:16:30 -0500
Subject: [PATCH] refactor(cli): decouple UI from live tool execution via
ToolActionsContext (#17183)
---
packages/cli/src/test-utils/render.tsx | 37 ++--
packages/cli/src/ui/AppContainer.tsx | 20 +-
.../AlternateBufferQuittingDisplay.test.tsx | 1 +
.../messages/RedirectionConfirmation.test.tsx | 1 +
.../messages/ToolConfirmationMessage.test.tsx | 25 +++
.../messages/ToolConfirmationMessage.tsx | 67 +++----
.../messages/ToolGroupMessage.test.tsx | 102 +++++++++-
.../components/messages/ToolGroupMessage.tsx | 1 +
.../ui/contexts/ToolActionsContext.test.tsx | 180 ++++++++++++++++++
.../src/ui/contexts/ToolActionsContext.tsx | 142 ++++++++++++++
packages/cli/src/ui/hooks/toolMapping.test.ts | 27 +++
packages/cli/src/ui/hooks/toolMapping.ts | 22 +--
packages/cli/src/ui/types.ts | 13 +-
packages/core/src/confirmation-bus/types.ts | 2 +
14 files changed, 561 insertions(+), 79 deletions(-)
create mode 100644 packages/cli/src/ui/contexts/ToolActionsContext.test.tsx
create mode 100644 packages/cli/src/ui/contexts/ToolActionsContext.tsx
diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx
index bb4cd5ca8d..55444cf694 100644
--- a/packages/cli/src/test-utils/render.tsx
+++ b/packages/cli/src/test-utils/render.tsx
@@ -14,7 +14,6 @@ import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
import { UIStateContext, type UIState } from '../ui/contexts/UIStateContext.js';
-import { StreamingState } from '../ui/types.js';
import { ConfigContext } from '../ui/contexts/ConfigContext.js';
import { calculateMainAreaWidth } from '../ui/utils/ui-sizing.js';
import { VimModeProvider } from '../ui/contexts/VimModeContext.js';
@@ -25,6 +24,8 @@ import {
type UIActions,
UIActionsContext,
} 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';
@@ -239,6 +240,10 @@ export const renderWithProviders = (
const finalUIActions = { ...mockUIActions, ...uiActions };
+ const allToolCalls = (finalUiState.pendingHistoryItems || [])
+ .filter((item): item is HistoryItemToolGroup => item.type === 'tool_group')
+ .flatMap((item) => item.tools);
+
const renderResult = render(
@@ -247,20 +252,22 @@ export const renderWithProviders = (
-
-
-
-
- {component}
-
-
-
-
+
+
+
+
+
+ {component}
+
+
+
+
+
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 79c6fcd8af..c1f322ee1d 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -25,9 +25,11 @@ import {
type HistoryItem,
ToolCallStatus,
type HistoryItemWithoutId,
+ type HistoryItemToolGroup,
AuthState,
} from './types.js';
import { MessageType, StreamingState } from './types.js';
+import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
import {
type EditorType,
type Config,
@@ -1486,6 +1488,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
+ const allToolCalls = useMemo(
+ () =>
+ pendingHistoryItems
+ .filter(
+ (item): item is HistoryItemToolGroup => item.type === 'tool_group',
+ )
+ .flatMap((item) => item.tools),
+ [pendingHistoryItems],
+ );
+
const [geminiMdFileCount, setGeminiMdFileCount] = useState(
config.getGeminiMdFileCount(),
);
@@ -1832,9 +1844,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
startupWarnings: props.startupWarnings || [],
}}
>
-
-
-
+
+
+
+
+
diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx
index c487357081..0863e50286 100644
--- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx
+++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx
@@ -88,6 +88,7 @@ const mockConfig = {
getModel: () => 'gemini-pro',
getTargetDir: () => '/tmp',
getDebugMode: () => false,
+ getIdeMode: () => false,
getGeminiMdFileCount: () => 0,
getExperiments: () => ({
flags: {},
diff --git a/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx b/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx
index d5b7f54f0e..7dc5341331 100644
--- a/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx
+++ b/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx
@@ -35,6 +35,7 @@ describe('ToolConfirmationMessage Redirection', () => {
const { lastFrame } = renderWithProviders(
{
+ const actual =
+ await importOriginal<
+ typeof import('../../contexts/ToolActionsContext.js')
+ >();
+ return {
+ ...actual,
+ useToolActions: vi.fn(),
+ };
+});
describe('ToolConfirmationMessage', () => {
+ const mockConfirm = vi.fn();
+ vi.mocked(useToolActions).mockReturnValue({
+ confirm: mockConfirm,
+ cancel: vi.fn(),
+ });
+
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
@@ -32,6 +50,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
{
const { lastFrame } = renderWithProviders(
{
const { lastFrame } = renderWithProviders(
{
const { lastFrame } = renderWithProviders(
{
const { lastFrame } = renderWithProviders(
{
const { lastFrame } = renderWithProviders(
{
const { lastFrame } = renderWithProviders(
= ({
+ callId,
confirmationDetails,
config,
isFocused = true,
availableTerminalHeight,
terminalWidth,
}) => {
- const { onConfirm } = confirmationDetails;
+ const { confirm } = useToolActions();
const settings = useSettings();
const allowPermanentApproval =
settings.merged.security.enablePermanentToolApproval;
- const [ideClient, setIdeClient] = useState(null);
- const [isDiffingEnabled, setIsDiffingEnabled] = useState(false);
-
- useEffect(() => {
- let isMounted = true;
- 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 handleConfirm = (outcome: ToolConfirmationOutcome) => {
+ void confirm(callId, outcome).catch((error) => {
+ debugLogger.error(
+ `Failed to handle tool confirmation for ${callId}:`,
+ error,
+ );
+ });
};
const isTrustedFolder = config.isTrustedFolder();
@@ -96,7 +73,6 @@ export const ToolConfirmationMessage: React.FC<
(key) => {
if (!isFocused) return;
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
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({
label: 'Modify with external editor',
value: ToolConfirmationOutcome.ModifyWithEditor,
@@ -400,7 +378,6 @@ export const ToolConfirmationMessage: React.FC<
confirmationDetails,
isTrustedFolder,
config,
- isDiffingEnabled,
availableTerminalHeight,
terminalWidth,
allowPermanentApproval,
diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
index b36523715a..3f61959440 100644
--- a/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
+++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.test.tsx
@@ -39,6 +39,11 @@ describe('', () => {
const toolCalls = [createToolCall()];
const { lastFrame, unmount } = renderWithProviders(
,
+ {
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -67,6 +72,11 @@ describe('', () => {
];
const { lastFrame, unmount } = renderWithProviders(
,
+ {
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -89,6 +99,11 @@ describe('', () => {
];
const { lastFrame, unmount } = renderWithProviders(
,
+ {
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -105,6 +120,11 @@ describe('', () => {
];
const { lastFrame, unmount } = renderWithProviders(
,
+ {
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -133,6 +153,11 @@ describe('', () => {
];
const { lastFrame, unmount } = renderWithProviders(
,
+ {
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -160,6 +185,11 @@ describe('', () => {
toolCalls={toolCalls}
availableTerminalHeight={10}
/>,
+ {
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -173,6 +203,11 @@ describe('', () => {
toolCalls={toolCalls}
isFocused={false}
/>,
+ {
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -192,6 +227,11 @@ describe('', () => {
toolCalls={toolCalls}
terminalWidth={40}
/>,
+ {
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -200,6 +240,11 @@ describe('', () => {
it('renders empty tool calls array', () => {
const { lastFrame, unmount } = renderWithProviders(
,
+ {
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: [] }],
+ },
+ },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -225,6 +270,11 @@ describe('', () => {
,
+ {
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -242,6 +292,11 @@ describe('', () => {
];
const { lastFrame, unmount } = renderWithProviders(
,
+ {
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -270,6 +325,14 @@ describe('', () => {
,
+ {
+ uiState: {
+ pendingHistoryItems: [
+ { type: 'tool_group', tools: toolCalls1 },
+ { type: 'tool_group', tools: toolCalls2 },
+ ],
+ },
+ },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -281,6 +344,11 @@ describe('', () => {
const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })];
const { lastFrame, unmount } = renderWithProviders(
,
+ {
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
// The snapshot will capture the visual appearance including border color
expect(lastFrame()).toMatchSnapshot();
@@ -296,6 +364,11 @@ describe('', () => {
];
const { lastFrame, unmount } = renderWithProviders(
,
+ {
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -312,6 +385,11 @@ describe('', () => {
];
const { lastFrame, unmount } = renderWithProviders(
,
+ {
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -340,6 +418,11 @@ describe('', () => {
toolCalls={toolCalls}
availableTerminalHeight={20}
/>,
+ {
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -374,6 +457,11 @@ describe('', () => {
];
const { lastFrame, unmount } = renderWithProviders(
,
+ {
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
// Should only show confirmation for the first tool
expect(lastFrame()).toMatchSnapshot();
@@ -399,7 +487,12 @@ describe('', () => {
});
const { lastFrame, unmount } = renderWithProviders(
,
- { settings },
+ {
+ settings,
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
expect(lastFrame()).toContain('Allow for all future sessions');
expect(lastFrame()).toMatchSnapshot();
@@ -425,7 +518,12 @@ describe('', () => {
});
const { lastFrame, unmount } = renderWithProviders(
,
- { settings },
+ {
+ settings,
+ uiState: {
+ pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
+ },
+ },
);
expect(lastFrame()).not.toContain('Allow for all future sessions');
expect(lastFrame()).toMatchSnapshot();
diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
index d41ff534d0..dda785b906 100644
--- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx
@@ -157,6 +157,7 @@ export const ToolGroupMessage: React.FC = ({
isConfirming &&
tool.confirmationDetails && (
{
+ const actual =
+ await importOriginal();
+ 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 }) => (
+
+ {children}
+
+ );
+
+ 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');
+ }
+ });
+});
diff --git a/packages/cli/src/ui/contexts/ToolActionsContext.tsx b/packages/cli/src/ui/contexts/ToolActionsContext.tsx
new file mode 100644
index 0000000000..46c2026c26
--- /dev/null
+++ b/packages/cli/src/ui/contexts/ToolActionsContext.tsx
@@ -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;
+ cancel: (callId: string) => Promise;
+}
+
+const ToolActionsContext = createContext(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 = (
+ props: ToolActionsProviderProps,
+) => {
+ const { children, config, toolCalls } = props;
+ // Hoist IdeClient logic here to keep UI pure
+ const [ideClient, setIdeClient] = useState(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 (
+
+ {children}
+
+ );
+};
diff --git a/packages/cli/src/ui/hooks/toolMapping.test.ts b/packages/cli/src/ui/hooks/toolMapping.test.ts
index 9ebabc2f65..16d518135f 100644
--- a/packages/cli/src/ui/hooks/toolMapping.test.ts
+++ b/packages/cli/src/ui/hooks/toolMapping.test.ts
@@ -195,6 +195,33 @@ describe('toolMapping', () => {
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', () => {
// e.g. "TOOL_NOT_REGISTERED" errors
const toolCall: ToolCall = {
diff --git a/packages/cli/src/ui/hooks/toolMapping.ts b/packages/cli/src/ui/hooks/toolMapping.ts
index 7865ba1c5e..237044135f 100644
--- a/packages/cli/src/ui/hooks/toolMapping.ts
+++ b/packages/cli/src/ui/hooks/toolMapping.ts
@@ -8,6 +8,7 @@ import {
type ToolCall,
type Status as CoreStatus,
type ToolCallConfirmationDetails,
+ type SerializableConfirmationDetails,
type ToolResultDisplay,
debugLogger,
} from '@google/gemini-cli-core';
@@ -72,10 +73,13 @@ export function mapToDisplay(
};
let resultDisplay: ToolResultDisplay | undefined = undefined;
- let confirmationDetails: ToolCallConfirmationDetails | undefined =
- undefined;
+ let confirmationDetails:
+ | ToolCallConfirmationDetails
+ | SerializableConfirmationDetails
+ | undefined = undefined;
let outputFile: string | undefined = undefined;
let ptyId: number | undefined = undefined;
+ let correlationId: string | undefined = undefined;
switch (call.status) {
case 'success':
@@ -87,16 +91,9 @@ export function mapToDisplay(
resultDisplay = call.response.resultDisplay;
break;
case 'awaiting_approval':
- // Only map if it's the legacy callback-based details.
- // Serializable details will be handled in a later milestone.
- if (
- call.confirmationDetails &&
- 'onConfirm' in call.confirmationDetails &&
- typeof call.confirmationDetails.onConfirm === 'function'
- ) {
- confirmationDetails =
- call.confirmationDetails as ToolCallConfirmationDetails;
- }
+ correlationId = call.correlationId;
+ // Pass through details. Context handles dispatch (callback vs bus).
+ confirmationDetails = call.confirmationDetails;
break;
case 'executing':
resultDisplay = call.liveOutput;
@@ -123,6 +120,7 @@ export function mapToDisplay(
confirmationDetails,
outputFile,
ptyId,
+ correlationId,
};
});
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index f013d27fbf..9442b44c51 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -10,6 +10,7 @@ import type {
MCPServerConfig,
ThoughtSummary,
ToolCallConfirmationDetails,
+ SerializableConfirmationDetails,
ToolResultDisplay,
RetrieveUserQuotaResponse,
SkillDefinition,
@@ -63,7 +64,11 @@ export interface ToolCallEvent {
name: string;
args: Record;
resultDisplay: ToolResultDisplay | undefined;
- confirmationDetails: ToolCallConfirmationDetails | undefined;
+ confirmationDetails:
+ | ToolCallConfirmationDetails
+ | SerializableConfirmationDetails
+ | undefined;
+ correlationId?: string;
}
export interface IndividualToolCallDisplay {
@@ -72,10 +77,14 @@ export interface IndividualToolCallDisplay {
description: string;
resultDisplay: ToolResultDisplay | undefined;
status: ToolCallStatus;
- confirmationDetails: ToolCallConfirmationDetails | undefined;
+ confirmationDetails:
+ | ToolCallConfirmationDetails
+ | SerializableConfirmationDetails
+ | undefined;
renderOutputAsMarkdown?: boolean;
ptyId?: number;
outputFile?: string;
+ correlationId?: string;
}
export interface CompressionProps {
diff --git a/packages/core/src/confirmation-bus/types.ts b/packages/core/src/confirmation-bus/types.ts
index d9caa9c5c2..0e7e7eae06 100644
--- a/packages/core/src/confirmation-bus/types.ts
+++ b/packages/core/src/confirmation-bus/types.ts
@@ -74,6 +74,7 @@ export type SerializableConfirmationDetails =
fileDiff: string;
originalContent: string | null;
newContent: string;
+ isModifying?: boolean;
}
| {
type: 'exec';
@@ -81,6 +82,7 @@ export type SerializableConfirmationDetails =
command: string;
rootCommand: string;
rootCommands: string[];
+ commands?: string[];
}
| {
type: 'mcp';