diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 305cedc97f..efae760cc1 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -525,12 +525,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 ( @@ -544,6 +554,9 @@ export const AppContainer = (props: AppContainerProps) => { } }, [bannerVisible, bannerText, settings, config, refreshStatic]); + const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } = + useSettingsCommand(); + const { isThemeDialogOpen, openThemeDialog, @@ -739,17 +752,6 @@ Logging in with Google... Restarting Gemini CLI to continue. onAuthError, ]); - const [editorError, setEditorError] = useState(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/editors/editorSettingsManager.ts b/packages/cli/src/ui/editors/editorSettingsManager.ts index 5a9b2e3147..6869cd7f8e 100644 --- a/packages/cli/src/ui/editors/editorSettingsManager.ts +++ b/packages/cli/src/ui/editors/editorSettingsManager.ts @@ -6,7 +6,7 @@ import { allowEditorTypeInSandbox, - checkHasEditorType, + hasValidEditorCommand, type EditorType, EDITOR_DISPLAY_NAMES, } from '@google/gemini-cli-core'; @@ -31,7 +31,7 @@ class EditorSettingsManager { disabled: false, }, ...editorTypes.map((type) => { - const hasEditor = checkHasEditorType(type); + const hasEditor = hasValidEditorCommand(type); const isAllowedInSandbox = allowEditorTypeInSandbox(type); let labelSuffix = !isAllowedInSandbox diff --git a/packages/cli/src/ui/hooks/useEditorSettings.test.tsx b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx index 2b39fae02c..68c2b93f22 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.test.tsx +++ b/packages/cli/src/ui/hooks/useEditorSettings.test.tsx @@ -24,7 +24,7 @@ import { SettingScope } from '../../config/settings.js'; import { MessageType } from '../types.js'; import { type EditorType, - checkHasEditorType, + hasValidEditorCommand, allowEditorTypeInSandbox, } from '@google/gemini-cli-core'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; @@ -35,12 +35,12 @@ vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual('@google/gemini-cli-core'); return { ...actual, - checkHasEditorType: vi.fn(() => true), + hasValidEditorCommand: vi.fn(() => true), allowEditorTypeInSandbox: vi.fn(() => true), }; }); -const mockCheckHasEditorType = vi.mocked(checkHasEditorType); +const mockHasValidEditorCommand = vi.mocked(hasValidEditorCommand); const mockAllowEditorTypeInSandbox = vi.mocked(allowEditorTypeInSandbox); describe('useEditorSettings', () => { @@ -69,7 +69,7 @@ describe('useEditorSettings', () => { mockAddItem = vi.fn(); // Reset mock implementations to default - mockCheckHasEditorType.mockReturnValue(true); + mockHasValidEditorCommand.mockReturnValue(true); mockAllowEditorTypeInSandbox.mockReturnValue(true); }); @@ -224,7 +224,7 @@ describe('useEditorSettings', () => { it('should not set preference for unavailable editors', () => { render(); - mockCheckHasEditorType.mockReturnValue(false); + mockHasValidEditorCommand.mockReturnValue(false); const editorType: EditorType = 'vscode'; const scope = SettingScope.User; diff --git a/packages/cli/src/ui/hooks/useEditorSettings.ts b/packages/cli/src/ui/hooks/useEditorSettings.ts index fa15202661..0a432e303b 100644 --- a/packages/cli/src/ui/hooks/useEditorSettings.ts +++ b/packages/cli/src/ui/hooks/useEditorSettings.ts @@ -13,8 +13,10 @@ import { MessageType } from '../types.js'; import type { EditorType } from '@google/gemini-cli-core'; import { allowEditorTypeInSandbox, - checkHasEditorType, + hasValidEditorCommand, getEditorDisplayName, + coreEvents, + CoreEvent, } from '@google/gemini-cli-core'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; @@ -45,7 +47,7 @@ export const useEditorSettings = ( (editorType: EditorType | undefined, scope: LoadableSettingScope) => { if ( editorType && - (!checkHasEditorType(editorType) || + (!hasValidEditorCommand(editorType) || !allowEditorTypeInSandbox(editorType)) ) { return; @@ -66,6 +68,7 @@ export const useEditorSettings = ( ); setEditorError(null); setIsEditorDialogOpen(false); + coreEvents.emit(CoreEvent.EditorSelected, { editor: editorType }); } catch (error) { setEditorError(`Failed to set editor preference: ${error}`); } @@ -75,6 +78,7 @@ export const useEditorSettings = ( const exitEditorDialog = useCallback(() => { setIsEditorDialogOpen(false); + coreEvents.emit(CoreEvent.EditorSelected, { editor: undefined }); }, []); return { diff --git a/packages/core/src/scheduler/confirmation.ts b/packages/core/src/scheduler/confirmation.ts index e5e94d5501..4fba731cfb 100644 --- a/packages/core/src/scheduler/confirmation.ts +++ b/packages/core/src/scheduler/confirmation.ts @@ -21,9 +21,14 @@ import type { ValidatingToolCall, WaitingToolCall } from './types.js'; import type { Config } from '../config/config.js'; import type { SchedulerStateManager } from './state-manager.js'; import type { ToolModificationHandler } from './tool-modifier.js'; -import type { EditorType } from '../utils/editor.js'; +import { + resolveEditorAsync, + type EditorType, + NO_EDITOR_AVAILABLE_ERROR, +} from '../utils/editor.js'; import type { DiffUpdateResult } from '../ide/ide-client.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { coreEvents } from '../utils/events.js'; export interface ConfirmationResult { outcome: ToolConfirmationOutcome; @@ -155,7 +160,16 @@ export async function resolveConfirmation( } if (outcome === ToolConfirmationOutcome.ModifyWithEditor) { - await handleExternalModification(deps, toolCall, signal); + const modResult = await handleExternalModification( + deps, + toolCall, + signal, + ); + // Editor is not available - emit error feedback and stay in the loop + // to return to previous confirmation screen. + if (modResult.error) { + coreEvents.emitFeedback('error', modResult.error); + } } else if (response.payload && 'newContent' in response.payload) { await handleInlineModification(deps, toolCall, response.payload, signal); outcome = ToolConfirmationOutcome.ProceedOnce; @@ -182,8 +196,18 @@ async function notifyHooks( } } +/** + * Result of attempting external modification. + * If error is defined, the modification failed. + */ +interface ExternalModificationResult { + /** Error message if the modification failed */ + error?: string; +} + /** * Handles modification via an external editor (e.g. Vim). + * Returns a result indicating success or failure with an error message. */ async function handleExternalModification( deps: { @@ -193,10 +217,16 @@ async function handleExternalModification( }, toolCall: ValidatingToolCall, signal: AbortSignal, -): Promise { +): Promise { const { state, modifier, getPreferredEditor } = deps; - const editor = getPreferredEditor(); - if (!editor) return; + + const preferredEditor = getPreferredEditor(); + const editor = await resolveEditorAsync(preferredEditor, signal); + + if (!editor) { + // No editor available - return failure with error message + return { error: NO_EDITOR_AVAILABLE_ERROR }; + } const result = await modifier.handleModifyWithEditor( state.firstActiveCall as WaitingToolCall, @@ -211,6 +241,7 @@ async function handleExternalModification( newInvocation, ); } + return {}; } /** diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts index 6e24dacb8d..d46c58d677 100644 --- a/packages/core/src/utils/editor.test.ts +++ b/packages/core/src/utils/editor.test.ts @@ -14,17 +14,22 @@ import { type Mock, } from 'vitest'; import { - checkHasEditorType, + hasValidEditorCommand, + hasValidEditorCommandAsync, getDiffCommand, openDiff, allowEditorTypeInSandbox, isEditorAvailable, + isEditorAvailableAsync, + resolveEditorAsync, type EditorType, } from './editor.js'; -import { execSync, spawn, spawnSync } from 'node:child_process'; +import { coreEvents, CoreEvent } from './events.js'; +import { exec, execSync, spawn, spawnSync } from 'node:child_process'; import { debugLogger } from './debugLogger.js'; vi.mock('child_process', () => ({ + exec: vi.fn(), execSync: vi.fn(), spawn: vi.fn(), spawnSync: vi.fn(() => ({ error: null, status: 0 })), @@ -51,7 +56,7 @@ describe('editor utils', () => { }); }); - describe('checkHasEditorType', () => { + describe('hasValidEditorCommand', () => { const testCases: Array<{ editor: EditorType; commands: string[]; @@ -89,7 +94,7 @@ describe('editor utils', () => { (execSync as Mock).mockReturnValue( Buffer.from(`/usr/bin/${commands[0]}`), ); - expect(checkHasEditorType(editor)).toBe(true); + expect(hasValidEditorCommand(editor)).toBe(true); expect(execSync).toHaveBeenCalledWith(`command -v ${commands[0]}`, { stdio: 'ignore', }); @@ -103,7 +108,7 @@ describe('editor utils', () => { throw new Error(); // first command not found }) .mockReturnValueOnce(Buffer.from(`/usr/bin/${commands[1]}`)); // second command found - expect(checkHasEditorType(editor)).toBe(true); + expect(hasValidEditorCommand(editor)).toBe(true); expect(execSync).toHaveBeenCalledTimes(2); }); } @@ -113,7 +118,7 @@ describe('editor utils', () => { (execSync as Mock).mockImplementation(() => { throw new Error(); // all commands not found }); - expect(checkHasEditorType(editor)).toBe(false); + expect(hasValidEditorCommand(editor)).toBe(false); expect(execSync).toHaveBeenCalledTimes(commands.length); }); @@ -123,7 +128,7 @@ describe('editor utils', () => { (execSync as Mock).mockReturnValue( Buffer.from(`C:\\Program Files\\...\\${win32Commands[0]}`), ); - expect(checkHasEditorType(editor)).toBe(true); + expect(hasValidEditorCommand(editor)).toBe(true); expect(execSync).toHaveBeenCalledWith( `where.exe ${win32Commands[0]}`, { @@ -142,7 +147,7 @@ describe('editor utils', () => { .mockReturnValueOnce( Buffer.from(`C:\\Program Files\\...\\${win32Commands[1]}`), ); // second command found - expect(checkHasEditorType(editor)).toBe(true); + expect(hasValidEditorCommand(editor)).toBe(true); expect(execSync).toHaveBeenCalledTimes(2); }); } @@ -152,7 +157,7 @@ describe('editor utils', () => { (execSync as Mock).mockImplementation(() => { throw new Error(); // all commands not found }); - expect(checkHasEditorType(editor)).toBe(false); + expect(hasValidEditorCommand(editor)).toBe(false); expect(execSync).toHaveBeenCalledTimes(win32Commands.length); }); }); @@ -542,4 +547,167 @@ describe('editor utils', () => { expect(isEditorAvailable('neovim')).toBe(true); }); }); + + // Helper to create a mock exec that simulates async behavior + const mockExecAsync = (implementation: (cmd: string) => boolean): void => { + (exec as unknown as Mock).mockImplementation( + ( + cmd: string, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + if (implementation(cmd)) { + callback(null, '/usr/bin/cmd', ''); + } else { + callback(new Error('Command not found'), '', ''); + } + }, + ); + }; + + describe('hasValidEditorCommandAsync', () => { + it('should return true if vim command exists', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + mockExecAsync((cmd) => cmd.includes('vim')); + expect(await hasValidEditorCommandAsync('vim')).toBe(true); + }); + + it('should return false if vim command does not exist', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + mockExecAsync(() => false); + expect(await hasValidEditorCommandAsync('vim')).toBe(false); + }); + + it('should check zed and zeditor commands in order', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + mockExecAsync((cmd) => cmd.includes('zeditor')); + expect(await hasValidEditorCommandAsync('zed')).toBe(true); + }); + }); + + describe('isEditorAvailableAsync', () => { + it('should return false for undefined editor', async () => { + expect(await isEditorAvailableAsync(undefined)).toBe(false); + }); + + it('should return false for empty string editor', async () => { + expect(await isEditorAvailableAsync('')).toBe(false); + }); + + it('should return false for invalid editor type', async () => { + expect(await isEditorAvailableAsync('invalid-editor')).toBe(false); + }); + + it('should return true for vscode when installed and not in sandbox mode', async () => { + mockExecAsync((cmd) => cmd.includes('code')); + vi.stubEnv('SANDBOX', ''); + expect(await isEditorAvailableAsync('vscode')).toBe(true); + }); + + it('should return false for vscode when not installed', async () => { + mockExecAsync(() => false); + expect(await isEditorAvailableAsync('vscode')).toBe(false); + }); + + it('should return false for vscode in sandbox mode', async () => { + mockExecAsync((cmd) => cmd.includes('code')); + vi.stubEnv('SANDBOX', 'sandbox'); + expect(await isEditorAvailableAsync('vscode')).toBe(false); + }); + + it('should return true for vim in sandbox mode', async () => { + mockExecAsync((cmd) => cmd.includes('vim')); + vi.stubEnv('SANDBOX', 'sandbox'); + expect(await isEditorAvailableAsync('vim')).toBe(true); + }); + }); + + describe('resolveEditorAsync', () => { + it('should return the preferred editor when available', async () => { + mockExecAsync((cmd) => cmd.includes('vim')); + vi.stubEnv('SANDBOX', ''); + const result = await resolveEditorAsync('vim'); + expect(result).toBe('vim'); + }); + + it('should request editor selection when preferred editor is not installed', async () => { + mockExecAsync(() => false); + vi.stubEnv('SANDBOX', ''); + const resolvePromise = resolveEditorAsync('vim'); + setTimeout( + () => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'neovim' }), + 0, + ); + const result = await resolvePromise; + expect(result).toBe('neovim'); + }); + + it('should request editor selection when preferred GUI editor cannot be used in sandbox mode', async () => { + mockExecAsync((cmd) => cmd.includes('code')); + vi.stubEnv('SANDBOX', 'sandbox'); + const resolvePromise = resolveEditorAsync('vscode'); + setTimeout( + () => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }), + 0, + ); + const result = await resolvePromise; + expect(result).toBe('vim'); + }); + + it('should request editor selection when no preference is set', async () => { + const emitSpy = vi.spyOn(coreEvents, 'emit'); + vi.stubEnv('SANDBOX', ''); + + const resolvePromise = resolveEditorAsync(undefined); + + // Simulate UI selection + setTimeout( + () => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }), + 0, + ); + + const result = await resolvePromise; + expect(result).toBe('vim'); + expect(emitSpy).toHaveBeenCalledWith(CoreEvent.RequestEditorSelection); + }); + + it('should return undefined when editor selection is cancelled', async () => { + const resolvePromise = resolveEditorAsync(undefined); + + // Simulate UI cancellation (exit dialog) + setTimeout( + () => coreEvents.emit(CoreEvent.EditorSelected, { editor: undefined }), + 0, + ); + + const result = await resolvePromise; + expect(result).toBeUndefined(); + }); + + it('should return undefined when abort signal is triggered', async () => { + const controller = new AbortController(); + const resolvePromise = resolveEditorAsync(undefined, controller.signal); + + setTimeout(() => controller.abort(), 0); + + const result = await resolvePromise; + expect(result).toBeUndefined(); + }); + + it('should request editor selection in sandbox mode when no preference is set', async () => { + const emitSpy = vi.spyOn(coreEvents, 'emit'); + vi.stubEnv('SANDBOX', 'sandbox'); + + const resolvePromise = resolveEditorAsync(undefined); + + // Simulate UI selection + setTimeout( + () => coreEvents.emit(CoreEvent.EditorSelected, { editor: 'vim' }), + 0, + ); + + const result = await resolvePromise; + expect(result).toBe('vim'); + expect(emitSpy).toHaveBeenCalledWith(CoreEvent.RequestEditorSelection); + }); + }); }); diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index 7eab0839fe..08cb359a49 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -4,9 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { execSync, spawn, spawnSync } from 'node:child_process'; +import { exec, execSync, spawn, spawnSync } from 'node:child_process'; +import { promisify } from 'node:util'; +import { once } from 'node:events'; import { debugLogger } from './debugLogger.js'; -import { coreEvents, CoreEvent } from './events.js'; +import { coreEvents, CoreEvent, type EditorSelectedPayload } from './events.js'; const GUI_EDITORS = [ 'vscode', @@ -23,6 +25,9 @@ const GUI_EDITORS_SET = new Set(GUI_EDITORS); const TERMINAL_EDITORS_SET = new Set(TERMINAL_EDITORS); const EDITORS_SET = new Set(EDITORS); +export const NO_EDITOR_AVAILABLE_ERROR = + 'No external editor is available. Please run /editor to configure one.'; + export const DEFAULT_GUI_EDITOR: GuiEditorType = 'vscode'; export type GuiEditorType = (typeof GUI_EDITORS)[number]; @@ -73,12 +78,26 @@ interface DiffCommand { args: string[]; } +const execAsync = promisify(exec); + +function getCommandExistsCmd(cmd: string): string { + return process.platform === 'win32' + ? `where.exe ${cmd}` + : `command -v ${cmd}`; +} + function commandExists(cmd: string): boolean { try { - execSync( - process.platform === 'win32' ? `where.exe ${cmd}` : `command -v ${cmd}`, - { stdio: 'ignore' }, - ); + execSync(getCommandExistsCmd(cmd), { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +async function commandExistsAsync(cmd: string): Promise { + try { + await execAsync(getCommandExistsCmd(cmd)); return true; } catch { return false; @@ -108,17 +127,29 @@ const editorCommands: Record< hx: { win32: ['hx'], default: ['hx'] }, }; -export function checkHasEditorType(editor: EditorType): boolean { +function getEditorCommands(editor: EditorType): string[] { const commandConfig = editorCommands[editor]; - const commands = - process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; - return commands.some((cmd) => commandExists(cmd)); + return process.platform === 'win32' + ? commandConfig.win32 + : commandConfig.default; +} + +export function hasValidEditorCommand(editor: EditorType): boolean { + return getEditorCommands(editor).some((cmd) => commandExists(cmd)); +} + +export async function hasValidEditorCommandAsync( + editor: EditorType, +): Promise { + return Promise.any( + getEditorCommands(editor).map((cmd) => + commandExistsAsync(cmd).then((exists) => exists || Promise.reject()), + ), + ).catch(() => false); } export function getEditorCommand(editor: EditorType): string { - const commandConfig = editorCommands[editor]; - const commands = - process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; + const commands = getEditorCommands(editor); return ( commands.slice(0, -1).find((cmd) => commandExists(cmd)) || commands[commands.length - 1] @@ -134,15 +165,52 @@ export function allowEditorTypeInSandbox(editor: EditorType): boolean { return true; } +function isEditorTypeAvailable( + editor: string | undefined, +): editor is EditorType { + return ( + !!editor && isValidEditorType(editor) && allowEditorTypeInSandbox(editor) + ); +} + /** * Check if the editor is valid and can be used. * Returns false if preferred editor is not set / invalid / not available / not allowed in sandbox. */ export function isEditorAvailable(editor: string | undefined): boolean { - if (editor && isValidEditorType(editor)) { - return checkHasEditorType(editor) && allowEditorTypeInSandbox(editor); + return isEditorTypeAvailable(editor) && hasValidEditorCommand(editor); +} + +/** + * Check if the editor is valid and can be used. + * Returns false if preferred editor is not set / invalid / not available / not allowed in sandbox. + */ +export async function isEditorAvailableAsync( + editor: string | undefined, +): Promise { + return ( + isEditorTypeAvailable(editor) && (await hasValidEditorCommandAsync(editor)) + ); +} + +/** + * Resolves an editor to use for external editing without blocking the event loop. + * 1. If a preferred editor is set and available, uses it. + * 2. If no preferred editor is set (or preferred is unavailable), requests selection from user and waits for it. + */ +export async function resolveEditorAsync( + preferredEditor: EditorType | undefined, + signal?: AbortSignal, +): Promise { + if (preferredEditor && (await isEditorAvailableAsync(preferredEditor))) { + return preferredEditor; } - return false; + + coreEvents.emit(CoreEvent.RequestEditorSelection); + + return once(coreEvents, CoreEvent.EditorSelected, { signal }) + .then(([payload]) => (payload as EditorSelectedPayload).editor) + .catch(() => undefined); } /** 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 = {