fix(core): resolve infinite loop and improve editor selection flow

- Fixes an infinite loop when using 'Modify with Editor' without a configured editor.
- Implements interactive editor selection via a UI dialog.
- Returns to the previous confirmation prompt if selection is cancelled or fails.
- Simplifies editor availability logic and removes deprecated sync functions.

Fixes #7669
This commit is contained in:
ehedlund
2026-02-04 14:00:08 -05:00
parent 37dabd873a
commit 02fc2aaef2
7 changed files with 147 additions and 379 deletions
+14 -12
View File
@@ -524,12 +524,22 @@ export const AppContainer = (props: AppContainerProps) => {
refreshStatic(); refreshStatic();
}, [refreshStatic, isAlternateBuffer, app, config]); }, [refreshStatic, isAlternateBuffer, app, config]);
const [editorError, setEditorError] = useState<string | null>(null);
const {
isEditorDialogOpen,
openEditorDialog,
handleEditorSelect,
exitEditorDialog,
} = useEditorSettings(settings, setEditorError, historyManager.addItem);
useEffect(() => { useEffect(() => {
coreEvents.on(CoreEvent.ExternalEditorClosed, handleEditorClose); coreEvents.on(CoreEvent.ExternalEditorClosed, handleEditorClose);
coreEvents.on(CoreEvent.RequestEditorSelection, openEditorDialog);
return () => { return () => {
coreEvents.off(CoreEvent.ExternalEditorClosed, handleEditorClose); coreEvents.off(CoreEvent.ExternalEditorClosed, handleEditorClose);
coreEvents.off(CoreEvent.RequestEditorSelection, openEditorDialog);
}; };
}, [handleEditorClose]); }, [handleEditorClose, openEditorDialog]);
useEffect(() => { useEffect(() => {
if ( if (
@@ -543,6 +553,9 @@ export const AppContainer = (props: AppContainerProps) => {
} }
}, [bannerVisible, bannerText, settings, config, refreshStatic]); }, [bannerVisible, bannerText, settings, config, refreshStatic]);
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
useSettingsCommand();
const { const {
isThemeDialogOpen, isThemeDialogOpen,
openThemeDialog, openThemeDialog,
@@ -738,17 +751,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
onAuthError, 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 } = const { isModelDialogOpen, openModelDialog, closeModelDialog } =
useModelCommand(); useModelCommand();
@@ -15,6 +15,8 @@ import {
allowEditorTypeInSandbox, allowEditorTypeInSandbox,
checkHasEditorType, checkHasEditorType,
getEditorDisplayName, getEditorDisplayName,
coreEvents,
CoreEvent,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -66,6 +68,7 @@ export const useEditorSettings = (
); );
setEditorError(null); setEditorError(null);
setIsEditorDialogOpen(false); setIsEditorDialogOpen(false);
coreEvents.emit(CoreEvent.EditorSelected, { editor: editorType });
} catch (error) { } catch (error) {
setEditorError(`Failed to set editor preference: ${error}`); setEditorError(`Failed to set editor preference: ${error}`);
} }
@@ -75,6 +78,7 @@ export const useEditorSettings = (
const exitEditorDialog = useCallback(() => { const exitEditorDialog = useCallback(() => {
setIsEditorDialogOpen(false); setIsEditorDialogOpen(false);
coreEvents.emit(CoreEvent.EditorSelected, { editor: undefined });
}, []); }, []);
return { return {
+12 -11
View File
@@ -752,18 +752,19 @@ export class CoreToolScheduler {
} else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) { } else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) {
const waitingToolCall = toolCall as WaitingToolCall; const waitingToolCall = toolCall as WaitingToolCall;
// Use resolveEditorAsync to check availability and auto-detect if needed
// Using async version to avoid blocking the event loop
const preferredEditor = this.getPreferredEditor(); const preferredEditor = this.getPreferredEditor();
const resolution = await resolveEditorAsync(preferredEditor); const editor = await resolveEditorAsync(preferredEditor, signal);
if (!resolution.editor) { if (!editor) {
// No editor available - emit error feedback and cancel the operation // No editor available - emit error feedback and return to previous confirmation screen
// This fixes the infinite loop issue reported in #7669 coreEvents.emitFeedback(
if (resolution.error) { 'error',
coreEvents.emitFeedback('error', resolution.error); 'No external editor is available. Please run /editor to configure one.',
} );
this.cancelAll(signal); this.setStatusInternal(callId, 'awaiting_approval', signal, {
...waitingToolCall.confirmationDetails,
isModifying: false,
} as ToolCallConfirmationDetails);
return; return;
} }
@@ -774,7 +775,7 @@ export class CoreToolScheduler {
const result = await this.toolModifier.handleModifyWithEditor( const result = await this.toolModifier.handleModifyWithEditor(
waitingToolCall, waitingToolCall,
resolution.editor, editor,
signal, signal,
); );
+5 -14
View File
@@ -162,14 +162,11 @@ export async function resolveConfirmation(
signal, signal,
); );
if (!modResult.success) { if (!modResult.success) {
// Editor is not available - emit error feedback and break the loop // Editor is not available - emit error feedback and stay in the loop
// by cancelling the operation to prevent infinite loop // to return to previous confirmation screen.
if (modResult.error) { if (modResult.error) {
coreEvents.emitFeedback('error', modResult.error); coreEvents.emitFeedback('error', modResult.error);
} }
// Break the loop by changing outcome to Cancel
// This prevents the infinite loop issue reported in #7669
outcome = ToolConfirmationOutcome.Cancel;
} }
} else if (response.payload && 'newContent' in response.payload) { } else if (response.payload && 'newContent' in response.payload) {
await handleInlineModification(deps, toolCall, response.payload, signal); await handleInlineModification(deps, toolCall, response.payload, signal);
@@ -222,27 +219,21 @@ async function handleExternalModification(
): Promise<ExternalModificationResult> { ): Promise<ExternalModificationResult> {
const { state, modifier, getPreferredEditor } = deps; const { state, modifier, getPreferredEditor } = deps;
// Use the new resolveEditorAsync function which handles:
// 1. Checking if preferred editor is available
// 2. Auto-detecting an available editor if none is configured
// 3. Providing helpful error messages
// Using async version to avoid blocking the event loop
const preferredEditor = getPreferredEditor(); const preferredEditor = getPreferredEditor();
const resolution = await resolveEditorAsync(preferredEditor); const editor = await resolveEditorAsync(preferredEditor, signal);
if (!resolution.editor) { if (!editor) {
// No editor available - return failure with error message // No editor available - return failure with error message
return { return {
success: false, success: false,
error: error:
resolution.error ||
'No external editor is available. Please run /editor to configure one.', 'No external editor is available. Please run /editor to configure one.',
}; };
} }
const result = await modifier.handleModifyWithEditor( const result = await modifier.handleModifyWithEditor(
state.firstActiveCall as WaitingToolCall, state.firstActiveCall as WaitingToolCall,
resolution.editor, editor,
signal, signal,
); );
if (result) { if (result) {
+67 -188
View File
@@ -21,12 +21,10 @@ import {
allowEditorTypeInSandbox, allowEditorTypeInSandbox,
isEditorAvailable, isEditorAvailable,
isEditorAvailableAsync, isEditorAvailableAsync,
detectFirstAvailableEditor,
detectFirstAvailableEditorAsync,
resolveEditor,
resolveEditorAsync, resolveEditorAsync,
type EditorType, type EditorType,
} from './editor.js'; } from './editor.js';
import { coreEvents, CoreEvent } from './events.js';
import { exec, execSync, spawn, spawnSync } from 'node:child_process'; import { exec, execSync, spawn, spawnSync } from 'node:child_process';
import { debugLogger } from './debugLogger.js'; import { debugLogger } from './debugLogger.js';
@@ -550,132 +548,6 @@ describe('editor utils', () => {
}); });
}); });
describe('detectFirstAvailableEditor', () => {
it('should return undefined when no editors are installed', () => {
(execSync as Mock).mockImplementation(() => {
throw new Error('Command not found');
});
vi.stubEnv('SANDBOX', '');
expect(detectFirstAvailableEditor()).toBeUndefined();
});
it('should prioritize terminal editors over GUI editors', () => {
// Mock vim as available
(execSync as Mock).mockImplementation((cmd: string) => {
if (cmd.includes('vim') && !cmd.includes('nvim')) {
return Buffer.from('/usr/bin/vim');
}
if (cmd.includes('code')) {
return Buffer.from('/usr/bin/code');
}
throw new Error('Command not found');
});
vi.stubEnv('SANDBOX', '');
expect(detectFirstAvailableEditor()).toBe('vim');
});
it('should return vim when vim is the only editor available in sandbox mode', () => {
(execSync as Mock).mockImplementation((cmd: string) => {
if (cmd.includes('vim') && !cmd.includes('nvim')) {
return Buffer.from('/usr/bin/vim');
}
throw new Error('Command not found');
});
vi.stubEnv('SANDBOX', 'sandbox');
expect(detectFirstAvailableEditor()).toBe('vim');
});
it('should skip GUI editors in sandbox mode', () => {
(execSync as Mock).mockImplementation((cmd: string) => {
if (cmd.includes('code')) {
return Buffer.from('/usr/bin/code');
}
throw new Error('Command not found');
});
vi.stubEnv('SANDBOX', 'sandbox');
// vscode is installed but not allowed in sandbox
expect(detectFirstAvailableEditor()).toBeUndefined();
});
it('should return first available terminal editor (neovim)', () => {
(execSync as Mock).mockImplementation((cmd: string) => {
if (cmd.includes('nvim')) {
return Buffer.from('/usr/bin/nvim');
}
throw new Error('Command not found');
});
vi.stubEnv('SANDBOX', '');
expect(detectFirstAvailableEditor()).toBe('neovim');
});
});
describe('resolveEditor', () => {
it('should return the preferred editor when available', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/vim'));
vi.stubEnv('SANDBOX', '');
const result = resolveEditor('vim');
expect(result.editor).toBe('vim');
expect(result.error).toBeUndefined();
});
it('should return error when preferred editor is not installed', () => {
(execSync as Mock).mockImplementation(() => {
throw new Error('Command not found');
});
vi.stubEnv('SANDBOX', '');
const result = resolveEditor('vim');
expect(result.editor).toBeUndefined();
expect(result.error).toContain('Vim');
expect(result.error).toContain('not installed');
});
it('should return error when preferred GUI editor cannot be used in sandbox mode', () => {
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/code'));
vi.stubEnv('SANDBOX', 'sandbox');
const result = resolveEditor('vscode');
expect(result.editor).toBeUndefined();
expect(result.error).toContain('VS Code');
expect(result.error).toContain('sandbox mode');
});
it('should auto-detect editor when no preference is set', () => {
(execSync as Mock).mockImplementation((cmd: string) => {
if (cmd.includes('vim') && !cmd.includes('nvim')) {
return Buffer.from('/usr/bin/vim');
}
throw new Error('Command not found');
});
vi.stubEnv('SANDBOX', '');
const result = resolveEditor(undefined);
expect(result.editor).toBe('vim');
expect(result.error).toBeUndefined();
});
it('should return error when no preference is set and no editors are available', () => {
(execSync as Mock).mockImplementation(() => {
throw new Error('Command not found');
});
vi.stubEnv('SANDBOX', '');
const result = resolveEditor(undefined);
expect(result.editor).toBeUndefined();
expect(result.error).toContain('No external editor');
expect(result.error).toContain('/editor');
});
it('should work with terminal editors in sandbox mode when no preference is set', () => {
(execSync as Mock).mockImplementation((cmd: string) => {
if (cmd.includes('vim') && !cmd.includes('nvim')) {
return Buffer.from('/usr/bin/vim');
}
throw new Error('Command not found');
});
vi.stubEnv('SANDBOX', 'sandbox');
const result = resolveEditor(undefined);
expect(result.editor).toBe('vim');
expect(result.error).toBeUndefined();
});
});
// Helper to create a mock exec that simulates async behavior // Helper to create a mock exec that simulates async behavior
const mockExecAsync = (implementation: (cmd: string) => boolean): void => { const mockExecAsync = (implementation: (cmd: string) => boolean): void => {
(exec as unknown as Mock).mockImplementation( (exec as unknown as Mock).mockImplementation(
@@ -749,86 +621,93 @@ describe('editor utils', () => {
}); });
}); });
describe('detectFirstAvailableEditorAsync', () => {
it('should return undefined when no editors are installed', async () => {
mockExecAsync(() => false);
vi.stubEnv('SANDBOX', '');
expect(await detectFirstAvailableEditorAsync()).toBeUndefined();
});
it('should prioritize terminal editors over GUI editors', async () => {
mockExecAsync(
(cmd) =>
(cmd.includes('vim') && !cmd.includes('nvim')) ||
cmd.includes('code'),
);
vi.stubEnv('SANDBOX', '');
expect(await detectFirstAvailableEditorAsync()).toBe('vim');
});
it('should return vim in sandbox mode', async () => {
mockExecAsync((cmd) => cmd.includes('vim') && !cmd.includes('nvim'));
vi.stubEnv('SANDBOX', 'sandbox');
expect(await detectFirstAvailableEditorAsync()).toBe('vim');
});
it('should skip GUI editors in sandbox mode', async () => {
mockExecAsync((cmd) => cmd.includes('code'));
vi.stubEnv('SANDBOX', 'sandbox');
expect(await detectFirstAvailableEditorAsync()).toBeUndefined();
});
});
describe('resolveEditorAsync', () => { describe('resolveEditorAsync', () => {
it('should return the preferred editor when available', async () => { it('should return the preferred editor when available', async () => {
mockExecAsync((cmd) => cmd.includes('vim')); mockExecAsync((cmd) => cmd.includes('vim'));
vi.stubEnv('SANDBOX', ''); vi.stubEnv('SANDBOX', '');
const result = await resolveEditorAsync('vim'); const result = await resolveEditorAsync('vim');
expect(result.editor).toBe('vim'); expect(result).toBe('vim');
expect(result.error).toBeUndefined();
}); });
it('should return error when preferred editor is not installed', async () => { it('should request editor selection when preferred editor is not installed', async () => {
mockExecAsync(() => false); mockExecAsync(() => false);
vi.stubEnv('SANDBOX', ''); vi.stubEnv('SANDBOX', '');
const result = await resolveEditorAsync('vim'); const resolvePromise = resolveEditorAsync('vim');
expect(result.editor).toBeUndefined(); setTimeout(
expect(result.error).toContain('Vim'); () => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'neovim' }),
expect(result.error).toContain('not installed'); 0,
);
const result = await resolvePromise;
expect(result).toBe('neovim');
}); });
it('should return error when preferred GUI editor cannot be used in sandbox mode', async () => { it('should request editor selection when preferred GUI editor cannot be used in sandbox mode', async () => {
mockExecAsync((cmd) => cmd.includes('code')); mockExecAsync((cmd) => cmd.includes('code'));
vi.stubEnv('SANDBOX', 'sandbox'); vi.stubEnv('SANDBOX', 'sandbox');
const result = await resolveEditorAsync('vscode'); const resolvePromise = resolveEditorAsync('vscode');
expect(result.editor).toBeUndefined(); setTimeout(
expect(result.error).toContain('VS Code'); () => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }),
expect(result.error).toContain('sandbox mode'); 0,
);
const result = await resolvePromise;
expect(result).toBe('vim');
}); });
it('should auto-detect editor when no preference is set', async () => { it('should request editor selection when no preference is set', async () => {
mockExecAsync((cmd) => cmd.includes('vim') && !cmd.includes('nvim')); const emitSpy = vi.spyOn(coreEvents, 'emit');
vi.stubEnv('SANDBOX', ''); vi.stubEnv('SANDBOX', '');
const result = await resolveEditorAsync(undefined);
expect(result.editor).toBe('vim'); const resolvePromise = resolveEditorAsync(undefined);
expect(result.error).toBeUndefined();
// 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 error when no preference is set and no editors are available', async () => { it('should return undefined when editor selection is cancelled', async () => {
mockExecAsync(() => false); const resolvePromise = resolveEditorAsync(undefined);
vi.stubEnv('SANDBOX', '');
const result = await resolveEditorAsync(undefined); // Simulate UI cancellation (exit dialog)
expect(result.editor).toBeUndefined(); setTimeout(
expect(result.error).toContain('No external editor'); () => coreEvents.emit(CoreEvent.EditorSelected, { editor: undefined }),
expect(result.error).toContain('/editor'); 0,
);
const result = await resolvePromise;
expect(result).toBeUndefined();
}); });
it('should work with terminal editors in sandbox mode when no preference is set', async () => { it('should return undefined when abort signal is triggered', async () => {
mockExecAsync((cmd) => cmd.includes('vim') && !cmd.includes('nvim')); 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'); vi.stubEnv('SANDBOX', 'sandbox');
const result = await resolveEditorAsync(undefined);
expect(result.editor).toBe('vim'); const resolvePromise = resolveEditorAsync(undefined);
expect(result.error).toBeUndefined();
// Simulate UI selection
setTimeout(
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }),
0,
);
const result = await resolvePromise;
expect(result).toBe('vim');
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.RequestEditorSelection);
}); });
}); });
}); });
+33 -154
View File
@@ -6,8 +6,9 @@
import { exec, execSync, spawn, spawnSync } from 'node:child_process'; import { exec, execSync, spawn, spawnSync } from 'node:child_process';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { once } from 'node:events';
import { debugLogger } from './debugLogger.js'; import { debugLogger } from './debugLogger.js';
import { coreEvents, CoreEvent } from './events.js'; import { coreEvents, CoreEvent, type EditorSelectedPayload } from './events.js';
const GUI_EDITORS = [ const GUI_EDITORS = [
'vscode', 'vscode',
@@ -123,20 +124,21 @@ const editorCommands: Record<
hx: { win32: ['hx'], default: ['hx'] }, hx: { win32: ['hx'], default: ['hx'] },
}; };
export function checkHasEditorType(editor: EditorType): boolean { function getEditorCommands(editor: EditorType): string[] {
const commandConfig = editorCommands[editor]; const commandConfig = editorCommands[editor];
const commands = return process.platform === 'win32'
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; ? commandConfig.win32
return commands.some((cmd) => commandExists(cmd)); : commandConfig.default;
}
export function checkHasEditorType(editor: EditorType): boolean {
return getEditorCommands(editor).some((cmd) => commandExists(cmd));
} }
export async function checkHasEditorTypeAsync( export async function checkHasEditorTypeAsync(
editor: EditorType, editor: EditorType,
): Promise<boolean> { ): Promise<boolean> {
const commandConfig = editorCommands[editor]; for (const cmd of getEditorCommands(editor)) {
const commands =
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
for (const cmd of commands) {
if (await commandExistsAsync(cmd)) { if (await commandExistsAsync(cmd)) {
return true; return true;
} }
@@ -145,9 +147,7 @@ export async function checkHasEditorTypeAsync(
} }
export function getEditorCommand(editor: EditorType): string { export function getEditorCommand(editor: EditorType): string {
const commandConfig = editorCommands[editor]; const commands = getEditorCommands(editor);
const commands =
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
return ( return (
commands.slice(0, -1).find((cmd) => commandExists(cmd)) || commands.slice(0, -1).find((cmd) => commandExists(cmd)) ||
commands[commands.length - 1] commands[commands.length - 1]
@@ -163,15 +163,20 @@ export function allowEditorTypeInSandbox(editor: EditorType): boolean {
return true; 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. * 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. * Returns false if preferred editor is not set / invalid / not available / not allowed in sandbox.
*/ */
export function isEditorAvailable(editor: string | undefined): boolean { export function isEditorAvailable(editor: string | undefined): boolean {
if (editor && isValidEditorType(editor)) { return isEditorTypeAvailable(editor) && checkHasEditorType(editor);
return checkHasEditorType(editor) && allowEditorTypeInSandbox(editor);
}
return false;
} }
/** /**
@@ -182,155 +187,29 @@ export function isEditorAvailable(editor: string | undefined): boolean {
export async function isEditorAvailableAsync( export async function isEditorAvailableAsync(
editor: string | undefined, editor: string | undefined,
): Promise<boolean> { ): Promise<boolean> {
if (editor && isValidEditorType(editor)) { return (
return ( isEditorTypeAvailable(editor) && (await checkHasEditorTypeAsync(editor))
(await checkHasEditorTypeAsync(editor)) && );
allowEditorTypeInSandbox(editor)
);
}
return false;
} }
/** /**
* Detects the first available editor from the supported list.
* Prioritizes terminal editors (vim, neovim, emacs, hx) as they work in all environments
* including sandboxed mode, then falls back to GUI editors.
* Returns undefined if no supported editor is found.
*/
export function detectFirstAvailableEditor(): EditorType | undefined {
// Prioritize terminal editors as they work in sandbox mode
for (const editor of TERMINAL_EDITORS) {
if (isEditorAvailable(editor)) {
return editor;
}
}
// Fall back to GUI editors (won't work in sandbox mode but checked above)
for (const editor of GUI_EDITORS) {
if (isEditorAvailable(editor)) {
return editor;
}
}
return undefined;
}
/**
* Async version of detectFirstAvailableEditor.
* Detects the first available editor from the supported list without blocking the event loop.
* Prioritizes terminal editors (vim, neovim, emacs, hx) as they work in all environments
* including sandboxed mode, then falls back to GUI editors.
* Returns undefined if no supported editor is found.
*/
export async function detectFirstAvailableEditorAsync(): Promise<
EditorType | undefined
> {
// Prioritize terminal editors as they work in sandbox mode
for (const editor of TERMINAL_EDITORS) {
if (await isEditorAvailableAsync(editor)) {
return editor;
}
}
// Fall back to GUI editors (won't work in sandbox mode but checked above)
for (const editor of GUI_EDITORS) {
if (await isEditorAvailableAsync(editor)) {
return editor;
}
}
return undefined;
}
/**
* Result of attempting to resolve an editor for use.
*/
export interface EditorResolutionResult {
/** The editor to use, if available */
editor?: EditorType;
/** Error message if no editor is available */
error?: string;
}
/**
* Resolves an editor to use for external editing.
* 1. If a preferred editor is set and available, uses it.
* 2. If a preferred editor is set but not available, returns an error.
* 3. If no preferred editor is set, attempts to auto-detect an available editor.
* 4. If no editor can be found, returns an error with instructions.
*
* @deprecated Use resolveEditorAsync instead to avoid blocking the event loop.
*/
export function resolveEditor(
preferredEditor: EditorType | undefined,
): EditorResolutionResult {
// Case 1: Preferred editor is set
if (preferredEditor) {
if (isEditorAvailable(preferredEditor)) {
return { editor: preferredEditor };
}
// Preferred editor is set but not available
const displayName = getEditorDisplayName(preferredEditor);
if (!checkHasEditorType(preferredEditor)) {
return {
error: `${displayName} is configured as your preferred editor but is not installed. Please install it or run /editor to choose a different editor.`,
};
}
// If the editor is installed but not available, it must be due to sandbox restrictions.
return {
error: `${displayName} cannot be used in sandbox mode. Please run /editor to choose a terminal-based editor (vim, neovim, emacs, or helix).`,
};
}
// Case 2: No preferred editor set, try to auto-detect
const detectedEditor = detectFirstAvailableEditor();
if (detectedEditor) {
return { editor: detectedEditor };
}
// Case 3: No editor available at all
return {
error:
'No external editor is configured or available. Please run /editor to set your preferred editor, or install one of the supported editors: vim, neovim, emacs, helix, VS Code, Cursor, Zed, or Windsurf.',
};
}
/**
* Async version of resolveEditor.
* Resolves an editor to use for external editing without blocking the event loop. * Resolves an editor to use for external editing without blocking the event loop.
* 1. If a preferred editor is set and available, uses it. * 1. If a preferred editor is set and available, uses it.
* 2. If a preferred editor is set but not available, returns an error. * 2. If no preferred editor is set (or preferred is unavailable), requests selection from user and waits for it.
* 3. If no preferred editor is set, attempts to auto-detect an available editor.
* 4. If no editor can be found, returns an error with instructions.
*/ */
export async function resolveEditorAsync( export async function resolveEditorAsync(
preferredEditor: EditorType | undefined, preferredEditor: EditorType | undefined,
): Promise<EditorResolutionResult> { signal?: AbortSignal,
// Case 1: Preferred editor is set ): Promise<EditorType | undefined> {
if (preferredEditor) { if (preferredEditor && (await isEditorAvailableAsync(preferredEditor))) {
if (await isEditorAvailableAsync(preferredEditor)) { return preferredEditor;
return { editor: preferredEditor };
}
// Preferred editor is set but not available
const displayName = getEditorDisplayName(preferredEditor);
if (!(await checkHasEditorTypeAsync(preferredEditor))) {
return {
error: `${displayName} is configured as your preferred editor but is not installed. Please install it or run /editor to choose a different editor.`,
};
}
// If the editor is installed but not available, it must be due to sandbox restrictions.
return {
error: `${displayName} cannot be used in sandbox mode. Please run /editor to choose a terminal-based editor (vim, neovim, emacs, or helix).`,
};
} }
// Case 2: No preferred editor set, try to auto-detect coreEvents.emit(CoreEvent.RequestEditorSelection);
const detectedEditor = await detectFirstAvailableEditorAsync();
if (detectedEditor) {
return { editor: detectedEditor };
}
// Case 3: No editor available at all return once(coreEvents, CoreEvent.EditorSelected, { signal })
return { .then(([payload]) => (payload as EditorSelectedPayload).editor)
error: .catch(() => undefined);
'No external editor is configured or available. Please run /editor to set your preferred editor, or install one of the supported editors: vim, neovim, emacs, helix, VS Code, Cursor, Zed, or Windsurf.',
};
} }
/** /**
+12
View File
@@ -8,6 +8,7 @@ import { EventEmitter } from 'node:events';
import type { AgentDefinition } from '../agents/types.js'; import type { AgentDefinition } from '../agents/types.js';
import type { McpClient } from '../tools/mcp-client.js'; import type { McpClient } from '../tools/mcp-client.js';
import type { ExtensionEvents } from './extensionLoader.js'; import type { ExtensionEvents } from './extensionLoader.js';
import type { EditorType } from './editor.js';
/** /**
* Defines the severity level for user-facing feedback. * Defines the severity level for user-facing feedback.
@@ -143,6 +144,15 @@ export enum CoreEvent {
RetryAttempt = 'retry-attempt', RetryAttempt = 'retry-attempt',
ConsentRequest = 'consent-request', ConsentRequest = 'consent-request',
AgentsDiscovered = 'agents-discovered', 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 { export interface CoreEvents extends ExtensionEvents {
@@ -162,6 +172,8 @@ export interface CoreEvents extends ExtensionEvents {
[CoreEvent.RetryAttempt]: [RetryAttemptPayload]; [CoreEvent.RetryAttempt]: [RetryAttemptPayload];
[CoreEvent.ConsentRequest]: [ConsentRequestPayload]; [CoreEvent.ConsentRequest]: [ConsentRequestPayload];
[CoreEvent.AgentsDiscovered]: [AgentsDiscoveredPayload]; [CoreEvent.AgentsDiscovered]: [AgentsDiscoveredPayload];
[CoreEvent.RequestEditorSelection]: never[];
[CoreEvent.EditorSelected]: [EditorSelectedPayload];
} }
type EventBacklogItem = { type EventBacklogItem = {