From a3f6c6a5ca47258f707c78acb8b2b67dc1d6a123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=9D=EC=A0=95=EC=9D=8C?= Date: Fri, 16 Oct 2026 06:56:20 +0000 Subject: [PATCH 1/2] fix(cli): restore approval focus after cancelling editor selection --- .../ToolConfirmationQueue.editor.test.tsx | 254 ++++++++++++++++++ .../ui/components/ToolConfirmationQueue.tsx | 5 +- 2 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/ui/components/ToolConfirmationQueue.editor.test.tsx diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.editor.test.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.editor.test.tsx new file mode 100644 index 00000000000..eaf869f98dd --- /dev/null +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.editor.test.tsx @@ -0,0 +1,254 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act, useEffect, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { + type AgentLoopContext, + type AnyDeclarativeTool, + type AnyToolInvocation, + CoreEvent, + CoreToolCallStatus, + Kind, + MessageBusType, + PolicyDecision, + Scheduler, + ToolConfirmationOutcome, + coreEvents, + makeFakeConfig, + type ToolCall, + type ToolCallConfirmationDetails, + type ToolCallsUpdateMessage, + type CompletedToolCall, +} from '@google/gemini-cli-core'; +import { createMockSettings } from '../../test-utils/settings.js'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { waitFor } from '../../test-utils/async.js'; +import { UIStateContext, useUIState } from '../contexts/UIStateContext.js'; +import { ToolActionsProvider } from '../contexts/ToolActionsContext.js'; +import { useEditorSettings } from '../hooks/useEditorSettings.js'; +import { ToolConfirmationQueue } from './ToolConfirmationQueue.js'; +import { EditorSettingsDialog } from './EditorSettingsDialog.js'; + +vi.mock('../editors/editorSettingsManager.js', () => ({ + editorSettingsManager: { + getAvailableEditorDisplays: () => [ + { name: 'None', type: 'not_set', disabled: false }, + { name: 'Vim', type: 'vim', disabled: false }, + ], + }, +})); + +describe('Tool confirmation while selecting an external editor', () => { + it.each([ + ['Allow once', '1', ToolConfirmationOutcome.ProceedOnce], + ['No, suggest changes', '3', ToolConfirmationOutcome.Cancel], + ])( + 'returns to an actionable approval after Escape, then accepts %s', + async (_label, key, outcome) => { + const config = makeFakeConfig({ + interactive: true, + usageStatisticsEnabled: false, + }); + const messageBus = config.getMessageBus(); + vi.spyOn(config.getPolicyEngine(), 'check').mockResolvedValue({ + decision: PolicyDecision.ASK_USER, + }); + const settings = createMockSettings(); + vi.spyOn(settings, 'setValue').mockImplementation(() => {}); + const onConfirm = vi.fn().mockResolvedValue(undefined); + const execute = vi.fn().mockResolvedValue({ + llmContent: 'Edited package.json', + returnDisplay: 'Edited package.json', + }); + const details: ToolCallConfirmationDetails = { + type: 'edit', + title: 'Confirm edit', + fileName: 'package.json', + filePath: '/project/package.json', + fileDiff: + '--- a/package.json\n+++ b/package.json\n@@ -1 +1 @@\n-old\n+new', + originalContent: 'old', + newContent: 'new', + isBuildFile: true, + onConfirm, + }; + const invocation = { + params: {}, + getDescription: () => 'Editing package.json', + shouldConfirmExecute: vi.fn().mockResolvedValue(details), + execute, + } as unknown as AnyToolInvocation; + const tool = { + name: 'replace', + displayName: 'Edit', + description: 'Editing package.json', + kind: Kind.Edit, + build: () => invocation, + } as unknown as AnyDeclarativeTool; + const context = { + config, + messageBus, + toolRegistry: { + getTool: () => tool, + getAllToolNames: () => [tool.name], + }, + } as unknown as AgentLoopContext; + const scheduler = new Scheduler({ + context, + getPreferredEditor: () => undefined, + schedulerId: 'editor-selection-test', + }); + const controller = new AbortController(); + + function ConfirmationWithEditorDialog() { + const uiState = useUIState(); + const [call, setCall] = useState(); + const editor = useEditorSettings(settings, vi.fn(), vi.fn()); + + useEffect(() => { + const onUpdate = (event: ToolCallsUpdateMessage) => { + setCall(event.toolCalls[0]); + }; + messageBus.subscribe(MessageBusType.TOOL_CALLS_UPDATE, onUpdate); + coreEvents.on( + CoreEvent.RequestEditorSelection, + editor.openEditorDialog, + ); + return () => { + messageBus.unsubscribe(MessageBusType.TOOL_CALLS_UPDATE, onUpdate); + coreEvents.off( + CoreEvent.RequestEditorSelection, + editor.openEditorDialog, + ); + }; + }, [editor.openEditorDialog]); + + const awaiting = + call?.status === CoreToolCallStatus.AwaitingApproval + ? call + : undefined; + const toolDisplay = awaiting + ? { + callId: awaiting.request.callId, + name: 'Edit', + description: 'Editing package.json', + resultDisplay: undefined, + status: awaiting.status, + confirmationDetails: awaiting.confirmationDetails, + correlationId: awaiting.correlationId, + } + : undefined; + + return ( + + false} + toggleExpansion={vi.fn()} + toggleAllExpansion={vi.fn()} + > + {toolDisplay && ( + + )} + {editor.isEditorDialogOpen && ( + + )} + + + ); + } + + const { stdin, lastFrame, waitUntilReady, unmount } = + await renderWithProviders(, { + config, + settings, + uiState: { terminalWidth: 100, constrainHeight: false }, + }); + let settled = false; + let schedule: Promise; + await act(async () => { + schedule = scheduler + .schedule( + { + callId: 'edit-build-file', + name: tool.name, + args: {}, + isClientInitiated: false, + prompt_id: 'prompt', + }, + controller.signal, + ) + .then((result) => { + settled = true; + return result; + }); + }); + + try { + await waitUntilReady(); + await waitFor(() => + expect(lastFrame()).toContain('Apply this change?'), + ); + await act(async () => stdin.write('2')); + await waitUntilReady(); + await waitFor(() => expect(lastFrame()).toContain('> Select Editor')); + expect(onConfirm).toHaveBeenCalledExactlyOnceWith( + ToolConfirmationOutcome.ModifyWithEditor, + undefined, + ); + expect(execute).not.toHaveBeenCalled(); + + await act(async () => stdin.write('\x1b')); + await waitUntilReady(); + await waitFor(() => + expect(lastFrame()).not.toContain('> Select Editor'), + ); + await waitFor(() => + expect(invocation.shouldConfirmExecute).toHaveBeenCalledTimes(2), + ); + expect(lastFrame()).toContain('CRITICAL SECURITY WARNING'); + expect(lastFrame()).toContain('Target: package.json'); + expect(lastFrame()).toContain('Apply this change?'); + expect(settled).toBe(false); + expect(execute).not.toHaveBeenCalled(); + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(settings.setValue).not.toHaveBeenCalled(); + expect(coreEvents.listenerCount(CoreEvent.EditorSelected)).toBe(0); + + await act(async () => stdin.write(key)); + await waitUntilReady(); + await waitFor(() => expect(settled).toBe(true)); + const [result] = await schedule!; + expect(result.status).toBe( + outcome === ToolConfirmationOutcome.ProceedOnce + ? CoreToolCallStatus.Success + : CoreToolCallStatus.Cancelled, + ); + expect(onConfirm).toHaveBeenLastCalledWith(outcome, undefined); + expect(execute).toHaveBeenCalledTimes( + outcome === ToolConfirmationOutcome.ProceedOnce ? 1 : 0, + ); + } finally { + await act(async () => { + controller.abort(); + await schedule; + }); + scheduler.dispose(); + unmount(); + } + }, + ); +}); diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx index 0e001fb5267..cb39994a2e8 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx @@ -58,6 +58,7 @@ export const ToolConfirmationQueue: React.FC = ({ mainAreaWidth, terminalHeight, constrainHeight, + dialogsVisible, availableTerminalHeight: uiAvailableHeight, } = useUIState(); const { tool, index, total } = confirmingTool; @@ -129,7 +130,7 @@ export const ToolConfirmationQueue: React.FC = ({ terminalWidth={safeContentWidth} // Adjust for parent border/padding availableTerminalHeight={availableContentHeight} toolName={tool.name} - isFocused={true} + isFocused={!dialogsVisible} /> @@ -210,7 +211,7 @@ export const ToolConfirmationQueue: React.FC = ({ terminalWidth={safeContentWidth} // Adjust for parent border/padding availableTerminalHeight={availableContentHeight} toolName={tool.name} - isFocused={true} + isFocused={!dialogsVisible} /> Date: Sun, 18 Oct 2026 15:33:17 +0900 Subject: [PATCH 2/2] fix(cli): prioritize editor cancellation over stream Escape --- .../src/ui/components/EditorSettingsDialog.tsx | 2 +- .../ToolConfirmationQueue.editor.test.tsx | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.tsx index a98042773f2..031fd8020ea 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.tsx @@ -58,7 +58,7 @@ export function EditorSettingsDialog({ } return false; }, - { isActive: true }, + { isActive: true, priority: true }, ); const editorItems: EditorDisplay[] = diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.editor.test.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.editor.test.tsx index eaf869f98dd..daa92e09776 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.editor.test.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.editor.test.tsx @@ -30,6 +30,7 @@ import { waitFor } from '../../test-utils/async.js'; import { UIStateContext, useUIState } from '../contexts/UIStateContext.js'; import { ToolActionsProvider } from '../contexts/ToolActionsContext.js'; import { useEditorSettings } from '../hooks/useEditorSettings.js'; +import { useKeypress } from '../hooks/useKeypress.js'; import { ToolConfirmationQueue } from './ToolConfirmationQueue.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; @@ -103,12 +104,25 @@ describe('Tool confirmation while selecting an external editor', () => { schedulerId: 'editor-selection-test', }); const controller = new AbortController(); + const cancelRequest = vi.fn(() => controller.abort()); function ConfirmationWithEditorDialog() { const uiState = useUIState(); const [call, setCall] = useState(); const editor = useEditorSettings(settings, vi.fn(), vi.fn()); + // The stream handler can subscribe after the modal on a parent render. + useKeypress( + (key) => { + if (key.name === 'escape') { + cancelRequest(); + return true; + } + return false; + }, + { isActive: true }, + ); + useEffect(() => { const onUpdate = (event: ToolCallsUpdateMessage) => { setCall(event.toolCalls[0]); @@ -223,6 +237,7 @@ describe('Tool confirmation while selecting an external editor', () => { expect(lastFrame()).toContain('Target: package.json'); expect(lastFrame()).toContain('Apply this change?'); expect(settled).toBe(false); + expect(cancelRequest).not.toHaveBeenCalled(); expect(execute).not.toHaveBeenCalled(); expect(onConfirm).toHaveBeenCalledTimes(1); expect(settings.setValue).not.toHaveBeenCalled();