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
@@ -88,6 +88,7 @@ const mockConfig = {
getModel: () => 'gemini-pro',
getTargetDir: () => '/tmp',
getDebugMode: () => false,
getIdeMode: () => false,
getGeminiMdFileCount: () => 0,
getExperiments: () => ({
flags: {},
@@ -35,6 +35,7 @@ describe('ToolConfirmationMessage Redirection', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
@@ -14,8 +14,26 @@ import {
renderWithProviders,
createMockSettings,
} 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', () => {
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(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
@@ -56,6 +75,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
@@ -79,6 +99,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
@@ -161,6 +182,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={details}
config={mockConfig}
availableTerminalHeight={30}
@@ -179,6 +201,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={details}
config={mockConfig}
availableTerminalHeight={30}
@@ -211,6 +234,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={editConfirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
@@ -234,6 +258,7 @@ describe('ToolConfirmationMessage', () => {
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={editConfirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
@@ -5,20 +5,20 @@
*/
import type React from 'react';
import { useEffect, useState, useMemo } from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
import type {
ToolCallConfirmationDetails,
Config,
} from '@google/gemini-cli-core';
import {
IdeClient,
type SerializableConfirmationDetails,
type ToolCallConfirmationDetails,
type Config,
ToolConfirmationOutcome,
hasRedirection,
debugLogger,
} from '@google/gemini-cli-core';
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js';
@@ -32,7 +32,10 @@ import {
} from '../../textConstants.js';
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
callId: string;
confirmationDetails:
| ToolCallConfirmationDetails
| SerializableConfirmationDetails;
config: Config;
isFocused?: boolean;
availableTerminalHeight?: number;
@@ -42,52 +45,26 @@ export interface ToolConfirmationMessageProps {
export const ToolConfirmationMessage: React.FC<
ToolConfirmationMessageProps
> = ({
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<IdeClient | null>(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,
@@ -39,6 +39,11 @@ describe('<ToolGroupMessage />', () => {
const toolCalls = [createToolCall()];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -67,6 +72,11 @@ describe('<ToolGroupMessage />', () => {
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -89,6 +99,11 @@ describe('<ToolGroupMessage />', () => {
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -105,6 +120,11 @@ describe('<ToolGroupMessage />', () => {
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -133,6 +153,11 @@ describe('<ToolGroupMessage />', () => {
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -160,6 +185,11 @@ describe('<ToolGroupMessage />', () => {
toolCalls={toolCalls}
availableTerminalHeight={10}
/>,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -173,6 +203,11 @@ describe('<ToolGroupMessage />', () => {
toolCalls={toolCalls}
isFocused={false}
/>,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -192,6 +227,11 @@ describe('<ToolGroupMessage />', () => {
toolCalls={toolCalls}
terminalWidth={40}
/>,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -200,6 +240,11 @@ describe('<ToolGroupMessage />', () => {
it('renders empty tool calls array', () => {
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={[]} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: [] }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -225,6 +270,11 @@ describe('<ToolGroupMessage />', () => {
<Scrollable height={10} hasFocus={true} scrollToBottom={true}>
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />
</Scrollable>,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -242,6 +292,11 @@ describe('<ToolGroupMessage />', () => {
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -270,6 +325,14 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} toolCalls={toolCalls1} />
<ToolGroupMessage {...baseProps} toolCalls={toolCalls2} />
</Scrollable>,
{
uiState: {
pendingHistoryItems: [
{ type: 'tool_group', tools: toolCalls1 },
{ type: 'tool_group', tools: toolCalls2 },
],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -281,6 +344,11 @@ describe('<ToolGroupMessage />', () => {
const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
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('<ToolGroupMessage />', () => {
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -312,6 +385,11 @@ describe('<ToolGroupMessage />', () => {
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -340,6 +418,11 @@ describe('<ToolGroupMessage />', () => {
toolCalls={toolCalls}
availableTerminalHeight={20}
/>,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).toMatchSnapshot();
unmount();
@@ -374,6 +457,11 @@ describe('<ToolGroupMessage />', () => {
];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
// Should only show confirmation for the first tool
expect(lastFrame()).toMatchSnapshot();
@@ -399,7 +487,12 @@ describe('<ToolGroupMessage />', () => {
});
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{ 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('<ToolGroupMessage />', () => {
});
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{ settings },
{
settings,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
);
expect(lastFrame()).not.toContain('Allow for all future sessions');
expect(lastFrame()).toMatchSnapshot();
@@ -157,6 +157,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
isConfirming &&
tool.confirmationDetails && (
<ToolConfirmationMessage
callId={tool.callId}
confirmationDetails={tool.confirmationDetails}
config={config}
isFocused={isFocused}