mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 02:24:09 -07:00
refactor(cli): unify shell confirmation dialogs (#16828)
This commit is contained in:
@@ -11,7 +11,6 @@ import { Text } from 'ink';
|
||||
import { type UIState } from '../contexts/UIStateContext.js';
|
||||
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||
import { type IdeInfo } from '@google/gemini-cli-core';
|
||||
import { type ShellConfirmationRequest } from '../types.js';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('../IdeIntegrationNudge.js', () => ({
|
||||
@@ -23,9 +22,6 @@ vi.mock('./LoopDetectionConfirmation.js', () => ({
|
||||
vi.mock('./FolderTrustDialog.js', () => ({
|
||||
FolderTrustDialog: () => <Text>FolderTrustDialog</Text>,
|
||||
}));
|
||||
vi.mock('./ShellConfirmationDialog.js', () => ({
|
||||
ShellConfirmationDialog: () => <Text>ShellConfirmationDialog</Text>,
|
||||
}));
|
||||
vi.mock('./ConsentPrompt.js', () => ({
|
||||
ConsentPrompt: () => <Text>ConsentPrompt</Text>,
|
||||
}));
|
||||
@@ -79,7 +75,6 @@ describe('DialogManager', () => {
|
||||
proQuotaRequest: null,
|
||||
shouldShowIdePrompt: false,
|
||||
isFolderTrustDialogOpen: false,
|
||||
shellConfirmationRequest: null,
|
||||
loopDetectionConfirmationRequest: null,
|
||||
confirmationRequest: null,
|
||||
isThemeDialogOpen: false,
|
||||
@@ -130,15 +125,6 @@ describe('DialogManager', () => {
|
||||
'IdeIntegrationNudge',
|
||||
],
|
||||
[{ isFolderTrustDialogOpen: true }, 'FolderTrustDialog'],
|
||||
[
|
||||
{
|
||||
shellConfirmationRequest: {
|
||||
commands: [],
|
||||
onConfirm: vi.fn(),
|
||||
} as unknown as ShellConfirmationRequest,
|
||||
},
|
||||
'ShellConfirmationDialog',
|
||||
],
|
||||
[
|
||||
{ loopDetectionConfirmationRequest: { onComplete: vi.fn() } },
|
||||
'LoopDetectionConfirmation',
|
||||
|
||||
@@ -8,7 +8,6 @@ import { Box, Text } from 'ink';
|
||||
import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js';
|
||||
import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js';
|
||||
import { FolderTrustDialog } from './FolderTrustDialog.js';
|
||||
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
|
||||
import { ConsentPrompt } from './ConsentPrompt.js';
|
||||
import { ThemeDialog } from './ThemeDialog.js';
|
||||
import { SettingsDialog } from './SettingsDialog.js';
|
||||
@@ -85,11 +84,6 @@ export const DialogManager = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.shellConfirmationRequest) {
|
||||
return (
|
||||
<ShellConfirmationDialog request={uiState.shellConfirmationRequest} />
|
||||
);
|
||||
}
|
||||
if (uiState.loopDetectionConfirmationRequest) {
|
||||
return (
|
||||
<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();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const editConfirmationDetails: ToolCallConfirmationDetails = {
|
||||
type: 'edit',
|
||||
|
||||
@@ -139,7 +139,11 @@ export const ToolConfirmationMessage: React.FC<
|
||||
} else if (confirmationDetails.type === 'exec') {
|
||||
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({
|
||||
label: 'Allow once',
|
||||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
@@ -276,8 +280,18 @@ export const ToolConfirmationMessage: React.FC<
|
||||
maxHeight={bodyContentHeight}
|
||||
maxWidth={Math.max(terminalWidth, 1)}
|
||||
>
|
||||
<Box>
|
||||
<Text color={theme.text.link}>{executionProps.command}</Text>
|
||||
<Box flexDirection="column">
|
||||
{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>
|
||||
</MaxSizedBox>
|
||||
);
|
||||
|
||||
+13
@@ -1,5 +1,18 @@
|
||||
// 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`] = `
|
||||
"fetch https://github.com/google/gemini-react/blob/main/README.md
|
||||
|
||||
|
||||
Reference in New Issue
Block a user