mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-29 14:34:55 -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();
|
||||||
}, [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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user