diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 455a84b8e0..1b64c07d7b 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -607,6 +607,7 @@ const mockUIActions: UIActions = { onHintSubmit: vi.fn(), handleRestart: vi.fn(), handleNewAgentsSelect: vi.fn(), + getPreferredEditor: vi.fn(), }; let capturedOverflowState: OverflowState | undefined; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 86a4938a66..b89d0b83c0 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2554,6 +2554,7 @@ Logging in with Google... Restarting Gemini CLI to continue. } setNewAgents(null); }, + getPreferredEditor, }), [ handleThemeSelect, @@ -2605,6 +2606,7 @@ Logging in with Google... Restarting Gemini CLI to continue. newAgents, config, historyManager, + getPreferredEditor, ], ); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 1d31b1a1f4..9606513510 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -183,6 +183,10 @@ interface AskUserDialogProps { * Height constraint for scrollable content. */ availableHeight?: number; + /** + * Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"]) + */ + extraParts?: string[]; } interface ReviewViewProps { @@ -190,6 +194,7 @@ interface ReviewViewProps { answers: { [key: string]: string }; onSubmit: () => void; progressHeader?: React.ReactNode; + extraParts?: string[]; } const ReviewView: React.FC = ({ @@ -197,6 +202,7 @@ const ReviewView: React.FC = ({ answers, onSubmit, progressHeader, + extraParts, }) => { const unansweredCount = questions.length - Object.keys(answers).length; const hasUnanswered = unansweredCount > 0; @@ -247,6 +253,7 @@ const ReviewView: React.FC = ({ ); @@ -925,6 +932,7 @@ export const AskUserDialog: React.FC = ({ onActiveTextInputChange, width, availableHeight: availableHeightProp, + extraParts, }) => { const uiState = useContext(UIStateContext); const availableHeight = @@ -1120,6 +1128,7 @@ export const AskUserDialog: React.FC = ({ answers={answers} onSubmit={handleReviewSubmit} progressHeader={progressHeader} + extraParts={extraParts} /> ); @@ -1143,6 +1152,7 @@ export const AskUserDialog: React.FC = ({ ? undefined : '↑/↓ to navigate' } + extraParts={extraParts} /> ); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index 26b61829a0..c9def1a8c2 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -11,6 +11,7 @@ import { waitFor } from '../../test-utils/async.js'; import { ExitPlanModeDialog } from './ExitPlanModeDialog.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { keyMatchers, Command } from '../keyMatchers.js'; +import { openFileInEditor } from '../utils/editorUtils.js'; import { ApprovalMode, validatePlanContent, @@ -19,6 +20,10 @@ import { } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; +vi.mock('../utils/editorUtils.js', () => ({ + openFileInEditor: vi.fn(), +})); + vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); @@ -144,6 +149,7 @@ Implement a comprehensive authentication system with multiple providers. onApprove={onApprove} onFeedback={onFeedback} onCancel={onCancel} + getPreferredEditor={vi.fn()} width={80} availableHeight={24} />, @@ -153,6 +159,7 @@ Implement a comprehensive authentication system with multiple providers. getTargetDir: () => mockTargetDir, getIdeMode: () => false, isTrustedFolder: () => true, + getPreferredEditor: () => undefined, storage: { getPlansDir: () => mockPlansDir, }, @@ -418,6 +425,7 @@ Implement a comprehensive authentication system with multiple providers. onApprove={onApprove} onFeedback={onFeedback} onCancel={onCancel} + getPreferredEditor={vi.fn()} width={80} availableHeight={24} /> @@ -535,6 +543,40 @@ Implement a comprehensive authentication system with multiple providers. }); expect(onFeedback).not.toHaveBeenCalled(); }); + + it('opens plan in external editor when Ctrl+X is pressed', async () => { + const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + + await act(async () => { + vi.runAllTimers(); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('Add user authentication'); + }); + + // Reset the mock to track the second call during refresh + vi.mocked(processSingleFileContent).mockClear(); + + // Press Ctrl+X + await act(async () => { + writeKey(stdin, '\x18'); // Ctrl+X + }); + + await waitFor(() => { + expect(openFileInEditor).toHaveBeenCalledWith( + mockPlanFullPath, + expect.anything(), + expect.anything(), + undefined, + ); + }); + + // Verify that content is refreshed (processSingleFileContent called again) + await waitFor(() => { + expect(processSingleFileContent).toHaveBeenCalled(); + }); + }); }, ); }); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx index 8777136d86..6a5da1c299 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx @@ -5,25 +5,32 @@ */ import type React from 'react'; -import { useEffect, useState } from 'react'; -import { Box, Text } from 'ink'; +import { useEffect, useState, useCallback } from 'react'; +import { Box, Text, useStdin } from 'ink'; import { ApprovalMode, validatePlanPath, validatePlanContent, QuestionType, type Config, + type EditorType, processSingleFileContent, + debugLogger, } from '@google/gemini-cli-core'; import { theme } from '../semantic-colors.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { AskUserDialog } from './AskUserDialog.js'; +import { openFileInEditor } from '../utils/editorUtils.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; +import { formatCommand } from '../utils/keybindingUtils.js'; export interface ExitPlanModeDialogProps { planPath: string; onApprove: (approvalMode: ApprovalMode) => void; onFeedback: (feedback: string) => void; onCancel: () => void; + getPreferredEditor: () => EditorType | undefined; width: number; availableHeight?: number; } @@ -38,6 +45,7 @@ interface PlanContentState { status: PlanStatus; content?: string; error?: string; + refresh: () => void; } enum ApprovalOption { @@ -53,10 +61,15 @@ const StatusMessage: React.FC<{ }> = ({ children }) => {children}; function usePlanContent(planPath: string, config: Config): PlanContentState { - const [state, setState] = useState({ + const [version, setVersion] = useState(0); + const [state, setState] = useState>({ status: PlanStatus.Loading, }); + const refresh = useCallback(() => { + setVersion((v) => v + 1); + }, []); + useEffect(() => { let ignore = false; setState({ status: PlanStatus.Loading }); @@ -120,9 +133,9 @@ function usePlanContent(planPath: string, config: Config): PlanContentState { return () => { ignore = true; }; - }, [planPath, config]); + }, [planPath, config, version]); - return state; + return { ...state, refresh }; } export const ExitPlanModeDialog: React.FC = ({ @@ -130,13 +143,36 @@ export const ExitPlanModeDialog: React.FC = ({ onApprove, onFeedback, onCancel, + getPreferredEditor, width, availableHeight, }) => { const config = useConfig(); + const { stdin, setRawMode } = useStdin(); const planState = usePlanContent(planPath, config); + const { refresh } = planState; const [showLoading, setShowLoading] = useState(false); + const handleOpenEditor = useCallback(async () => { + try { + await openFileInEditor(planPath, stdin, setRawMode, getPreferredEditor()); + refresh(); + } catch (err) { + debugLogger.error('Failed to open plan in editor:', err); + } + }, [planPath, stdin, setRawMode, getPreferredEditor, refresh]); + + useKeypress( + (key) => { + if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) { + void handleOpenEditor(); + return true; + } + return false; + }, + { isActive: true, priority: true }, + ); + useEffect(() => { if (planState.status !== PlanStatus.Loading) { setShowLoading(false); @@ -183,6 +219,8 @@ export const ExitPlanModeDialog: React.FC = ({ ); } + const editHint = formatCommand(Command.OPEN_EXTERNAL_EDITOR); + return ( = ({ onCancel={onCancel} width={width} availableHeight={availableHeight} + extraParts={[`${editHint} to edit plan`]} /> ); diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx index c89c98f8d4..3fb1cc8c6f 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx @@ -17,6 +17,7 @@ import { ShowMoreLines } from './ShowMoreLines.js'; import { StickyHeader } from './StickyHeader.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import type { SerializableConfirmationDetails } from '@google/gemini-cli-core'; +import { useUIActions } from '../contexts/UIActionsContext.js'; function getConfirmationHeader( details: SerializableConfirmationDetails | undefined, @@ -41,6 +42,7 @@ export const ToolConfirmationQueue: React.FC = ({ confirmingTool, }) => { const config = useConfig(); + const { getPreferredEditor } = useUIActions(); const isAlternateBuffer = useAlternateBuffer(); const { mainAreaWidth, @@ -134,6 +136,7 @@ export const ToolConfirmationQueue: React.FC = ({ callId={tool.callId} confirmationDetails={tool.confirmationDetails} config={config} + getPreferredEditor={getPreferredEditor} terminalWidth={mainAreaWidth - 4} // Adjust for parent border/padding availableTerminalHeight={availableContentHeight} isFocused={true} diff --git a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap index 587ded8f29..0cd4553c77 100644 --- a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap @@ -23,7 +23,7 @@ Files to Modify Approves plan but requires confirmation for each tool 3. Type your feedback... -Enter to select · ↑/↓ to navigate · Esc to cancel +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; @@ -50,7 +50,7 @@ Files to Modify Approves plan but requires confirmation for each tool 3. Type your feedback... -Enter to select · ↑/↓ to navigate · Esc to cancel +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; @@ -82,7 +82,7 @@ Implementation Steps Approves plan but requires confirmation for each tool 3. Type your feedback... -Enter to select · ↑/↓ to navigate · Esc to cancel +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; @@ -109,7 +109,7 @@ Files to Modify Approves plan but requires confirmation for each tool 3. Type your feedback... -Enter to select · ↑/↓ to navigate · Esc to cancel +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; @@ -136,7 +136,7 @@ Files to Modify Approves plan but requires confirmation for each tool 3. Type your feedback... -Enter to select · ↑/↓ to navigate · Esc to cancel +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; @@ -163,7 +163,7 @@ Files to Modify Approves plan but requires confirmation for each tool 3. Type your feedback... -Enter to select · ↑/↓ to navigate · Esc to cancel +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; @@ -216,7 +216,7 @@ Testing Strategy Approves plan but requires confirmation for each tool 3. Type your feedback... -Enter to select · ↑/↓ to navigate · Esc to cancel +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; @@ -243,6 +243,6 @@ Files to Modify Approves plan but requires confirmation for each tool 3. Type your feedback... -Enter to select · ↑/↓ to navigate · Esc to cancel +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; 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 b5e013ef48..ad7e046465 100644 --- a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap @@ -85,7 +85,7 @@ exports[`ToolConfirmationQueue > renders ExitPlanMode tool confirmation with Suc │ Approves plan but requires confirmation for each tool │ │ 3. Type your feedback... │ │ │ -│ Enter to select · ↑/↓ to navigate · Esc to cancel │ +│ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel │ ╰──────────────────────────────────────────────────────────────────────────────╯ " `; diff --git a/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx b/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx index 1c95a526f5..15763bdae7 100644 --- a/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx +++ b/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx @@ -37,6 +37,7 @@ describe('ToolConfirmationMessage Redirection', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={100} />, diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index ec1fd3d4db..b3b34ae0a8 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -52,6 +52,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -78,6 +79,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -101,6 +103,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -131,6 +134,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -161,6 +165,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -190,6 +195,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -219,6 +225,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -300,6 +307,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={details} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -321,6 +329,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={details} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -355,6 +364,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={editConfirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -381,6 +391,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={editConfirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -425,6 +436,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={editConfirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -452,6 +464,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={editConfirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -479,6 +492,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={editConfirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -505,6 +519,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -550,6 +565,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -581,6 +597,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 9a49e2aa5a..022a68e953 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -14,6 +14,7 @@ import { type Config, type ToolConfirmationPayload, ToolConfirmationOutcome, + type EditorType, hasRedirection, debugLogger, } from '@google/gemini-cli-core'; @@ -49,6 +50,7 @@ export interface ToolConfirmationMessageProps { callId: string; confirmationDetails: SerializableConfirmationDetails; config: Config; + getPreferredEditor: () => EditorType | undefined; isFocused?: boolean; availableTerminalHeight?: number; terminalWidth: number; @@ -60,6 +62,7 @@ export const ToolConfirmationMessage: React.FC< callId, confirmationDetails, config, + getPreferredEditor, isFocused = true, availableTerminalHeight, terminalWidth, @@ -424,6 +427,7 @@ export const ToolConfirmationMessage: React.FC< bodyContent = ( { handleConfirm(ToolConfirmationOutcome.ProceedOnce, { approved: true, @@ -629,6 +633,7 @@ export const ToolConfirmationMessage: React.FC< hasMcpToolDetails, mcpToolDetailsText, expandDetailsHintKey, + getPreferredEditor, ]); const bodyOverflowDirection: 'top' | 'bottom' = diff --git a/packages/cli/src/ui/components/shared/DialogFooter.tsx b/packages/cli/src/ui/components/shared/DialogFooter.tsx index af75074645..7411a91611 100644 --- a/packages/cli/src/ui/components/shared/DialogFooter.tsx +++ b/packages/cli/src/ui/components/shared/DialogFooter.tsx @@ -15,6 +15,8 @@ export interface DialogFooterProps { navigationActions?: string; /** Exit shortcut (defaults to "Esc to cancel") */ cancelAction?: string; + /** Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"]) */ + extraParts?: string[]; } /** @@ -25,11 +27,13 @@ export const DialogFooter: React.FC = ({ primaryAction, navigationActions, cancelAction = 'Esc to cancel', + extraParts = [], }) => { const parts = [primaryAction]; if (navigationActions) { parts.push(navigationActions); } + parts.push(...extraParts); parts.push(cancelAction); return ( diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index e641633e97..71ee40b642 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import pathMod from 'node:path'; @@ -13,12 +12,9 @@ import { useState, useCallback, useEffect, useMemo, useReducer } from 'react'; import { LRUCache } from 'mnemonist'; import { coreEvents, - CoreEvent, debugLogger, unescapePath, type EditorType, - getEditorCommand, - isGuiEditor, } from '@google/gemini-cli-core'; import { toCodePoints, @@ -33,6 +29,7 @@ import { keyMatchers, Command } from '../../keyMatchers.js'; import type { VimAction } from './vim-buffer-actions.js'; import { handleVimAction } from './vim-buffer-actions.js'; import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js'; +import { openFileInEditor } from '../../utils/editorUtils.js'; export const LARGE_PASTE_LINE_THRESHOLD = 5; export const LARGE_PASTE_CHAR_THRESHOLD = 500; @@ -3095,36 +3092,15 @@ export function useTextBuffer({ ); fs.writeFileSync(filePath, expandedText, 'utf8'); - let command: string | undefined = undefined; - const args = [filePath]; - - const preferredEditorType = getPreferredEditor?.(); - if (!command && preferredEditorType) { - command = getEditorCommand(preferredEditorType); - if (isGuiEditor(preferredEditorType)) { - args.unshift('--wait'); - } - } - - if (!command) { - command = - process.env['VISUAL'] ?? - process.env['EDITOR'] ?? - (process.platform === 'win32' ? 'notepad' : 'vi'); - } - dispatch({ type: 'create_undo_snapshot' }); - const wasRaw = stdin?.isRaw ?? false; try { - setRawMode?.(false); - const { status, error } = spawnSync(command, args, { - stdio: 'inherit', - shell: process.platform === 'win32', - }); - if (error) throw error; - if (typeof status === 'number' && status !== 0) - throw new Error(`External editor exited with status ${status}`); + await openFileInEditor( + filePath, + stdin, + setRawMode, + getPreferredEditor?.(), + ); let newText = fs.readFileSync(filePath, 'utf8'); newText = newText.replace(/\r\n?/g, '\n'); @@ -3147,8 +3123,6 @@ export function useTextBuffer({ err, ); } finally { - coreEvents.emit(CoreEvent.ExternalEditorClosed); - if (wasRaw) setRawMode?.(true); try { fs.unlinkSync(filePath); } catch { diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 03780c5068..23c5e995db 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -87,6 +87,7 @@ export interface UIActions { onHintSubmit: (hint: string) => void; handleRestart: () => void; handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise; + getPreferredEditor: () => EditorType | undefined; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/utils/editorUtils.ts b/packages/cli/src/ui/utils/editorUtils.ts new file mode 100644 index 0000000000..7b9efd5a81 --- /dev/null +++ b/packages/cli/src/ui/utils/editorUtils.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn, spawnSync } from 'node:child_process'; +import type { ReadStream } from 'node:tty'; +import { + coreEvents, + CoreEvent, + type EditorType, + getEditorCommand, + isGuiEditor, + isTerminalEditor, +} from '@google/gemini-cli-core'; + +/** + * Opens a file in an external editor and waits for it to close. + * Handles raw mode switching to ensure the editor can interact with the terminal. + * + * @param filePath Path to the file to open + * @param stdin The stdin stream from Ink/Node + * @param setRawMode Function to toggle raw mode + * @param preferredEditorType The user's preferred editor from config + */ +export async function openFileInEditor( + filePath: string, + stdin: ReadStream | null | undefined, + setRawMode: ((mode: boolean) => void) | undefined, + preferredEditorType?: EditorType, +): Promise { + let command: string | undefined = undefined; + const args = [filePath]; + + if (preferredEditorType) { + command = getEditorCommand(preferredEditorType); + if (isGuiEditor(preferredEditorType)) { + args.unshift('--wait'); + } + } + + if (!command) { + command = process.env['VISUAL'] ?? process.env['EDITOR']; + if (command) { + const lowerCommand = command.toLowerCase(); + const isGui = ['code', 'cursor', 'subl', 'zed', 'atom'].some((gui) => + lowerCommand.includes(gui), + ); + if ( + isGui && + !lowerCommand.includes('--wait') && + !lowerCommand.includes('-w') + ) { + args.unshift(lowerCommand.includes('subl') ? '-w' : '--wait'); + } + } + } + + if (!command) { + command = process.platform === 'win32' ? 'notepad' : 'vi'; + } + + const [executable = '', ...initialArgs] = command.split(' '); + + // Determine if we should use sync or async based on the command/editor type. + // If we have a preferredEditorType, we can check if it's a terminal editor. + // Otherwise, we guess based on the command name. + const terminalEditors = ['vi', 'vim', 'nvim', 'emacs', 'hx', 'nano']; + const isTerminal = preferredEditorType + ? isTerminalEditor(preferredEditorType) + : terminalEditors.some((te) => executable.toLowerCase().includes(te)); + + if ( + isTerminal && + (executable.includes('vi') || + executable.includes('vim') || + executable.includes('nvim')) + ) { + // Pass -i NONE to prevent E138 'Can't write viminfo file' errors in restricted environments. + args.unshift('-i', 'NONE'); + } + + const wasRaw = stdin?.isRaw ?? false; + setRawMode?.(false); + + try { + if (isTerminal) { + const result = spawnSync(executable, [...initialArgs, ...args], { + stdio: 'inherit', + shell: process.platform === 'win32', + }); + if (result.error) { + coreEvents.emitFeedback( + 'error', + '[editorUtils] external terminal editor error', + result.error, + ); + throw result.error; + } + if (typeof result.status === 'number' && result.status !== 0) { + const err = new Error( + `External editor exited with status ${result.status}`, + ); + coreEvents.emitFeedback( + 'error', + '[editorUtils] external editor error', + err, + ); + throw err; + } + } else { + await new Promise((resolve, reject) => { + const child = spawn(executable, [...initialArgs, ...args], { + stdio: 'inherit', + shell: process.platform === 'win32', + }); + + child.on('error', (err) => { + coreEvents.emitFeedback( + 'error', + '[editorUtils] external editor spawn error', + err, + ); + reject(err); + }); + + child.on('close', (status) => { + if (typeof status === 'number' && status !== 0) { + const err = new Error( + `External editor exited with status ${status}`, + ); + coreEvents.emitFeedback( + 'error', + '[editorUtils] external editor error', + err, + ); + reject(err); + } else { + resolve(); + } + }); + }); + } + } finally { + if (wasRaw) { + setRawMode?.(true); + } + coreEvents.emit(CoreEvent.ExternalEditorClosed); + } +}