Fix: make ctrl+x use preferred editor (#16556)

This commit is contained in:
Tommaso Sciortino
2026-01-13 16:55:07 -08:00
committed by GitHub
parent 778de55fd8
commit 8dbaa2bcea
3 changed files with 81 additions and 58 deletions

View File

@@ -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);

View File

@@ -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<void> => {
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<void> => {
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
* controlflow (callers can simply `await` the Promise).
*/
openInExternalEditor: (opts?: { editor?: string }) => Promise<void>;
openInExternalEditor: () => Promise<void>;
replaceRangeByOffset: (
startOffset: number,

View File

@@ -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':