mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-17 01:21:10 -07:00
358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type React from 'react';
|
|
import { useEffect, useState, useMemo } from 'react';
|
|
import { Box, Text } from 'ink';
|
|
import { DiffRenderer } from './DiffRenderer.js';
|
|
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
|
|
import type {
|
|
ToolCallConfirmationDetails,
|
|
ToolExecuteConfirmationDetails,
|
|
ToolMcpConfirmationDetails,
|
|
Config,
|
|
} from '@google/gemini-cli-core';
|
|
import { IdeClient, ToolConfirmationOutcome } from '@google/gemini-cli-core';
|
|
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
|
|
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
|
|
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
|
import { useKeypress } from '../../hooks/useKeypress.js';
|
|
import { theme } from '../../semantic-colors.js';
|
|
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
|
|
|
export interface ToolConfirmationMessageProps {
|
|
confirmationDetails: ToolCallConfirmationDetails;
|
|
config: Config;
|
|
isFocused?: boolean;
|
|
availableTerminalHeight?: number;
|
|
terminalWidth: number;
|
|
}
|
|
|
|
export const ToolConfirmationMessage: React.FC<
|
|
ToolConfirmationMessageProps
|
|
> = ({
|
|
confirmationDetails,
|
|
config,
|
|
isFocused = true,
|
|
availableTerminalHeight,
|
|
terminalWidth,
|
|
}) => {
|
|
const { onConfirm } = confirmationDetails;
|
|
|
|
const isAlternateBuffer = useAlternateBuffer();
|
|
|
|
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
|
|
const [isDiffingEnabled, setIsDiffingEnabled] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
if (config.getIdeMode()) {
|
|
const getIdeClient = async () => {
|
|
const client = await IdeClient.getInstance();
|
|
if (isMounted) {
|
|
setIdeClient(client);
|
|
setIsDiffingEnabled(client?.isDiffingEnabled() ?? false);
|
|
}
|
|
};
|
|
getIdeClient();
|
|
}
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, [config]);
|
|
|
|
const handleConfirm = async (outcome: ToolConfirmationOutcome) => {
|
|
if (confirmationDetails.type === 'edit') {
|
|
if (config.getIdeMode() && isDiffingEnabled) {
|
|
const cliOutcome =
|
|
outcome === ToolConfirmationOutcome.Cancel ? 'rejected' : 'accepted';
|
|
await ideClient?.resolveDiffFromCli(
|
|
confirmationDetails.filePath,
|
|
cliOutcome,
|
|
);
|
|
}
|
|
}
|
|
onConfirm(outcome);
|
|
};
|
|
|
|
const isTrustedFolder = config.isTrustedFolder();
|
|
|
|
useKeypress(
|
|
(key) => {
|
|
if (!isFocused) return;
|
|
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
|
handleConfirm(ToolConfirmationOutcome.Cancel);
|
|
}
|
|
},
|
|
{ isActive: isFocused },
|
|
);
|
|
|
|
const handleSelect = (item: ToolConfirmationOutcome) => handleConfirm(item);
|
|
|
|
const { question, bodyContent, options } = useMemo(() => {
|
|
let bodyContent: React.ReactNode | null = null;
|
|
let question = '';
|
|
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [];
|
|
|
|
if (confirmationDetails.type === 'edit') {
|
|
if (!confirmationDetails.isModifying) {
|
|
question = `Apply this change?`;
|
|
options.push({
|
|
label: 'Yes, allow once',
|
|
value: ToolConfirmationOutcome.ProceedOnce,
|
|
key: 'Yes, allow once',
|
|
});
|
|
if (isTrustedFolder) {
|
|
options.push({
|
|
label: 'Yes, allow always',
|
|
value: ToolConfirmationOutcome.ProceedAlways,
|
|
key: 'Yes, allow always',
|
|
});
|
|
}
|
|
if (!config.getIdeMode() || !isDiffingEnabled) {
|
|
options.push({
|
|
label: 'Modify with external editor',
|
|
value: ToolConfirmationOutcome.ModifyWithEditor,
|
|
key: 'Modify with external editor',
|
|
});
|
|
}
|
|
|
|
options.push({
|
|
label: 'No, suggest changes (esc)',
|
|
value: ToolConfirmationOutcome.Cancel,
|
|
key: 'No, suggest changes (esc)',
|
|
});
|
|
}
|
|
} else if (confirmationDetails.type === 'exec') {
|
|
const executionProps =
|
|
confirmationDetails as ToolExecuteConfirmationDetails;
|
|
|
|
question = `Allow execution of: '${executionProps.rootCommand}'?`;
|
|
options.push({
|
|
label: 'Yes, allow once',
|
|
value: ToolConfirmationOutcome.ProceedOnce,
|
|
key: 'Yes, allow once',
|
|
});
|
|
if (isTrustedFolder) {
|
|
options.push({
|
|
label: `Yes, allow always ...`,
|
|
value: ToolConfirmationOutcome.ProceedAlways,
|
|
key: `Yes, allow always ...`,
|
|
});
|
|
}
|
|
options.push({
|
|
label: 'No, suggest changes (esc)',
|
|
value: ToolConfirmationOutcome.Cancel,
|
|
key: 'No, suggest changes (esc)',
|
|
});
|
|
} else if (confirmationDetails.type === 'info') {
|
|
question = `Do you want to proceed?`;
|
|
options.push({
|
|
label: 'Yes, allow once',
|
|
value: ToolConfirmationOutcome.ProceedOnce,
|
|
key: 'Yes, allow once',
|
|
});
|
|
if (isTrustedFolder) {
|
|
options.push({
|
|
label: 'Yes, allow always',
|
|
value: ToolConfirmationOutcome.ProceedAlways,
|
|
key: 'Yes, allow always',
|
|
});
|
|
}
|
|
options.push({
|
|
label: 'No, suggest changes (esc)',
|
|
value: ToolConfirmationOutcome.Cancel,
|
|
key: 'No, suggest changes (esc)',
|
|
});
|
|
} else {
|
|
// mcp tool confirmation
|
|
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
|
|
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
|
|
options.push({
|
|
label: 'Yes, allow once',
|
|
value: ToolConfirmationOutcome.ProceedOnce,
|
|
key: 'Yes, allow once',
|
|
});
|
|
if (isTrustedFolder) {
|
|
options.push({
|
|
label: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
|
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
|
|
key: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
|
});
|
|
options.push({
|
|
label: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
|
value: ToolConfirmationOutcome.ProceedAlwaysServer,
|
|
key: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
|
});
|
|
}
|
|
options.push({
|
|
label: 'No, suggest changes (esc)',
|
|
value: ToolConfirmationOutcome.Cancel,
|
|
key: 'No, suggest changes (esc)',
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 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);
|
|
}
|
|
|
|
if (confirmationDetails.type === 'edit') {
|
|
if (!confirmationDetails.isModifying) {
|
|
bodyContent = (
|
|
<DiffRenderer
|
|
diffContent={confirmationDetails.fileDiff}
|
|
filename={confirmationDetails.fileName}
|
|
availableTerminalHeight={availableBodyContentHeight()}
|
|
terminalWidth={terminalWidth}
|
|
/>
|
|
);
|
|
}
|
|
} else if (confirmationDetails.type === 'exec') {
|
|
const executionProps =
|
|
confirmationDetails as ToolExecuteConfirmationDetails;
|
|
let bodyContentHeight = availableBodyContentHeight();
|
|
if (bodyContentHeight !== undefined) {
|
|
bodyContentHeight -= 2; // Account for padding;
|
|
}
|
|
|
|
const commandBox = (
|
|
<Box>
|
|
<Text color={theme.text.link}>{executionProps.command}</Text>
|
|
</Box>
|
|
);
|
|
|
|
bodyContent = isAlternateBuffer ? (
|
|
commandBox
|
|
) : (
|
|
<MaxSizedBox
|
|
maxHeight={bodyContentHeight}
|
|
maxWidth={Math.max(terminalWidth, 1)}
|
|
>
|
|
{commandBox}
|
|
</MaxSizedBox>
|
|
);
|
|
} else if (confirmationDetails.type === 'info') {
|
|
const infoProps = confirmationDetails;
|
|
const displayUrls =
|
|
infoProps.urls &&
|
|
!(
|
|
infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt
|
|
);
|
|
|
|
bodyContent = (
|
|
<Box flexDirection="column">
|
|
<Text color={theme.text.link}>
|
|
<RenderInline
|
|
text={infoProps.prompt}
|
|
defaultColor={theme.text.link}
|
|
/>
|
|
</Text>
|
|
{displayUrls && infoProps.urls && infoProps.urls.length > 0 && (
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<Text color={theme.text.primary}>URLs to fetch:</Text>
|
|
{infoProps.urls.map((url) => (
|
|
<Text key={url}>
|
|
{' '}
|
|
- <RenderInline text={url} />
|
|
</Text>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
} else {
|
|
// mcp tool confirmation
|
|
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
|
|
|
|
bodyContent = (
|
|
<Box flexDirection="column">
|
|
<Text color={theme.text.link}>MCP Server: {mcpProps.serverName}</Text>
|
|
<Text color={theme.text.link}>Tool: {mcpProps.toolName}</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return { question, bodyContent, options };
|
|
}, [
|
|
confirmationDetails,
|
|
isTrustedFolder,
|
|
config,
|
|
isDiffingEnabled,
|
|
availableTerminalHeight,
|
|
terminalWidth,
|
|
isAlternateBuffer,
|
|
]);
|
|
|
|
if (confirmationDetails.type === 'edit') {
|
|
if (confirmationDetails.isModifying) {
|
|
return (
|
|
<Box
|
|
width={terminalWidth}
|
|
borderStyle="round"
|
|
borderColor={theme.border.default}
|
|
justifyContent="space-around"
|
|
paddingTop={1}
|
|
paddingBottom={1}
|
|
overflow="hidden"
|
|
>
|
|
<Text color={theme.text.primary}>Modify in progress: </Text>
|
|
<Text color={theme.status.success}>
|
|
Save and close external editor to continue
|
|
</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Box flexDirection="column" paddingTop={0} paddingBottom={1}>
|
|
{/* 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}
|
|
</Box>
|
|
|
|
{/* Confirmation Question */}
|
|
<Box marginBottom={1} flexShrink={0}>
|
|
<Text color={theme.text.primary}>{question}</Text>
|
|
</Box>
|
|
|
|
{/* Select Input for Options */}
|
|
<Box flexShrink={0}>
|
|
<RadioButtonSelect
|
|
items={options}
|
|
onSelect={handleSelect}
|
|
isFocused={isFocused}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
};
|