fix: resolve infinite loop when using 'Modify with external editor' (#17453)

Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
Co-authored-by: ehedlund <ehedlund@google.com>
This commit is contained in:
Philippe
2026-02-05 21:52:41 +01:00
committed by GitHub
parent 8efae719ee
commit 2498114df6
8 changed files with 336 additions and 51 deletions
+14 -12
View File
@@ -525,12 +525,22 @@ export const AppContainer = (props: AppContainerProps) => {
refreshStatic();
}, [refreshStatic, isAlternateBuffer, app, config]);
const [editorError, setEditorError] = useState<string | null>(null);
const {
isEditorDialogOpen,
openEditorDialog,
handleEditorSelect,
exitEditorDialog,
} = useEditorSettings(settings, setEditorError, historyManager.addItem);
useEffect(() => {
coreEvents.on(CoreEvent.ExternalEditorClosed, handleEditorClose);
coreEvents.on(CoreEvent.RequestEditorSelection, openEditorDialog);
return () => {
coreEvents.off(CoreEvent.ExternalEditorClosed, handleEditorClose);
coreEvents.off(CoreEvent.RequestEditorSelection, openEditorDialog);
};
}, [handleEditorClose]);
}, [handleEditorClose, openEditorDialog]);
useEffect(() => {
if (
@@ -544,6 +554,9 @@ export const AppContainer = (props: AppContainerProps) => {
}
}, [bannerVisible, bannerText, settings, config, refreshStatic]);
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
useSettingsCommand();
const {
isThemeDialogOpen,
openThemeDialog,
@@ -739,17 +752,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
onAuthError,
]);
const [editorError, setEditorError] = useState<string | null>(null);
const {
isEditorDialogOpen,
openEditorDialog,
handleEditorSelect,
exitEditorDialog,
} = useEditorSettings(settings, setEditorError, historyManager.addItem);
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
useSettingsCommand();
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
useModelCommand();
@@ -6,7 +6,7 @@
import {
allowEditorTypeInSandbox,
checkHasEditorType,
hasValidEditorCommand,
type EditorType,
EDITOR_DISPLAY_NAMES,
} from '@google/gemini-cli-core';
@@ -31,7 +31,7 @@ class EditorSettingsManager {
disabled: false,
},
...editorTypes.map((type) => {
const hasEditor = checkHasEditorType(type);
const hasEditor = hasValidEditorCommand(type);
const isAllowedInSandbox = allowEditorTypeInSandbox(type);
let labelSuffix = !isAllowedInSandbox
@@ -24,7 +24,7 @@ import { SettingScope } from '../../config/settings.js';
import { MessageType } from '../types.js';
import {
type EditorType,
checkHasEditorType,
hasValidEditorCommand,
allowEditorTypeInSandbox,
} from '@google/gemini-cli-core';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -35,12 +35,12 @@ vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
return {
...actual,
checkHasEditorType: vi.fn(() => true),
hasValidEditorCommand: vi.fn(() => true),
allowEditorTypeInSandbox: vi.fn(() => true),
};
});
const mockCheckHasEditorType = vi.mocked(checkHasEditorType);
const mockHasValidEditorCommand = vi.mocked(hasValidEditorCommand);
const mockAllowEditorTypeInSandbox = vi.mocked(allowEditorTypeInSandbox);
describe('useEditorSettings', () => {
@@ -69,7 +69,7 @@ describe('useEditorSettings', () => {
mockAddItem = vi.fn();
// Reset mock implementations to default
mockCheckHasEditorType.mockReturnValue(true);
mockHasValidEditorCommand.mockReturnValue(true);
mockAllowEditorTypeInSandbox.mockReturnValue(true);
});
@@ -224,7 +224,7 @@ describe('useEditorSettings', () => {
it('should not set preference for unavailable editors', () => {
render(<TestComponent />);
mockCheckHasEditorType.mockReturnValue(false);
mockHasValidEditorCommand.mockReturnValue(false);
const editorType: EditorType = 'vscode';
const scope = SettingScope.User;
@@ -13,8 +13,10 @@ import { MessageType } from '../types.js';
import type { EditorType } from '@google/gemini-cli-core';
import {
allowEditorTypeInSandbox,
checkHasEditorType,
hasValidEditorCommand,
getEditorDisplayName,
coreEvents,
CoreEvent,
} from '@google/gemini-cli-core';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -45,7 +47,7 @@ export const useEditorSettings = (
(editorType: EditorType | undefined, scope: LoadableSettingScope) => {
if (
editorType &&
(!checkHasEditorType(editorType) ||
(!hasValidEditorCommand(editorType) ||
!allowEditorTypeInSandbox(editorType))
) {
return;
@@ -66,6 +68,7 @@ export const useEditorSettings = (
);
setEditorError(null);
setIsEditorDialogOpen(false);
coreEvents.emit(CoreEvent.EditorSelected, { editor: editorType });
} catch (error) {
setEditorError(`Failed to set editor preference: ${error}`);
}
@@ -75,6 +78,7 @@ export const useEditorSettings = (
const exitEditorDialog = useCallback(() => {
setIsEditorDialogOpen(false);
coreEvents.emit(CoreEvent.EditorSelected, { editor: undefined });
}, []);
return {
+36 -5
View File
@@ -21,9 +21,14 @@ import type { ValidatingToolCall, WaitingToolCall } from './types.js';
import type { Config } from '../config/config.js';
import type { SchedulerStateManager } from './state-manager.js';
import type { ToolModificationHandler } from './tool-modifier.js';
import type { EditorType } from '../utils/editor.js';
import {
resolveEditorAsync,
type EditorType,
NO_EDITOR_AVAILABLE_ERROR,
} from '../utils/editor.js';
import type { DiffUpdateResult } from '../ide/ide-client.js';
import { debugLogger } from '../utils/debugLogger.js';
import { coreEvents } from '../utils/events.js';
export interface ConfirmationResult {
outcome: ToolConfirmationOutcome;
@@ -155,7 +160,16 @@ export async function resolveConfirmation(
}
if (outcome === ToolConfirmationOutcome.ModifyWithEditor) {
await handleExternalModification(deps, toolCall, signal);
const modResult = await handleExternalModification(
deps,
toolCall,
signal,
);
// Editor is not available - emit error feedback and stay in the loop
// to return to previous confirmation screen.
if (modResult.error) {
coreEvents.emitFeedback('error', modResult.error);
}
} else if (response.payload && 'newContent' in response.payload) {
await handleInlineModification(deps, toolCall, response.payload, signal);
outcome = ToolConfirmationOutcome.ProceedOnce;
@@ -182,8 +196,18 @@ async function notifyHooks(
}
}
/**
* Result of attempting external modification.
* If error is defined, the modification failed.
*/
interface ExternalModificationResult {
/** Error message if the modification failed */
error?: string;
}
/**
* Handles modification via an external editor (e.g. Vim).
* Returns a result indicating success or failure with an error message.
*/
async function handleExternalModification(
deps: {
@@ -193,10 +217,16 @@ async function handleExternalModification(
},
toolCall: ValidatingToolCall,
signal: AbortSignal,
): Promise<void> {
): Promise<ExternalModificationResult> {
const { state, modifier, getPreferredEditor } = deps;
const editor = getPreferredEditor();
if (!editor) return;
const preferredEditor = getPreferredEditor();
const editor = await resolveEditorAsync(preferredEditor, signal);
if (!editor) {
// No editor available - return failure with error message
return { error: NO_EDITOR_AVAILABLE_ERROR };
}
const result = await modifier.handleModifyWithEditor(
state.firstActiveCall as WaitingToolCall,
@@ -211,6 +241,7 @@ async function handleExternalModification(
newInvocation,
);
}
return {};
}
/**
+177 -9
View File
@@ -14,17 +14,22 @@ import {
type Mock,
} from 'vitest';
import {
checkHasEditorType,
hasValidEditorCommand,
hasValidEditorCommandAsync,
getDiffCommand,
openDiff,
allowEditorTypeInSandbox,
isEditorAvailable,
isEditorAvailableAsync,
resolveEditorAsync,
type EditorType,
} from './editor.js';
import { execSync, spawn, spawnSync } from 'node:child_process';
import { coreEvents, CoreEvent } from './events.js';
import { exec, execSync, spawn, spawnSync } from 'node:child_process';
import { debugLogger } from './debugLogger.js';
vi.mock('child_process', () => ({
exec: vi.fn(),
execSync: vi.fn(),
spawn: vi.fn(),
spawnSync: vi.fn(() => ({ error: null, status: 0 })),
@@ -51,7 +56,7 @@ describe('editor utils', () => {
});
});
describe('checkHasEditorType', () => {
describe('hasValidEditorCommand', () => {
const testCases: Array<{
editor: EditorType;
commands: string[];
@@ -89,7 +94,7 @@ describe('editor utils', () => {
(execSync as Mock).mockReturnValue(
Buffer.from(`/usr/bin/${commands[0]}`),
);
expect(checkHasEditorType(editor)).toBe(true);
expect(hasValidEditorCommand(editor)).toBe(true);
expect(execSync).toHaveBeenCalledWith(`command -v ${commands[0]}`, {
stdio: 'ignore',
});
@@ -103,7 +108,7 @@ describe('editor utils', () => {
throw new Error(); // first command not found
})
.mockReturnValueOnce(Buffer.from(`/usr/bin/${commands[1]}`)); // second command found
expect(checkHasEditorType(editor)).toBe(true);
expect(hasValidEditorCommand(editor)).toBe(true);
expect(execSync).toHaveBeenCalledTimes(2);
});
}
@@ -113,7 +118,7 @@ describe('editor utils', () => {
(execSync as Mock).mockImplementation(() => {
throw new Error(); // all commands not found
});
expect(checkHasEditorType(editor)).toBe(false);
expect(hasValidEditorCommand(editor)).toBe(false);
expect(execSync).toHaveBeenCalledTimes(commands.length);
});
@@ -123,7 +128,7 @@ describe('editor utils', () => {
(execSync as Mock).mockReturnValue(
Buffer.from(`C:\\Program Files\\...\\${win32Commands[0]}`),
);
expect(checkHasEditorType(editor)).toBe(true);
expect(hasValidEditorCommand(editor)).toBe(true);
expect(execSync).toHaveBeenCalledWith(
`where.exe ${win32Commands[0]}`,
{
@@ -142,7 +147,7 @@ describe('editor utils', () => {
.mockReturnValueOnce(
Buffer.from(`C:\\Program Files\\...\\${win32Commands[1]}`),
); // second command found
expect(checkHasEditorType(editor)).toBe(true);
expect(hasValidEditorCommand(editor)).toBe(true);
expect(execSync).toHaveBeenCalledTimes(2);
});
}
@@ -152,7 +157,7 @@ describe('editor utils', () => {
(execSync as Mock).mockImplementation(() => {
throw new Error(); // all commands not found
});
expect(checkHasEditorType(editor)).toBe(false);
expect(hasValidEditorCommand(editor)).toBe(false);
expect(execSync).toHaveBeenCalledTimes(win32Commands.length);
});
});
@@ -542,4 +547,167 @@ describe('editor utils', () => {
expect(isEditorAvailable('neovim')).toBe(true);
});
});
// Helper to create a mock exec that simulates async behavior
const mockExecAsync = (implementation: (cmd: string) => boolean): void => {
(exec as unknown as Mock).mockImplementation(
(
cmd: string,
callback: (error: Error | null, stdout: string, stderr: string) => void,
) => {
if (implementation(cmd)) {
callback(null, '/usr/bin/cmd', '');
} else {
callback(new Error('Command not found'), '', '');
}
},
);
};
describe('hasValidEditorCommandAsync', () => {
it('should return true if vim command exists', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
mockExecAsync((cmd) => cmd.includes('vim'));
expect(await hasValidEditorCommandAsync('vim')).toBe(true);
});
it('should return false if vim command does not exist', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
mockExecAsync(() => false);
expect(await hasValidEditorCommandAsync('vim')).toBe(false);
});
it('should check zed and zeditor commands in order', async () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
mockExecAsync((cmd) => cmd.includes('zeditor'));
expect(await hasValidEditorCommandAsync('zed')).toBe(true);
});
});
describe('isEditorAvailableAsync', () => {
it('should return false for undefined editor', async () => {
expect(await isEditorAvailableAsync(undefined)).toBe(false);
});
it('should return false for empty string editor', async () => {
expect(await isEditorAvailableAsync('')).toBe(false);
});
it('should return false for invalid editor type', async () => {
expect(await isEditorAvailableAsync('invalid-editor')).toBe(false);
});
it('should return true for vscode when installed and not in sandbox mode', async () => {
mockExecAsync((cmd) => cmd.includes('code'));
vi.stubEnv('SANDBOX', '');
expect(await isEditorAvailableAsync('vscode')).toBe(true);
});
it('should return false for vscode when not installed', async () => {
mockExecAsync(() => false);
expect(await isEditorAvailableAsync('vscode')).toBe(false);
});
it('should return false for vscode in sandbox mode', async () => {
mockExecAsync((cmd) => cmd.includes('code'));
vi.stubEnv('SANDBOX', 'sandbox');
expect(await isEditorAvailableAsync('vscode')).toBe(false);
});
it('should return true for vim in sandbox mode', async () => {
mockExecAsync((cmd) => cmd.includes('vim'));
vi.stubEnv('SANDBOX', 'sandbox');
expect(await isEditorAvailableAsync('vim')).toBe(true);
});
});
describe('resolveEditorAsync', () => {
it('should return the preferred editor when available', async () => {
mockExecAsync((cmd) => cmd.includes('vim'));
vi.stubEnv('SANDBOX', '');
const result = await resolveEditorAsync('vim');
expect(result).toBe('vim');
});
it('should request editor selection when preferred editor is not installed', async () => {
mockExecAsync(() => false);
vi.stubEnv('SANDBOX', '');
const resolvePromise = resolveEditorAsync('vim');
setTimeout(
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'neovim' }),
0,
);
const result = await resolvePromise;
expect(result).toBe('neovim');
});
it('should request editor selection when preferred GUI editor cannot be used in sandbox mode', async () => {
mockExecAsync((cmd) => cmd.includes('code'));
vi.stubEnv('SANDBOX', 'sandbox');
const resolvePromise = resolveEditorAsync('vscode');
setTimeout(
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }),
0,
);
const result = await resolvePromise;
expect(result).toBe('vim');
});
it('should request editor selection when no preference is set', async () => {
const emitSpy = vi.spyOn(coreEvents, 'emit');
vi.stubEnv('SANDBOX', '');
const resolvePromise = resolveEditorAsync(undefined);
// Simulate UI selection
setTimeout(
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }),
0,
);
const result = await resolvePromise;
expect(result).toBe('vim');
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.RequestEditorSelection);
});
it('should return undefined when editor selection is cancelled', async () => {
const resolvePromise = resolveEditorAsync(undefined);
// Simulate UI cancellation (exit dialog)
setTimeout(
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: undefined }),
0,
);
const result = await resolvePromise;
expect(result).toBeUndefined();
});
it('should return undefined when abort signal is triggered', async () => {
const controller = new AbortController();
const resolvePromise = resolveEditorAsync(undefined, controller.signal);
setTimeout(() => controller.abort(), 0);
const result = await resolvePromise;
expect(result).toBeUndefined();
});
it('should request editor selection in sandbox mode when no preference is set', async () => {
const emitSpy = vi.spyOn(coreEvents, 'emit');
vi.stubEnv('SANDBOX', 'sandbox');
const resolvePromise = resolveEditorAsync(undefined);
// Simulate UI selection
setTimeout(
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }),
0,
);
const result = await resolvePromise;
expect(result).toBe('vim');
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.RequestEditorSelection);
});
});
});
+84 -16
View File
@@ -4,9 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync, spawn, spawnSync } from 'node:child_process';
import { exec, execSync, spawn, spawnSync } from 'node:child_process';
import { promisify } from 'node:util';
import { once } from 'node:events';
import { debugLogger } from './debugLogger.js';
import { coreEvents, CoreEvent } from './events.js';
import { coreEvents, CoreEvent, type EditorSelectedPayload } from './events.js';
const GUI_EDITORS = [
'vscode',
@@ -23,6 +25,9 @@ const GUI_EDITORS_SET = new Set<string>(GUI_EDITORS);
const TERMINAL_EDITORS_SET = new Set<string>(TERMINAL_EDITORS);
const EDITORS_SET = new Set<string>(EDITORS);
export const NO_EDITOR_AVAILABLE_ERROR =
'No external editor is available. Please run /editor to configure one.';
export const DEFAULT_GUI_EDITOR: GuiEditorType = 'vscode';
export type GuiEditorType = (typeof GUI_EDITORS)[number];
@@ -73,12 +78,26 @@ interface DiffCommand {
args: string[];
}
const execAsync = promisify(exec);
function getCommandExistsCmd(cmd: string): string {
return process.platform === 'win32'
? `where.exe ${cmd}`
: `command -v ${cmd}`;
}
function commandExists(cmd: string): boolean {
try {
execSync(
process.platform === 'win32' ? `where.exe ${cmd}` : `command -v ${cmd}`,
{ stdio: 'ignore' },
);
execSync(getCommandExistsCmd(cmd), { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
async function commandExistsAsync(cmd: string): Promise<boolean> {
try {
await execAsync(getCommandExistsCmd(cmd));
return true;
} catch {
return false;
@@ -108,17 +127,29 @@ const editorCommands: Record<
hx: { win32: ['hx'], default: ['hx'] },
};
export function checkHasEditorType(editor: EditorType): boolean {
function getEditorCommands(editor: EditorType): string[] {
const commandConfig = editorCommands[editor];
const commands =
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
return commands.some((cmd) => commandExists(cmd));
return process.platform === 'win32'
? commandConfig.win32
: commandConfig.default;
}
export function hasValidEditorCommand(editor: EditorType): boolean {
return getEditorCommands(editor).some((cmd) => commandExists(cmd));
}
export async function hasValidEditorCommandAsync(
editor: EditorType,
): Promise<boolean> {
return Promise.any(
getEditorCommands(editor).map((cmd) =>
commandExistsAsync(cmd).then((exists) => exists || Promise.reject()),
),
).catch(() => false);
}
export function getEditorCommand(editor: EditorType): string {
const commandConfig = editorCommands[editor];
const commands =
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
const commands = getEditorCommands(editor);
return (
commands.slice(0, -1).find((cmd) => commandExists(cmd)) ||
commands[commands.length - 1]
@@ -134,15 +165,52 @@ export function allowEditorTypeInSandbox(editor: EditorType): boolean {
return true;
}
function isEditorTypeAvailable(
editor: string | undefined,
): editor is EditorType {
return (
!!editor && isValidEditorType(editor) && allowEditorTypeInSandbox(editor)
);
}
/**
* Check if the editor is valid and can be used.
* Returns false if preferred editor is not set / invalid / not available / not allowed in sandbox.
*/
export function isEditorAvailable(editor: string | undefined): boolean {
if (editor && isValidEditorType(editor)) {
return checkHasEditorType(editor) && allowEditorTypeInSandbox(editor);
return isEditorTypeAvailable(editor) && hasValidEditorCommand(editor);
}
/**
* Check if the editor is valid and can be used.
* Returns false if preferred editor is not set / invalid / not available / not allowed in sandbox.
*/
export async function isEditorAvailableAsync(
editor: string | undefined,
): Promise<boolean> {
return (
isEditorTypeAvailable(editor) && (await hasValidEditorCommandAsync(editor))
);
}
/**
* Resolves an editor to use for external editing without blocking the event loop.
* 1. If a preferred editor is set and available, uses it.
* 2. If no preferred editor is set (or preferred is unavailable), requests selection from user and waits for it.
*/
export async function resolveEditorAsync(
preferredEditor: EditorType | undefined,
signal?: AbortSignal,
): Promise<EditorType | undefined> {
if (preferredEditor && (await isEditorAvailableAsync(preferredEditor))) {
return preferredEditor;
}
return false;
coreEvents.emit(CoreEvent.RequestEditorSelection);
return once(coreEvents, CoreEvent.EditorSelected, { signal })
.then(([payload]) => (payload as EditorSelectedPayload).editor)
.catch(() => undefined);
}
/**
+12
View File
@@ -8,6 +8,7 @@ import { EventEmitter } from 'node:events';
import type { AgentDefinition } from '../agents/types.js';
import type { McpClient } from '../tools/mcp-client.js';
import type { ExtensionEvents } from './extensionLoader.js';
import type { EditorType } from './editor.js';
/**
* Defines the severity level for user-facing feedback.
@@ -143,6 +144,15 @@ export enum CoreEvent {
RetryAttempt = 'retry-attempt',
ConsentRequest = 'consent-request',
AgentsDiscovered = 'agents-discovered',
RequestEditorSelection = 'request-editor-selection',
EditorSelected = 'editor-selected',
}
/**
* Payload for the 'editor-selected' event.
*/
export interface EditorSelectedPayload {
editor?: EditorType;
}
export interface CoreEvents extends ExtensionEvents {
@@ -162,6 +172,8 @@ export interface CoreEvents extends ExtensionEvents {
[CoreEvent.RetryAttempt]: [RetryAttemptPayload];
[CoreEvent.ConsentRequest]: [ConsentRequestPayload];
[CoreEvent.AgentsDiscovered]: [AgentsDiscoveredPayload];
[CoreEvent.RequestEditorSelection]: never[];
[CoreEvent.EditorSelected]: [EditorSelectedPayload];
}
type EventBacklogItem = {