From 8dbaa2bceaf947b293868ad5ed9244fa872ab31f Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Tue, 13 Jan 2026 16:55:07 -0800 Subject: [PATCH] Fix: make ctrl+x use preferred editor (#16556) --- packages/cli/src/ui/AppContainer.tsx | 11 +- .../src/ui/components/shared/text-buffer.ts | 111 ++++++++++-------- packages/core/src/utils/editor.ts | 17 ++- 3 files changed, 81 insertions(+), 58 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 4f3c9617a8..8e05ac1fd7 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -391,6 +391,11 @@ export const AppContainer = (props: AppContainerProps) => { } }, []); + const getPreferredEditor = useCallback( + () => settings.merged.general?.preferredEditor as EditorType, + [settings.merged.general?.preferredEditor], + ); + const buffer = useTextBuffer({ initialText: '', viewport: { height: 10, width: inputWidth }, @@ -398,6 +403,7 @@ export const AppContainer = (props: AppContainerProps) => { setRawMode, isValidPath, shellModeActive, + getPreferredEditor, }); // Initialize input history from logger (past sessions) @@ -758,11 +764,6 @@ Logging in with Google... Restarting Gemini CLI to continue. () => {}, ); - const getPreferredEditor = useCallback( - () => settings.merged.general?.preferredEditor as EditorType, - [settings.merged.general?.preferredEditor], - ); - const onCancelSubmit = useCallback((shouldRestorePrompt?: boolean) => { if (shouldRestorePrompt) { setPendingRestorePrompt(true); diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index cdf689c24f..26c1ddca80 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -15,6 +15,9 @@ import { CoreEvent, debugLogger, unescapePath, + type EditorType, + getEditorCommand, + isGuiEditor, } from '@google/gemini-cli-core'; import { toCodePoints, @@ -566,6 +569,7 @@ interface UseTextBufferProps { shellModeActive?: boolean; // Whether the text buffer is in shell mode inputFilter?: (text: string) => string; // Optional filter for input text singleLine?: boolean; + getPreferredEditor?: () => EditorType | undefined; } interface UndoHistoryEntry { @@ -1826,6 +1830,7 @@ export function useTextBuffer({ shellModeActive = false, inputFilter, singleLine = false, + getPreferredEditor, }: UseTextBufferProps): TextBuffer { const initialState = useMemo((): TextBufferState => { const lines = initialText.split('\n'); @@ -2152,55 +2157,67 @@ export function useTextBuffer({ dispatch({ type: 'vim_escape_insert_mode' }); }, []); - const openInExternalEditor = useCallback( - async (opts: { editor?: string } = {}): Promise => { - const editor = - opts.editor ?? - process.env['VISUAL'] ?? - process.env['EDITOR'] ?? - (process.platform === 'win32' ? 'notepad' : 'vi'); - const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-')); - const filePath = pathMod.join(tmpDir, 'buffer.txt'); - fs.writeFileSync(filePath, text, 'utf8'); + const openInExternalEditor = useCallback(async (): Promise => { + const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-')); + const filePath = pathMod.join(tmpDir, 'buffer.txt'); + fs.writeFileSync(filePath, text, 'utf8'); - dispatch({ type: 'create_undo_snapshot' }); + let command: string | undefined = undefined; + const args = [filePath]; - const wasRaw = stdin?.isRaw ?? false; - try { - setRawMode?.(false); - const { status, error } = spawnSync(editor, [filePath], { - stdio: 'inherit', - }); - if (error) throw error; - if (typeof status === 'number' && status !== 0) - throw new Error(`External editor exited with status ${status}`); - - let newText = fs.readFileSync(filePath, 'utf8'); - newText = newText.replace(/\r\n?/g, '\n'); - dispatch({ type: 'set_text', payload: newText, pushToUndo: false }); - } catch (err) { - coreEvents.emitFeedback( - 'error', - '[useTextBuffer] external editor error', - err, - ); - } finally { - coreEvents.emit(CoreEvent.ExternalEditorClosed); - if (wasRaw) setRawMode?.(true); - try { - fs.unlinkSync(filePath); - } catch { - /* ignore */ - } - try { - fs.rmdirSync(tmpDir); - } catch { - /* ignore */ - } + const preferredEditorType = getPreferredEditor?.(); + if (!command && preferredEditorType) { + command = getEditorCommand(preferredEditorType); + if (isGuiEditor(preferredEditorType)) { + args.unshift('--wait'); } - }, - [text, stdin, setRawMode], - ); + } + + 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', + }); + if (error) throw error; + if (typeof status === 'number' && status !== 0) + throw new Error(`External editor exited with status ${status}`); + + let newText = fs.readFileSync(filePath, 'utf8'); + newText = newText.replace(/\r\n?/g, '\n'); + dispatch({ type: 'set_text', payload: newText, pushToUndo: false }); + } catch (err) { + coreEvents.emitFeedback( + 'error', + '[useTextBuffer] external editor error', + err, + ); + } finally { + coreEvents.emit(CoreEvent.ExternalEditorClosed); + if (wasRaw) setRawMode?.(true); + try { + fs.unlinkSync(filePath); + } catch { + /* ignore */ + } + try { + fs.rmdirSync(tmpDir); + } catch { + /* ignore */ + } + } + }, [text, stdin, setRawMode, getPreferredEditor]); const handleInput = useCallback( (key: Key): void => { @@ -2616,7 +2633,7 @@ export interface TextBuffer { * continuing. This mirrors Git's behaviour and simplifies downstream * control‑flow (callers can simply `await` the Promise). */ - openInExternalEditor: (opts?: { editor?: string }) => Promise; + openInExternalEditor: () => Promise; replaceRangeByOffset: ( startOffset: number, diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index 742d1157fb..e48a055d40 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -112,6 +112,16 @@ export function checkHasEditorType(editor: EditorType): boolean { return commands.some((cmd) => commandExists(cmd)); } +export function getEditorCommand(editor: EditorType): string { + const commandConfig = editorCommands[editor]; + const commands = + process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; + return ( + commands.slice(0, -1).find((cmd) => commandExists(cmd)) || + commands[commands.length - 1] + ); +} + export function allowEditorTypeInSandbox(editor: EditorType): boolean { const notUsingSandbox = !process.env['SANDBOX']; if (isGuiEditor(editor)) { @@ -143,12 +153,7 @@ export function getDiffCommand( if (!isValidEditorType(editor)) { return null; } - const commandConfig = editorCommands[editor]; - const commands = - process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; - const command = - commands.slice(0, -1).find((cmd) => commandExists(cmd)) || - commands[commands.length - 1]; + const command = getEditorCommand(editor); switch (editor) { case 'vscode':