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
+64 -44
View File
@@ -9,6 +9,7 @@ import { Box } from 'ink';
import type React from 'react';
import { vi } from 'vitest';
import { act, useState } from 'react';
import os from 'node:os';
import { LoadedSettings, type Settings } from '../config/settings.js';
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
@@ -27,8 +28,9 @@ import {
import { type HistoryItemToolGroup, StreamingState } from '../ui/types.js';
import { ToolActionsProvider } from '../ui/contexts/ToolActionsContext.js';
import { type Config } from '@google/gemini-cli-core';
import { makeFakeConfig, type Config } from '@google/gemini-cli-core';
import { FakePersistentState } from './persistentStateFake.js';
import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
export const persistentStateMock = new FakePersistentState();
@@ -91,21 +93,27 @@ export const simulateClick = async (
});
};
const mockConfig = {
getModel: () => 'gemini-pro',
getTargetDir: () =>
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long',
getDebugMode: () => false,
isTrustedFolder: () => true,
getIdeMode: () => false,
getEnableInteractiveShell: () => true,
getPreviewFeatures: () => false,
let mockConfigInternal: Config | undefined;
const getMockConfigInternal = (): Config => {
if (!mockConfigInternal) {
mockConfigInternal = makeFakeConfig({
targetDir: os.tmpdir(),
enableEventDrivenScheduler: true,
});
}
return mockConfigInternal;
};
const configProxy = new Proxy(mockConfig, {
get(target, prop) {
if (prop in target) {
return target[prop as keyof typeof target];
const configProxy = new Proxy({} as Config, {
get(_target, prop) {
if (prop === 'getTargetDir') {
return () =>
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long';
}
const internal = getMockConfigInternal();
if (prop in internal) {
return internal[prop as keyof typeof internal];
}
throw new Error(`mockConfig does not have property ${String(prop)}`);
},
@@ -146,6 +154,11 @@ const baseMockUiState = {
terminalBackgroundColor: undefined,
};
export const mockAppState: AppState = {
version: '1.2.3',
startupWarnings: [],
};
const mockUIActions: UIActions = {
handleThemeSelect: vi.fn(),
closeThemeDialog: vi.fn(),
@@ -199,6 +212,7 @@ export const renderWithProviders = (
useAlternateBuffer = true,
uiActions,
persistentState,
appState = mockAppState,
}: {
shellFocus?: boolean;
settings?: LoadedSettings;
@@ -212,6 +226,7 @@ export const renderWithProviders = (
get?: typeof persistentStateMock.get;
set?: typeof persistentStateMock.set;
};
appState?: AppState;
} = {},
): ReturnType<typeof render> & { simulateClick: typeof simulateClick } => {
const baseState: UIState = new Proxy(
@@ -268,36 +283,41 @@ export const renderWithProviders = (
.flatMap((item) => item.tools);
const renderResult = render(
<ConfigContext.Provider value={config}>
<SettingsContext.Provider value={finalSettings}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider settings={finalSettings}>
<ShellFocusContext.Provider value={shellFocus}>
<StreamingContext.Provider value={finalUiState.streamingState}>
<UIActionsContext.Provider value={finalUIActions}>
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
<KeypressProvider>
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
<ScrollProvider>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ScrollProvider>
</MouseProvider>
</KeypressProvider>
</ToolActionsProvider>
</UIActionsContext.Provider>
</StreamingContext.Provider>
</ShellFocusContext.Provider>
</VimModeProvider>
</UIStateContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>,
<AppContext.Provider value={appState}>
<ConfigContext.Provider value={config}>
<SettingsContext.Provider value={finalSettings}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider settings={finalSettings}>
<ShellFocusContext.Provider value={shellFocus}>
<StreamingContext.Provider value={finalUiState.streamingState}>
<UIActionsContext.Provider value={finalUIActions}>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
>
<KeypressProvider>
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
<ScrollProvider>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{component}
</Box>
</ScrollProvider>
</MouseProvider>
</KeypressProvider>
</ToolActionsProvider>
</UIActionsContext.Provider>
</StreamingContext.Provider>
</ShellFocusContext.Provider>
</VimModeProvider>
</UIStateContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>
</AppContext.Provider>,
terminalWidth,
);
+105 -72
View File
@@ -4,17 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, type Mock } from 'vitest';
import { render } from '../test-utils/render.js';
import { Text, useIsScreenReaderEnabled } from 'ink';
import { makeFakeConfig } from '@google/gemini-cli-core';
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
import type React from 'react';
import { renderWithProviders } from '../test-utils/render.js';
import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink';
import { type Config } from '@google/gemini-cli-core';
import { App } from './App.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
import { StreamingState } from './types.js';
import { ConfigContext } from './contexts/ConfigContext.js';
import { AppContext, type AppState } from './contexts/AppContext.js';
import { SettingsContext } from './contexts/SettingsContext.js';
import { LoadedSettings, type SettingsFile } from '../config/settings.js';
import { type UIState } from './contexts/UIStateContext.js';
import { StreamingState, ToolCallStatus } from './types.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
vi.mock('ink', async (importOriginal) => {
const original = await importOriginal<typeof import('ink')>();
@@ -53,12 +51,20 @@ vi.mock('./components/Footer.js', () => ({
}));
describe('App', () => {
beforeEach(() => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
});
const mockUIState: Partial<UIState> = {
streamingState: StreamingState.Idle,
quittingMessages: null,
dialogsVisible: false,
mainControlsRef: { current: null },
rootUiRef: { current: null },
mainControlsRef: {
current: null,
} as unknown as React.MutableRefObject<DOMElement | null>,
rootUiRef: {
current: null,
} as unknown as React.MutableRefObject<DOMElement | null>,
historyManager: {
addItem: vi.fn(),
history: [],
@@ -68,49 +74,18 @@ describe('App', () => {
},
history: [],
pendingHistoryItems: [],
pendingGeminiHistoryItems: [],
bannerData: {
defaultText: 'Mock Banner Text',
warningText: '',
},
};
const mockConfig = makeFakeConfig();
const mockSettingsFile: SettingsFile = {
settings: {},
originalSettings: {},
path: '/mock/path',
};
const mockLoadedSettings = new LoadedSettings(
mockSettingsFile,
mockSettingsFile,
mockSettingsFile,
mockSettingsFile,
true,
[],
);
const mockAppState: AppState = {
version: '1.0.0',
startupWarnings: [],
};
const renderWithProviders = (ui: React.ReactElement, state: UIState) =>
render(
<AppContext.Provider value={mockAppState}>
<ConfigContext.Provider value={mockConfig}>
<SettingsContext.Provider value={mockLoadedSettings}>
<UIStateContext.Provider value={state}>
{ui}
</UIStateContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>
</AppContext.Provider>,
);
it('should render main content and composer when not quitting', () => {
const { lastFrame } = renderWithProviders(<App />, mockUIState as UIState);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
useAlternateBuffer: false,
});
expect(lastFrame()).toContain('MainContent');
expect(lastFrame()).toContain('Notifications');
@@ -123,7 +98,10 @@ describe('App', () => {
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
} as UIState;
const { lastFrame } = renderWithProviders(<App />, quittingUIState);
const { lastFrame } = renderWithProviders(<App />, {
uiState: quittingUIState,
useAlternateBuffer: false,
});
expect(lastFrame()).toContain('Quitting...');
});
@@ -136,15 +114,13 @@ describe('App', () => {
pendingHistoryItems: [{ type: 'user', text: 'pending item' }],
} as UIState;
mockLoadedSettings.merged.ui.useAlternateBuffer = true;
const { lastFrame } = renderWithProviders(<App />, quittingUIState);
const { lastFrame } = renderWithProviders(<App />, {
uiState: quittingUIState,
useAlternateBuffer: true,
});
expect(lastFrame()).toContain('HistoryItemDisplay');
expect(lastFrame()).toContain('Quitting...');
// Reset settings
mockLoadedSettings.merged.ui.useAlternateBuffer = false;
});
it('should render dialog manager when dialogs are visible', () => {
@@ -153,7 +129,9 @@ describe('App', () => {
dialogsVisible: true,
} as UIState;
const { lastFrame } = renderWithProviders(<App />, dialogUIState);
const { lastFrame } = renderWithProviders(<App />, {
uiState: dialogUIState,
});
expect(lastFrame()).toContain('MainContent');
expect(lastFrame()).toContain('Notifications');
@@ -172,7 +150,9 @@ describe('App', () => {
[stateKey]: true,
} as UIState;
const { lastFrame } = renderWithProviders(<App />, uiState);
const { lastFrame } = renderWithProviders(<App />, {
uiState,
});
expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`);
},
@@ -181,37 +161,88 @@ describe('App', () => {
it('should render ScreenReaderAppLayout when screen reader is enabled', () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame } = renderWithProviders(<App />, mockUIState as UIState);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
});
expect(lastFrame()).toContain(
'Notifications\nFooter\nMainContent\nComposer',
);
expect(lastFrame()).toContain(`Notifications
Footer
MainContent
Composer`);
});
it('should render DefaultAppLayout when screen reader is not enabled', () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame } = renderWithProviders(<App />, mockUIState as UIState);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
});
expect(lastFrame()).toContain('MainContent\nNotifications\nComposer');
expect(lastFrame()).toContain(`MainContent
Notifications
Composer`);
});
it('should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on', () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const toolCalls = [
{
callId: 'call-1',
name: 'ls',
description: 'list directory',
status: ToolCallStatus.Confirming,
resultDisplay: '',
confirmationDetails: {
type: 'exec' as const,
title: 'Confirm execution',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
];
const stateWithConfirmingTool = {
...mockUIState,
pendingHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
pendingGeminiHistoryItems: [{ type: 'tool_group', tools: toolCalls }],
} as UIState;
const configWithExperiment = {
...makeFakeConfig(),
isEventDrivenSchedulerEnabled: () => true,
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame } = renderWithProviders(<App />, {
uiState: stateWithConfirmingTool,
config: configWithExperiment,
});
expect(lastFrame()).toContain('MainContent');
expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Action Required'); // From ToolConfirmationQueue
expect(lastFrame()).toContain('1 of 1');
expect(lastFrame()).toContain('Composer');
expect(lastFrame()).toMatchSnapshot();
});
describe('Snapshots', () => {
it('renders default layout correctly', () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(false);
const { lastFrame } = renderWithProviders(
<App />,
mockUIState as UIState,
);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders screen reader layout correctly', () => {
(useIsScreenReaderEnabled as Mock).mockReturnValue(true);
const { lastFrame } = renderWithProviders(
<App />,
mockUIState as UIState,
);
const { lastFrame } = renderWithProviders(<App />, {
uiState: mockUIState,
});
expect(lastFrame()).toMatchSnapshot();
});
@@ -220,7 +251,9 @@ describe('App', () => {
...mockUIState,
dialogsVisible: true,
} as UIState;
const { lastFrame } = renderWithProviders(<App />, dialogUIState);
const { lastFrame } = renderWithProviders(<App />, {
uiState: dialogUIState,
});
expect(lastFrame()).toMatchSnapshot();
});
});
+1
View File
@@ -1010,6 +1010,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
* - Any future streaming states not explicitly allowed
*/
const isInputActive =
isConfigInitialized &&
!initError &&
!isProcessing &&
!!slashCommands &&
@@ -3,7 +3,44 @@
exports[`App > Snapshots > renders default layout correctly 1`] = `
"MainContent
Notifications
Composer"
Composer
"
`;
exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
@@ -14,8 +51,87 @@ Composer"
`;
exports[`App > Snapshots > renders with dialogs visible 1`] = `
"Notifications
Footer
MainContent
DialogManager"
"MainContent
Notifications
DialogManager
"
`;
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
"MainContent
Notifications
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Action Required 1 of 1 │
│ │
│ ? ls list directory │
│ │
│ ls │
│ │
│ Allow execution of: 'ls'? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Composer
"
`;
+13 -10
View File
@@ -74,11 +74,12 @@ describe('AuthDialog', () => {
onAuthError: (error: string | null) => void;
setAuthContext: (context: { requiresRestart?: boolean }) => void;
};
const originalEnv = { ...process.env };
beforeEach(() => {
vi.resetAllMocks();
process.env = {};
vi.stubEnv('CLOUD_SHELL', undefined as unknown as string);
vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', undefined as unknown as string);
vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', undefined as unknown as string);
vi.stubEnv('GEMINI_API_KEY', undefined as unknown as string);
props = {
config: {
@@ -100,7 +101,7 @@ describe('AuthDialog', () => {
});
afterEach(() => {
process.env = originalEnv;
vi.unstubAllEnvs();
});
describe('Environment Variable Effects on Auth Options', () => {
@@ -138,7 +139,9 @@ describe('AuthDialog', () => {
])(
'correctly shows/hides COMPUTE_ADC options $desc',
({ env, shouldContain, shouldNotContain }) => {
process.env = { ...env };
for (const [key, value] of Object.entries(env)) {
vi.stubEnv(key, value as string);
}
renderWithProviders(<AuthDialog {...props} />);
const items = mockedRadioButtonSelect.mock.calls[0][0].items;
for (const item of shouldContain) {
@@ -178,14 +181,14 @@ describe('AuthDialog', () => {
},
{
setup: () => {
process.env['GEMINI_DEFAULT_AUTH_TYPE'] = AuthType.USE_GEMINI;
vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', AuthType.USE_GEMINI);
},
expected: AuthType.USE_GEMINI,
desc: 'from GEMINI_DEFAULT_AUTH_TYPE env var',
},
{
setup: () => {
process.env['GEMINI_API_KEY'] = 'test-key';
vi.stubEnv('GEMINI_API_KEY', 'test-key');
},
expected: AuthType.USE_GEMINI,
desc: 'from GEMINI_API_KEY env var',
@@ -243,7 +246,7 @@ describe('AuthDialog', () => {
it('skips API key dialog on initial setup if env var is present', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
process.env['GEMINI_API_KEY'] = 'test-key-from-env';
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
renderWithProviders(<AuthDialog {...props} />);
@@ -258,7 +261,7 @@ describe('AuthDialog', () => {
it('skips API key dialog if env var is present but empty', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
process.env['GEMINI_API_KEY'] = ''; // Empty string
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
// props.settings.merged.security.auth.selectedType is undefined here
renderWithProviders(<AuthDialog {...props} />);
@@ -288,7 +291,7 @@ describe('AuthDialog', () => {
it('skips API key dialog on re-auth if env var is present (cannot edit)', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
process.env['GEMINI_API_KEY'] = 'test-key-from-env';
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// Simulate that the user has already authenticated once
props.settings.merged.security.auth.selectedType =
AuthType.LOGIN_WITH_GOOGLE;
@@ -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');
@@ -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>
);
+2 -2
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}
@@ -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: {},
@@ -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: {},
@@ -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('');
});
});
@@ -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>
);
};
@@ -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`] = `
@@ -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) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -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`] = `
@@ -52,6 +52,7 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
props: ToolActionsProviderProps,
) => {
const { children, config, toolCalls } = props;
// Hoist IdeClient logic here to keep UI pure
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
useEffect(() => {
@@ -124,7 +125,7 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
debugLogger.warn(`ToolActions: No confirmation mechanism for ${callId}`);
},
[config, toolCalls, ideClient],
[config, ideClient, toolCalls],
);
const cancel = useCallback(
@@ -7,7 +7,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mapCoreStatusToDisplayStatus, mapToDisplay } from './toolMapping.js';
import {
debugLogger,
type AnyDeclarativeTool,
type AnyToolInvocation,
type ToolCallRequestInfo,
@@ -40,7 +39,7 @@ describe('toolMapping', () => {
describe('mapCoreStatusToDisplayStatus', () => {
it.each([
['validating', ToolCallStatus.Executing],
['validating', ToolCallStatus.Pending],
['awaiting_approval', ToolCallStatus.Confirming],
['executing', ToolCallStatus.Executing],
['success', ToolCallStatus.Success],
@@ -53,12 +52,10 @@ describe('toolMapping', () => {
);
});
it('logs warning and defaults to Error for unknown status', () => {
const result = mapCoreStatusToDisplayStatus('unknown_status' as Status);
expect(result).toBe(ToolCallStatus.Error);
expect(debugLogger.warn).toHaveBeenCalledWith(
'Unknown core status encountered: unknown_status',
);
it('throws error for unknown status due to checkExhaustive', () => {
expect(() =>
mapCoreStatusToDisplayStatus('unknown_status' as Status),
).toThrow('unexpected value unknown_status!');
});
});
+4 -3
View File
@@ -18,12 +18,14 @@ import {
type IndividualToolCallDisplay,
} from '../types.js';
import { checkExhaustive } from '../../utils/checks.js';
export function mapCoreStatusToDisplayStatus(
coreStatus: CoreStatus,
): ToolCallStatus {
switch (coreStatus) {
case 'validating':
return ToolCallStatus.Executing;
return ToolCallStatus.Pending;
case 'awaiting_approval':
return ToolCallStatus.Confirming;
case 'executing':
@@ -37,8 +39,7 @@ export function mapCoreStatusToDisplayStatus(
case 'scheduled':
return ToolCallStatus.Pending;
default:
debugLogger.warn(`Unknown core status encountered: ${coreStatus}`);
return ToolCallStatus.Error;
return checkExhaustive(coreStatus);
}
}
@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useMemo } from 'react';
import { useUIState } from '../contexts/UIStateContext.js';
import {
ToolCallStatus,
type IndividualToolCallDisplay,
type HistoryItemToolGroup,
} from '../types.js';
export interface ConfirmingToolState {
tool: IndividualToolCallDisplay;
index: number;
total: number;
}
/**
* Selects the "Head" of the confirmation queue.
* Returns the first tool in the pending state that requires confirmation.
*/
export function useConfirmingTool(): ConfirmingToolState | null {
// We use pendingHistoryItems to ensure we capture tools from both
// Gemini responses and Slash commands.
const { pendingHistoryItems } = useUIState();
return useMemo(() => {
// 1. Flatten all pending tools from all pending history groups
const allPendingTools = pendingHistoryItems
.filter(
(item): item is HistoryItemToolGroup => item.type === 'tool_group',
)
.flatMap((group) => group.tools);
// 2. Filter for those requiring confirmation
const confirmingTools = allPendingTools.filter(
(t) => t.status === ToolCallStatus.Confirming,
);
if (confirmingTools.length === 0) {
return null;
}
// 3. Select Head (FIFO)
const head = confirmingTools[0];
// 4. Calculate progress based on the full tool list
// This gives the user context of where they are in the current batch.
const headIndexInFullList = allPendingTools.findIndex(
(t) => t.callId === head.callId,
);
return {
tool: head,
index: headIndexInFullList + 1,
total: allPendingTools.length,
};
}, [pendingHistoryItems]);
}
@@ -938,7 +938,7 @@ describe('mapToDisplay', () => {
name: 'validating',
status: 'validating',
extraProps: { tool: baseTool, invocation: baseInvocation },
expectedStatus: ToolCallStatus.Executing,
expectedStatus: ToolCallStatus.Pending,
expectedName: baseTool.displayName,
expectedDescription: baseInvocation.getDescription(),
},
@@ -15,11 +15,21 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useFlickerDetector } from '../hooks/useFlickerDetector.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { CopyModeWarning } from '../components/CopyModeWarning.js';
import { ToolConfirmationQueue } from '../components/ToolConfirmationQueue.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { useConfig } from '../contexts/ConfigContext.js';
export const DefaultAppLayout: React.FC = () => {
const uiState = useUIState();
const config = useConfig();
const isAlternateBuffer = useAlternateBuffer();
// If the event-driven scheduler is enabled AND we have a tool waiting,
// we switch the footer mode to "Queue".
const confirmingTool = useConfirmingTool();
const showConfirmationQueue =
config.isEventDrivenSchedulerEnabled() && confirmingTool !== null;
const { rootUiRef, terminalHeight } = uiState;
useFlickerDetector(rootUiRef, terminalHeight);
// If in alternate buffer mode, need to leave room to draw the scrollbar on
@@ -57,7 +67,12 @@ export const DefaultAppLayout: React.FC = () => {
addItem={uiState.historyManager.addItem}
/>
) : (
<Composer />
<>
{showConfirmationQueue && confirmingTool && (
<ToolConfirmationQueue confirmingTool={confirmingTool} />
)}
<Composer isFocused={!showConfirmationQueue} />
</>
)}
<ExitWarning />
@@ -58,11 +58,12 @@ vi.mock('./terminalCapabilityManager.js', () => ({
}));
describe('terminalSetup', () => {
const originalEnv = process.env;
beforeEach(() => {
vi.resetAllMocks();
process.env = { ...originalEnv };
vi.stubEnv('TERM_PROGRAM', '');
vi.stubEnv('CURSOR_TRACE_ID', '');
vi.stubEnv('VSCODE_GIT_ASKPASS_MAIN', '');
vi.stubEnv('VSCODE_GIT_IPC_HANDLE', '');
// Default mocks
mocks.homedir.mockReturnValue('/home/user');
@@ -73,7 +74,7 @@ describe('terminalSetup', () => {
});
afterEach(() => {
process.env = originalEnv;
vi.unstubAllEnvs();
});
describe('detectTerminal', () => {