diff --git a/ui-tui/README.md b/ui-tui/README.md index 988448718f..2f95a47aa2 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -110,7 +110,7 @@ Current input behavior is split across `app.tsx`, `components/textInput.tsx`, an | `\` + `Enter` | Append the line to the multiline buffer (fallback for terminals without modifier support) | | `Ctrl+C` | Interrupt active run, or clear the current draft, or exit if nothing is pending | | `Ctrl+D` | Exit | -| `Ctrl+G` / `Alt+G` | Open `$EDITOR` with the current draft (use `Alt+G` in VSCode/Cursor — they bind `Ctrl+G` to Find Next) | +| `Cmd/Ctrl+G` / `Alt+G` | Open `$EDITOR` with the current draft (use `Alt+G` in VSCode/Cursor — they bind the primary keystroke to Find Next) | | `Ctrl+L` | New session (same as `/clear`) | | `Ctrl+V` / `Alt+V` | Paste text first, then fall back to image/path attachment when applicable | | `Tab` | Apply the active completion | @@ -169,7 +169,7 @@ Notes: - If you load a queued item into the input and resubmit plain text, that queue item is replaced, removed from the queue preview, and promoted to send next. If the agent is still busy, the edited item is moved to the front of the queue and sent after the current run completes. - Completion requests are debounced by 60 ms. Input starting with `/` uses `complete.slash`. A trailing token that starts with `./`, `../`, `~/`, `/`, or `@` uses `complete.path`. - Text pastes are inserted inline directly into the draft. Nothing is newline-flattened. -- `Ctrl+G` (or `Alt+G` in VSCode/Cursor, which intercept `Ctrl+G` for Find Next) writes the current draft, including any multiline buffer, to a temp file, suspends Ink, launches `$EDITOR`, then restores the TUI and submits the saved text if the editor exits cleanly. +- `Cmd/Ctrl+G` (or `Alt+G` in VSCode/Cursor, which intercept the primary keystroke for Find Next) writes the current draft, including any multiline buffer, to a temp file, suspends Ink, launches `$EDITOR`, then restores the TUI and submits the saved text if the editor exits cleanly. - Input history is stored in `~/.hermes/.hermes_history` or under `HERMES_HOME`. ## Rendering diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 9bc12b61b8..26dbc9796f 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -257,13 +257,14 @@ export function useComposerState({ const openEditor = useCallback(async () => { const dir = mkdtempSync(join(tmpdir(), 'hermes-')) const file = join(dir, 'prompt.md') + const [cmd, ...args] = resolveEditor() writeFileSync(file, [...inputBuf, input].join('\n')) let exitCode: null | number = null await withInkSuspended(async () => { - exitCode = spawnSync(resolveEditor(), [file], { stdio: 'inherit' }).status + exitCode = spawnSync(cmd!, [...args, file], { stdio: 'inherit' }).status }) try { diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 51a65a8d41..d2b8bf2717 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -366,10 +366,13 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return voiceRecordToggle() } - // Ctrl+G, plus Alt+G fallback for VSCode/Cursor (they bind Ctrl+G to - // "Find Next" before the TUI sees it; Alt+G arrives as meta+g). + // Cmd/Ctrl+G, plus Alt+G fallback for VSCode/Cursor (they bind the + // primary keystroke to "Find Next" before the TUI sees it; Alt+G + // arrives as meta+g across platforms). if (ch.toLowerCase() === 'g' && (isAction(key, ch, 'g') || key.meta)) { - return cActions.openEditor() + return void cActions.openEditor().catch((err: unknown) => { + actions.sys(err instanceof Error ? `failed to open editor: ${err.message}` : 'failed to open editor') + }) } // shift-tab flips yolo without spending a turn (claude-code parity) diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index f14444691d..9a079fd2c6 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -18,7 +18,7 @@ const copyHotkeys: [string, string][] = isMac export const HOTKEYS: [string, string][] = [ ...copyHotkeys, [action + '+D', 'exit'], - [action + '+G / Alt+G', 'open $EDITOR for prompt (Alt+G in VSCode/Cursor)'], + [action + '+G / Alt+G', 'open $EDITOR (Alt+G fallback for VSCode/Cursor)'], [action + '+L', 'new session (clear)'], [paste + '+V / /paste', 'paste text; /paste attaches clipboard image'], ['Tab', 'apply completion'], diff --git a/ui-tui/src/lib/editor.test.ts b/ui-tui/src/lib/editor.test.ts index 0aba6cd2f9..dc18be716a 100644 --- a/ui-tui/src/lib/editor.test.ts +++ b/ui-tui/src/lib/editor.test.ts @@ -23,11 +23,22 @@ describe('resolveEditor', () => { }) it('honors $VISUAL above all else', () => { - expect(resolveEditor({ EDITOR: 'vim', PATH: dir, VISUAL: 'helix' })).toBe('helix') + expect(resolveEditor({ EDITOR: 'vim', PATH: dir, VISUAL: 'helix' })).toEqual(['helix']) }) it('falls back to $EDITOR when $VISUAL is unset', () => { - expect(resolveEditor({ EDITOR: 'nvim', PATH: dir })).toBe('nvim') + expect(resolveEditor({ EDITOR: 'nvim', PATH: dir })).toEqual(['nvim']) + }) + + it('shell-tokenizes editors with arguments', () => { + expect(resolveEditor({ EDITOR: 'code --wait', PATH: dir })).toEqual(['code', '--wait']) + expect(resolveEditor({ PATH: dir, VISUAL: 'emacsclient -t' })).toEqual(['emacsclient', '-t']) + }) + + it('ignores whitespace-only env vars', () => { + const expected = exe(dir, 'editor') + + expect(resolveEditor({ EDITOR: ' ', PATH: dir, VISUAL: '' })).toEqual([expected]) }) it('prefers `editor` over nano over vi on $PATH', () => { @@ -35,18 +46,18 @@ describe('resolveEditor', () => { exe(dir, 'vi') const expected = exe(dir, 'editor') - expect(resolveEditor({ PATH: dir })).toBe(expected) + expect(resolveEditor({ PATH: dir })).toEqual([expected]) }) it('falls back to nano before vi when both exist', () => { exe(dir, 'vi') const expected = exe(dir, 'nano') - expect(resolveEditor({ PATH: dir })).toBe(expected) + expect(resolveEditor({ PATH: dir })).toEqual([expected]) }) - it('returns literal "vi" when $PATH is empty', () => { - expect(resolveEditor({ PATH: '' })).toBe('vi') + it('returns ["vi"] when $PATH is empty', () => { + expect(resolveEditor({ PATH: '' })).toEqual(['vi']) }) it('walks multi-entry $PATH', () => { @@ -54,6 +65,10 @@ describe('resolveEditor', () => { const b = mkdtempSync(join(tmpdir(), 'editor-b-')) const expected = exe(b, 'editor') - expect(resolveEditor({ PATH: [a, b].join(delimiter) })).toBe(expected) + expect(resolveEditor({ PATH: [a, b].join(delimiter) })).toEqual([expected]) + }) + + it('uses notepad.exe on Windows when no env override', () => { + expect(resolveEditor({ PATH: dir }, 'win32')).toEqual(['notepad.exe']) }) }) diff --git a/ui-tui/src/lib/editor.ts b/ui-tui/src/lib/editor.ts index 018fe2c88e..806ee693ff 100644 --- a/ui-tui/src/lib/editor.ts +++ b/ui-tui/src/lib/editor.ts @@ -19,22 +19,29 @@ const isExecutable = (path: string): boolean => { } /** - * Resolve the editor to launch when the user hits Ctrl+G / Alt+G. + * Resolve the editor invocation argv (without the file argument). * - * 1. $VISUAL / $EDITOR (user's explicit choice) - * 2. first FALLBACKS entry resolvable on $PATH - * 3. literal `'vi'` so spawnSync still has something to try + * 1. $VISUAL / $EDITOR, shell-tokenized so `EDITOR="code --wait"` works + * 2. on POSIX: first FALLBACKS entry resolvable on $PATH + * 3. on Windows: `notepad.exe` + * 4. literal `['vi']` as the last-resort POSIX floor */ -export const resolveEditor = (env: NodeJS.ProcessEnv = process.env): string => { - if (env.VISUAL) { - return env.VISUAL +export const resolveEditor = ( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): string[] => { + const explicit = env.VISUAL ?? env.EDITOR + + if (explicit?.trim()) { + return explicit.trim().split(/\s+/) } - if (env.EDITOR) { - return env.EDITOR + if (platform === 'win32') { + return ['notepad.exe'] } const dirs = (env.PATH ?? '').split(delimiter).filter(Boolean) + const found = FALLBACKS.flatMap(name => dirs.map(d => join(d, name))).find(isExecutable) - return FALLBACKS.flatMap(name => dirs.map(d => join(d, name))).find(isExecutable) ?? 'vi' + return [found ?? 'vi'] }