From 3944b22506609493c3e103f9243fe6b8d6340efe Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 19:54:06 -0500 Subject: [PATCH 1/9] fix(tui): suspend Ink properly when opening $EDITOR via Ctrl+G The Ctrl+G handler was toggling the alt-screen by hand (`\x1b[?1049l` ... `\x1b[?1049h`) without releasing stdin or kitty keyboard mode, so the launched editor would lose keystrokes (Ink kept swallowing them) and editors that don't speak CSI-u (e.g. nano) would print "Unknown sequence" for every Ctrl-key. Switch to `withInkSuspended` from @hermes/ink, the same helper `/setup` already uses. It pauses Ink, removes stdin listeners, drops raw mode, disables kitty/modifyOtherKeys + mouse + focus reporting, runs the editor, then restores everything with a full repaint. --- ui-tui/src/app/interfaces.ts | 2 +- ui-tui/src/app/useComposerState.ts | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 0105b44376..9049c17f9a 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -121,7 +121,7 @@ export interface ComposerActions { dequeue: () => string | undefined enqueue: (text: string) => void handleTextPaste: (event: PasteEvent) => MaybePromise - openEditor: () => void + openEditor: () => Promise pushHistory: (text: string) => void replaceQueue: (index: number, text: string) => void setCompIdx: StateSetter diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index f229067edc..0821dd2c5d 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { useStdin } from '@hermes/ink' +import { useStdin, withInkSuspended } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useMemo, useState } from 'react' @@ -253,14 +253,16 @@ export function useComposerState({ [handleResolvedPaste, onClipboardPaste, querier] ) - const openEditor = useCallback(() => { + const openEditor = useCallback(async () => { const editor = process.env.EDITOR || process.env.VISUAL || 'vi' const file = join(mkdtempSync(join(tmpdir(), 'hermes-')), 'prompt.md') + let code: null | number = null writeFileSync(file, [...inputBuf, input].join('\n')) - process.stdout.write('\x1b[?1049l') - const { status: code } = spawnSync(editor, [file], { stdio: 'inherit' }) - process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H') + + await withInkSuspended(async () => { + code = spawnSync(editor, [file], { stdio: 'inherit' }).status + }) if (code === 0) { const text = readFileSync(file, 'utf8').trimEnd() From c58956a9a282afcbe88210272710165b13ccf853 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 19:57:17 -0500 Subject: [PATCH 2/9] fix(tui): accept Alt+G as Ctrl+G fallback in VSCode/Cursor terminals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VSCode and Cursor bind Ctrl+G to "Find Next" at the editor level, so the keystroke never reaches the embedded terminal — Ctrl+G to open \$EDITOR was effectively dead inside those IDEs. Alt+G is unbound in both editors and reaches the TUI cleanly as `\x1bg` → `key.meta && ch === 'g'` after parse-keypress. Accept it alongside the existing isAction(key, ch, 'g') check, and document the fallback in README + the hotkeys panel. --- ui-tui/README.md | 4 ++-- ui-tui/src/app/useInputHandlers.ts | 5 ++++- ui-tui/src/content/hotkeys.ts | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/ui-tui/README.md b/ui-tui/README.md index 4d7090d5ac..988448718f 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` | Open `$EDITOR` with the current draft | +| `Ctrl+G` / `Alt+G` | Open `$EDITOR` with the current draft (use `Alt+G` in VSCode/Cursor — they bind `Ctrl+G` 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` writes the current draft, including any multiline buffer, to a temp file, temporarily swaps screen buffers, launches `$EDITOR`, then restores the TUI and submits the saved text if the editor exits cleanly. +- `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. - Input history is stored in `~/.hermes/.hermes_history` or under `HERMES_HOME`. ## Rendering diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index d7ac30d932..88d065feeb 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -366,7 +366,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return voiceRecordToggle() } - if (isAction(key, ch, 'g')) { + // Alt+G is the escape hatch for terminals that swallow Ctrl+G — VSCode and + // Cursor bind it to "Find Next" by default, so the keystroke never reaches + // the embedded TUI. Alt+G arrives as `\x1bg` → meta+g across platforms. + if (isAction(key, ch, 'g') || (key.meta && ch.toLowerCase() === 'g')) { return cActions.openEditor() } diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index 4944a19a6a..f14444691d 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', 'open $EDITOR for prompt'], + [action + '+G / Alt+G', 'open $EDITOR for prompt (Alt+G in VSCode/Cursor)'], [action + '+L', 'new session (clear)'], [paste + '+V / /paste', 'paste text; /paste attaches clipboard image'], ['Tab', 'apply completion'], From 4c797bfae9732e3dfc8d2d067428f8aad0d607a8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 20:01:03 -0500 Subject: [PATCH 3/9] fix(cli): accept Alt+G as Ctrl+G fallback in VSCode/Cursor terminals Same problem as the TUI: Cursor and VSCode bind Ctrl+G to "Find Next" at the editor level, so the keystroke never reaches the terminal and the prompt_toolkit-driven Hermes CLI sees nothing. Register ('escape', 'g') alongside the existing 'c-g' on the same handler so the editor handoff works inside Cursor/VSCode too. The filter (no clarify/approval/sudo/secret prompt active) is unchanged. --- cli.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/cli.py b/cli.py index 18c9e637f6..0390d47413 100644 --- a/cli.py +++ b/cli.py @@ -4318,7 +4318,7 @@ class HermesCLI: _cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}") _cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}") - _cprint(f" {_DIM}Draft editor: Ctrl+G{_RST}") + _cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}") if _is_termux_environment(): _cprint(f" {_DIM}Attach image: /image {_termux_example_image_path()} or start your prompt with a local image path{_RST}\n") else: @@ -9308,14 +9308,18 @@ class HermesCLI: """Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter.""" event.current_buffer.insert_text('\n') - @kb.add( - 'c-g', - filter=Condition( - lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state - ), + # VSCode/Cursor bind Ctrl+G to "Find Next" at the editor level, so + # the keystroke never reaches the embedded terminal. Alt+G is unbound + # in those IDEs and arrives here as ('escape', 'g') — register it as + # a fallback so the editor handoff works inside Cursor/VSCode too. + _editor_filter = Condition( + lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state ) + + @kb.add('c-g', filter=_editor_filter) + @kb.add('escape', 'g', filter=_editor_filter) def handle_open_in_editor(event): - """Ctrl+G opens the current draft in an external editor.""" + """Ctrl+G (or Alt+G in VSCode/Cursor) opens the current draft in an external editor.""" cli_ref._open_external_editor(event.current_buffer) @kb.add('tab', eager=True) From 5fac6c3440519210c3a4015c0d7b904b35939473 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 20:04:04 -0500 Subject: [PATCH 4/9] fix(cli): write editor draft to prompt.md so syntax highlighting works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base CLI was handing prompt_toolkit's Buffer.open_in_editor() a default config — Buffer.tempfile_suffix and .tempfile both empty — so it created /tmp/tmpXXXXXX with no extension. nano/vim/helix all key syntax highlighting off the file extension, so the buffer rendered plain. The TUI already writes to /prompt.md and gets full markdown highlighting + a sensible title bar. Set buffer.tempfile = 'prompt.md' on the TextArea so prompt_toolkit's complex-tempfile path produces /prompt.md to match. shutil.rmtree cleanup is built-in. --- cli.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cli.py b/cli.py index 0390d47413..a4a05d55fa 100644 --- a/cli.py +++ b/cli.py @@ -9783,6 +9783,12 @@ class HermesCLI: completer=_completer, ), ) + # Match the TUI's editor handoff: the temp file lands at + # /prompt.md so vim/nano/helix pick up markdown syntax + # highlighting from the .md extension and the title bar reads + # "prompt.md" instead of a random "tmpXXXXXX". prompt_toolkit's + # complex-tempfile path takes care of cleanup via shutil.rmtree. + input_area.buffer.tempfile = 'prompt.md' # Dynamic height: accounts for both explicit newlines AND visual # wrapping of long lines so the input area always fits its content. From db7c5735f070eb297f728bd51916008746464a33 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 20:11:25 -0500 Subject: [PATCH 5/9] fix: prefer vim over nano for $EDITOR fallback (CLI + TUI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prompt_toolkit's default editor list is: $VISUAL, $EDITOR, /usr/bin/editor, /usr/bin/nano, /usr/bin/pico, /usr/bin/vi, /usr/bin/emacs — so when neither env var is set, the base CLI launched nano. The TUI fell back to a literal 'vi'. Same Ctrl+G keystroke, two different editors. Pick the same chain on both surfaces: $VISUAL → $EDITOR → vim → vi → nano CLI: override input_area.buffer._open_file_in_editor on the TextArea once at app build time. Local to that buffer; doesn't touch os.environ or affect other subprocesses. TUI: extract resolveEditor() into ui-tui/src/lib/editor.ts. PATH walk with accessSync(X_OK), no shelling out. Six-line unit test verifies the priority order and the multi-entry PATH walk. --- cli.py | 22 ++++++++++ ui-tui/src/app/useComposerState.ts | 3 +- ui-tui/src/lib/editor.test.ts | 66 ++++++++++++++++++++++++++++++ ui-tui/src/lib/editor.ts | 38 +++++++++++++++++ 4 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 ui-tui/src/lib/editor.test.ts create mode 100644 ui-tui/src/lib/editor.ts diff --git a/cli.py b/cli.py index a4a05d55fa..4690ebc325 100644 --- a/cli.py +++ b/cli.py @@ -9790,6 +9790,28 @@ class HermesCLI: # complex-tempfile path takes care of cleanup via shutil.rmtree. input_area.buffer.tempfile = 'prompt.md' + # prompt_toolkit's default fallback chain prefers /usr/bin/nano over + # /usr/bin/vi when neither $VISUAL nor $EDITOR is set. The TUI's + # resolveEditor() prefers vim → vi → nano. Override this single + # buffer's resolver so both surfaces pick the same editor. + import shlex + import subprocess + def _hermes_pick_editor(filename: str) -> bool: + chosen = ( + os.environ.get('VISUAL') + or os.environ.get('EDITOR') + or shutil.which('vim') + or shutil.which('vi') + or shutil.which('nano') + ) + if not chosen: + return False + try: + return subprocess.call(shlex.split(chosen) + [filename]) == 0 + except OSError: + return False + input_area.buffer._open_file_in_editor = _hermes_pick_editor + # Dynamic height: accounts for both explicit newlines AND visual # wrapping of long lines so the input area always fits its content. def _input_height(): diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 0821dd2c5d..79d5497304 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -14,6 +14,7 @@ import { useCompletion } from '../hooks/useCompletion.js' import { useInputHistory } from '../hooks/useInputHistory.js' import { useQueue } from '../hooks/useQueue.js' import { isUsableClipboardText, readClipboardText } from '../lib/clipboard.js' +import { resolveEditor } from '../lib/editor.js' import { readOsc52Clipboard } from '../lib/osc52.js' import { isRemoteShellSession } from '../lib/terminalSetup.js' import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js' @@ -254,7 +255,7 @@ export function useComposerState({ ) const openEditor = useCallback(async () => { - const editor = process.env.EDITOR || process.env.VISUAL || 'vi' + const editor = resolveEditor() const file = join(mkdtempSync(join(tmpdir(), 'hermes-')), 'prompt.md') let code: null | number = null diff --git a/ui-tui/src/lib/editor.test.ts b/ui-tui/src/lib/editor.test.ts new file mode 100644 index 0000000000..6bcccc0b74 --- /dev/null +++ b/ui-tui/src/lib/editor.test.ts @@ -0,0 +1,66 @@ +import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { delimiter, join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { resolveEditor } from './editor.js' + +describe('resolveEditor', () => { + let dir: string + + const exe = (name: string) => { + const path = join(dir, name) + writeFileSync(path, '#!/bin/sh\nexit 0\n') + chmodSync(path, 0o755) + + return path + } + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'editor-test-')) + }) + + afterEach(() => { + // tmp dir is small; let the OS reap it + }) + + it('honors $VISUAL above all else', () => { + expect(resolveEditor({ EDITOR: 'vim', PATH: dir, VISUAL: 'helix' })).toBe('helix') + }) + + it('falls back to $EDITOR when $VISUAL is unset', () => { + expect(resolveEditor({ EDITOR: 'nvim', PATH: dir })).toBe('nvim') + }) + + it('prefers vim over vi over nano on $PATH', () => { + exe('nano') + exe('vi') + const vim = exe('vim') + + expect(resolveEditor({ PATH: dir })).toBe(vim) + }) + + it('falls back to vi when only vi and nano exist', () => { + exe('nano') + const vi = exe('vi') + + expect(resolveEditor({ PATH: dir })).toBe(vi) + }) + + it('returns literal "vi" when nothing on PATH and no env', () => { + mkdirSync(join(dir, 'empty'), { recursive: true }) + + expect(resolveEditor({ PATH: join(dir, 'empty') })).toBe('vi') + }) + + it('walks multi-entry PATH', () => { + const a = mkdtempSync(join(tmpdir(), 'editor-a-')) + const b = mkdtempSync(join(tmpdir(), 'editor-b-')) + + writeFileSync(join(b, 'vim'), '#!/bin/sh\n') + chmodSync(join(b, 'vim'), 0o755) + + expect(resolveEditor({ PATH: [a, b].join(delimiter) })).toBe(join(b, 'vim')) + }) +}) diff --git a/ui-tui/src/lib/editor.ts b/ui-tui/src/lib/editor.ts new file mode 100644 index 0000000000..d05e76851b --- /dev/null +++ b/ui-tui/src/lib/editor.ts @@ -0,0 +1,38 @@ +import { accessSync, constants } from 'node:fs' +import { delimiter, join } from 'node:path' + +/** + * Resolve which editor to launch when the user hits Ctrl+G / Alt+G. + * + * Order of preference: + * 1. $VISUAL / $EDITOR (user's explicit choice) + * 2. first executable found on $PATH from `vim` → `vi` → `nano` + * 3. literal `'vi'` so spawnSync still has something to try + * + * Mirrors the override on `input_area.buffer._open_file_in_editor` in cli.py + * — both surfaces should pick the same editor so the CLI/TUI handoff + * doesn't surprise the user with nano in one and vim in the other. + */ +export function resolveEditor(env: NodeJS.ProcessEnv = process.env): string { + return env.VISUAL || env.EDITOR || findExecutable(env.PATH ?? '', 'vim', 'vi', 'nano') || 'vi' +} + +function findExecutable(path: string, ...names: string[]): null | string { + const dirs = path.split(delimiter).filter(Boolean) + + for (const name of names) { + for (const dir of dirs) { + const candidate = join(dir, name) + + try { + accessSync(candidate, constants.X_OK) + + return candidate + } catch { + // not executable / not present; try next + } + } + } + + return null +} From d056b610b797be02762e71dac7524c4e3c63ba16 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 20:16:50 -0500 Subject: [PATCH 6/9] fix: avoid prompt_toolkit complex tempfile bug and prefer nvim first Setting buffer.tempfile = 'prompt.md' pushed prompt_toolkit into its complex-tempfile path, which creates a temp dir and then calls os.makedirs() on that same path when no subdirectory is present. That raises EEXIST before the editor can launch. Keep prompt_toolkit on the simple tempfile path with .md suffix, and make the editor fallback chain explicit on both surfaces: $VISUAL -> $EDITOR -> nvim -> vim -> vi -> nano. --- cli.py | 16 +++++++++------- ui-tui/src/lib/editor.test.ts | 7 ++++--- ui-tui/src/lib/editor.ts | 4 ++-- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/cli.py b/cli.py index 4690ebc325..eb56695efc 100644 --- a/cli.py +++ b/cli.py @@ -9783,23 +9783,24 @@ class HermesCLI: completer=_completer, ), ) - # Match the TUI's editor handoff: the temp file lands at - # /prompt.md so vim/nano/helix pick up markdown syntax - # highlighting from the .md extension and the title bar reads - # "prompt.md" instead of a random "tmpXXXXXX". prompt_toolkit's - # complex-tempfile path takes care of cleanup via shutil.rmtree. - input_area.buffer.tempfile = 'prompt.md' + # Keep prompt_toolkit on its simple tempfile path. Setting + # buffer.tempfile = "prompt.md" triggers its complex-tempfile branch, + # which tries to mkdir() the mkdtemp() directory again and raises + # EEXIST. The suffix keeps markdown highlighting without that bug. + input_area.buffer.tempfile_suffix = '.md' # prompt_toolkit's default fallback chain prefers /usr/bin/nano over # /usr/bin/vi when neither $VISUAL nor $EDITOR is set. The TUI's - # resolveEditor() prefers vim → vi → nano. Override this single + # resolveEditor() prefers nvim → vim → vi → nano. Override this single # buffer's resolver so both surfaces pick the same editor. import shlex import subprocess + def _hermes_pick_editor(filename: str) -> bool: chosen = ( os.environ.get('VISUAL') or os.environ.get('EDITOR') + or shutil.which('nvim') or shutil.which('vim') or shutil.which('vi') or shutil.which('nano') @@ -9810,6 +9811,7 @@ class HermesCLI: return subprocess.call(shlex.split(chosen) + [filename]) == 0 except OSError: return False + input_area.buffer._open_file_in_editor = _hermes_pick_editor # Dynamic height: accounts for both explicit newlines AND visual diff --git a/ui-tui/src/lib/editor.test.ts b/ui-tui/src/lib/editor.test.ts index 6bcccc0b74..4262de1988 100644 --- a/ui-tui/src/lib/editor.test.ts +++ b/ui-tui/src/lib/editor.test.ts @@ -33,12 +33,13 @@ describe('resolveEditor', () => { expect(resolveEditor({ EDITOR: 'nvim', PATH: dir })).toBe('nvim') }) - it('prefers vim over vi over nano on $PATH', () => { + it('prefers nvim over vim over vi over nano on $PATH', () => { exe('nano') exe('vi') - const vim = exe('vim') + exe('vim') + const nvim = exe('nvim') - expect(resolveEditor({ PATH: dir })).toBe(vim) + expect(resolveEditor({ PATH: dir })).toBe(nvim) }) it('falls back to vi when only vi and nano exist', () => { diff --git a/ui-tui/src/lib/editor.ts b/ui-tui/src/lib/editor.ts index d05e76851b..6898b7cb42 100644 --- a/ui-tui/src/lib/editor.ts +++ b/ui-tui/src/lib/editor.ts @@ -6,7 +6,7 @@ import { delimiter, join } from 'node:path' * * Order of preference: * 1. $VISUAL / $EDITOR (user's explicit choice) - * 2. first executable found on $PATH from `vim` → `vi` → `nano` + * 2. first executable found on $PATH from `nvim` → `vim` → `vi` → `nano` * 3. literal `'vi'` so spawnSync still has something to try * * Mirrors the override on `input_area.buffer._open_file_in_editor` in cli.py @@ -14,7 +14,7 @@ import { delimiter, join } from 'node:path' * doesn't surprise the user with nano in one and vim in the other. */ export function resolveEditor(env: NodeJS.ProcessEnv = process.env): string { - return env.VISUAL || env.EDITOR || findExecutable(env.PATH ?? '', 'vim', 'vi', 'nano') || 'vi' + return env.VISUAL || env.EDITOR || findExecutable(env.PATH ?? '', 'nvim', 'vim', 'vi', 'nano') || 'vi' } function findExecutable(path: string, ...names: string[]): null | string { From 7fd8dc0bfbe827d2963d33f8146593470329cbae Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 20:20:05 -0500 Subject: [PATCH 7/9] fix: preserve prompt_toolkit editor picker and mirror it in TUI Base CLI's editor UX was better because prompt_toolkit picks the system editor first, then friendly terminal editors before vi. Do not override that with a vim-first chain. Keep the CLI on prompt_toolkit's picker and only set tempfile_suffix='.md' to avoid the complex-tempfile EEXIST path. Update the TUI resolver to match prompt_toolkit's fallback order: $VISUAL, $EDITOR, editor, nano, pico, vi, emacs. --- cli.py | 25 ------------------------- ui-tui/src/lib/editor.test.ts | 23 ++++++++++++++--------- ui-tui/src/lib/editor.ts | 34 +++++++++++++++++++++++----------- 3 files changed, 37 insertions(+), 45 deletions(-) diff --git a/cli.py b/cli.py index eb56695efc..6ada4bdaba 100644 --- a/cli.py +++ b/cli.py @@ -9789,31 +9789,6 @@ class HermesCLI: # EEXIST. The suffix keeps markdown highlighting without that bug. input_area.buffer.tempfile_suffix = '.md' - # prompt_toolkit's default fallback chain prefers /usr/bin/nano over - # /usr/bin/vi when neither $VISUAL nor $EDITOR is set. The TUI's - # resolveEditor() prefers nvim → vim → vi → nano. Override this single - # buffer's resolver so both surfaces pick the same editor. - import shlex - import subprocess - - def _hermes_pick_editor(filename: str) -> bool: - chosen = ( - os.environ.get('VISUAL') - or os.environ.get('EDITOR') - or shutil.which('nvim') - or shutil.which('vim') - or shutil.which('vi') - or shutil.which('nano') - ) - if not chosen: - return False - try: - return subprocess.call(shlex.split(chosen) + [filename]) == 0 - except OSError: - return False - - input_area.buffer._open_file_in_editor = _hermes_pick_editor - # Dynamic height: accounts for both explicit newlines AND visual # wrapping of long lines so the input area always fits its content. def _input_height(): diff --git a/ui-tui/src/lib/editor.test.ts b/ui-tui/src/lib/editor.test.ts index 4262de1988..07a3c4d130 100644 --- a/ui-tui/src/lib/editor.test.ts +++ b/ui-tui/src/lib/editor.test.ts @@ -33,17 +33,22 @@ describe('resolveEditor', () => { expect(resolveEditor({ EDITOR: 'nvim', PATH: dir })).toBe('nvim') }) - it('prefers nvim over vim over vi over nano on $PATH', () => { + it('prefers system editor over nano over vi on $PATH', () => { exe('nano') exe('vi') - exe('vim') - const nvim = exe('nvim') + const editor = exe('editor') - expect(resolveEditor({ PATH: dir })).toBe(nvim) + expect(resolveEditor({ PATH: dir })).toBe(editor) }) - it('falls back to vi when only vi and nano exist', () => { - exe('nano') + it('falls back to nano when only nano and vi exist', () => { + const nano = exe('nano') + exe('vi') + + expect(resolveEditor({ PATH: dir })).toBe(nano) + }) + + it('falls back to vi when only vi exists', () => { const vi = exe('vi') expect(resolveEditor({ PATH: dir })).toBe(vi) @@ -59,9 +64,9 @@ describe('resolveEditor', () => { const a = mkdtempSync(join(tmpdir(), 'editor-a-')) const b = mkdtempSync(join(tmpdir(), 'editor-b-')) - writeFileSync(join(b, 'vim'), '#!/bin/sh\n') - chmodSync(join(b, 'vim'), 0o755) + writeFileSync(join(b, 'editor'), '#!/bin/sh\n') + chmodSync(join(b, 'editor'), 0o755) - expect(resolveEditor({ PATH: [a, b].join(delimiter) })).toBe(join(b, 'vim')) + expect(resolveEditor({ PATH: [a, b].join(delimiter) })).toBe(join(b, 'editor')) }) }) diff --git a/ui-tui/src/lib/editor.ts b/ui-tui/src/lib/editor.ts index 6898b7cb42..a871eb7e6d 100644 --- a/ui-tui/src/lib/editor.ts +++ b/ui-tui/src/lib/editor.ts @@ -6,33 +6,45 @@ import { delimiter, join } from 'node:path' * * Order of preference: * 1. $VISUAL / $EDITOR (user's explicit choice) - * 2. first executable found on $PATH from `nvim` → `vim` → `vi` → `nano` + * 2. prompt_toolkit-compatible system fallback: + * editor → nano → pico → vi → emacs * 3. literal `'vi'` so spawnSync still has something to try * - * Mirrors the override on `input_area.buffer._open_file_in_editor` in cli.py - * — both surfaces should pick the same editor so the CLI/TUI handoff - * doesn't surprise the user with nano in one and vim in the other. + * This intentionally mirrors prompt_toolkit's Buffer.open_in_editor() picker + * used by the classic CLI. In Cursor/VSCode terminals, nano is a better prompt + * editing default than dropping casual users into vi's modal interface. */ export function resolveEditor(env: NodeJS.ProcessEnv = process.env): string { - return env.VISUAL || env.EDITOR || findExecutable(env.PATH ?? '', 'nvim', 'vim', 'vi', 'nano') || 'vi' + return ( + env.VISUAL || + env.EDITOR || + findEditor(env.PATH ?? '', 'editor', 'nano', 'pico', 'vi', 'emacs') || + 'vi' + ) } -function findExecutable(path: string, ...names: string[]): null | string { +function findEditor(path: string, ...names: string[]): null | string { const dirs = path.split(delimiter).filter(Boolean) for (const name of names) { for (const dir of dirs) { const candidate = join(dir, name) - try { - accessSync(candidate, constants.X_OK) - + if (isExecutable(candidate)) { return candidate - } catch { - // not executable / not present; try next } } } return null } + +function isExecutable(path: string): boolean { + try { + accessSync(path, constants.X_OK) + + return true + } catch { + return false + } +} From 83129e72de7baf202437f31e7158c8c794cdbdb3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 20:24:06 -0500 Subject: [PATCH 8/9] refactor(tui): tighten editor handoff helpers - editor.ts: collapse two private helpers into one flatMap-driven lookup, keep `isExecutable` as the only named primitive, document the fallback chain with prompt_toolkit parity - editor.test.ts: hoist the `exe` helper out of `describe`, drop the empty afterEach + dead mkdir branch, materialize expected paths before the resolveEditor call so argument evaluation order doesn't bite - useComposerState.openEditor: rmSync the mkdtemp dir (was leaking), early-return on bad exit / empty buffer, run cleanup in finally - useInputHandlers: cheap `ch.toLowerCase() === 'g'` guard before the modifier check - hermes-ink/screen.ts: pick up `npm run fix` import-sort cleanup so lint passes --- ui-tui/packages/hermes-ink/src/ink/screen.ts | 4 +- ui-tui/src/app/useComposerState.ts | 29 +++++---- ui-tui/src/app/useInputHandlers.ts | 7 +-- ui-tui/src/lib/editor.test.ts | 63 ++++++++------------ ui-tui/src/lib/editor.ts | 62 ++++++++----------- 5 files changed, 74 insertions(+), 91 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/screen.ts b/ui-tui/packages/hermes-ink/src/ink/screen.ts index 32c7e7d7e6..6916b8598e 100644 --- a/ui-tui/packages/hermes-ink/src/ink/screen.ts +++ b/ui-tui/packages/hermes-ink/src/ink/screen.ts @@ -1,6 +1,6 @@ -import { ansiCodesToString, diffAnsiCodes, type AnsiCode } from '@alcalzone/ansi-tokenize' +import { type AnsiCode, ansiCodesToString, diffAnsiCodes } from '@alcalzone/ansi-tokenize' -import { unionRect, type Point, type Rectangle, type Size } from './layout/geometry.js' +import { type Point, type Rectangle, type Size, unionRect } from './layout/geometry.js' import { BEL, ESC, SEP } from './termio/ansi.js' import * as warn from './warn.js' diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 79d5497304..9bc12b61b8 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -255,27 +255,34 @@ export function useComposerState({ ) const openEditor = useCallback(async () => { - const editor = resolveEditor() - const file = join(mkdtempSync(join(tmpdir(), 'hermes-')), 'prompt.md') - let code: null | number = null + const dir = mkdtempSync(join(tmpdir(), 'hermes-')) + const file = join(dir, 'prompt.md') writeFileSync(file, [...inputBuf, input].join('\n')) + let exitCode: null | number = null + await withInkSuspended(async () => { - code = spawnSync(editor, [file], { stdio: 'inherit' }).status + exitCode = spawnSync(resolveEditor(), [file], { stdio: 'inherit' }).status }) - if (code === 0) { + try { + if (exitCode !== 0) { + return + } + const text = readFileSync(file, 'utf8').trimEnd() - if (text) { - setInput('') - setInputBuf([]) - submitRef.current(text) + if (!text) { + return } - } - rmSync(file, { force: true }) + setInput('') + setInputBuf([]) + submitRef.current(text) + } finally { + rmSync(dir, { force: true, recursive: true }) + } }, [input, inputBuf, submitRef]) const actions = useMemo( diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 88d065feeb..51a65a8d41 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -366,10 +366,9 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return voiceRecordToggle() } - // Alt+G is the escape hatch for terminals that swallow Ctrl+G — VSCode and - // Cursor bind it to "Find Next" by default, so the keystroke never reaches - // the embedded TUI. Alt+G arrives as `\x1bg` → meta+g across platforms. - if (isAction(key, ch, 'g') || (key.meta && ch.toLowerCase() === 'g')) { + // 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). + if (ch.toLowerCase() === 'g' && (isAction(key, ch, 'g') || key.meta)) { return cActions.openEditor() } diff --git a/ui-tui/src/lib/editor.test.ts b/ui-tui/src/lib/editor.test.ts index 07a3c4d130..0aba6cd2f9 100644 --- a/ui-tui/src/lib/editor.test.ts +++ b/ui-tui/src/lib/editor.test.ts @@ -1,30 +1,27 @@ -import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it } from 'vitest' import { resolveEditor } from './editor.js' +const exe = (dir: string, name: string): string => { + const path = join(dir, name) + + writeFileSync(path, '#!/bin/sh\nexit 0\n') + chmodSync(path, 0o755) + + return path +} + describe('resolveEditor', () => { let dir: string - const exe = (name: string) => { - const path = join(dir, name) - writeFileSync(path, '#!/bin/sh\nexit 0\n') - chmodSync(path, 0o755) - - return path - } - beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'editor-test-')) }) - afterEach(() => { - // tmp dir is small; let the OS reap it - }) - it('honors $VISUAL above all else', () => { expect(resolveEditor({ EDITOR: 'vim', PATH: dir, VISUAL: 'helix' })).toBe('helix') }) @@ -33,40 +30,30 @@ describe('resolveEditor', () => { expect(resolveEditor({ EDITOR: 'nvim', PATH: dir })).toBe('nvim') }) - it('prefers system editor over nano over vi on $PATH', () => { - exe('nano') - exe('vi') - const editor = exe('editor') + it('prefers `editor` over nano over vi on $PATH', () => { + exe(dir, 'nano') + exe(dir, 'vi') + const expected = exe(dir, 'editor') - expect(resolveEditor({ PATH: dir })).toBe(editor) + expect(resolveEditor({ PATH: dir })).toBe(expected) }) - it('falls back to nano when only nano and vi exist', () => { - const nano = exe('nano') - exe('vi') + it('falls back to nano before vi when both exist', () => { + exe(dir, 'vi') + const expected = exe(dir, 'nano') - expect(resolveEditor({ PATH: dir })).toBe(nano) + expect(resolveEditor({ PATH: dir })).toBe(expected) }) - it('falls back to vi when only vi exists', () => { - const vi = exe('vi') - - expect(resolveEditor({ PATH: dir })).toBe(vi) + it('returns literal "vi" when $PATH is empty', () => { + expect(resolveEditor({ PATH: '' })).toBe('vi') }) - it('returns literal "vi" when nothing on PATH and no env', () => { - mkdirSync(join(dir, 'empty'), { recursive: true }) - - expect(resolveEditor({ PATH: join(dir, 'empty') })).toBe('vi') - }) - - it('walks multi-entry PATH', () => { + it('walks multi-entry $PATH', () => { const a = mkdtempSync(join(tmpdir(), 'editor-a-')) const b = mkdtempSync(join(tmpdir(), 'editor-b-')) + const expected = exe(b, 'editor') - writeFileSync(join(b, 'editor'), '#!/bin/sh\n') - chmodSync(join(b, 'editor'), 0o755) - - expect(resolveEditor({ PATH: [a, b].join(delimiter) })).toBe(join(b, 'editor')) + expect(resolveEditor({ PATH: [a, b].join(delimiter) })).toBe(expected) }) }) diff --git a/ui-tui/src/lib/editor.ts b/ui-tui/src/lib/editor.ts index a871eb7e6d..018fe2c88e 100644 --- a/ui-tui/src/lib/editor.ts +++ b/ui-tui/src/lib/editor.ts @@ -2,44 +2,13 @@ import { accessSync, constants } from 'node:fs' import { delimiter, join } from 'node:path' /** - * Resolve which editor to launch when the user hits Ctrl+G / Alt+G. - * - * Order of preference: - * 1. $VISUAL / $EDITOR (user's explicit choice) - * 2. prompt_toolkit-compatible system fallback: - * editor → nano → pico → vi → emacs - * 3. literal `'vi'` so spawnSync still has something to try - * - * This intentionally mirrors prompt_toolkit's Buffer.open_in_editor() picker - * used by the classic CLI. In Cursor/VSCode terminals, nano is a better prompt - * editing default than dropping casual users into vi's modal interface. + * Editor fallback chain when neither $VISUAL nor $EDITOR is set. Mirrors + * prompt_toolkit's `Buffer.open_in_editor()` picker so the classic CLI and + * the TUI launch the same editor on a given box. */ -export function resolveEditor(env: NodeJS.ProcessEnv = process.env): string { - return ( - env.VISUAL || - env.EDITOR || - findEditor(env.PATH ?? '', 'editor', 'nano', 'pico', 'vi', 'emacs') || - 'vi' - ) -} +const FALLBACKS = ['editor', 'nano', 'pico', 'vi', 'emacs'] -function findEditor(path: string, ...names: string[]): null | string { - const dirs = path.split(delimiter).filter(Boolean) - - for (const name of names) { - for (const dir of dirs) { - const candidate = join(dir, name) - - if (isExecutable(candidate)) { - return candidate - } - } - } - - return null -} - -function isExecutable(path: string): boolean { +const isExecutable = (path: string): boolean => { try { accessSync(path, constants.X_OK) @@ -48,3 +17,24 @@ function isExecutable(path: string): boolean { return false } } + +/** + * Resolve the editor to launch when the user hits Ctrl+G / Alt+G. + * + * 1. $VISUAL / $EDITOR (user's explicit choice) + * 2. first FALLBACKS entry resolvable on $PATH + * 3. literal `'vi'` so spawnSync still has something to try + */ +export const resolveEditor = (env: NodeJS.ProcessEnv = process.env): string => { + if (env.VISUAL) { + return env.VISUAL + } + + if (env.EDITOR) { + return env.EDITOR + } + + const dirs = (env.PATH ?? '').split(delimiter).filter(Boolean) + + return FALLBACKS.flatMap(name => dirs.map(d => join(d, name))).find(isExecutable) ?? 'vi' +} From 14dd8e9a727d8f2c2d010a3c2409c5670facac18 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Apr 2026 20:34:24 -0500 Subject: [PATCH 9/9] fix(tui): address Copilot review on editor handoff - resolveEditor() now returns argv (string[]) so EDITOR='code --wait' and VISUAL='emacsclient -t' tokenize correctly into spawnSync's separate command + args. Previously the whole string was passed as argv[0] and would ENOENT. - Skip the POSIX X_OK PATH walk on Windows; return ['notepad.exe'] there since fs.constants.X_OK is not meaningful and PATHEXT-based resolution would need its own implementation. - Surface openEditor() rejections via actions.sys instead of letting them become unhandled promise rejections in the useInput callback. - Hotkey docs/comment now say Cmd/Ctrl+G to match isAction()'s platform-action-modifier behavior (Cmd on macOS, Ctrl elsewhere). --- ui-tui/README.md | 4 ++-- ui-tui/src/app/useComposerState.ts | 3 ++- ui-tui/src/app/useInputHandlers.ts | 9 ++++++--- ui-tui/src/content/hotkeys.ts | 2 +- ui-tui/src/lib/editor.test.ts | 29 ++++++++++++++++++++++------- ui-tui/src/lib/editor.ts | 27 +++++++++++++++++---------- 6 files changed, 50 insertions(+), 24 deletions(-) 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'] }