mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 22:51:00 -07:00
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:
@@ -524,12 +524,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 (
|
||||
@@ -543,6 +553,9 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
}
|
||||
}, [bannerVisible, bannerText, settings, config, refreshStatic]);
|
||||
|
||||
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
|
||||
useSettingsCommand();
|
||||
|
||||
const {
|
||||
isThemeDialogOpen,
|
||||
openThemeDialog,
|
||||
@@ -738,17 +751,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();
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
allowEditorTypeInSandbox,
|
||||
checkHasEditorType,
|
||||
getEditorDisplayName,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -752,18 +752,19 @@ export class CoreToolScheduler {
|
||||
} else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) {
|
||||
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 resolution = await resolveEditorAsync(preferredEditor);
|
||||
const editor = await resolveEditorAsync(preferredEditor, signal);
|
||||
|
||||
if (!resolution.editor) {
|
||||
// No editor available - emit error feedback and cancel the operation
|
||||
// This fixes the infinite loop issue reported in #7669
|
||||
if (resolution.error) {
|
||||
coreEvents.emitFeedback('error', resolution.error);
|
||||
}
|
||||
this.cancelAll(signal);
|
||||
if (!editor) {
|
||||
// No editor available - emit error feedback and return to previous confirmation screen
|
||||
coreEvents.emitFeedback(
|
||||
'error',
|
||||
'No external editor is available. Please run /editor to configure one.',
|
||||
);
|
||||
this.setStatusInternal(callId, 'awaiting_approval', signal, {
|
||||
...waitingToolCall.confirmationDetails,
|
||||
isModifying: false,
|
||||
} as ToolCallConfirmationDetails);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -774,7 +775,7 @@ export class CoreToolScheduler {
|
||||
|
||||
const result = await this.toolModifier.handleModifyWithEditor(
|
||||
waitingToolCall,
|
||||
resolution.editor,
|
||||
editor,
|
||||
signal,
|
||||
);
|
||||
|
||||
|
||||
@@ -162,14 +162,11 @@ export async function resolveConfirmation(
|
||||
signal,
|
||||
);
|
||||
if (!modResult.success) {
|
||||
// Editor is not available - emit error feedback and break the loop
|
||||
// by cancelling the operation to prevent infinite loop
|
||||
// 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);
|
||||
}
|
||||
// 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) {
|
||||
await handleInlineModification(deps, toolCall, response.payload, signal);
|
||||
@@ -222,27 +219,21 @@ async function handleExternalModification(
|
||||
): Promise<ExternalModificationResult> {
|
||||
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 resolution = await resolveEditorAsync(preferredEditor);
|
||||
const editor = await resolveEditorAsync(preferredEditor, signal);
|
||||
|
||||
if (!resolution.editor) {
|
||||
if (!editor) {
|
||||
// No editor available - return failure with error message
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
resolution.error ||
|
||||
'No external editor is available. Please run /editor to configure one.',
|
||||
};
|
||||
}
|
||||
|
||||
const result = await modifier.handleModifyWithEditor(
|
||||
state.firstActiveCall as WaitingToolCall,
|
||||
resolution.editor,
|
||||
editor,
|
||||
signal,
|
||||
);
|
||||
if (result) {
|
||||
|
||||
@@ -21,12 +21,10 @@ import {
|
||||
allowEditorTypeInSandbox,
|
||||
isEditorAvailable,
|
||||
isEditorAvailableAsync,
|
||||
detectFirstAvailableEditor,
|
||||
detectFirstAvailableEditorAsync,
|
||||
resolveEditor,
|
||||
resolveEditorAsync,
|
||||
type EditorType,
|
||||
} from './editor.js';
|
||||
import { coreEvents, CoreEvent } from './events.js';
|
||||
import { exec, execSync, spawn, spawnSync } from 'node:child_process';
|
||||
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
|
||||
const mockExecAsync = (implementation: (cmd: string) => boolean): void => {
|
||||
(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', () => {
|
||||
it('should return the preferred editor when available', async () => {
|
||||
mockExecAsync((cmd) => cmd.includes('vim'));
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
const result = await resolveEditorAsync('vim');
|
||||
expect(result.editor).toBe('vim');
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result).toBe('vim');
|
||||
});
|
||||
|
||||
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);
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
const result = await resolveEditorAsync('vim');
|
||||
expect(result.editor).toBeUndefined();
|
||||
expect(result.error).toContain('Vim');
|
||||
expect(result.error).toContain('not installed');
|
||||
const resolvePromise = resolveEditorAsync('vim');
|
||||
setTimeout(
|
||||
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'neovim' }),
|
||||
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'));
|
||||
vi.stubEnv('SANDBOX', 'sandbox');
|
||||
const result = await resolveEditorAsync('vscode');
|
||||
expect(result.editor).toBeUndefined();
|
||||
expect(result.error).toContain('VS Code');
|
||||
expect(result.error).toContain('sandbox mode');
|
||||
const resolvePromise = resolveEditorAsync('vscode');
|
||||
setTimeout(
|
||||
() => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }),
|
||||
0,
|
||||
);
|
||||
const result = await resolvePromise;
|
||||
expect(result).toBe('vim');
|
||||
});
|
||||
|
||||
it('should auto-detect editor when no preference is set', async () => {
|
||||
mockExecAsync((cmd) => cmd.includes('vim') && !cmd.includes('nvim'));
|
||||
it('should request editor selection when no preference is set', async () => {
|
||||
const emitSpy = vi.spyOn(coreEvents, 'emit');
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
const result = await resolveEditorAsync(undefined);
|
||||
expect(result.editor).toBe('vim');
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
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 error when no preference is set and no editors are available', async () => {
|
||||
mockExecAsync(() => false);
|
||||
vi.stubEnv('SANDBOX', '');
|
||||
const result = await resolveEditorAsync(undefined);
|
||||
expect(result.editor).toBeUndefined();
|
||||
expect(result.error).toContain('No external editor');
|
||||
expect(result.error).toContain('/editor');
|
||||
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 work with terminal editors in sandbox mode when no preference is set', async () => {
|
||||
mockExecAsync((cmd) => cmd.includes('vim') && !cmd.includes('nvim'));
|
||||
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 result = await resolveEditorAsync(undefined);
|
||||
expect(result.editor).toBe('vim');
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
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',
|
||||
@@ -123,20 +124,21 @@ 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 checkHasEditorType(editor: EditorType): boolean {
|
||||
return getEditorCommands(editor).some((cmd) => commandExists(cmd));
|
||||
}
|
||||
|
||||
export async function checkHasEditorTypeAsync(
|
||||
editor: EditorType,
|
||||
): Promise<boolean> {
|
||||
const commandConfig = editorCommands[editor];
|
||||
const commands =
|
||||
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;
|
||||
for (const cmd of commands) {
|
||||
for (const cmd of getEditorCommands(editor)) {
|
||||
if (await commandExistsAsync(cmd)) {
|
||||
return true;
|
||||
}
|
||||
@@ -145,9 +147,7 @@ export async function checkHasEditorTypeAsync(
|
||||
}
|
||||
|
||||
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]
|
||||
@@ -163,15 +163,20 @@ 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 false;
|
||||
return isEditorTypeAvailable(editor) && checkHasEditorType(editor);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,155 +187,29 @@ export function isEditorAvailable(editor: string | undefined): boolean {
|
||||
export async function isEditorAvailableAsync(
|
||||
editor: string | undefined,
|
||||
): Promise<boolean> {
|
||||
if (editor && isValidEditorType(editor)) {
|
||||
return (
|
||||
(await checkHasEditorTypeAsync(editor)) &&
|
||||
allowEditorTypeInSandbox(editor)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
return (
|
||||
isEditorTypeAvailable(editor) && (await checkHasEditorTypeAsync(editor))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
* 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,
|
||||
): Promise<EditorResolutionResult> {
|
||||
// Case 1: Preferred editor is set
|
||||
if (preferredEditor) {
|
||||
if (await isEditorAvailableAsync(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).`,
|
||||
};
|
||||
signal?: AbortSignal,
|
||||
): Promise<EditorType | undefined> {
|
||||
if (preferredEditor && (await isEditorAvailableAsync(preferredEditor))) {
|
||||
return preferredEditor;
|
||||
}
|
||||
|
||||
// Case 2: No preferred editor set, try to auto-detect
|
||||
const detectedEditor = await detectFirstAvailableEditorAsync();
|
||||
if (detectedEditor) {
|
||||
return { editor: detectedEditor };
|
||||
}
|
||||
coreEvents.emit(CoreEvent.RequestEditorSelection);
|
||||
|
||||
// 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.',
|
||||
};
|
||||
return once(coreEvents, CoreEvent.EditorSelected, { signal })
|
||||
.then(([payload]) => (payload as EditorSelectedPayload).editor)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user