From 139ef0d5bd169a8c67efac786beaa7bd0bb93302 Mon Sep 17 00:00:00 2001 From: Dev Randalpura Date: Mon, 23 Mar 2026 15:42:30 -0400 Subject: [PATCH] fix(ui): make tool confirmations take up entire terminal height (#22366) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/cli/GEMINI.md | 5 +- packages/cli/src/test-utils/render.tsx | 2 +- packages/cli/src/ui/AppContainer.tsx | 2 +- .../src/ui/ToolConfirmationFullFrame.test.tsx | 179 +++++++ ...-the-frame-of-the-entire-terminal.snap.svg | 239 +++++++++ .../ToolConfirmationFullFrame.test.tsx.snap | 44 ++ packages/cli/src/ui/components/Composer.tsx | 4 +- .../components/ToolConfirmationQueue.test.tsx | 211 ++++++-- .../ui/components/ToolConfirmationQueue.tsx | 145 +++--- ...g-messages-sequentially-correctly.snap.svg | 12 +- .../__snapshots__/MainContent.test.tsx.snap | 42 +- ...security-warning-height-correctly.snap.svg | 130 +++++ ...-and-content-for-large-edit-diffs.snap.svg | 458 +++++++++++++++++ ...d-content-for-large-exec-commands.snap.svg | 156 ++++++ .../ToolConfirmationQueue.test.tsx.snap | 145 +++++- .../messages/ToolConfirmationMessage.test.tsx | 79 ++- .../messages/ToolConfirmationMessage.tsx | 126 +++-- ...lable-height-for-large-edit-diffs.snap.svg | 468 ++++++++++++++++++ ...le-height-for-large-exec-commands.snap.svg | 87 ++++ ...newlines-and-syntax-highlighting.snap.svg} | 0 .../ToolConfirmationMessage.test.tsx.snap | 86 +++- 21 files changed, 2393 insertions(+), 227 deletions(-) create mode 100644 packages/cli/src/ui/ToolConfirmationFullFrame.test.tsx create mode 100644 packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg create mode 100644 packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap create mode 100644 packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-handle-security-warning-height-correctly.snap.svg create mode 100644 packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-render-the-full-queue-wrapper-with-borders-and-content-for-large-edit-diffs.snap.svg create mode 100644 packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-render-the-full-queue-wrapper-with-borders-and-content-for-large-exec-commands.snap.svg create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-height-allocation-and-layout-should-expand-to-available-height-for-large-edit-diffs.snap.svg create mode 100644 packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-height-allocation-and-layout-should-expand-to-available-height-for-large-exec-commands.snap.svg rename packages/cli/src/ui/components/messages/__snapshots__/{ToolConfirmationMessage-ToolConfirmationMessage-should-render-multiline-shell-scripts-with-correct-newlines-and-syntax-highlighting-SVG-snapshot-.snap.svg => ToolConfirmationMessage-ToolConfirmationMessage-should-render-multiline-shell-scripts-with-correct-newlines-and-syntax-highlighting.snap.svg} (100%) diff --git a/packages/cli/GEMINI.md b/packages/cli/GEMINI.md index e98ca81376..8bad8f0721 100644 --- a/packages/cli/GEMINI.md +++ b/packages/cli/GEMINI.md @@ -7,7 +7,10 @@ - **Shortcuts**: only define keyboard shortcuts in `packages/cli/src/ui/key/keyBindings.ts` - Do not implement any logic performing custom string measurement or string - truncation. Use Ink layout instead leveraging ResizeObserver as needed. + truncation. Use Ink layout instead leveraging ResizeObserver as needed. When + using `ResizeObserver`, prefer the `useCallback` ref pattern (as seen in + `MaxSizedBox.tsx`) to ensure size measurements are captured as soon as the + element is available, avoiding potential rendering timing issues. - Avoid prop drilling when at all possible. ## Testing diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 04a642d687..9dd0f96758 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -665,7 +665,7 @@ export const renderWithProviders = async ( ); } - const mainAreaWidth = terminalWidth; + const mainAreaWidth = providedUiState?.mainAreaWidth ?? terminalWidth; const finalUiState = { ...baseState, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 9d05f54347..68b4f339e2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1419,7 +1419,7 @@ Logging in with Google... Restarting Gemini CLI to continue. setControlsHeight(roundedHeight); } } - }, [buffer, terminalWidth, terminalHeight, controlsHeight]); + }, [buffer, terminalWidth, terminalHeight, controlsHeight, isInputActive]); // Compute available terminal height based on controls measurement const availableTerminalHeight = Math.max( diff --git a/packages/cli/src/ui/ToolConfirmationFullFrame.test.tsx b/packages/cli/src/ui/ToolConfirmationFullFrame.test.tsx new file mode 100644 index 0000000000..c8456fb237 --- /dev/null +++ b/packages/cli/src/ui/ToolConfirmationFullFrame.test.tsx @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, renderWithProviders } from '../test-utils/render.js'; +import { createMockSettings } from '../test-utils/settings.js'; +import { App } from './App.js'; +import { + CoreToolCallStatus, + ApprovalMode, + makeFakeConfig, +} from '@google/gemini-cli-core'; +import { type UIState } from './contexts/UIStateContext.js'; +import type { SerializableConfirmationDetails } from '@google/gemini-cli-core'; +import { act } from 'react'; +import { StreamingState } from './types.js'; + +vi.mock('ink', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + useIsScreenReaderEnabled: vi.fn(() => false), + }; +}); + +vi.mock('./components/GeminiSpinner.js', () => ({ + GeminiSpinner: () => null, +})); + +vi.mock('./components/CliSpinner.js', () => ({ + CliSpinner: () => null, +})); + +// Mock hooks to align with codebase style, even if App uses UIState directly +vi.mock('./hooks/useGeminiStream.js'); +vi.mock('./hooks/useHistoryManager.js'); +vi.mock('./hooks/useQuotaAndFallback.js'); +vi.mock('./hooks/useThemeCommand.js'); +vi.mock('./auth/useAuth.js'); +vi.mock('./hooks/useEditorSettings.js'); +vi.mock('./hooks/useSettingsCommand.js'); +vi.mock('./hooks/useModelCommand.js'); +vi.mock('./hooks/slashCommandProcessor.js'); +vi.mock('./hooks/useConsoleMessages.js'); +vi.mock('./hooks/useTerminalSize.js', () => ({ + useTerminalSize: vi.fn(() => ({ columns: 100, rows: 30 })), +})); + +describe('Full Terminal Tool Confirmation Snapshot', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it('renders tool confirmation box in the frame of the entire terminal', async () => { + // Generate a large diff to warrant truncation + let largeDiff = + '--- a/packages/cli/src/ui/components/InputPrompt.tsx\n+++ b/packages/cli/src/ui/components/InputPrompt.tsx\n@@ -1,100 +1,105 @@\n'; + for (let i = 1; i <= 60; i++) { + largeDiff += ` const line${i} = true;\n`; + } + largeDiff += '- return kittyProtocolSupporte...;\n'; + largeDiff += '+ return kittyProtocolSupporte...;\n'; + largeDiff += ' buffer: TextBuffer;\n'; + largeDiff += ' onSubmit: (value: string) => void;'; + + const confirmationDetails: SerializableConfirmationDetails = { + type: 'edit', + title: 'Edit packages/.../InputPrompt.tsx', + fileName: 'InputPrompt.tsx', + filePath: 'packages/.../InputPrompt.tsx', + fileDiff: largeDiff, + originalContent: 'old', + newContent: 'new', + isModifying: false, + }; + + const toolCalls = [ + { + callId: 'call-1-modify-selected', + name: 'Edit', + description: + 'packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProtocolSupporte...', + status: CoreToolCallStatus.AwaitingApproval, + resultDisplay: '', + confirmationDetails, + }, + ]; + + const mockUIState = { + history: [ + { + id: 1, + type: 'user', + text: 'Can you edit InputPrompt.tsx for me?', + }, + ], + mainAreaWidth: 99, + availableTerminalHeight: 36, + streamingState: StreamingState.WaitingForConfirmation, + constrainHeight: true, + isConfigInitialized: true, + cleanUiDetailsVisible: true, + quota: { + userTier: 'PRO', + stats: { + limits: {}, + usage: {}, + }, + proQuotaRequest: null, + validationRequest: null, + }, + pendingHistoryItems: [ + { + id: 2, + type: 'tool_group', + tools: toolCalls, + }, + ], + showApprovalModeIndicator: ApprovalMode.DEFAULT, + sessionStats: { + lastPromptTokenCount: 175400, + contextPercentage: 3, + }, + buffer: { text: '' }, + messageQueue: [], + activeHooks: [], + contextFileNames: [], + rootUiRef: { current: null }, + } as unknown as UIState; + + const mockConfig = makeFakeConfig(); + mockConfig.getUseAlternateBuffer = () => true; + mockConfig.isTrustedFolder = () => true; + mockConfig.getDisableAlwaysAllow = () => false; + mockConfig.getIdeMode = () => false; + mockConfig.getTargetDir = () => '/directory'; + + const { waitUntilReady, lastFrame, generateSvg, unmount } = + await renderWithProviders(, { + uiState: mockUIState, + config: mockConfig, + settings: createMockSettings({ + merged: { + ui: { + useAlternateBuffer: true, + theme: 'default', + showUserIdentity: false, + showShortcutsHint: false, + footer: { + hideContextPercentage: false, + hideTokens: false, + hideModel: false, + }, + }, + security: { + enablePermanentToolApproval: true, + }, + }, + }), + }); + + await waitUntilReady(); + + // Give it a moment to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 500)); + }); + + await expect({ lastFrame, generateSvg }).toMatchSvgSnapshot(); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg new file mode 100644 index 0000000000..e8f43ed9fa --- /dev/null +++ b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame-Full-Terminal-Tool-Confirmation-Snapshot-renders-tool-confirmation-box-in-the-frame-of-the-entire-terminal.snap.svg @@ -0,0 +1,239 @@ + + + + + ╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ + + Action Required + + + + + ? + Edit + packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto + + + + ───────────────────────────────────────────────────────────────────────────────────────────────── + + + 46 + const + line46 + = + true + ; + + + 47 + const + line47 + = + true + ; + + + 48 + const + line48 + = + true + ; + + + 49 + const + line49 + = + true + ; + + + 50 + const + line50 + = + true + ; + + + 51 + const + line51 + = + true + ; + + + 52 + const + line52 + = + true + ; + + + 53 + const + line53 + = + true + ; + + + 54 + const + line54 + = + true + ; + + + 55 + const + line55 + = + true + ; + + + 56 + const + line56 + = + true + ; + + + 57 + const + line57 + = + true + ; + + + 58 + const + line58 + = + true + ; + + + 59 + const + line59 + = + true + ; + + + 60 + const + line60 + = + true + ; + + + + 61 + + + - + + + + return + + kittyProtocolSupporte...; + + + + 61 + + + + + + + + return + + kittyProtocolSupporte...; + + + 62 + buffer: TextBuffer; + + + 63 + onSubmit + : ( + value + : + string + ) => + void + ; + + + Apply this change? + + + + + + + + + + 1. + + + Allow once + + + + + 2. + Allow for this session + + + + 3. + Allow for this file in all future sessions + + + + 4. + Modify with external editor + + + + 5. + No, suggest changes (esc) + + + + + + ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ + + Initializing... + ──────────────────────────────────────────────────────────────────────────────────────────────────── + Shift+Tab to accept edits + undefined undefined file + workspace (/directory) + sandbox + /model + context + /directory + no sandbox + gemini-pro + 17% used + + \ No newline at end of file diff --git a/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap new file mode 100644 index 0000000000..3e99760310 --- /dev/null +++ b/packages/cli/src/ui/__snapshots__/ToolConfirmationFullFrame.test.tsx.snap @@ -0,0 +1,44 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = ` +"╭─────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Action Required │ +│ │ +│ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… │ +│─────────────────────────────────────────────────────────────────────────────────────────────────│ +│ 46 const line46 = true; │ +│ 47 const line47 = true; │ +│ 48 const line48 = true; │ +│ 49 const line49 = true; │ +│ 50 const line50 = true; │ +│ 51 const line51 = true; │ +│ 52 const line52 = true; │ +│ 53 const line53 = true; │ +│ 54 const line54 = true; │ +│ 55 const line55 = true; │ +│ 56 const line56 = true; │ +│ 57 const line57 = true; │ +│ 58 const line58 = true; │ +│ 59 const line59 = true; │ +│ 60 const line60 = true; │ +│ 61 - return kittyProtocolSupporte...; │ +│ 61 + return kittyProtocolSupporte...; │ +│ 62 buffer: TextBuffer; │ +│ 63 onSubmit: (value: string) => void; │ +│ Apply this change? │ +│ │█ +│ ● 1. Allow once │█ +│ 2. Allow for this session │█ +│ 3. Allow for this file in all future sessions │█ +│ 4. Modify with external editor │█ +│ 5. No, suggest changes (esc) │█ +│ │█ +╰─────────────────────────────────────────────────────────────────────────────────────────────────╯█ + + Initializing... +──────────────────────────────────────────────────────────────────────────────────────────────────── + Shift+Tab to accept edits undefined undefined file + workspace (/directory) sandbox /model context + /directory no sandbox gemini-pro 17% used +" +`; diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 89c9c9d3d6..053aaa5260 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -172,7 +172,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { }, [canShowShortcutsHint]); const shouldReserveSpaceForShortcutsHint = - settings.merged.ui.showShortcutsHint && !hideShortcutsHintForSuggestions; + settings.merged.ui.showShortcutsHint && + !hideShortcutsHintForSuggestions && + !hasPendingActionRequired; const showShortcutsHint = shouldReserveSpaceForShortcutsHint && showShortcutsHintDebounced; const showMinimalModeBleedThrough = diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx index ec13eda2e6..4edf1e4f35 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx @@ -6,13 +6,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { act } from 'react'; -import { Box } from 'ink'; import { ToolConfirmationQueue } from './ToolConfirmationQueue.js'; import { StreamingState } from '../types.js'; import { renderWithProviders } from '../../test-utils/render.js'; import { createMockSettings } from '../../test-utils/settings.js'; import { waitFor } from '../../test-utils/async.js'; -import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core'; +import { + type Config, + CoreToolCallStatus, + type SerializableConfirmationDetails, +} from '@google/gemini-cli-core'; import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js'; import { theme } from '../semantic-colors.js'; @@ -133,59 +136,6 @@ describe('ToolConfirmationQueue', () => { unmount(); }); - it('renders expansion hint when content is long and constrained', async () => { - const longDiff = '@@ -1,1 +1,50 @@\n' + '+line\n'.repeat(50); - const confirmingTool = { - tool: { - callId: 'call-1', - name: 'replace', - description: 'edit file', - status: CoreToolCallStatus.AwaitingApproval, - confirmationDetails: { - type: 'edit' as const, - title: 'Confirm edit', - fileName: 'test.ts', - filePath: '/test.ts', - fileDiff: longDiff, - originalContent: 'old', - newContent: 'new', - }, - }, - index: 1, - total: 1, - }; - - const { lastFrame, unmount } = await renderWithProviders( - - - , - { - config: { - // eslint-disable-next-line @typescript-eslint/no-misused-spread - ...mockConfig, - getUseAlternateBuffer: () => true, - } as unknown as Config, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - uiState: { - terminalWidth: 80, - terminalHeight: 20, - constrainHeight: true, - streamingState: StreamingState.WaitingForConfirmation, - }, - }, - ); - - await waitFor(() => - expect(lastFrame()?.toLowerCase()).toContain( - 'press ctrl+o to show more lines', - ), - ); - expect(lastFrame()).toMatchSnapshot(); - unmount(); - }); - it('calculates availableContentHeight based on availableTerminalHeight from UI state', async () => { const longDiff = '@@ -1,1 +1,50 @@\n' + '+line\n'.repeat(50); const confirmingTool = { @@ -414,4 +364,155 @@ describe('ToolConfirmationQueue', () => { expect(stickyHeaderProps.borderColor).toBe(theme.status.success); unmount(); }); + + describe('height allocation and layout', () => { + it('should render the full queue wrapper with borders and content for large edit diffs', async () => { + let largeDiff = '--- a/file.ts\n+++ b/file.ts\n@@ -1,10 +1,15 @@\n'; + for (let i = 1; i <= 20; i++) { + largeDiff += `-const oldLine${i} = true;\n`; + largeDiff += `+const newLine${i} = true;\n`; + } + + const confirmationDetails: SerializableConfirmationDetails = { + type: 'edit', + title: 'Confirm Edit', + fileName: 'file.ts', + filePath: '/file.ts', + fileDiff: largeDiff, + originalContent: 'old', + newContent: 'new', + isModifying: false, + }; + + const confirmingTool = { + tool: { + callId: 'test-call-id', + name: 'replace', + status: CoreToolCallStatus.AwaitingApproval, + description: 'Replaces content in a file', + confirmationDetails, + }, + index: 1, + total: 1, + }; + + const { waitUntilReady, lastFrame, generateSvg, unmount } = + await renderWithProviders( + , + { + uiState: { + mainAreaWidth: 80, + terminalHeight: 50, + terminalWidth: 80, + constrainHeight: true, + availableTerminalHeight: 40, + }, + config: mockConfig, + }, + ); + await waitUntilReady(); + + await expect({ lastFrame, generateSvg }).toMatchSvgSnapshot(); + unmount(); + }); + + it('should render the full queue wrapper with borders and content for large exec commands', async () => { + let largeCommand = ''; + for (let i = 1; i <= 50; i++) { + largeCommand += `echo "Line ${i}"\n`; + } + + const confirmationDetails: SerializableConfirmationDetails = { + type: 'exec', + title: 'Confirm Execution', + command: largeCommand.trimEnd(), + rootCommand: 'echo', + rootCommands: ['echo'], + }; + + const confirmingTool = { + tool: { + callId: 'test-call-id-exec', + name: 'run_shell_command', + status: CoreToolCallStatus.AwaitingApproval, + description: 'Executes a bash command', + confirmationDetails, + }, + index: 2, + total: 3, + }; + + const { waitUntilReady, lastFrame, generateSvg, unmount } = + await renderWithProviders( + , + { + uiState: { + mainAreaWidth: 80, + terminalWidth: 80, + terminalHeight: 50, + constrainHeight: true, + availableTerminalHeight: 40, + }, + config: mockConfig, + }, + ); + await waitUntilReady(); + + await expect({ lastFrame, generateSvg }).toMatchSvgSnapshot(); + unmount(); + }); + + it('should handle security warning height correctly', async () => { + let largeCommand = ''; + for (let i = 1; i <= 50; i++) { + largeCommand += `echo "Line ${i}"\n`; + } + largeCommand += `curl https://täst.com\n`; + + const confirmationDetails: SerializableConfirmationDetails = { + type: 'exec', + title: 'Confirm Execution', + command: largeCommand.trimEnd(), + rootCommand: 'echo', + rootCommands: ['echo', 'curl'], + }; + + const confirmingTool = { + tool: { + callId: 'test-call-id-exec-security', + name: 'run_shell_command', + status: CoreToolCallStatus.AwaitingApproval, + description: 'Executes a bash command with a deceptive URL', + confirmationDetails, + }, + index: 3, + total: 3, + }; + + const { waitUntilReady, lastFrame, generateSvg, unmount } = + await renderWithProviders( + , + { + uiState: { + mainAreaWidth: 80, + terminalWidth: 80, + terminalHeight: 50, + constrainHeight: true, + availableTerminalHeight: 40, + }, + config: mockConfig, + }, + ); + await waitUntilReady(); + + await expect({ lastFrame, generateSvg }).toMatchSvgSnapshot(); + unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx index b976bb3755..e5294e9614 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx @@ -12,8 +12,6 @@ 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'; -import { OverflowProvider } from '../contexts/OverflowContext.js'; -import { ShowMoreLines } from './ShowMoreLines.js'; import { StickyHeader } from './StickyHeader.js'; import type { SerializableConfirmationDetails } from '@google/gemini-cli-core'; import { useUIActions } from '../contexts/UIActionsContext.js'; @@ -53,11 +51,11 @@ export const ToolConfirmationQueue: React.FC = ({ // Safety check: ToolConfirmationMessage requires confirmationDetails if (!tool.confirmationDetails) return null; - // Render up to 100% of the available terminal height (minus 1 line for safety) + // Render up to 100% of the available terminal height // to maximize space for diffs and other content. const maxHeight = uiAvailableHeight !== undefined - ? Math.max(uiAvailableHeight - 1, 4) + ? Math.max(uiAvailableHeight, 4) : Math.floor(terminalHeight * 0.5); const isRoutine = @@ -76,84 +74,81 @@ export const ToolConfirmationQueue: React.FC = ({ : undefined; const content = ( - <> - - - - {/* Header */} - - - {getConfirmationHeader(tool.confirmationDetails)} + + + + {/* Header */} + + + {getConfirmationHeader(tool.confirmationDetails)} + + {total > 1 && ( + + {index} of {total} - {total > 1 && ( - - {index} of {total} - - )} - - - {!hideToolIdentity && ( - - - - )} - - - {/* Interactive Area */} - {/* - Note: We force isFocused={true} because if this component is rendered, - it effectively acts as a modal over the shell/composer. - */} - + {!hideToolIdentity && ( + + + + + )} - + + + {/* Interactive Area */} + {/* + Note: We force isFocused={true} because if this component is rendered, + it effectively acts as a modal over the shell/composer. + */} + - - + + ); - return {content}; + return content; }; diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg index 558118cdfb..0527f43327 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/MainContent-MainContent-renders-multiple-thinking-messages-sequentially-correctly.snap.svg @@ -21,22 +21,22 @@ Initial analysis - This is a multiple line paragraph for the first thinking message of how the model analyzes the + This is a multiple line paragraph for the first thinking message of how the - problem. + model analyzes the problem. Planning execution - This a second multiple line paragraph for the second thinking message explaining the plan in + This a second multiple line paragraph for the second thinking message - detail so that it wraps around the terminal display. + explaining the plan in detail so that it wraps around the terminal display. Refining approach - And finally a third multiple line paragraph for the third thinking message to refine the + And finally a third multiple line paragraph for the third thinking message to - solution. + refine the solution. \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap index 785dc6b6f0..8e9d8488e9 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -96,15 +96,15 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc exports[`MainContent > renders a split tool group without a gap between static and pending areas 1`] = ` "AppHeader(full) -╭──────────────────────────────────────────────────────────────────────────────────────────────╮ -│ ✓ test-tool A tool for testing │ -│ │ -│ Part 1 │ -│ │ -│ ✓ test-tool A tool for testing │ -│ │ -│ Part 2 │ -╰──────────────────────────────────────────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────────────╮ +│ ✓ test-tool A tool for testing │ +│ │ +│ Part 1 │ +│ │ +│ ✓ test-tool A tool for testing │ +│ │ +│ Part 2 │ +╰──────────────────────────────────────────────────────────────────────────╯ " `; @@ -163,16 +163,16 @@ AppHeader(full) Thinking... │ │ Initial analysis - │ This is a multiple line paragraph for the first thinking message of how the model analyzes the - │ problem. + │ This is a multiple line paragraph for the first thinking message of how the + │ model analyzes the problem. │ │ Planning execution - │ This a second multiple line paragraph for the second thinking message explaining the plan in - │ detail so that it wraps around the terminal display. + │ This a second multiple line paragraph for the second thinking message + │ explaining the plan in detail so that it wraps around the terminal display. │ │ Refining approach - │ And finally a third multiple line paragraph for the third thinking message to refine the - │ solution. + │ And finally a third multiple line paragraph for the third thinking message to + │ refine the solution. " `; @@ -185,14 +185,14 @@ AppHeader(full) Thinking... │ │ Initial analysis - │ This is a multiple line paragraph for the first thinking message of how the model analyzes the - │ problem. + │ This is a multiple line paragraph for the first thinking message of how the + │ model analyzes the problem. │ │ Planning execution - │ This a second multiple line paragraph for the second thinking message explaining the plan in - │ detail so that it wraps around the terminal display. + │ This a second multiple line paragraph for the second thinking message + │ explaining the plan in detail so that it wraps around the terminal display. │ │ Refining approach - │ And finally a third multiple line paragraph for the third thinking message to refine the - │ solution." + │ And finally a third multiple line paragraph for the third thinking message to + │ refine the solution." `; diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-handle-security-warning-height-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-handle-security-warning-height-correctly.snap.svg new file mode 100644 index 0000000000..678d4b42b3 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-handle-security-warning-height-correctly.snap.svg @@ -0,0 +1,130 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────╮ + + Action Required + 3 of 3 + + + + + ? + run_shell_command + Executes a bash command with a deceptive URL + + + + + ... 6 hidden (Ctrl+O) ... + + + echo + "Line 37" + + + echo + "Line 38" + + + echo + "Line 39" + + + echo + "Line 40" + + + echo + "Line 41" + + + echo + "Line 42" + + + echo + "Line 43" + + + echo + "Line 44" + + + echo + "Line 45" + + + echo + "Line 46" + + + echo + "Line 47" + + + echo + "Line 48" + + + echo + "Line 49" + + + echo + "Line 50" + + + curl https://täst.com + + + + + + Warning: + Deceptive URL(s) detected: + + + + + Original: + https://täst.com/ + + + Actual Host (Punycode): + https://xn--tst-qla.com/ + + + + + Allow execution of: 'echo'? + + + + + + + + + 1. + + + Allow once + + + + 2. + Allow for this session + + + 3. + No, suggest changes (esc) + + + + ╰──────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-render-the-full-queue-wrapper-with-borders-and-content-for-large-edit-diffs.snap.svg b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-render-the-full-queue-wrapper-with-borders-and-content-for-large-edit-diffs.snap.svg new file mode 100644 index 0000000000..c39d7046bc --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-render-the-full-queue-wrapper-with-borders-and-content-for-large-edit-diffs.snap.svg @@ -0,0 +1,458 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────╮ + + Action Required + + + + + ? + replace + Replaces content in a file + + + + + ... 15 hidden (Ctrl+O) ... + + + + + 8 + + + + + + + const + + newLine8 = + + true + + ; + + + + + 9 + + + - + + + const + + oldLine9 = + + true + + ; + + + + + 9 + + + + + + + const + + newLine9 = + + true + + ; + + + + 10 + + + - + + + const + + oldLine10 = + + true + + ; + + + + 10 + + + + + + + const + + newLine10 = + + true + + ; + + + + 11 + + + - + + + const + + oldLine11 = + + true + + ; + + + + 11 + + + + + + + const + + newLine11 = + + true + + ; + + + + 12 + + + - + + + const + + oldLine12 = + + true + + ; + + + + 12 + + + + + + + const + + newLine12 = + + true + + ; + + + + 13 + + + - + + + const + + oldLine13 = + + true + + ; + + + + 13 + + + + + + + const + + newLine13 = + + true + + ; + + + + 14 + + + - + + + const + + oldLine14 = + + true + + ; + + + + 14 + + + + + + + const + + newLine14 = + + true + + ; + + + + 15 + + + - + + + const + + oldLine15 = + + true + + ; + + + + 15 + + + + + + + const + + newLine15 = + + true + + ; + + + + 16 + + + - + + + const + + oldLine16 = + + true + + ; + + + + 16 + + + + + + + const + + newLine16 = + + true + + ; + + + + 17 + + + - + + + const + + oldLine17 = + + true + + ; + + + + 17 + + + + + + + const + + newLine17 = + + true + + ; + + + + 18 + + + - + + + const + + oldLine18 = + + true + + ; + + + + 18 + + + + + + + const + + newLine18 = + + true + + ; + + + + 19 + + + - + + + const + + oldLine19 = + + true + + ; + + + + 19 + + + + + + + const + + newLine19 = + + true + + ; + + + + 20 + + + - + + + const + + oldLine20 = + + true + + ; + + + + 20 + + + + + + + const + + newLine20 = + + true + + ; + + + Apply this change? + + + + + + + + + 1. + + + Allow once + + + + 2. + Allow for this session + + + 3. + Modify with external editor + + + 4. + No, suggest changes (esc) + + + + ╰──────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-render-the-full-queue-wrapper-with-borders-and-content-for-large-exec-commands.snap.svg b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-render-the-full-queue-wrapper-with-borders-and-content-for-large-exec-commands.snap.svg new file mode 100644 index 0000000000..508fc9d3c4 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue-ToolConfirmationQueue-height-allocation-and-layout-should-render-the-full-queue-wrapper-with-borders-and-content-for-large-exec-commands.snap.svg @@ -0,0 +1,156 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────╮ + + Action Required + 2 of 3 + + + + + ? + run_shell_command + Executes a bash command + + + + + ... 24 hidden (Ctrl+O) ... + + + echo + "Line 25" + + + echo + "Line 26" + + + echo + "Line 27" + + + echo + "Line 28" + + + echo + "Line 29" + + + echo + "Line 30" + + + echo + "Line 31" + + + echo + "Line 32" + + + echo + "Line 33" + + + echo + "Line 34" + + + echo + "Line 35" + + + echo + "Line 36" + + + echo + "Line 37" + + + echo + "Line 38" + + + echo + "Line 39" + + + echo + "Line 40" + + + echo + "Line 41" + + + echo + "Line 42" + + + echo + "Line 43" + + + echo + "Line 44" + + + echo + "Line 45" + + + echo + "Line 46" + + + echo + "Line 47" + + + echo + "Line 48" + + + echo + "Line 49" + + + echo + "Line 50" + + + Allow execution of: 'echo'? + + + + + + + + + 1. + + + Allow once + + + + 2. + Allow for this session + + + 3. + No, suggest changes (esc) + + + + ╰──────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap index 6d9baba94f..fdbb216cde 100644 --- a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap @@ -16,7 +16,6 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai │ 4. No, suggest changes (esc) │ │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ - Press Ctrl+O to show more lines " `; @@ -42,6 +41,130 @@ exports[`ToolConfirmationQueue > does not render expansion hint when constrainHe " `; +exports[`ToolConfirmationQueue > height allocation and layout > should handle security warning height correctly 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Action Required 3 of 3 │ +│ │ +│ ? run_shell_command Executes a bash command with a deceptive URL │ +│ │ +│ ... 6 hidden (Ctrl+O) ... │ +│ echo "Line 37" │ +│ echo "Line 38" │ +│ echo "Line 39" │ +│ echo "Line 40" │ +│ echo "Line 41" │ +│ echo "Line 42" │ +│ echo "Line 43" │ +│ echo "Line 44" │ +│ echo "Line 45" │ +│ echo "Line 46" │ +│ echo "Line 47" │ +│ echo "Line 48" │ +│ echo "Line 49" │ +│ echo "Line 50" │ +│ curl https://täst.com │ +│ │ +│ ⚠ Warning: Deceptive URL(s) detected: │ +│ │ +│ Original: https://täst.com/ │ +│ Actual Host (Punycode): https://xn--tst-qla.com/ │ +│ │ +│ Allow execution of: 'echo'? │ +│ │ +│ ● 1. Allow once │ +│ 2. Allow for this session │ +│ 3. No, suggest changes (esc) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[`ToolConfirmationQueue > height allocation and layout > should render the full queue wrapper with borders and content for large edit diffs 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Action Required │ +│ │ +│ ? replace Replaces content in a file │ +│ │ +│ ... 15 hidden (Ctrl+O) ... │ +│ 8 + const newLine8 = true; │ +│ 9 - const oldLine9 = true; │ +│ 9 + const newLine9 = true; │ +│ 10 - const oldLine10 = true; │ +│ 10 + const newLine10 = true; │ +│ 11 - const oldLine11 = true; │ +│ 11 + const newLine11 = true; │ +│ 12 - const oldLine12 = true; │ +│ 12 + const newLine12 = true; │ +│ 13 - const oldLine13 = true; │ +│ 13 + const newLine13 = true; │ +│ 14 - const oldLine14 = true; │ +│ 14 + const newLine14 = true; │ +│ 15 - const oldLine15 = true; │ +│ 15 + const newLine15 = true; │ +│ 16 - const oldLine16 = true; │ +│ 16 + const newLine16 = true; │ +│ 17 - const oldLine17 = true; │ +│ 17 + const newLine17 = true; │ +│ 18 - const oldLine18 = true; │ +│ 18 + const newLine18 = true; │ +│ 19 - const oldLine19 = true; │ +│ 19 + const newLine19 = true; │ +│ 20 - const oldLine20 = true; │ +│ 20 + const newLine20 = true; │ +│ Apply this change? │ +│ │ +│ ● 1. Allow once │ +│ 2. Allow for this session │ +│ 3. Modify with external editor │ +│ 4. No, suggest changes (esc) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +" +`; + +exports[`ToolConfirmationQueue > height allocation and layout > should render the full queue wrapper with borders and content for large exec commands 1`] = ` +"╭──────────────────────────────────────────────────────────────────────────────╮ +│ Action Required 2 of 3 │ +│ │ +│ ? run_shell_command Executes a bash command │ +│ │ +│ ... 24 hidden (Ctrl+O) ... │ +│ echo "Line 25" │ +│ echo "Line 26" │ +│ echo "Line 27" │ +│ echo "Line 28" │ +│ echo "Line 29" │ +│ echo "Line 30" │ +│ echo "Line 31" │ +│ echo "Line 32" │ +│ echo "Line 33" │ +│ echo "Line 34" │ +│ echo "Line 35" │ +│ echo "Line 36" │ +│ echo "Line 37" │ +│ echo "Line 38" │ +│ echo "Line 39" │ +│ echo "Line 40" │ +│ echo "Line 41" │ +│ echo "Line 42" │ +│ echo "Line 43" │ +│ echo "Line 44" │ +│ echo "Line 45" │ +│ echo "Line 46" │ +│ echo "Line 47" │ +│ echo "Line 48" │ +│ echo "Line 49" │ +│ echo "Line 50" │ +│ Allow execution of: 'echo'? │ +│ │ +│ ● 1. Allow once │ +│ 2. Allow for this session │ +│ 3. No, suggest changes (esc) │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +" +`; + exports[`ToolConfirmationQueue > provides more height for ask_user by subtracting less overhead 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ Answer Questions │ @@ -91,26 +214,6 @@ exports[`ToolConfirmationQueue > renders ExitPlanMode tool confirmation with Suc " `; -exports[`ToolConfirmationQueue > renders expansion hint when content is long and constrained 1`] = ` -"╭──────────────────────────────────────────────────────────────────────────────╮ -│ Action Required │ -│ │ -│ ? replace edit file │ -│ │ -│ ... 49 hidden (Ctrl+O) ... │ -│ 50 line │ -│ Apply this change? │ -│ │ -│ ● 1. Allow once │ -│ 2. Allow for this session │ -│ 3. Modify with external editor │ -│ 4. No, suggest changes (esc) │ -│ │ -╰──────────────────────────────────────────────────────────────────────────────╯ - Press Ctrl+O to show more lines -" -`; - exports[`ToolConfirmationQueue > renders the confirming tool with progress indicator 1`] = ` "╭──────────────────────────────────────────────────────────────────────────────╮ │ Action Required 1 of 3 │ diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index 1759b0484c..171d41647c 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -232,7 +232,7 @@ describe('ToolConfirmationMessage', () => { unmount(); }); - it('should render multiline shell scripts with correct newlines and syntax highlighting (SVG snapshot)', async () => { + it('should render multiline shell scripts with correct newlines and syntax highlighting', async () => { const confirmationDetails: SerializableConfirmationDetails = { type: 'exec', title: 'Confirm Multiline Script', @@ -628,6 +628,83 @@ describe('ToolConfirmationMessage', () => { unmount(); }); + describe('height allocation and layout', () => { + it('should expand to available height for large exec commands', async () => { + let largeCommand = ''; + for (let i = 1; i <= 50; i++) { + largeCommand += `echo "Line ${i}"\n`; + } + + const confirmationDetails: SerializableConfirmationDetails = { + type: 'exec', + title: 'Confirm Execution', + command: largeCommand.trimEnd(), + rootCommand: 'echo', + rootCommands: ['echo'], + }; + + const { waitUntilReady, lastFrame, generateSvg, unmount } = + await renderWithProviders( + , + ); + await waitUntilReady(); + + const outputLines = lastFrame().split('\n'); + // Should use the entire terminal height minus 1 line for the "Press Ctrl+O to show more lines" hint + expect(outputLines.length).toBe(39); + + await expect({ lastFrame, generateSvg }).toMatchSvgSnapshot(); + unmount(); + }); + + it('should expand to available height for large edit diffs', async () => { + // Create a large diff string + let largeDiff = '--- a/file.ts\n+++ b/file.ts\n@@ -1,10 +1,15 @@\n'; + for (let i = 1; i <= 20; i++) { + largeDiff += `-const oldLine${i} = true;\n`; + largeDiff += `+const newLine${i} = true;\n`; + } + + const confirmationDetails: SerializableConfirmationDetails = { + type: 'edit', + title: 'Confirm Edit', + fileName: 'file.ts', + filePath: '/file.ts', + fileDiff: largeDiff, + originalContent: 'old', + newContent: 'new', + isModifying: false, + }; + + const { waitUntilReady, lastFrame, generateSvg, unmount } = + await renderWithProviders( + , + ); + await waitUntilReady(); + + const outputLines = lastFrame().split('\n'); + // Should use the entire terminal height minus 1 line for the "Press Ctrl+O to show more lines" hint + expect(outputLines.length).toBe(39); + + await expect({ lastFrame, generateSvg }).toMatchSvgSnapshot(); + unmount(); + }); + }); + describe('ESCAPE key behavior', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 6d6d85780c..d9ca2e66c6 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -5,8 +5,8 @@ */ import type React from 'react'; -import { useEffect, useMemo, useCallback, useState } from 'react'; -import { Box, Text } from 'ink'; +import { useEffect, useMemo, useCallback, useState, useRef } from 'react'; +import { Box, Text, ResizeObserver, type DOMElement } from 'ink'; import { DiffRenderer } from './DiffRenderer.js'; import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; import { @@ -85,6 +85,64 @@ export const ToolConfirmationMessage: React.FC< ? mcpDetailsExpansionState.expanded : false; + const [measuredSecurityWarningsHeight, setMeasuredSecurityWarningsHeight] = + useState(0); + const observerRef = useRef(null); + + const deceptiveUrlWarnings = useMemo(() => { + const urls: string[] = []; + if (confirmationDetails.type === 'info' && confirmationDetails.urls) { + urls.push(...confirmationDetails.urls); + } else if (confirmationDetails.type === 'exec') { + const commands = + confirmationDetails.commands && confirmationDetails.commands.length > 0 + ? confirmationDetails.commands + : [confirmationDetails.command]; + for (const cmd of commands) { + const matches = cmd.match(/https?:\/\/[^\s"'`<>;&|()]+/g); + if (matches) urls.push(...matches); + } + } + + const uniqueUrls = Array.from(new Set(urls)); + return uniqueUrls + .map(getDeceptiveUrlDetails) + .filter((d): d is DeceptiveUrlDetails => d !== null); + }, [confirmationDetails]); + + const deceptiveUrlWarningText = useMemo(() => { + if (deceptiveUrlWarnings.length === 0) return null; + return `**Warning:** Deceptive URL(s) detected:\n\n${deceptiveUrlWarnings + .map( + (w) => + ` **Original:** ${w.originalUrl}\n **Actual Host (Punycode):** ${w.punycodeUrl}`, + ) + .join('\n\n')}`; + }, [deceptiveUrlWarnings]); + + const onSecurityWarningsRefChange = useCallback((node: DOMElement | null) => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + + if (node) { + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + const newHeight = Math.round(entry.contentRect.height); + setMeasuredSecurityWarningsHeight((prev) => + newHeight !== prev ? newHeight : prev, + ); + } + }); + observer.observe(node); + observerRef.current = observer; + } else { + setMeasuredSecurityWarningsHeight((prev) => (prev !== 0 ? 0 : prev)); + } + }, []); + const settings = useSettings(); const allowPermanentApproval = settings.merged.security.enablePermanentToolApproval && @@ -216,37 +274,6 @@ export const ToolConfirmationMessage: React.FC< [handleConfirm], ); - const deceptiveUrlWarnings = useMemo(() => { - const urls: string[] = []; - if (confirmationDetails.type === 'info' && confirmationDetails.urls) { - urls.push(...confirmationDetails.urls); - } else if (confirmationDetails.type === 'exec') { - const commands = - confirmationDetails.commands && confirmationDetails.commands.length > 0 - ? confirmationDetails.commands - : [confirmationDetails.command]; - for (const cmd of commands) { - const matches = cmd.match(/https?:\/\/[^\s"'`<>;&|()]+/g); - if (matches) urls.push(...matches); - } - } - - const uniqueUrls = Array.from(new Set(urls)); - return uniqueUrls - .map(getDeceptiveUrlDetails) - .filter((d): d is DeceptiveUrlDetails => d !== null); - }, [confirmationDetails]); - - const deceptiveUrlWarningText = useMemo(() => { - if (deceptiveUrlWarnings.length === 0) return null; - return `**Warning:** Deceptive URL(s) detected:\n\n${deceptiveUrlWarnings - .map( - (w) => - ` **Original:** ${w.originalUrl}\n **Actual Host (Punycode):** ${w.punycodeUrl}`, - ) - .join('\n\n')}`; - }, [deceptiveUrlWarnings]); - const getOptions = useCallback(() => { const options: Array> = []; @@ -389,23 +416,36 @@ export const ToolConfirmationMessage: React.FC< // 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 PADDING_OUTER_Y = 1; // Main container has `paddingBottom={1}`. const HEIGHT_QUESTION = 1; // The question text is one line. const MARGIN_QUESTION_BOTTOM = 1; // Margin on the question container. + const SECURITY_WARNING_BOTTOM_MARGIN = 1; // Margin on the securityWarnings container. + const SHOW_MORE_LINES_HEIGHT = 1; // The "Press Ctrl+O to show more lines" hint. const optionsCount = getOptions().length; + // The measured height includes the margin inside WarningMessage (1 line). + // We also add 1 line for the marginBottom on the securityWarnings container. + const securityWarningsHeight = deceptiveUrlWarningText + ? measuredSecurityWarningsHeight + SECURITY_WARNING_BOTTOM_MARGIN + : 0; + const surroundingElementsHeight = PADDING_OUTER_Y + - MARGIN_BODY_BOTTOM + HEIGHT_QUESTION + MARGIN_QUESTION_BOTTOM + + SHOW_MORE_LINES_HEIGHT + optionsCount + - 1; // Reserve one line for 'ShowMoreLines' hint + securityWarningsHeight; return Math.max(availableTerminalHeight - surroundingElementsHeight, 1); - }, [availableTerminalHeight, getOptions, handlesOwnUI]); + }, [ + availableTerminalHeight, + handlesOwnUI, + getOptions, + measuredSecurityWarningsHeight, + deceptiveUrlWarningText, + ]); const { question, bodyContent, options, securityWarnings, initialIndex } = useMemo<{ @@ -547,10 +587,6 @@ export const ToolConfirmationMessage: React.FC< 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); @@ -759,7 +795,11 @@ export const ToolConfirmationMessage: React.FC< {securityWarnings && ( - + {securityWarnings} )} diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-height-allocation-and-layout-should-expand-to-available-height-for-large-edit-diffs.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-height-allocation-and-layout-should-expand-to-available-height-for-large-edit-diffs.snap.svg new file mode 100644 index 0000000000..4c570fb451 --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-height-allocation-and-layout-should-expand-to-available-height-for-large-edit-diffs.snap.svg @@ -0,0 +1,468 @@ + + + + + ... first 9 lines hidden (Ctrl+O to show) ... + + + 5 + + + + + + + const + + newLine5 = + + true + + ; + + + 6 + + + - + + + const + + oldLine6 = + + true + + ; + + + 6 + + + + + + + const + + newLine6 = + + true + + ; + + + 7 + + + - + + + const + + oldLine7 = + + true + + ; + + + 7 + + + + + + + const + + newLine7 = + + true + + ; + + + 8 + + + - + + + const + + oldLine8 = + + true + + ; + + + 8 + + + + + + + const + + newLine8 = + + true + + ; + + + 9 + + + - + + + const + + oldLine9 = + + true + + ; + + + 9 + + + + + + + const + + newLine9 = + + true + + ; + + 10 + + + - + + + const + + oldLine10 = + + true + + ; + + 10 + + + + + + + const + + newLine10 = + + true + + ; + + 11 + + + - + + + const + + oldLine11 = + + true + + ; + + 11 + + + + + + + const + + newLine11 = + + true + + ; + + 12 + + + - + + + const + + oldLine12 = + + true + + ; + + 12 + + + + + + + const + + newLine12 = + + true + + ; + + 13 + + + - + + + const + + oldLine13 = + + true + + ; + + 13 + + + + + + + const + + newLine13 = + + true + + ; + + 14 + + + - + + + const + + oldLine14 = + + true + + ; + + 14 + + + + + + + const + + newLine14 = + + true + + ; + + 15 + + + - + + + const + + oldLine15 = + + true + + ; + + 15 + + + + + + + const + + newLine15 = + + true + + ; + + 16 + + + - + + + const + + oldLine16 = + + true + + ; + + 16 + + + + + + + const + + newLine16 = + + true + + ; + + 17 + + + - + + + const + + oldLine17 = + + true + + ; + + 17 + + + + + + + const + + newLine17 = + + true + + ; + + 18 + + + - + + + const + + oldLine18 = + + true + + ; + + 18 + + + + + + + const + + newLine18 = + + true + + ; + + 19 + + + - + + + const + + oldLine19 = + + true + + ; + + 19 + + + + + + + const + + newLine19 = + + true + + ; + + 20 + + + - + + + const + + oldLine20 = + + true + + ; + + 20 + + + + + + + const + + newLine20 = + + true + + ; + Apply this change? + + + + + 1. + + + Allow once + + 2. + Allow for this session + 3. + Modify with external editor + 4. + No, suggest changes (esc) + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-height-allocation-and-layout-should-expand-to-available-height-for-large-exec-commands.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-height-allocation-and-layout-should-expand-to-available-height-for-large-exec-commands.snap.svg new file mode 100644 index 0000000000..4b34a3405f --- /dev/null +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-height-allocation-and-layout-should-expand-to-available-height-for-large-exec-commands.snap.svg @@ -0,0 +1,87 @@ + + + + + ... first 18 lines hidden (Ctrl+O to show) ... + echo + "Line 19" + echo + "Line 20" + echo + "Line 21" + echo + "Line 22" + echo + "Line 23" + echo + "Line 24" + echo + "Line 25" + echo + "Line 26" + echo + "Line 27" + echo + "Line 28" + echo + "Line 29" + echo + "Line 30" + echo + "Line 31" + echo + "Line 32" + echo + "Line 33" + echo + "Line 34" + echo + "Line 35" + echo + "Line 36" + echo + "Line 37" + echo + "Line 38" + echo + "Line 39" + echo + "Line 40" + echo + "Line 41" + echo + "Line 42" + echo + "Line 43" + echo + "Line 44" + echo + "Line 45" + echo + "Line 46" + echo + "Line 47" + echo + "Line 48" + echo + "Line 49" + echo + "Line 50" + Allow execution of: 'echo'? + + + + + 1. + + + Allow once + + 2. + Allow for this session + 3. + No, suggest changes (esc) + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-should-render-multiline-shell-scripts-with-correct-newlines-and-syntax-highlighting-SVG-snapshot-.snap.svg b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-should-render-multiline-shell-scripts-with-correct-newlines-and-syntax-highlighting.snap.svg similarity index 100% rename from packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-should-render-multiline-shell-scripts-with-correct-newlines-and-syntax-highlighting-SVG-snapshot-.snap.svg rename to packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage-ToolConfirmationMessage-should-render-multiline-shell-scripts-with-correct-newlines-and-syntax-highlighting.snap.svg diff --git a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap index 085d0bc445..eb9f856b0b 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/ToolConfirmationMessage.test.tsx.snap @@ -16,6 +16,90 @@ Apply this change? " `; +exports[`ToolConfirmationMessage > height allocation and layout > should expand to available height for large edit diffs 1`] = ` +"... first 9 lines hidden (Ctrl+O to show) ... + 5 + const newLine5 = true; + 6 - const oldLine6 = true; + 6 + const newLine6 = true; + 7 - const oldLine7 = true; + 7 + const newLine7 = true; + 8 - const oldLine8 = true; + 8 + const newLine8 = true; + 9 - const oldLine9 = true; + 9 + const newLine9 = true; +10 - const oldLine10 = true; +10 + const newLine10 = true; +11 - const oldLine11 = true; +11 + const newLine11 = true; +12 - const oldLine12 = true; +12 + const newLine12 = true; +13 - const oldLine13 = true; +13 + const newLine13 = true; +14 - const oldLine14 = true; +14 + const newLine14 = true; +15 - const oldLine15 = true; +15 + const newLine15 = true; +16 - const oldLine16 = true; +16 + const newLine16 = true; +17 - const oldLine17 = true; +17 + const newLine17 = true; +18 - const oldLine18 = true; +18 + const newLine18 = true; +19 - const oldLine19 = true; +19 + const newLine19 = true; +20 - const oldLine20 = true; +20 + const newLine20 = true; +Apply this change? + +● 1. Allow once + 2. Allow for this session + 3. Modify with external editor + 4. No, suggest changes (esc) +" +`; + +exports[`ToolConfirmationMessage > height allocation and layout > should expand to available height for large exec commands 1`] = ` +"... first 18 lines hidden (Ctrl+O to show) ... +echo "Line 19" +echo "Line 20" +echo "Line 21" +echo "Line 22" +echo "Line 23" +echo "Line 24" +echo "Line 25" +echo "Line 26" +echo "Line 27" +echo "Line 28" +echo "Line 29" +echo "Line 30" +echo "Line 31" +echo "Line 32" +echo "Line 33" +echo "Line 34" +echo "Line 35" +echo "Line 36" +echo "Line 37" +echo "Line 38" +echo "Line 39" +echo "Line 40" +echo "Line 41" +echo "Line 42" +echo "Line 43" +echo "Line 44" +echo "Line 45" +echo "Line 46" +echo "Line 47" +echo "Line 48" +echo "Line 49" +echo "Line 50" +Allow execution of: 'echo'? + +● 1. Allow once + 2. Allow for this session + 3. No, suggest changes (esc) +" +`; + exports[`ToolConfirmationMessage > should display multiple commands for exec type when provided 1`] = ` "echo "hello" @@ -53,7 +137,7 @@ Do you want to proceed? " `; -exports[`ToolConfirmationMessage > should render multiline shell scripts with correct newlines and syntax highlighting (SVG snapshot) 1`] = ` +exports[`ToolConfirmationMessage > should render multiline shell scripts with correct newlines and syntax highlighting 1`] = ` "echo "hello" for i in 1 2 3; do echo $i