mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
refactor(cli): unify shell confirmation dialogs (#16828)
This commit is contained in:
@@ -708,7 +708,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
slashCommands,
|
slashCommands,
|
||||||
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
pendingHistoryItems: pendingSlashCommandHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
shellConfirmationRequest,
|
|
||||||
confirmationRequest,
|
confirmationRequest,
|
||||||
} = useSlashCommandProcessor(
|
} = useSlashCommandProcessor(
|
||||||
config,
|
config,
|
||||||
@@ -1358,7 +1357,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
|
|
||||||
useKeypress(handleGlobalKeypress, { isActive: true });
|
useKeypress(handleGlobalKeypress, { isActive: true });
|
||||||
|
|
||||||
// Update terminal title with Gemini CLI status and thoughts
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Respect hideWindowTitle settings
|
// Respect hideWindowTitle settings
|
||||||
if (settings.merged.ui.hideWindowTitle) return;
|
if (settings.merged.ui.hideWindowTitle) return;
|
||||||
@@ -1366,10 +1364,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
const paddedTitle = computeTerminalTitle({
|
const paddedTitle = computeTerminalTitle({
|
||||||
streamingState,
|
streamingState,
|
||||||
thoughtSubject: thought?.subject,
|
thoughtSubject: thought?.subject,
|
||||||
isConfirming:
|
isConfirming: !!confirmationRequest || showShellActionRequired,
|
||||||
!!shellConfirmationRequest ||
|
|
||||||
!!confirmationRequest ||
|
|
||||||
showShellActionRequired,
|
|
||||||
folderName: basename(config.getTargetDir()),
|
folderName: basename(config.getTargetDir()),
|
||||||
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
showThoughts: !!settings.merged.ui.showStatusInTitle,
|
||||||
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
useDynamicTitle: settings.merged.ui.dynamicWindowTitle,
|
||||||
@@ -1384,7 +1379,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
}, [
|
}, [
|
||||||
streamingState,
|
streamingState,
|
||||||
thought,
|
thought,
|
||||||
shellConfirmationRequest,
|
|
||||||
confirmationRequest,
|
confirmationRequest,
|
||||||
showShellActionRequired,
|
showShellActionRequired,
|
||||||
settings.merged.ui.showStatusInTitle,
|
settings.merged.ui.showStatusInTitle,
|
||||||
@@ -1463,7 +1457,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
shouldShowIdePrompt ||
|
shouldShowIdePrompt ||
|
||||||
isFolderTrustDialogOpen ||
|
isFolderTrustDialogOpen ||
|
||||||
adminSettingsChanged ||
|
adminSettingsChanged ||
|
||||||
!!shellConfirmationRequest ||
|
|
||||||
!!confirmationRequest ||
|
!!confirmationRequest ||
|
||||||
!!customDialog ||
|
!!customDialog ||
|
||||||
confirmUpdateExtensionRequests.length > 0 ||
|
confirmUpdateExtensionRequests.length > 0 ||
|
||||||
@@ -1558,7 +1551,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
slashCommands,
|
slashCommands,
|
||||||
pendingSlashCommandHistoryItems,
|
pendingSlashCommandHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
shellConfirmationRequest,
|
|
||||||
confirmationRequest,
|
confirmationRequest,
|
||||||
confirmUpdateExtensionRequests,
|
confirmUpdateExtensionRequests,
|
||||||
loopDetectionConfirmationRequest,
|
loopDetectionConfirmationRequest,
|
||||||
@@ -1650,7 +1642,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
slashCommands,
|
slashCommands,
|
||||||
pendingSlashCommandHistoryItems,
|
pendingSlashCommandHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
shellConfirmationRequest,
|
|
||||||
confirmationRequest,
|
confirmationRequest,
|
||||||
confirmUpdateExtensionRequests,
|
confirmUpdateExtensionRequests,
|
||||||
loopDetectionConfirmationRequest,
|
loopDetectionConfirmationRequest,
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { Text } from 'ink';
|
|||||||
import { type UIState } from '../contexts/UIStateContext.js';
|
import { type UIState } from '../contexts/UIStateContext.js';
|
||||||
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||||
import { type IdeInfo } from '@google/gemini-cli-core';
|
import { type IdeInfo } from '@google/gemini-cli-core';
|
||||||
import { type ShellConfirmationRequest } from '../types.js';
|
|
||||||
|
|
||||||
// Mock child components
|
// Mock child components
|
||||||
vi.mock('../IdeIntegrationNudge.js', () => ({
|
vi.mock('../IdeIntegrationNudge.js', () => ({
|
||||||
@@ -23,9 +22,6 @@ vi.mock('./LoopDetectionConfirmation.js', () => ({
|
|||||||
vi.mock('./FolderTrustDialog.js', () => ({
|
vi.mock('./FolderTrustDialog.js', () => ({
|
||||||
FolderTrustDialog: () => <Text>FolderTrustDialog</Text>,
|
FolderTrustDialog: () => <Text>FolderTrustDialog</Text>,
|
||||||
}));
|
}));
|
||||||
vi.mock('./ShellConfirmationDialog.js', () => ({
|
|
||||||
ShellConfirmationDialog: () => <Text>ShellConfirmationDialog</Text>,
|
|
||||||
}));
|
|
||||||
vi.mock('./ConsentPrompt.js', () => ({
|
vi.mock('./ConsentPrompt.js', () => ({
|
||||||
ConsentPrompt: () => <Text>ConsentPrompt</Text>,
|
ConsentPrompt: () => <Text>ConsentPrompt</Text>,
|
||||||
}));
|
}));
|
||||||
@@ -79,7 +75,6 @@ describe('DialogManager', () => {
|
|||||||
proQuotaRequest: null,
|
proQuotaRequest: null,
|
||||||
shouldShowIdePrompt: false,
|
shouldShowIdePrompt: false,
|
||||||
isFolderTrustDialogOpen: false,
|
isFolderTrustDialogOpen: false,
|
||||||
shellConfirmationRequest: null,
|
|
||||||
loopDetectionConfirmationRequest: null,
|
loopDetectionConfirmationRequest: null,
|
||||||
confirmationRequest: null,
|
confirmationRequest: null,
|
||||||
isThemeDialogOpen: false,
|
isThemeDialogOpen: false,
|
||||||
@@ -130,15 +125,6 @@ describe('DialogManager', () => {
|
|||||||
'IdeIntegrationNudge',
|
'IdeIntegrationNudge',
|
||||||
],
|
],
|
||||||
[{ isFolderTrustDialogOpen: true }, 'FolderTrustDialog'],
|
[{ isFolderTrustDialogOpen: true }, 'FolderTrustDialog'],
|
||||||
[
|
|
||||||
{
|
|
||||||
shellConfirmationRequest: {
|
|
||||||
commands: [],
|
|
||||||
onConfirm: vi.fn(),
|
|
||||||
} as unknown as ShellConfirmationRequest,
|
|
||||||
},
|
|
||||||
'ShellConfirmationDialog',
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
{ loopDetectionConfirmationRequest: { onComplete: vi.fn() } },
|
{ loopDetectionConfirmationRequest: { onComplete: vi.fn() } },
|
||||||
'LoopDetectionConfirmation',
|
'LoopDetectionConfirmation',
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { Box, Text } from 'ink';
|
|||||||
import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js';
|
import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js';
|
||||||
import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js';
|
import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js';
|
||||||
import { FolderTrustDialog } from './FolderTrustDialog.js';
|
import { FolderTrustDialog } from './FolderTrustDialog.js';
|
||||||
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
|
|
||||||
import { ConsentPrompt } from './ConsentPrompt.js';
|
import { ConsentPrompt } from './ConsentPrompt.js';
|
||||||
import { ThemeDialog } from './ThemeDialog.js';
|
import { ThemeDialog } from './ThemeDialog.js';
|
||||||
import { SettingsDialog } from './SettingsDialog.js';
|
import { SettingsDialog } from './SettingsDialog.js';
|
||||||
@@ -85,11 +84,6 @@ export const DialogManager = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (uiState.shellConfirmationRequest) {
|
|
||||||
return (
|
|
||||||
<ShellConfirmationDialog request={uiState.shellConfirmationRequest} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (uiState.loopDetectionConfirmationRequest) {
|
if (uiState.loopDetectionConfirmationRequest) {
|
||||||
return (
|
return (
|
||||||
<LoopDetectionConfirmation
|
<LoopDetectionConfirmation
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Google LLC
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { renderWithProviders } from '../../test-utils/render.js';
|
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
|
|
||||||
|
|
||||||
describe('ShellConfirmationDialog', () => {
|
|
||||||
const onConfirm = vi.fn();
|
|
||||||
|
|
||||||
const request = {
|
|
||||||
commands: ['ls -la', 'echo "hello"'],
|
|
||||||
onConfirm,
|
|
||||||
};
|
|
||||||
|
|
||||||
it('renders correctly', () => {
|
|
||||||
const { lastFrame } = renderWithProviders(
|
|
||||||
<ShellConfirmationDialog request={request} />,
|
|
||||||
{ width: 101 },
|
|
||||||
);
|
|
||||||
expect(lastFrame()).toMatchSnapshot();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onConfirm with ProceedOnce when "Allow once" is selected', () => {
|
|
||||||
const { lastFrame } = renderWithProviders(
|
|
||||||
<ShellConfirmationDialog request={request} />,
|
|
||||||
);
|
|
||||||
const select = lastFrame()!.toString();
|
|
||||||
// Simulate selecting the first option
|
|
||||||
// This is a simplified way to test the selection
|
|
||||||
expect(select).toContain('Allow once');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onConfirm with ProceedAlways when "Allow for this session" is selected', () => {
|
|
||||||
const { lastFrame } = renderWithProviders(
|
|
||||||
<ShellConfirmationDialog request={request} />,
|
|
||||||
);
|
|
||||||
const select = lastFrame()!.toString();
|
|
||||||
// Simulate selecting the second option
|
|
||||||
expect(select).toContain('Allow for this session');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls onConfirm with Cancel when "No (esc)" is selected', () => {
|
|
||||||
const { lastFrame } = renderWithProviders(
|
|
||||||
<ShellConfirmationDialog request={request} />,
|
|
||||||
{ width: 100 },
|
|
||||||
);
|
|
||||||
const select = lastFrame()!.toString();
|
|
||||||
// Simulate selecting the third option
|
|
||||||
expect(select).toContain('No (esc)');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Google LLC
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ToolConfirmationOutcome } from '@google/gemini-cli-core';
|
|
||||||
import { Box, Text } from 'ink';
|
|
||||||
import type React from 'react';
|
|
||||||
import { theme } from '../semantic-colors.js';
|
|
||||||
import { RenderInline } from '../utils/InlineMarkdownRenderer.js';
|
|
||||||
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
|
|
||||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
|
||||||
import { useKeypress } from '../hooks/useKeypress.js';
|
|
||||||
|
|
||||||
export interface ShellConfirmationRequest {
|
|
||||||
commands: string[];
|
|
||||||
onConfirm: (
|
|
||||||
outcome: ToolConfirmationOutcome,
|
|
||||||
approvedCommands?: string[],
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShellConfirmationDialogProps {
|
|
||||||
request: ShellConfirmationRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ShellConfirmationDialog: React.FC<
|
|
||||||
ShellConfirmationDialogProps
|
|
||||||
> = ({ request }) => {
|
|
||||||
const { commands, onConfirm } = request;
|
|
||||||
|
|
||||||
useKeypress(
|
|
||||||
(key) => {
|
|
||||||
if (key.name === 'escape') {
|
|
||||||
onConfirm(ToolConfirmationOutcome.Cancel);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ isActive: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelect = (item: ToolConfirmationOutcome) => {
|
|
||||||
if (item === ToolConfirmationOutcome.Cancel) {
|
|
||||||
onConfirm(item);
|
|
||||||
} else {
|
|
||||||
// For both ProceedOnce and ProceedAlways, we approve all the
|
|
||||||
// commands that were requested.
|
|
||||||
onConfirm(item, commands);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [
|
|
||||||
{
|
|
||||||
label: 'Allow once',
|
|
||||||
value: ToolConfirmationOutcome.ProceedOnce,
|
|
||||||
key: 'Allow once',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Allow for this session',
|
|
||||||
value: ToolConfirmationOutcome.ProceedAlways,
|
|
||||||
key: 'Allow for this session',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'No (esc)',
|
|
||||||
value: ToolConfirmationOutcome.Cancel,
|
|
||||||
key: 'No (esc)',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box flexDirection="row" width="100%">
|
|
||||||
<Box
|
|
||||||
flexDirection="column"
|
|
||||||
borderStyle="round"
|
|
||||||
borderColor={theme.status.warning}
|
|
||||||
padding={1}
|
|
||||||
flexGrow={1}
|
|
||||||
marginLeft={1}
|
|
||||||
>
|
|
||||||
<Box flexDirection="column" marginBottom={1}>
|
|
||||||
<Text bold color={theme.text.primary}>
|
|
||||||
Shell Command Execution
|
|
||||||
</Text>
|
|
||||||
<Text color={theme.text.primary}>
|
|
||||||
A custom command wants to run the following shell commands:
|
|
||||||
</Text>
|
|
||||||
<Box
|
|
||||||
flexDirection="column"
|
|
||||||
borderStyle="round"
|
|
||||||
borderColor={theme.border.default}
|
|
||||||
paddingX={1}
|
|
||||||
marginTop={1}
|
|
||||||
>
|
|
||||||
{commands.map((cmd) => (
|
|
||||||
<Text key={cmd} color={theme.text.link}>
|
|
||||||
<RenderInline text={cmd} defaultColor={theme.text.link} />
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box marginBottom={1}>
|
|
||||||
<Text color={theme.text.primary}>Do you want to proceed?</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<RadioButtonSelect items={options} onSelect={handleSelect} isFocused />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
||||||
|
|
||||||
exports[`ShellConfirmationDialog > renders correctly 1`] = `
|
|
||||||
" ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
|
|
||||||
│ │
|
|
||||||
│ Shell Command Execution │
|
|
||||||
│ A custom command wants to run the following shell commands: │
|
|
||||||
│ │
|
|
||||||
│ ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ │
|
|
||||||
│ │ ls -la │ │
|
|
||||||
│ │ echo "hello" │ │
|
|
||||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
|
||||||
│ │
|
|
||||||
│ Do you want to proceed? │
|
|
||||||
│ │
|
|
||||||
│ ● 1. Allow once │
|
|
||||||
│ 2. Allow for this session │
|
|
||||||
│ 3. No (esc) │
|
|
||||||
│ │
|
|
||||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
|
||||||
`;
|
|
||||||
@@ -66,6 +66,33 @@ describe('ToolConfirmationMessage', () => {
|
|||||||
expect(lastFrame()).toMatchSnapshot();
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should display multiple commands for exec type when provided', () => {
|
||||||
|
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||||
|
type: 'exec',
|
||||||
|
title: 'Confirm Multiple Commands',
|
||||||
|
command: 'echo "hello"', // Primary command
|
||||||
|
rootCommand: 'echo',
|
||||||
|
rootCommands: ['echo'],
|
||||||
|
commands: ['echo "hello"', 'ls -la', 'whoami'], // Multi-command list
|
||||||
|
onConfirm: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { lastFrame } = renderWithProviders(
|
||||||
|
<ToolConfirmationMessage
|
||||||
|
confirmationDetails={confirmationDetails}
|
||||||
|
config={mockConfig}
|
||||||
|
availableTerminalHeight={30}
|
||||||
|
terminalWidth={80}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const output = lastFrame();
|
||||||
|
expect(output).toContain('echo "hello"');
|
||||||
|
expect(output).toContain('ls -la');
|
||||||
|
expect(output).toContain('whoami');
|
||||||
|
expect(output).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
describe('with folder trust', () => {
|
describe('with folder trust', () => {
|
||||||
const editConfirmationDetails: ToolCallConfirmationDetails = {
|
const editConfirmationDetails: ToolCallConfirmationDetails = {
|
||||||
type: 'edit',
|
type: 'edit',
|
||||||
|
|||||||
@@ -139,7 +139,11 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
} else if (confirmationDetails.type === 'exec') {
|
} else if (confirmationDetails.type === 'exec') {
|
||||||
const executionProps = confirmationDetails;
|
const executionProps = confirmationDetails;
|
||||||
|
|
||||||
question = `Allow execution of: '${executionProps.rootCommand}'?`;
|
if (executionProps.commands && executionProps.commands.length > 1) {
|
||||||
|
question = `Allow execution of ${executionProps.commands.length} commands?`;
|
||||||
|
} else {
|
||||||
|
question = `Allow execution of: '${executionProps.rootCommand}'?`;
|
||||||
|
}
|
||||||
options.push({
|
options.push({
|
||||||
label: 'Allow once',
|
label: 'Allow once',
|
||||||
value: ToolConfirmationOutcome.ProceedOnce,
|
value: ToolConfirmationOutcome.ProceedOnce,
|
||||||
@@ -276,8 +280,18 @@ export const ToolConfirmationMessage: React.FC<
|
|||||||
maxHeight={bodyContentHeight}
|
maxHeight={bodyContentHeight}
|
||||||
maxWidth={Math.max(terminalWidth, 1)}
|
maxWidth={Math.max(terminalWidth, 1)}
|
||||||
>
|
>
|
||||||
<Box>
|
<Box flexDirection="column">
|
||||||
<Text color={theme.text.link}>{executionProps.command}</Text>
|
{executionProps.commands && executionProps.commands.length > 1 ? (
|
||||||
|
executionProps.commands.map((cmd, idx) => (
|
||||||
|
<Text key={idx} color={theme.text.link}>
|
||||||
|
{cmd}
|
||||||
|
</Text>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<Text color={theme.text.link}>{executionProps.command}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</MaxSizedBox>
|
</MaxSizedBox>
|
||||||
);
|
);
|
||||||
|
|||||||
+13
@@ -1,5 +1,18 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`ToolConfirmationMessage > should display multiple commands for exec type when provided 1`] = `
|
||||||
|
"echo "hello"
|
||||||
|
ls -la
|
||||||
|
whoami
|
||||||
|
|
||||||
|
Allow execution of 3 commands?
|
||||||
|
|
||||||
|
● 1. Allow once
|
||||||
|
2. Allow for this session
|
||||||
|
3. No, suggest changes (esc)
|
||||||
|
"
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`ToolConfirmationMessage > should display urls if prompt and url are different 1`] = `
|
exports[`ToolConfirmationMessage > should display urls if prompt and url are different 1`] = `
|
||||||
"fetch https://github.com/google/gemini-react/blob/main/README.md
|
"fetch https://github.com/google/gemini-react/blob/main/README.md
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type {
|
|||||||
HistoryItem,
|
HistoryItem,
|
||||||
ThoughtSummary,
|
ThoughtSummary,
|
||||||
ConsoleMessageItem,
|
ConsoleMessageItem,
|
||||||
ShellConfirmationRequest,
|
|
||||||
ConfirmationRequest,
|
ConfirmationRequest,
|
||||||
LoopDetectionConfirmationRequest,
|
LoopDetectionConfirmationRequest,
|
||||||
HistoryItemWithoutId,
|
HistoryItemWithoutId,
|
||||||
@@ -68,7 +67,6 @@ export interface UIState {
|
|||||||
slashCommands: readonly SlashCommand[] | undefined;
|
slashCommands: readonly SlashCommand[] | undefined;
|
||||||
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
pendingSlashCommandHistoryItems: HistoryItemWithoutId[];
|
||||||
commandContext: CommandContext;
|
commandContext: CommandContext;
|
||||||
shellConfirmationRequest: ShellConfirmationRequest | null;
|
|
||||||
confirmationRequest: ConfirmationRequest | null;
|
confirmationRequest: ConfirmationRequest | null;
|
||||||
confirmUpdateExtensionRequests: ConfirmationRequest[];
|
confirmUpdateExtensionRequests: ConfirmationRequest[];
|
||||||
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
|
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
|
||||||
|
|||||||
@@ -9,21 +9,16 @@ import { act } from 'react';
|
|||||||
import { renderHook } from '../../test-utils/render.js';
|
import { renderHook } from '../../test-utils/render.js';
|
||||||
import { waitFor } from '../../test-utils/async.js';
|
import { waitFor } from '../../test-utils/async.js';
|
||||||
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
|
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
|
||||||
import type {
|
import type { SlashCommand } from '../commands/types.js';
|
||||||
CommandContext,
|
|
||||||
ConfirmShellCommandsActionReturn,
|
|
||||||
SlashCommand,
|
|
||||||
} from '../commands/types.js';
|
|
||||||
import { CommandKind } from '../commands/types.js';
|
import { CommandKind } from '../commands/types.js';
|
||||||
import type { LoadedSettings } from '../../config/settings.js';
|
import type { LoadedSettings } from '../../config/settings.js';
|
||||||
import { MessageType, type SlashCommandProcessorResult } from '../types.js';
|
import { MessageType } from '../types.js';
|
||||||
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
|
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
|
||||||
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
|
||||||
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
|
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
|
||||||
import {
|
import {
|
||||||
type GeminiClient,
|
type GeminiClient,
|
||||||
SlashCommandStatus,
|
SlashCommandStatus,
|
||||||
ToolConfirmationOutcome,
|
|
||||||
makeFakeConfig,
|
makeFakeConfig,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { appEvents } from '../../utils/events.js';
|
import { appEvents } from '../../utils/events.js';
|
||||||
@@ -638,197 +633,6 @@ describe('useSlashCommandProcessor', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Shell Command Confirmation Flow', () => {
|
|
||||||
// Use a generic vi.fn() for the action. We will change its behavior in each test.
|
|
||||||
const mockCommandAction = vi.fn();
|
|
||||||
|
|
||||||
const shellCommand = createTestCommand({
|
|
||||||
name: 'shellcmd',
|
|
||||||
action: mockCommandAction,
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// Reset the mock before each test
|
|
||||||
mockCommandAction.mockClear();
|
|
||||||
|
|
||||||
// Default behavior: request confirmation
|
|
||||||
mockCommandAction.mockResolvedValue({
|
|
||||||
type: 'confirm_shell_commands',
|
|
||||||
commandsToConfirm: ['rm -rf /'],
|
|
||||||
originalInvocation: { raw: '/shellcmd' },
|
|
||||||
} as ConfirmShellCommandsActionReturn);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set confirmation request when action returns confirm_shell_commands', async () => {
|
|
||||||
const result = await setupProcessorHook([shellCommand]);
|
|
||||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
|
||||||
|
|
||||||
// Trigger command, don't await it yet as it suspends for confirmation
|
|
||||||
await act(async () => {
|
|
||||||
void result.current.handleSlashCommand('/shellcmd');
|
|
||||||
});
|
|
||||||
|
|
||||||
// We now wait for the state to be updated with the request.
|
|
||||||
await act(async () => {
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.shellConfirmationRequest).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.shellConfirmationRequest?.commands).toEqual([
|
|
||||||
'rm -rf /',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should do nothing if user cancels confirmation', async () => {
|
|
||||||
const result = await setupProcessorHook([shellCommand]);
|
|
||||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
void result.current.handleSlashCommand('/shellcmd');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for the confirmation dialog to be set
|
|
||||||
await act(async () => {
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.shellConfirmationRequest).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const onConfirm = result.current.shellConfirmationRequest?.onConfirm;
|
|
||||||
expect(onConfirm).toBeDefined();
|
|
||||||
|
|
||||||
// Change the mock action's behavior for a potential second run.
|
|
||||||
// If the test is flawed, this will be called, and we can detect it.
|
|
||||||
mockCommandAction.mockResolvedValue({
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'info',
|
|
||||||
content: 'This should not be called',
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
onConfirm!(ToolConfirmationOutcome.Cancel, []); // Pass empty array for safety
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.shellConfirmationRequest).toBeNull();
|
|
||||||
// Verify the action was only called the initial time.
|
|
||||||
expect(mockCommandAction).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should re-run command with one-time allowlist on "Proceed Once"', async () => {
|
|
||||||
const result = await setupProcessorHook([shellCommand]);
|
|
||||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
|
||||||
|
|
||||||
let commandPromise:
|
|
||||||
| Promise<false | SlashCommandProcessorResult>
|
|
||||||
| undefined;
|
|
||||||
await act(async () => {
|
|
||||||
commandPromise = result.current.handleSlashCommand('/shellcmd');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.shellConfirmationRequest).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const onConfirm = result.current.shellConfirmationRequest?.onConfirm;
|
|
||||||
|
|
||||||
// **Change the mock's behavior for the SECOND run.**
|
|
||||||
// This is the key to testing the outcome.
|
|
||||||
mockCommandAction.mockResolvedValue({
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'info',
|
|
||||||
content: 'Success!',
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
onConfirm!(ToolConfirmationOutcome.ProceedOnce, ['rm -rf /']);
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await commandPromise;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.shellConfirmationRequest).toBeNull();
|
|
||||||
|
|
||||||
// The action should have been called twice (initial + re-run).
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockCommandAction).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
// We can inspect the context of the second call to ensure the one-time list was used.
|
|
||||||
const secondCallContext = mockCommandAction.mock
|
|
||||||
.calls[1][0] as CommandContext;
|
|
||||||
expect(
|
|
||||||
secondCallContext.session.sessionShellAllowlist.has('rm -rf /'),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
// Verify the final success message was added.
|
|
||||||
expect(mockAddItem).toHaveBeenCalledWith(
|
|
||||||
{ type: MessageType.INFO, text: 'Success!' },
|
|
||||||
expect.any(Number),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify the session-wide allowlist was NOT permanently updated.
|
|
||||||
// Re-render the hook by calling a no-op command to get the latest context.
|
|
||||||
await act(async () => {
|
|
||||||
await result.current.handleSlashCommand('/no-op');
|
|
||||||
});
|
|
||||||
const finalContext = result.current.commandContext;
|
|
||||||
expect(finalContext.session.sessionShellAllowlist.size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should re-run command and update session allowlist on "Proceed Always"', async () => {
|
|
||||||
const result = await setupProcessorHook([shellCommand]);
|
|
||||||
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
|
|
||||||
|
|
||||||
let commandPromise:
|
|
||||||
| Promise<false | SlashCommandProcessorResult>
|
|
||||||
| undefined;
|
|
||||||
await act(async () => {
|
|
||||||
commandPromise = result.current.handleSlashCommand('/shellcmd');
|
|
||||||
});
|
|
||||||
await act(async () => {
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(result.current.shellConfirmationRequest).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const onConfirm = result.current.shellConfirmationRequest?.onConfirm;
|
|
||||||
mockCommandAction.mockResolvedValue({
|
|
||||||
type: 'message',
|
|
||||||
messageType: 'info',
|
|
||||||
content: 'Success!',
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
onConfirm!(ToolConfirmationOutcome.ProceedAlways, ['rm -rf /']);
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await commandPromise;
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.shellConfirmationRequest).toBeNull();
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockCommandAction).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockAddItem).toHaveBeenCalledWith(
|
|
||||||
{ type: MessageType.INFO, text: 'Success!' },
|
|
||||||
expect.any(Number),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check that the session-wide allowlist WAS updated.
|
|
||||||
await waitFor(() => {
|
|
||||||
const finalContext = result.current.commandContext;
|
|
||||||
expect(finalContext.session.sessionShellAllowlist.has('rm -rf /')).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Command Parsing and Matching', () => {
|
describe('Command Parsing and Matching', () => {
|
||||||
it('should be case-sensitive', async () => {
|
it('should be case-sensitive', async () => {
|
||||||
const command = createTestCommand({ name: 'test' });
|
const command = createTestCommand({ name: 'test' });
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type {
|
|||||||
Config,
|
Config,
|
||||||
ExtensionsStartingEvent,
|
ExtensionsStartingEvent,
|
||||||
ExtensionsStoppingEvent,
|
ExtensionsStoppingEvent,
|
||||||
|
ToolCallConfirmationDetails,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
GitService,
|
GitService,
|
||||||
@@ -39,8 +40,9 @@ import type {
|
|||||||
SlashCommandProcessorResult,
|
SlashCommandProcessorResult,
|
||||||
HistoryItem,
|
HistoryItem,
|
||||||
ConfirmationRequest,
|
ConfirmationRequest,
|
||||||
|
IndividualToolCallDisplay,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { MessageType } from '../types.js';
|
import { MessageType, ToolCallStatus } from '../types.js';
|
||||||
import type { LoadedSettings } from '../../config/settings.js';
|
import type { LoadedSettings } from '../../config/settings.js';
|
||||||
import { type CommandContext, type SlashCommand } from '../commands/types.js';
|
import { type CommandContext, type SlashCommand } from '../commands/types.js';
|
||||||
import { CommandService } from '../../services/CommandService.js';
|
import { CommandService } from '../../services/CommandService.js';
|
||||||
@@ -103,14 +105,6 @@ export const useSlashCommandProcessor = (
|
|||||||
const reloadCommands = useCallback(() => {
|
const reloadCommands = useCallback(() => {
|
||||||
setReloadTrigger((v) => v + 1);
|
setReloadTrigger((v) => v + 1);
|
||||||
}, []);
|
}, []);
|
||||||
const [shellConfirmationRequest, setShellConfirmationRequest] =
|
|
||||||
useState<null | {
|
|
||||||
commands: string[];
|
|
||||||
onConfirm: (
|
|
||||||
outcome: ToolConfirmationOutcome,
|
|
||||||
approvedCommands?: string[],
|
|
||||||
) => void;
|
|
||||||
}>(null);
|
|
||||||
const [confirmationRequest, setConfirmationRequest] = useState<null | {
|
const [confirmationRequest, setConfirmationRequest] = useState<null | {
|
||||||
prompt: React.ReactNode;
|
prompt: React.ReactNode;
|
||||||
onConfirm: (confirmed: boolean) => void;
|
onConfirm: (confirmed: boolean) => void;
|
||||||
@@ -484,30 +478,59 @@ export const useSlashCommandProcessor = (
|
|||||||
content: result.content,
|
content: result.content,
|
||||||
};
|
};
|
||||||
case 'confirm_shell_commands': {
|
case 'confirm_shell_commands': {
|
||||||
|
const callId = `expansion-${Date.now()}`;
|
||||||
const { outcome, approvedCommands } = await new Promise<{
|
const { outcome, approvedCommands } = await new Promise<{
|
||||||
outcome: ToolConfirmationOutcome;
|
outcome: ToolConfirmationOutcome;
|
||||||
approvedCommands?: string[];
|
approvedCommands?: string[];
|
||||||
}>((resolve) => {
|
}>((resolve) => {
|
||||||
setShellConfirmationRequest({
|
const confirmationDetails: ToolCallConfirmationDetails = {
|
||||||
|
type: 'exec',
|
||||||
|
title: `Confirm Shell Expansion`,
|
||||||
|
command: result.commandsToConfirm[0] || '',
|
||||||
|
rootCommand: result.commandsToConfirm[0] || '',
|
||||||
|
rootCommands: result.commandsToConfirm,
|
||||||
commands: result.commandsToConfirm,
|
commands: result.commandsToConfirm,
|
||||||
onConfirm: (
|
onConfirm: async (resolvedOutcome) => {
|
||||||
resolvedOutcome,
|
// Close the pending tool display by resolving
|
||||||
resolvedApprovedCommands,
|
|
||||||
) => {
|
|
||||||
setShellConfirmationRequest(null); // Close the dialog
|
|
||||||
resolve({
|
resolve({
|
||||||
outcome: resolvedOutcome,
|
outcome: resolvedOutcome,
|
||||||
approvedCommands: resolvedApprovedCommands,
|
approvedCommands:
|
||||||
|
resolvedOutcome === ToolConfirmationOutcome.Cancel
|
||||||
|
? []
|
||||||
|
: result.commandsToConfirm,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolDisplay: IndividualToolCallDisplay = {
|
||||||
|
callId,
|
||||||
|
name: 'Expansion',
|
||||||
|
description: 'Command expansion needs shell access',
|
||||||
|
status: ToolCallStatus.Confirming,
|
||||||
|
resultDisplay: undefined,
|
||||||
|
confirmationDetails,
|
||||||
|
};
|
||||||
|
|
||||||
|
setPendingItem({
|
||||||
|
type: 'tool_group',
|
||||||
|
tools: [toolDisplay],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setPendingItem(null);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
outcome === ToolConfirmationOutcome.Cancel ||
|
outcome === ToolConfirmationOutcome.Cancel ||
|
||||||
!approvedCommands ||
|
!approvedCommands ||
|
||||||
approvedCommands.length === 0
|
approvedCommands.length === 0
|
||||||
) {
|
) {
|
||||||
|
addItem(
|
||||||
|
{
|
||||||
|
type: MessageType.INFO,
|
||||||
|
text: 'Slash command shell execution declined.',
|
||||||
|
},
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
return { type: 'handled' };
|
return { type: 'handled' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,6 +544,8 @@ export const useSlashCommandProcessor = (
|
|||||||
result.originalInvocation.raw,
|
result.originalInvocation.raw,
|
||||||
// Pass the approved commands as a one-time grant for this execution.
|
// Pass the approved commands as a one-time grant for this execution.
|
||||||
new Set(approvedCommands),
|
new Set(approvedCommands),
|
||||||
|
undefined,
|
||||||
|
false, // Do not add to history again
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'confirm_action': {
|
case 'confirm_action': {
|
||||||
@@ -633,7 +658,6 @@ export const useSlashCommandProcessor = (
|
|||||||
commands,
|
commands,
|
||||||
commandContext,
|
commandContext,
|
||||||
addMessage,
|
addMessage,
|
||||||
setShellConfirmationRequest,
|
|
||||||
setSessionShellAllowlist,
|
setSessionShellAllowlist,
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
setConfirmationRequest,
|
setConfirmationRequest,
|
||||||
@@ -646,7 +670,6 @@ export const useSlashCommandProcessor = (
|
|||||||
slashCommands: commands,
|
slashCommands: commands,
|
||||||
pendingHistoryItems,
|
pendingHistoryItems,
|
||||||
commandContext,
|
commandContext,
|
||||||
shellConfirmationRequest,
|
|
||||||
confirmationRequest,
|
confirmationRequest,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import type {
|
|||||||
MCPServerConfig,
|
MCPServerConfig,
|
||||||
ThoughtSummary,
|
ThoughtSummary,
|
||||||
ToolCallConfirmationDetails,
|
ToolCallConfirmationDetails,
|
||||||
ToolConfirmationOutcome,
|
|
||||||
ToolResultDisplay,
|
ToolResultDisplay,
|
||||||
RetrieveUserQuotaResponse,
|
RetrieveUserQuotaResponse,
|
||||||
SkillDefinition,
|
SkillDefinition,
|
||||||
@@ -417,14 +416,6 @@ export type SlashCommandProcessorResult =
|
|||||||
}
|
}
|
||||||
| SubmitPromptResult;
|
| SubmitPromptResult;
|
||||||
|
|
||||||
export interface ShellConfirmationRequest {
|
|
||||||
commands: string[];
|
|
||||||
onConfirm: (
|
|
||||||
outcome: ToolConfirmationOutcome,
|
|
||||||
approvedCommands?: string[],
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConfirmationRequest {
|
export interface ConfirmationRequest {
|
||||||
prompt: ReactNode;
|
prompt: ReactNode;
|
||||||
onConfirm: (confirm: boolean) => void;
|
onConfirm: (confirm: boolean) => void;
|
||||||
|
|||||||
@@ -694,6 +694,7 @@ export interface ToolExecuteConfirmationDetails {
|
|||||||
command: string;
|
command: string;
|
||||||
rootCommand: string;
|
rootCommand: string;
|
||||||
rootCommands: string[];
|
rootCommands: string[];
|
||||||
|
commands?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolMcpConfirmationDetails {
|
export interface ToolMcpConfirmationDetails {
|
||||||
|
|||||||
Reference in New Issue
Block a user