diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 1f1299072bc..0c96a9ba2f2 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -95,8 +95,37 @@ their corresponding top-level category object in your `settings.json` file. #### `general` - **`general.preferredEditor`** (string): - - **Description:** The preferred editor to open files in. + - **Description:** The preferred editor to open files in when using the + external editor feature (Ctrl-X). - **Default:** `undefined` + - **Accepted values:** + + | Identifier | Editor | Platform | + | ------------- | ---------------------------------------------- | -------- | + | `vscode` | Visual Studio Code | All | + | `vscodium` | VSCodium | All | + | `cursor` | Cursor | All | + | `windsurf` | Windsurf | All | + | `zed` | Zed | All | + | `antigravity` | Antigravity | All | + | `sublimetext` | Sublime Text | All | + | `lapce` | Lapce | All | + | `nova` | Nova | macOS | + | `bbedit` | BBEdit | macOS | + | `vim` | Vim | All | + | `neovim` | Neovim | All | + | `emacs` | Emacs | All | + | `emacsclient` | Emacs Client (requires a running Emacs daemon) | All | + | `hx` | Helix | All | + | `micro` | Micro | All | + + - **`$VISUAL` / `$EDITOR` fallback:** If `preferredEditor` is not set, the CLI + falls back to the `$VISUAL` environment variable, then `$EDITOR`, and + finally `vi` (Unix) or `notepad` (Windows). This lets you use any editor not + in the list above — including editors with custom arguments like + `emacsclient -nw` — by setting the environment variable directly. + - **Note:** Setting an unrecognized identifier produces a clear error at + runtime. To configure your editor interactively, use the `/editor` command. - **`general.vimMode`** (boolean): - **Description:** Enable Vim keybindings diff --git a/package-lock.json b/package-lock.json index 85448711c7b..a5437ac5c53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2292,6 +2292,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2472,6 +2473,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2521,6 +2523,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2895,6 +2898,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2928,6 +2932,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz", "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0" @@ -2982,6 +2987,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", @@ -4178,6 +4184,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4451,6 +4458,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5298,6 +5306,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7901,6 +7910,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8533,6 +8543,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9847,6 +9858,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz", "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -10126,6 +10138,7 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz", "integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -13808,6 +13821,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13818,6 +13832,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -15906,6 +15921,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16129,7 +16145,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -16137,6 +16154,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16296,6 +16314,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16519,6 +16538,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16632,6 +16652,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16644,6 +16665,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17288,6 +17310,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17687,6 +17710,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.tsx index f75b1c27b89..d53a15f8178 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.tsx @@ -24,7 +24,6 @@ import { EDITOR_DISPLAY_NAMES, } from '@google/gemini-cli-core'; import { useKeypress } from '../hooks/useKeypress.js'; -import { coreEvents } from '@google/gemini-cli-core'; interface EditorDialogProps { onSelect: ( @@ -72,10 +71,6 @@ export function EditorSettingsDialog({ ) : 0; if (editorIndex === -1) { - coreEvents.emitFeedback( - 'error', - `Editor is not supported: ${currentPreference}`, - ); editorIndex = 0; } @@ -161,6 +156,7 @@ export function EditorSettingsDialog({ onSelect={handleEditorSelect} isFocused={focusedSection === 'editor'} key={selectedScope} + maxItemsToShow={editorItems.length} /> diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 71ee40b6425..137dfbbaf9a 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -13,6 +13,7 @@ import { LRUCache } from 'mnemonist'; import { coreEvents, debugLogger, + getErrorMessage, unescapePath, type EditorType, } from '@google/gemini-cli-core'; @@ -3117,11 +3118,7 @@ export function useTextBuffer({ dispatch({ type: 'set_text', payload: newText, pushToUndo: false }); } catch (err) { - coreEvents.emitFeedback( - 'error', - '[useTextBuffer] external editor error', - err, - ); + coreEvents.emitFeedback('error', getErrorMessage(err), err); } finally { try { fs.unlinkSync(filePath); diff --git a/packages/cli/src/ui/utils/editorUtils.ts b/packages/cli/src/ui/utils/editorUtils.ts index 7b9efd5a810..387741cbdb1 100644 --- a/packages/cli/src/ui/utils/editorUtils.ts +++ b/packages/cli/src/ui/utils/editorUtils.ts @@ -7,12 +7,17 @@ import { spawn, spawnSync } from 'node:child_process'; import type { ReadStream } from 'node:tty'; import { - coreEvents, + ALL_EDITORS, CoreEvent, + coreEvents, type EditorType, getEditorCommand, + getEditorExtraArgs, + getEditorWaitFlag, isGuiEditor, isTerminalEditor, + isValidEditorType, + resolveEditorTypeFromCommand, } from '@google/gemini-cli-core'; /** @@ -32,27 +37,48 @@ export async function openFileInEditor( ): Promise { let command: string | undefined = undefined; const args = [filePath]; + // Extra args that come before the file path (e.g. -nw for emacsclient) + const extraArgs: string[] = []; if (preferredEditorType) { + if (!isValidEditorType(preferredEditorType)) { + throw new Error( + `Editor '${preferredEditorType}' is not a recognized editor identifier. ` + + `Supported editors: ${ALL_EDITORS.join(', ')}. ` + + `Use /editor to select one, or set the $VISUAL or $EDITOR environment variable.`, + ); + } command = getEditorCommand(preferredEditorType); if (isGuiEditor(preferredEditorType)) { - args.unshift('--wait'); + args.unshift(getEditorWaitFlag(preferredEditorType)); } + extraArgs.push(...getEditorExtraArgs(preferredEditorType)); } if (!command) { - command = process.env['VISUAL'] ?? process.env['EDITOR']; - if (command) { - const lowerCommand = command.toLowerCase(); - const isGui = ['code', 'cursor', 'subl', 'zed', 'atom'].some((gui) => - lowerCommand.includes(gui), - ); - if ( - isGui && - !lowerCommand.includes('--wait') && - !lowerCommand.includes('-w') - ) { - args.unshift(lowerCommand.includes('subl') ? '-w' : '--wait'); + const envCommand = process.env['VISUAL'] ?? process.env['EDITOR']; + if (envCommand) { + command = envCommand; + const [envExecutable = ''] = envCommand.split(' '); + const resolvedType = resolveEditorTypeFromCommand(envExecutable); + if (resolvedType) { + if ( + isGuiEditor(resolvedType) && + !envCommand.includes('--wait') && + !envCommand.includes('-w') + ) { + args.unshift(getEditorWaitFlag(resolvedType)); + } + extraArgs.push(...getEditorExtraArgs(resolvedType)); + } else { + // Heuristic fallback for commands not in the registry + const lower = envCommand.toLowerCase(); + const isGui = ['code', 'cursor', 'subl', 'zed', 'atom'].some((g) => + lower.includes(g), + ); + if (isGui && !lower.includes('--wait') && !lower.includes('-w')) { + args.unshift(lower.includes('subl') ? '-w' : '--wait'); + } } } } @@ -66,7 +92,16 @@ export async function openFileInEditor( // Determine if we should use sync or async based on the command/editor type. // If we have a preferredEditorType, we can check if it's a terminal editor. // Otherwise, we guess based on the command name. - const terminalEditors = ['vi', 'vim', 'nvim', 'emacs', 'hx', 'nano']; + const terminalEditors = [ + 'vi', + 'vim', + 'nvim', + 'emacs', + 'emacsclient', + 'hx', + 'nano', + 'micro', + ]; const isTerminal = preferredEditorType ? isTerminalEditor(preferredEditorType) : terminalEditors.some((te) => executable.toLowerCase().includes(te)); @@ -86,56 +121,50 @@ export async function openFileInEditor( try { if (isTerminal) { - const result = spawnSync(executable, [...initialArgs, ...args], { - stdio: 'inherit', - shell: process.platform === 'win32', - }); + const result = spawnSync( + executable, + [...initialArgs, ...extraArgs, ...args], + { + stdio: 'inherit', + shell: process.platform === 'win32', + }, + ); if (result.error) { - coreEvents.emitFeedback( - 'error', - '[editorUtils] external terminal editor error', - result.error, - ); - throw result.error; + const spawnErr = result.error as NodeJS.ErrnoException; + throw spawnErr.code === 'ENOENT' + ? new Error( + `Editor command '${executable}' was not found in PATH. Install it or use /editor to choose another editor.`, + ) + : result.error; } if (typeof result.status === 'number' && result.status !== 0) { - const err = new Error( - `External editor exited with status ${result.status}`, - ); - coreEvents.emitFeedback( - 'error', - '[editorUtils] external editor error', - err, - ); - throw err; + throw new Error(`External editor exited with status ${result.status}`); } } else { await new Promise((resolve, reject) => { - const child = spawn(executable, [...initialArgs, ...args], { - stdio: 'inherit', - shell: process.platform === 'win32', - }); + const child = spawn( + executable, + [...initialArgs, ...extraArgs, ...args], + { + stdio: 'inherit', + shell: process.platform === 'win32', + }, + ); child.on('error', (err) => { - coreEvents.emitFeedback( - 'error', - '[editorUtils] external editor spawn error', - err, + const spawnErr = err as NodeJS.ErrnoException; + reject( + spawnErr.code === 'ENOENT' + ? new Error( + `Editor command '${executable}' was not found in PATH. Install it or use /editor to choose another editor.`, + ) + : err, ); - reject(err); }); child.on('close', (status) => { if (typeof status === 'number' && status !== 0) { - const err = new Error( - `External editor exited with status ${status}`, - ); - coreEvents.emitFeedback( - 'error', - '[editorUtils] external editor error', - err, - ); - reject(err); + reject(new Error(`External editor exited with status ${status}`)); } else { resolve(); } diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts index d46c58d6771..5f381aeeb3b 100644 --- a/packages/core/src/utils/editor.test.ts +++ b/packages/core/src/utils/editor.test.ts @@ -21,7 +21,11 @@ import { allowEditorTypeInSandbox, isEditorAvailable, isEditorAvailableAsync, + isValidEditorType, + getEditorWaitFlag, + getEditorExtraArgs, resolveEditorAsync, + resolveEditorTypeFromCommand, type EditorType, } from './editor.js'; import { coreEvents, CoreEvent } from './events.js'; @@ -84,6 +88,20 @@ describe('editor utils', () => { win32Commands: ['agy.cmd', 'antigravity.cmd', 'antigravity'], }, { editor: 'hx', commands: ['hx'], win32Commands: ['hx'] }, + { + editor: 'sublimetext', + commands: ['subl'], + win32Commands: ['subl'], + }, + { editor: 'lapce', commands: ['lapce'], win32Commands: ['lapce'] }, + { editor: 'nova', commands: ['nova'], win32Commands: ['nova'] }, + { editor: 'bbedit', commands: ['bbedit'], win32Commands: ['bbedit'] }, + { + editor: 'emacsclient', + commands: ['emacsclient'], + win32Commands: ['emacsclient'], + }, + { editor: 'micro', commands: ['micro'], win32Commands: ['micro'] }, ]; for (const { editor, commands, win32Commands } of testCases) { @@ -188,6 +206,7 @@ describe('editor utils', () => { commands: ['agy', 'antigravity'], win32Commands: ['agy.cmd', 'antigravity.cmd', 'antigravity'], }, + { editor: 'bbedit', commands: ['bbedit'], win32Commands: ['bbedit'] }, ]; for (const { editor, commands, win32Commands } of guiEditors) { @@ -331,6 +350,14 @@ describe('editor utils', () => { }); }); + it('should return the correct command for emacsclient', () => { + const command = getDiffCommand('old.txt', 'new.txt', 'emacsclient'); + expect(command).toEqual({ + command: 'emacsclient', + args: ['-nw', '--eval', '(ediff "old.txt" "new.txt")'], + }); + }); + it('should return the correct command for helix', () => { const command = getDiffCommand('old.txt', 'new.txt', 'hx'); expect(command).toEqual({ @@ -339,6 +366,22 @@ describe('editor utils', () => { }); }); + it('should return null for sublimetext (no CLI diff support)', () => { + expect(getDiffCommand('old.txt', 'new.txt', 'sublimetext')).toBeNull(); + }); + + it('should return null for lapce (no CLI diff support)', () => { + expect(getDiffCommand('old.txt', 'new.txt', 'lapce')).toBeNull(); + }); + + it('should return null for nova (no CLI diff support)', () => { + expect(getDiffCommand('old.txt', 'new.txt', 'nova')).toBeNull(); + }); + + it('should return null for micro (no CLI diff support)', () => { + expect(getDiffCommand('old.txt', 'new.txt', 'micro')).toBeNull(); + }); + it('should return null for an unsupported editor', () => { // @ts-expect-error Testing unsupported editor const command = getDiffCommand('old.txt', 'new.txt', 'foobar'); @@ -353,6 +396,7 @@ describe('editor utils', () => { 'windsurf', 'cursor', 'zed', + 'bbedit', ]; for (const editor of guiEditors) { @@ -406,7 +450,14 @@ describe('editor utils', () => { }); } - const terminalEditors: EditorType[] = ['vim', 'neovim', 'emacs', 'hx']; + // micro has no CLI diff support (getDiffCommand returns null) so is excluded here + const terminalEditors: EditorType[] = [ + 'vim', + 'neovim', + 'emacs', + 'hx', + 'emacsclient', + ]; for (const editor of terminalEditors) { it(`should call spawnSync for ${editor}`, async () => { @@ -477,6 +528,10 @@ describe('editor utils', () => { 'windsurf', 'cursor', 'zed', + 'sublimetext', + 'lapce', + 'nova', + 'bbedit', ]; for (const editor of guiEditors) { it(`should not allow ${editor} in sandbox mode`, () => { @@ -710,4 +765,99 @@ describe('editor utils', () => { expect(emitSpy).toHaveBeenCalledWith(CoreEvent.RequestEditorSelection); }); }); + + describe('isValidEditorType', () => { + it('should return true for known editor identifiers', () => { + expect(isValidEditorType('vscode')).toBe(true); + expect(isValidEditorType('vim')).toBe(true); + expect(isValidEditorType('sublimetext')).toBe(true); + expect(isValidEditorType('emacsclient')).toBe(true); + expect(isValidEditorType('micro')).toBe(true); + expect(isValidEditorType('lapce')).toBe(true); + expect(isValidEditorType('nova')).toBe(true); + expect(isValidEditorType('bbedit')).toBe(true); + }); + + it('should return false for unrecognized strings', () => { + expect(isValidEditorType('emacsclient -nw')).toBe(false); + expect(isValidEditorType('subl')).toBe(false); + expect(isValidEditorType('code')).toBe(false); + expect(isValidEditorType('')).toBe(false); + expect(isValidEditorType('notepad')).toBe(false); + }); + }); + + describe('getEditorWaitFlag', () => { + it('should return -w for sublimetext', () => { + expect(getEditorWaitFlag('sublimetext')).toBe('-w'); + }); + + it('should return --wait for all other GUI editors', () => { + const standardGuiEditors: EditorType[] = [ + 'vscode', + 'vscodium', + 'windsurf', + 'cursor', + 'zed', + 'antigravity', + 'lapce', + 'nova', + 'bbedit', + ]; + for (const editor of standardGuiEditors) { + expect(getEditorWaitFlag(editor)).toBe('--wait'); + } + }); + }); + + describe('resolveEditorTypeFromCommand', () => { + it('should resolve known command names to their editor type', () => { + expect(resolveEditorTypeFromCommand('cursor')).toBe('cursor'); + expect(resolveEditorTypeFromCommand('code')).toBe('vscode'); + expect(resolveEditorTypeFromCommand('codium')).toBe('vscodium'); + expect(resolveEditorTypeFromCommand('vim')).toBe('vim'); + }); + + it('should be case-insensitive', () => { + expect(resolveEditorTypeFromCommand('Cursor')).toBe('cursor'); + expect(resolveEditorTypeFromCommand('CODE')).toBe('vscode'); + }); + + it('should return undefined for unknown commands', () => { + expect(resolveEditorTypeFromCommand('unknowntool')).toBeUndefined(); + expect(resolveEditorTypeFromCommand('')).toBeUndefined(); + }); + }); + + describe('getEditorExtraArgs', () => { + it('should return [-nw] for emacsclient', () => { + expect(getEditorExtraArgs('emacsclient')).toEqual(['-nw']); + }); + + it('should return [--new-window] for VS Code-family editors', () => { + const vscodeEditors: EditorType[] = [ + 'vscode', + 'vscodium', + 'cursor', + 'windsurf', + ]; + for (const editor of vscodeEditors) { + expect(getEditorExtraArgs(editor)).toEqual(['--new-window']); + } + }); + + it('should return [] for all other editors', () => { + const otherEditors: EditorType[] = [ + 'vim', + 'neovim', + 'emacs', + 'hx', + 'sublimetext', + 'micro', + ]; + for (const editor of otherEditors) { + expect(getEditorExtraArgs(editor)).toEqual([]); + } + }); + }); }); diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index cdc1e1d4a51..331fce4219a 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -17,10 +17,23 @@ const GUI_EDITORS = [ 'cursor', 'zed', 'antigravity', + 'sublimetext', + 'lapce', + 'nova', + 'bbedit', +] as const; +const TERMINAL_EDITORS = [ + 'vim', + 'neovim', + 'emacs', + 'hx', + 'emacsclient', + 'micro', ] as const; -const TERMINAL_EDITORS = ['vim', 'neovim', 'emacs', 'hx'] as const; const EDITORS = [...GUI_EDITORS, ...TERMINAL_EDITORS] as const; +export const ALL_EDITORS: readonly string[] = EDITORS; + const GUI_EDITORS_SET = new Set(GUI_EDITORS); const TERMINAL_EDITORS_SET = new Set(TERMINAL_EDITORS); const EDITORS_SET = new Set(EDITORS); @@ -55,13 +68,19 @@ export const EDITOR_DISPLAY_NAMES: Record = { emacs: 'Emacs', antigravity: 'Antigravity', hx: 'Helix', + sublimetext: 'Sublime Text', + lapce: 'Lapce', + nova: 'Nova', + bbedit: 'BBEdit', + emacsclient: 'Emacs Client', + micro: 'Micro', }; export function getEditorDisplayName(editor: EditorType): string { return EDITOR_DISPLAY_NAMES[editor] || editor; } -function isValidEditorType(editor: string): editor is EditorType { +export function isValidEditorType(editor: string): editor is EditorType { return EDITORS_SET.has(editor); } @@ -125,6 +144,13 @@ const editorCommands: Record< default: ['agy', 'antigravity'], }, hx: { win32: ['hx'], default: ['hx'] }, + sublimetext: { win32: ['subl'], default: ['subl'] }, + lapce: { win32: ['lapce'], default: ['lapce'] }, + // nova and bbedit are macOS-only; commandExists will return false on other platforms + nova: { win32: ['nova'], default: ['nova'] }, + bbedit: { win32: ['bbedit'], default: ['bbedit'] }, + emacsclient: { win32: ['emacsclient'], default: ['emacsclient'] }, + micro: { win32: ['micro'], default: ['micro'] }, }; function getEditorCommands(editor: EditorType): string[] { @@ -156,6 +182,56 @@ export function getEditorCommand(editor: EditorType): string { ); } +/** + * Given a command name (e.g. "cursor", "code"), returns the EditorType that uses + * that command, or undefined if no match is found. + */ +export function resolveEditorTypeFromCommand( + command: string, +): EditorType | undefined { + const lowerCmd = command.toLowerCase(); + for (const editor of EDITORS) { + const commands = getEditorCommands(editor); + if (commands.some((c) => c.toLowerCase() === lowerCmd)) { + return editor; + } + } + return undefined; +} + +/** + * Per-editor wait flags for GUI editors. Most use '--wait'; exceptions are listed here. + */ +const editorWaitFlags: Partial> = { + sublimetext: '-w', // subl uses -w instead of --wait +}; + +/** + * Returns the flag used to make a GUI editor block until the file is closed. + */ +export function getEditorWaitFlag(editor: EditorType): string { + return editorWaitFlags[editor] ?? '--wait'; +} + +/** + * Per-editor extra arguments prepended to the command invocation. + */ +const editorExtraArgs: Partial> = { + emacsclient: ['-nw'], // Force terminal (no-window) mode + vscode: ['--new-window'], // Force a new window to open the file directly + vscodium: ['--new-window'], + cursor: ['--new-window'], + windsurf: ['--new-window'], +}; + +/** + * Returns any extra arguments that must be passed to the editor executable + * (in addition to the file path and any wait flag). + */ +export function getEditorExtraArgs(editor: EditorType): string[] { + return editorExtraArgs[editor] ?? []; +} + export function allowEditorTypeInSandbox(editor: EditorType): boolean { const notUsingSandbox = !process.env['SANDBOX']; if (isGuiEditor(editor)) { @@ -274,11 +350,23 @@ export function getDiffCommand( `(ediff ${escapeELispString(oldPath)} ${escapeELispString(newPath)})`, ], }; + case 'emacsclient': + return { + command: 'emacsclient', + args: [ + '-nw', + '--eval', + `(ediff ${escapeELispString(oldPath)} ${escapeELispString(newPath)})`, + ], + }; case 'hx': return { command: 'hx', args: ['--vsplit', '--', oldPath, newPath], }; + case 'bbedit': + return { command, args: ['--wait', '--diff', oldPath, newPath] }; + // sublimetext, lapce, nova, micro do not support CLI-driven diff views default: return null; } diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 36816079caf..ff1cc51ffb5 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -41,9 +41,27 @@ "properties": { "preferredEditor": { "title": "Preferred Editor", - "description": "The preferred editor to open files in.", - "markdownDescription": "The preferred editor to open files in.\n\n- Category: `General`\n- Requires restart: `no`", - "type": "string" + "description": "The preferred editor to open files in when using the external editor feature (Ctrl-X). Accepted values: vscode, vscodium, cursor, windsurf, zed, antigravity, sublimetext, lapce, nova, bbedit, vim, neovim, emacs, emacsclient, hx, micro. If not set, falls back to $VISUAL, then $EDITOR, then vi/notepad.", + "markdownDescription": "The preferred editor to open files in when using the external editor feature (Ctrl-X).\n\nAccepted identifiers: `vscode`, `vscodium`, `cursor`, `windsurf`, `zed`, `antigravity`, `sublimetext`, `lapce`, `nova` (macOS), `bbedit` (macOS), `vim`, `neovim`, `emacs`, `emacsclient`, `hx`, `micro`.\n\nIf not set, falls back to `$VISUAL`, then `$EDITOR`, then `vi` (Unix) / `notepad` (Windows). Use `/editor` to configure interactively.\n\n- Category: `General`\n- Requires restart: `no`", + "type": "string", + "enum": [ + "vscode", + "vscodium", + "cursor", + "windsurf", + "zed", + "antigravity", + "sublimetext", + "lapce", + "nova", + "bbedit", + "vim", + "neovim", + "emacs", + "emacsclient", + "hx", + "micro" + ] }, "vimMode": { "title": "Vim Mode",