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 +}