/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import type React from 'react'; import { useMemo, useCallback } from 'react'; import { Box, Text } from 'ink'; import { DiffRenderer } from './DiffRenderer.js'; import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; import { type SerializableConfirmationDetails, type ToolCallConfirmationDetails, type Config, ToolConfirmationOutcome, hasRedirection, debugLogger, } from '@google/gemini-cli-core'; import type { RadioSelectItem } from '../shared/RadioButtonSelect.js'; import { useToolActions } from '../../contexts/ToolActionsContext.js'; import { RadioButtonSelect } from '../shared/RadioButtonSelect.js'; import { MaxSizedBox, MINIMUM_MAX_HEIGHT } from '../shared/MaxSizedBox.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { theme } from '../../semantic-colors.js'; import { useSettings } from '../../contexts/SettingsContext.js'; import { REDIRECTION_WARNING_NOTE_LABEL, REDIRECTION_WARNING_NOTE_TEXT, REDIRECTION_WARNING_TIP_LABEL, REDIRECTION_WARNING_TIP_TEXT, } from '../../textConstants.js'; export interface ToolConfirmationMessageProps { callId: string; confirmationDetails: | ToolCallConfirmationDetails | SerializableConfirmationDetails; config: Config; isFocused?: boolean; availableTerminalHeight?: number; terminalWidth: number; } export const ToolConfirmationMessage: React.FC< ToolConfirmationMessageProps > = ({ callId, confirmationDetails, config, isFocused = true, availableTerminalHeight, terminalWidth, }) => { const { confirm, isDiffingEnabled } = useToolActions(); const settings = useSettings(); const allowPermanentApproval = settings.merged.security.enablePermanentToolApproval; 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(); useKeypress( (key) => { if (!isFocused) return; if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { handleConfirm(ToolConfirmationOutcome.Cancel); } }, { isActive: isFocused }, ); const handleSelect = useCallback( (item: ToolConfirmationOutcome) => handleConfirm(item), [handleConfirm], ); const getOptions = useCallback(() => { const options: Array> = []; if (confirmationDetails.type === 'edit') { if (!confirmationDetails.isModifying) { options.push({ label: 'Allow once', value: ToolConfirmationOutcome.ProceedOnce, key: 'Allow once', }); if (isTrustedFolder) { options.push({ label: 'Allow for this session', value: ToolConfirmationOutcome.ProceedAlways, key: 'Allow for this session', }); if (allowPermanentApproval) { options.push({ label: 'Allow for all future sessions', value: ToolConfirmationOutcome.ProceedAlwaysAndSave, key: 'Allow for all future sessions', }); } } // We hide "Modify with external editor" if IDE mode is active AND // the IDE is actually capable of showing a diff (connected). 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') { options.push({ label: 'Allow once', value: ToolConfirmationOutcome.ProceedOnce, key: 'Allow once', }); if (isTrustedFolder) { options.push({ label: `Allow for this session`, value: ToolConfirmationOutcome.ProceedAlways, key: `Allow for this session`, }); if (allowPermanentApproval) { options.push({ label: `Allow for all future sessions`, value: ToolConfirmationOutcome.ProceedAlwaysAndSave, key: `Allow for all future sessions`, }); } } options.push({ label: 'No, suggest changes (esc)', value: ToolConfirmationOutcome.Cancel, key: 'No, suggest changes (esc)', }); } else if (confirmationDetails.type === 'info') { options.push({ label: 'Allow once', value: ToolConfirmationOutcome.ProceedOnce, key: 'Allow once', }); if (isTrustedFolder) { options.push({ label: 'Allow for this session', value: ToolConfirmationOutcome.ProceedAlways, key: 'Allow for this session', }); if (allowPermanentApproval) { options.push({ label: 'Allow for all future sessions', value: ToolConfirmationOutcome.ProceedAlwaysAndSave, key: 'Allow for all future sessions', }); } } options.push({ label: 'No, suggest changes (esc)', value: ToolConfirmationOutcome.Cancel, key: 'No, suggest changes (esc)', }); } else { // mcp tool confirmation options.push({ label: 'Allow once', value: ToolConfirmationOutcome.ProceedOnce, key: 'Allow once', }); if (isTrustedFolder) { options.push({ label: 'Allow tool for this session', value: ToolConfirmationOutcome.ProceedAlwaysTool, key: 'Allow tool for this session', }); options.push({ label: 'Allow all server tools for this session', value: ToolConfirmationOutcome.ProceedAlwaysServer, key: 'Allow all server tools for this session', }); if (allowPermanentApproval) { options.push({ label: 'Allow tool for all future sessions', value: ToolConfirmationOutcome.ProceedAlwaysAndSave, key: 'Allow tool for all future sessions', }); } } options.push({ label: 'No, suggest changes (esc)', value: ToolConfirmationOutcome.Cancel, key: 'No, suggest changes (esc)', }); } return options; }, [ confirmationDetails, isTrustedFolder, allowPermanentApproval, config, isDiffingEnabled, ]); 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 (executionProps.commands && executionProps.commands.length > 1) { question = `Allow execution of ${executionProps.commands.length} commands?`; } else { question = `Allow execution of: '${executionProps.rootCommand}'?`; } } 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') { if (!confirmationDetails.isModifying) { bodyContent = ( ); } } else if (confirmationDetails.type === 'exec') { const executionProps = confirmationDetails; const commandsToDisplay = executionProps.commands && executionProps.commands.length > 1 ? executionProps.commands : [executionProps.command]; const containsRedirection = commandsToDisplay.some((cmd) => hasRedirection(cmd), ); let bodyContentHeight = availableBodyContentHeight(); let warnings: React.ReactNode = null; if (bodyContentHeight !== undefined) { bodyContentHeight -= 2; // Account for padding; } if (containsRedirection) { // Calculate lines needed for Note and Tip const safeWidth = Math.max(terminalWidth, 1); const noteLength = REDIRECTION_WARNING_NOTE_LABEL.length + REDIRECTION_WARNING_NOTE_TEXT.length; const tipLength = REDIRECTION_WARNING_TIP_LABEL.length + REDIRECTION_WARNING_TIP_TEXT.length; const noteLines = Math.ceil(noteLength / safeWidth); const tipLines = Math.ceil(tipLength / safeWidth); const spacerLines = 1; const warningHeight = noteLines + tipLines + spacerLines; if (bodyContentHeight !== undefined) { bodyContentHeight = Math.max( bodyContentHeight - warningHeight, MINIMUM_MAX_HEIGHT, ); } warnings = ( <> {REDIRECTION_WARNING_NOTE_LABEL} {REDIRECTION_WARNING_NOTE_TEXT} {REDIRECTION_WARNING_TIP_LABEL} {REDIRECTION_WARNING_TIP_TEXT} ); } bodyContent = ( {commandsToDisplay.map((cmd, idx) => ( {cmd} ))} {warnings} ); } else if (confirmationDetails.type === 'info') { const infoProps = confirmationDetails; const displayUrls = infoProps.urls && !( infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt ); bodyContent = ( {displayUrls && infoProps.urls && infoProps.urls.length > 0 && ( URLs to fetch: {infoProps.urls.map((url) => ( {' '} - ))} )} ); } else { // mcp tool confirmation const mcpProps = confirmationDetails; bodyContent = ( MCP Server: {mcpProps.serverName} Tool: {mcpProps.toolName} ); } return { question, bodyContent, options }; }, [ confirmationDetails, getOptions, availableBodyContentHeight, terminalWidth, ]); if (confirmationDetails.type === 'edit') { if (confirmationDetails.isModifying) { return ( Modify in progress: Save and close external editor to continue ); } } return ( {/* Body Content (Diff Renderer or Command Info) */} {/* No separate context display here anymore for edits */} {bodyContent} {/* Confirmation Question */} {question} {/* Select Input for Options */} ); };