diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7c10569902..9799382d45 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -524,12 +524,22 @@ export const AppContainer = (props: AppContainerProps) => { refreshStatic(); }, [refreshStatic, isAlternateBuffer, app, config]); + const [editorError, setEditorError] = useState(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(null); - const { - isEditorDialogOpen, - openEditorDialog, - handleEditorSelect, - exitEditorDialog, - } = useEditorSettings(settings, setEditorError, historyManager.addItem); - - const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = - useSettingsCommand(); - const { isModelDialogOpen, openModelDialog, closeModelDialog } = useModelCommand(); diff --git a/packages/cli/src/ui/hooks/useEditorSettings.ts b/packages/cli/src/ui/hooks/useEditorSettings.ts index fa15202661..a855b0b405 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.ts +++ b/packages/cli/src/ui/hooks/useEditorSettings.ts @@ -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 { diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index f1f37b6c5c..79d5f132a6 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -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, ); diff --git a/packages/core/src/scheduler/confirmation.ts b/packages/core/src/scheduler/confirmation.ts index 3d1ef3b46c..dbd17c6312 100644 --- a/packages/core/src/scheduler/confirmation.ts +++ b/packages/core/src/scheduler/confirmation.ts @@ -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 { 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) { diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts index b4aa98b253..7f31ca1c12 100644 --- a/packages/core/src/utils/editor.test.ts +++ b/packages/core/src/utils/editor.test.ts @@ -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); }); }); }); diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index deafca2c3c..db6611ed6f 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -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 { - 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 { - 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 { - // 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 { + 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); } /** diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index cea80952f9..33d137980a 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -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 = {