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

View File

@@ -13,17 +13,21 @@ import { AlternateBufferQuittingDisplay } from './AlternateBufferQuittingDisplay
import { ToolCallStatus } from '../types.js';
import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
import { Text } from 'ink';
import type { Config } from '@google/gemini-cli-core';
vi.mock('../utils/terminalSetup.js', () => ({
getTerminalProgram: () => null,
}));
vi.mock('../contexts/AppContext.js', () => ({
useAppContext: () => ({
version: '0.10.0',
}),
}));
vi.mock('../contexts/AppContext.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../contexts/AppContext.js')>();
return {
...actual,
useAppContext: () => ({
version: '0.10.0',
}),
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -85,21 +89,6 @@ const mockPendingHistoryItems: HistoryItemWithoutId[] = [
},
];
const mockConfig = {
getScreenReader: () => false,
getEnableInteractiveShell: () => false,
getModel: () => 'gemini-pro',
getTargetDir: () => '/tmp',
getDebugMode: () => false,
getIdeMode: () => false,
getGeminiMdFileCount: () => 0,
getExperiments: () => ({
flags: {},
experimentIds: [],
}),
getPreviewFeatures: () => false,
} as unknown as Config;
describe('AlternateBufferQuittingDisplay', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -127,7 +116,6 @@ describe('AlternateBufferQuittingDisplay', () => {
history: mockHistory,
pendingHistoryItems: mockPendingHistoryItems,
},
config: mockConfig,
},
);
expect(lastFrame()).toMatchSnapshot('with_history_and_pending');
@@ -143,7 +131,6 @@ describe('AlternateBufferQuittingDisplay', () => {
history: [],
pendingHistoryItems: [],
},
config: mockConfig,
},
);
expect(lastFrame()).toMatchSnapshot('empty');
@@ -159,7 +146,6 @@ describe('AlternateBufferQuittingDisplay', () => {
history: mockHistory,
pendingHistoryItems: [],
},
config: mockConfig,
},
);
expect(lastFrame()).toMatchSnapshot('with_history_no_pending');
@@ -175,12 +161,50 @@ describe('AlternateBufferQuittingDisplay', () => {
history: [],
pendingHistoryItems: mockPendingHistoryItems,
},
config: mockConfig,
},
);
expect(lastFrame()).toMatchSnapshot('with_pending_no_history');
});
it('renders with a tool awaiting confirmation', () => {
persistentStateMock.setData({ tipsShown: 0 });
const pendingHistoryItems: HistoryItemWithoutId[] = [
{
type: 'tool_group',
tools: [
{
callId: 'call4',
name: 'confirming_tool',
description: 'Confirming tool description',
status: ToolCallStatus.Confirming,
resultDisplay: undefined,
confirmationDetails: {
type: 'info',
title: 'Confirm Tool',
prompt: 'Confirm this action?',
onConfirm: async () => {},
},
},
],
},
];
const { lastFrame } = renderWithProviders(
<AlternateBufferQuittingDisplay />,
{
uiState: {
...baseUIState,
history: [],
pendingHistoryItems,
},
},
);
const output = lastFrame();
expect(output).toContain('Action Required (was prompted):');
expect(output).toContain('confirming_tool');
expect(output).toContain('Confirming tool description');
expect(output).toMatchSnapshot('with_confirming_tool');
});
it('renders with user and gemini messages', () => {
persistentStateMock.setData({ tipsShown: 0 });
const history: HistoryItem[] = [
@@ -195,7 +219,6 @@ describe('AlternateBufferQuittingDisplay', () => {
history,
pendingHistoryItems: [],
},
config: mockConfig,
},
);
expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages');

View File

@@ -4,17 +4,26 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Box } from 'ink';
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { AppHeader } from './AppHeader.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { QuittingDisplay } from './QuittingDisplay.js';
import { useAppContext } from '../contexts/AppContext.js';
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js';
import { theme } from '../semantic-colors.js';
export const AlternateBufferQuittingDisplay = () => {
const { version } = useAppContext();
const uiState = useUIState();
const config = useConfig();
const confirmingTool = useConfirmingTool();
const showPromptedTool =
config.isEventDrivenSchedulerEnabled() && confirmingTool !== null;
// We render the entire chat history and header here to ensure that the
// conversation history is visible to the user after the app quits and the
@@ -52,6 +61,25 @@ export const AlternateBufferQuittingDisplay = () => {
embeddedShellFocused={uiState.embeddedShellFocused}
/>
))}
{showPromptedTool && (
<Box flexDirection="column" marginTop={1} marginBottom={1}>
<Text color={theme.status.warning} bold>
Action Required (was prompted):
</Text>
<Box marginTop={1}>
<ToolStatusIndicator
status={confirmingTool.tool.status}
name={confirmingTool.tool.name}
/>
<ToolInfo
name={confirmingTool.tool.name}
status={confirmingTool.tool.status}
description={confirmingTool.tool.description}
emphasis="high"
/>
</Box>
</Box>
)}
<QuittingDisplay />
</Box>
);

View File

@@ -29,7 +29,7 @@ import { StreamingState } from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { TodoTray } from './messages/Todo.js';
export const Composer = () => {
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const config = useConfig();
const settings = useSettings();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
@@ -133,7 +133,7 @@ export const Composer = () => {
setShellModeActive={uiActions.setShellModeActive}
approvalMode={showApprovalModeIndicator}
onEscapePromptChange={uiActions.onEscapePromptChange}
focus={true}
focus={isFocused}
vimHandleInput={uiActions.vimHandleInput}
isEmbeddedShellFocused={uiState.embeddedShellFocused}
popAllMessages={uiActions.popAllMessages}

View File

@@ -8,9 +8,14 @@ import { render } from '../../test-utils/render.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { describe, it, expect, vi } from 'vitest';
vi.mock('@google/gemini-cli-core', () => ({
tokenLimit: () => 10000,
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
tokenLimit: () => 10000,
};
});
vi.mock('../../config/settings.js', () => ({
DEFAULT_MODEL_CONFIGS: {},

View File

@@ -33,11 +33,17 @@ vi.mock('node:fs/promises', async () => {
unlink: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock('node:os', () => ({
default: {
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
return {
...actual,
default: {
...actual,
homedir: () => '/mock/home',
},
homedir: () => '/mock/home',
},
}));
};
});
vi.mock('node:path', async () => {
const actual = await vi.importActual<typeof import('node:path')>('node:path');
@@ -47,13 +53,19 @@ vi.mock('node:path', async () => {
};
});
vi.mock('@google/gemini-cli-core', () => ({
GEMINI_DIR: '.gemini',
homedir: () => '/mock/home',
Storage: {
getGlobalTempDir: () => '/mock/temp',
},
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
GEMINI_DIR: '.gemini',
homedir: () => '/mock/home',
Storage: {
...actual.Storage,
getGlobalTempDir: () => '/mock/temp',
},
};
});
vi.mock('../../config/settings.js', () => ({
DEFAULT_MODEL_CONFIGS: {},

View File

@@ -0,0 +1,89 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { ToolCallStatus } from '../types.js';
import { renderWithProviders } from '../../test-utils/render.js';
import type { Config } from '@google/gemini-cli-core';
import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js';
describe('ToolConfirmationQueue', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getModel: () => 'gemini-pro',
getDebugMode: () => false,
} as unknown as Config;
it('renders the confirming tool with progress indicator', () => {
const confirmingTool = {
tool: {
callId: 'call-1',
name: 'ls',
description: 'list files',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'exec' as const,
title: 'Confirm execution',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
onConfirm: vi.fn(),
},
},
index: 1,
total: 3,
};
const { lastFrame } = renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
{
config: mockConfig,
uiState: {
terminalWidth: 80,
},
},
);
const output = lastFrame();
expect(output).toContain('Action Required');
expect(output).toContain('1 of 3');
expect(output).toContain('ls'); // Tool name
expect(output).toContain('list files'); // Tool description
expect(output).toContain("Allow execution of: 'ls'?");
expect(output).toMatchSnapshot();
});
it('returns null if tool has no confirmation details', () => {
const confirmingTool = {
tool: {
callId: 'call-1',
name: 'ls',
status: ToolCallStatus.Confirming,
confirmationDetails: undefined,
},
index: 1,
total: 1,
};
const { lastFrame } = renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
{
config: mockConfig,
uiState: {
terminalWidth: 80,
},
},
);
expect(lastFrame()).toBe('');
});
});

View File

@@ -0,0 +1,89 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { ToolConfirmationMessage } from './messages/ToolConfirmationMessage.js';
import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js';
import { useUIState } from '../contexts/UIStateContext.js';
import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js';
interface ToolConfirmationQueueProps {
confirmingTool: ConfirmingToolState;
}
export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
confirmingTool,
}) => {
const config = useConfig();
const { terminalWidth, terminalHeight } = useUIState();
const { tool, index, total } = confirmingTool;
// Safety check: ToolConfirmationMessage requires confirmationDetails
if (!tool.confirmationDetails) return null;
// V1: Constrain the queue to at most 50% of the terminal height to ensure
// some history is always visible and to prevent flickering.
// We pass this to ToolConfirmationMessage so it can calculate internal
// truncation while keeping buttons visible.
const maxHeight = Math.floor(terminalHeight * 0.5);
// ToolConfirmationMessage needs to know the height available for its OWN content.
// We subtract the lines used by the Queue wrapper:
// - 2 lines for the rounded border
// - 2 lines for the Header (text + margin)
// - 2 lines for Tool Identity (text + margin)
const availableContentHeight = Math.max(maxHeight - 6, 4);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.status.warning}
paddingX={1}
// Matches existing layout spacing
width={terminalWidth}
flexShrink={0}
>
{/* Header */}
<Box marginBottom={1} justifyContent="space-between">
<Text color={theme.status.warning} bold>
Action Required
</Text>
<Text color={theme.text.secondary}>
{index} of {total}
</Text>
</Box>
{/* Tool Identity (Context) */}
<Box marginBottom={1}>
<ToolStatusIndicator status={tool.status} name={tool.name} />
<ToolInfo
name={tool.name}
status={tool.status}
description={tool.description}
emphasis="high"
/>
</Box>
{/* Interactive Area */}
{/*
Note: We force isFocused={true} because if this component is rendered,
it effectively acts as a modal over the shell/composer.
*/}
<ToolConfirmationMessage
callId={tool.callId}
confirmationDetails={tool.confirmationDetails}
config={config}
terminalWidth={terminalWidth - 4} // Adjust for parent border/padding
availableTerminalHeight={availableContentHeight}
isFocused={true}
/>
</Box>
);
};

View File

@@ -1,5 +1,28 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AlternateBufferQuittingDisplay > renders with a tool awaiting confirmation > with_confirming_tool 1`] = `
"
███ █████████
░░░███ ███░░░░░███
░░░███ ███ ░░░
░░░███░███
███░ ░███ █████
███░ ░░███ ░░███
███░ ░░█████████
░░░ ░░░░░░░░░
Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
Action Required (was prompted):
? confirming_tool Confirming tool description
"
`;
exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages > with_history_and_pending 1`] = `
"
███ █████████
@@ -23,10 +46,6 @@ Tips for getting started:
╭─────────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
╰─────────────────────────────────────────────────────────────────────────────╯
╭─────────────────────────────────────────────────────────────────────────────╮
│ o tool3 Description for tool 3 │
│ │
╰─────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -89,11 +108,7 @@ Tips for getting started:
1. Ask questions, edit files, or run commands.
2. Be specific for the best results.
3. Create GEMINI.md files to customize your interactions with Gemini.
4. /help for more information.
╭─────────────────────────────────────────────────────────────────────────────╮
│ o tool3 Description for tool 3 │
│ │
╰─────────────────────────────────────────────────────────────────────────────╯"
4. /help for more information."
`;
exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = `

View File

@@ -0,0 +1,18 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToolConfirmationQueue > renders the confirming tool with progress indicator 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 3 │
│ │
│ ? ls list files │
│ │
│ ls │
│ │
│ Allow execution of: 'ls'? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;

View File

@@ -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 */}

View File

@@ -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();
});
});
});

View File

@@ -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}

View File

@@ -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?

View File

@@ -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`] = `