fix: prefer vim over nano for $EDITOR fallback (CLI + TUI)

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.
This commit is contained in:
Brooklyn Nicholson
2026-04-25 20:11:25 -05:00
parent 5fac6c3440
commit db7c5735f0
4 changed files with 128 additions and 1 deletions

22
cli.py
View File

@@ -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():

View File

@@ -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

View File

@@ -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'))
})
})

38
ui-tui/src/lib/editor.ts Normal file
View File

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