feat(cli): Moves tool confirmations to a queue UX (#17276)

Co-authored-by: Christian Gunderman <gundermanc@google.com>
This commit is contained in:
Abhi
2026-01-23 20:32:35 -05:00
committed by GitHub
parent 77aef861fe
commit 1832f7b90a
27 changed files with 1009 additions and 285 deletions
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useMemo } from 'react';
import { useMemo, useCallback } from 'react';
import { Box, Text } from 'ink';
import { DiffRenderer } from './DiffRenderer.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
@@ -58,14 +58,17 @@ export const ToolConfirmationMessage: React.FC<
const allowPermanentApproval =
settings.merged.security.enablePermanentToolApproval;
const handleConfirm = (outcome: ToolConfirmationOutcome) => {
void confirm(callId, outcome).catch((error) => {
debugLogger.error(
`Failed to handle tool confirmation for ${callId}:`,
error,
);
});
};
const handleConfirm = useCallback(
(outcome: ToolConfirmationOutcome) => {
void confirm(callId, outcome).catch((error) => {
debugLogger.error(
`Failed to handle tool confirmation for ${callId}:`,
error,
);
});
},
[confirm, callId],
);
const isTrustedFolder = config.isTrustedFolder();
@@ -79,16 +82,16 @@ export const ToolConfirmationMessage: React.FC<
{ isActive: isFocused },
);
const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);
const handleSelect = useCallback(
(item: ToolConfirmationOutcome) => handleConfirm(item),
[handleConfirm],
);
const { question, bodyContent, options } = useMemo(() => {
let bodyContent: React.ReactNode | null = null;
let question = '';
const getOptions = useCallback(() => {
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [];
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
question = `Apply this change?`;
options.push({
label: 'Allow once',
value: ToolConfirmationOutcome.ProceedOnce,
@@ -125,13 +128,6 @@ export const ToolConfirmationMessage: React.FC<
});
}
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
if (executionProps.commands && executionProps.commands.length > 1) {
question = `Allow execution of ${executionProps.commands.length} commands?`;
} else {
question = `Allow execution of: '${executionProps.rootCommand}'?`;
}
options.push({
label: 'Allow once',
value: ToolConfirmationOutcome.ProceedOnce,
@@ -157,7 +153,6 @@ export const ToolConfirmationMessage: React.FC<
key: 'No, suggest changes (esc)',
});
} else if (confirmationDetails.type === 'info') {
question = `Do you want to proceed?`;
options.push({
label: 'Allow once',
value: ToolConfirmationOutcome.ProceedOnce,
@@ -184,8 +179,6 @@ export const ToolConfirmationMessage: React.FC<
});
} else {
// mcp tool confirmation
const mcpProps = confirmationDetails;
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
options.push({
label: 'Allow once',
value: ToolConfirmationOutcome.ProceedOnce,
@@ -216,33 +209,56 @@ export const ToolConfirmationMessage: React.FC<
key: 'No, suggest changes (esc)',
});
}
return options;
}, [confirmationDetails, isTrustedFolder, allowPermanentApproval, config]);
function availableBodyContentHeight() {
if (options.length === 0) {
// Should not happen if we populated options correctly above for all types
// except when isModifying is true, but in that case we don't call this because we don't enter the if block for it.
return undefined;
const availableBodyContentHeight = useCallback(() => {
if (availableTerminalHeight === undefined) {
return undefined;
}
// Calculate the vertical space (in lines) consumed by UI elements
// surrounding the main body content.
const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom).
const MARGIN_BODY_BOTTOM = 1; // margin on the body container.
const HEIGHT_QUESTION = 1; // The question text is one line.
const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container.
const optionsCount = getOptions().length;
const surroundingElementsHeight =
PADDING_OUTER_Y +
MARGIN_BODY_BOTTOM +
HEIGHT_QUESTION +
MARGIN_QUESTION_BOTTOM +
optionsCount;
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
}, [availableTerminalHeight, getOptions]);
const { question, bodyContent, options } = useMemo(() => {
let bodyContent: React.ReactNode | null = null;
let question = '';
const options = getOptions();
if (confirmationDetails.type === 'edit') {
if (!confirmationDetails.isModifying) {
question = `Apply this change?`;
}
} else if (confirmationDetails.type === 'exec') {
const executionProps = confirmationDetails;
if (availableTerminalHeight === undefined) {
return undefined;
if (executionProps.commands && executionProps.commands.length > 1) {
question = `Allow execution of ${executionProps.commands.length} commands?`;
} else {
question = `Allow execution of: '${executionProps.rootCommand}'?`;
}
// Calculate the vertical space (in lines) consumed by UI elements
// surrounding the main body content.
const PADDING_OUTER_Y = 2; // Main container has `padding={1}` (top & bottom).
const MARGIN_BODY_BOTTOM = 1; // margin on the body container.
const HEIGHT_QUESTION = 1; // The question text is one line.
const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container.
const HEIGHT_OPTIONS = options.length; // Each option in the radio select takes one line.
const surroundingElementsHeight =
PADDING_OUTER_Y +
MARGIN_BODY_BOTTOM +
HEIGHT_QUESTION +
MARGIN_QUESTION_BOTTOM +
HEIGHT_OPTIONS;
return Math.max(availableTerminalHeight - surroundingElementsHeight, 1);
} else if (confirmationDetails.type === 'info') {
question = `Do you want to proceed?`;
} else {
// mcp tool confirmation
const mcpProps = confirmationDetails;
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
}
if (confirmationDetails.type === 'edit') {
@@ -376,11 +392,9 @@ export const ToolConfirmationMessage: React.FC<
return { question, bodyContent, options };
}, [
confirmationDetails,
isTrustedFolder,
config,
availableTerminalHeight,
getOptions,
availableBodyContentHeight,
terminalWidth,
allowPermanentApproval,
]);
if (confirmationDetails.type === 'edit') {
@@ -409,7 +423,13 @@ export const ToolConfirmationMessage: React.FC<
{/* Body Content (Diff Renderer or Command Info) */}
{/* No separate context display here anymore for edits */}
<Box flexGrow={1} flexShrink={1} overflow="hidden" marginBottom={1}>
{bodyContent}
<MaxSizedBox
maxHeight={availableBodyContentHeight()}
maxWidth={terminalWidth}
overflowDirection="top"
>
{bodyContent}
</MaxSizedBox>
</Box>
{/* Confirmation Question */}
@@ -13,6 +13,7 @@ import { ToolGroupMessage } from './ToolGroupMessage.js';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import { Scrollable } from '../shared/Scrollable.js';
import type { Config } from '@google/gemini-cli-core';
describe('<ToolGroupMessage />', () => {
const createToolCall = (
@@ -34,12 +35,24 @@ describe('<ToolGroupMessage />', () => {
isFocused: true,
};
const baseMockConfig = {
getModel: () => 'gemini-pro',
getTargetDir: () => '/test',
getDebugMode: () => false,
isTrustedFolder: () => true,
getIdeMode: () => false,
getEnableInteractiveShell: () => true,
getPreviewFeatures: () => false,
isEventDrivenSchedulerEnabled: () => true,
} as unknown as Config;
describe('Golden Snapshots', () => {
it('renders single successful tool call', () => {
const toolCalls = [createToolCall()];
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
@@ -70,9 +83,15 @@ describe('<ToolGroupMessage />', () => {
status: ToolCallStatus.Error,
}),
];
const mockConfig = {
...baseMockConfig,
isEventDrivenSchedulerEnabled: () => false,
} as unknown as Config;
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
config: mockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
@@ -97,9 +116,15 @@ describe('<ToolGroupMessage />', () => {
},
}),
];
const mockConfig = {
...baseMockConfig,
isEventDrivenSchedulerEnabled: () => false,
} as unknown as Config;
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
config: mockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
@@ -121,6 +146,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
@@ -151,9 +177,15 @@ describe('<ToolGroupMessage />', () => {
status: ToolCallStatus.Pending,
}),
];
const mockConfig = {
...baseMockConfig,
isEventDrivenSchedulerEnabled: () => false,
} as unknown as Config;
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
config: mockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
@@ -186,6 +218,7 @@ describe('<ToolGroupMessage />', () => {
availableTerminalHeight={10}
/>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
@@ -204,6 +237,7 @@ describe('<ToolGroupMessage />', () => {
isFocused={false}
/>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
@@ -228,6 +262,7 @@ describe('<ToolGroupMessage />', () => {
terminalWidth={40}
/>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
@@ -241,6 +276,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={[]} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: [] }],
},
@@ -271,6 +307,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />
</Scrollable>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
@@ -293,6 +330,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
@@ -326,6 +364,7 @@ describe('<ToolGroupMessage />', () => {
<ToolGroupMessage {...baseProps} toolCalls={toolCalls2} />
</Scrollable>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [
{ type: 'tool_group', tools: toolCalls1 },
@@ -342,9 +381,15 @@ describe('<ToolGroupMessage />', () => {
describe('Border Color Logic', () => {
it('uses yellow border when tools are pending', () => {
const toolCalls = [createToolCall({ status: ToolCallStatus.Pending })];
const mockConfig = {
...baseMockConfig,
isEventDrivenSchedulerEnabled: () => false,
} as unknown as Config;
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
config: mockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
@@ -365,6 +410,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
@@ -386,6 +432,7 @@ describe('<ToolGroupMessage />', () => {
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
@@ -419,6 +466,7 @@ describe('<ToolGroupMessage />', () => {
availableTerminalHeight={20}
/>,
{
config: baseMockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
@@ -455,9 +503,15 @@ describe('<ToolGroupMessage />', () => {
},
}),
];
const mockConfig = {
...baseMockConfig,
isEventDrivenSchedulerEnabled: () => false,
} as unknown as Config;
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
config: mockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
@@ -485,10 +539,16 @@ describe('<ToolGroupMessage />', () => {
const settings = createMockSettings({
security: { enablePermanentToolApproval: true },
});
const mockConfig = {
...baseMockConfig,
isEventDrivenSchedulerEnabled: () => false,
} as unknown as Config;
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
settings,
config: mockConfig,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
@@ -502,32 +562,100 @@ describe('<ToolGroupMessage />', () => {
it('renders confirmation with permanent approval disabled', () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
callId: 'confirm-tool',
name: 'confirm-tool',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm Tool',
title: 'Confirm tool',
prompt: 'Do you want to proceed?',
onConfirm: vi.fn(),
},
}),
];
const settings = createMockSettings({
security: { enablePermanentToolApproval: false },
});
const mockConfig = {
...baseMockConfig,
isEventDrivenSchedulerEnabled: () => false,
} as unknown as Config;
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{
settings,
uiState: {
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
},
},
{ config: mockConfig },
);
expect(lastFrame()).not.toContain('Allow for all future sessions');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
describe('Event-Driven Scheduler', () => {
it('hides confirming tools when event-driven scheduler is enabled', () => {
const toolCalls = [
createToolCall({
callId: 'confirm-tool',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm tool',
prompt: 'Do you want to proceed?',
onConfirm: vi.fn(),
},
}),
];
const mockConfig = {
...baseMockConfig,
isEventDrivenSchedulerEnabled: () => true,
} as unknown as Config;
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{ config: mockConfig },
);
// Should render nothing because all tools in the group are confirming
expect(lastFrame()).toBe('');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('shows only successful tools when mixed with confirming tools', () => {
const toolCalls = [
createToolCall({
callId: 'success-tool',
name: 'success-tool',
status: ToolCallStatus.Success,
}),
createToolCall({
callId: 'confirm-tool',
name: 'confirm-tool',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm tool',
prompt: 'Do you want to proceed?',
onConfirm: vi.fn(),
},
}),
];
const mockConfig = {
...baseMockConfig,
isEventDrivenSchedulerEnabled: () => true,
} as unknown as Config;
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{ config: mockConfig },
);
const output = lastFrame();
expect(output).toContain('success-tool');
expect(output).not.toContain('confirm-tool');
expect(output).not.toContain('Do you want to proceed?');
expect(output).toMatchSnapshot();
unmount();
});
});
});
@@ -36,7 +36,28 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
activeShellPtyId,
embeddedShellFocused,
}) => {
const isEmbeddedShellFocused = toolCalls.some((t) =>
const config = useConfig();
const isEventDriven = config.isEventDrivenSchedulerEnabled();
// If Event-Driven Scheduler is enabled, we HIDE tools that are still in
// pre-execution states (Confirming, Pending) from the History log.
// They live in the Global Queue or wait for their turn.
const visibleToolCalls = useMemo(() => {
if (!isEventDriven) {
return toolCalls;
}
// Only show tools that are actually running or finished.
// We explicitly exclude Pending and Confirming to ensure they only
// appear in the Global Queue until they are approved and start executing.
return toolCalls.filter(
(t) =>
t.status !== ToolCallStatus.Pending &&
t.status !== ToolCallStatus.Confirming,
);
}, [toolCalls, isEventDriven]);
const isEmbeddedShellFocused = visibleToolCalls.some((t) =>
isThisShellFocused(
t.name,
t.status,
@@ -46,11 +67,10 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
),
);
const hasPending = !toolCalls.every(
const hasPending = !visibleToolCalls.every(
(t) => t.status === ToolCallStatus.Success,
);
const config = useConfig();
const isShellCommand = toolCalls.some((t) => isShellTool(t.name));
const borderColor =
(isShellCommand && hasPending) || isEmbeddedShellFocused
@@ -64,20 +84,29 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
const staticHeight = /* border */ 2 + /* marginBottom */ 1;
// only prompt for tool approval on the first 'confirming' tool in the list
// note, after the CTA, this automatically moves over to the next 'confirming' tool
// Inline confirmations are ONLY used when the Global Queue is disabled.
const toolAwaitingApproval = useMemo(
() => toolCalls.find((tc) => tc.status === ToolCallStatus.Confirming),
[toolCalls],
() =>
isEventDriven
? undefined
: toolCalls.find((tc) => tc.status === ToolCallStatus.Confirming),
[toolCalls, isEventDriven],
);
// If all tools are hidden (e.g. group only contains confirming or pending tools),
// render nothing in the history log.
if (visibleToolCalls.length === 0) {
return null;
}
let countToolCallsWithResults = 0;
for (const tool of toolCalls) {
for (const tool of visibleToolCalls) {
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
countToolCallsWithResults++;
}
}
const countOneLineToolCalls = toolCalls.length - countToolCallsWithResults;
const countOneLineToolCalls =
visibleToolCalls.length - countToolCallsWithResults;
const availableTerminalHeightPerToolMessage = availableTerminalHeight
? Math.max(
Math.floor(
@@ -102,7 +131,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
*/
width={terminalWidth}
>
{toolCalls.map((tool, index) => {
{visibleToolCalls.map((tool, index) => {
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
const isFirst = index === 0;
const isShellToolCall = isShellTool(tool.name);
@@ -180,7 +209,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/
toolCalls.length > 0 && (
visibleToolCalls.length > 0 && (
<Box
height={0}
width={terminalWidth}
@@ -39,11 +39,11 @@ Do you want to proceed?
`;
exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations' > should NOT show "allow always" when folder is untrusted 1`] = `
"╭──────────────────────╮
│ │
│ No changes detected. │
│ │
╰──────────────────────╯
"╭──────────────────────────────────────────────────────────────────────────────
│ No changes detected.
╰──────────────────────────────────────────────────────────────────────────────
Apply this change?
@@ -54,11 +54,11 @@ Apply this change?
`;
exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations' > should show "allow always" when folder is trusted 1`] = `
"╭──────────────────────╮
│ │
│ No changes detected. │
│ │
╰──────────────────────╯
"╭──────────────────────────────────────────────────────────────────────────────
│ No changes detected.
╰──────────────────────────────────────────────────────────────────────────────
Apply this change?
@@ -81,6 +81,16 @@ exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialo
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Event-Driven Scheduler > hides confirming tools when event-driven scheduler is enabled 1`] = `""`;
exports[`<ToolGroupMessage /> > Event-Driven Scheduler > shows only successful tools when mixed with confirming tools 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ success-tool A tool for testing │
│ │
│ Test result │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls array 1`] = `""`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled 1`] = `