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
This commit is contained in:
@@ -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 { BEL, ESC, SEP } from './termio/ansi.js'
|
||||||
import * as warn from './warn.js'
|
import * as warn from './warn.js'
|
||||||
|
|
||||||
|
|||||||
@@ -255,27 +255,34 @@ export function useComposerState({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const openEditor = useCallback(async () => {
|
const openEditor = useCallback(async () => {
|
||||||
const editor = resolveEditor()
|
const dir = mkdtempSync(join(tmpdir(), 'hermes-'))
|
||||||
const file = join(mkdtempSync(join(tmpdir(), 'hermes-')), 'prompt.md')
|
const file = join(dir, 'prompt.md')
|
||||||
let code: null | number = null
|
|
||||||
|
|
||||||
writeFileSync(file, [...inputBuf, input].join('\n'))
|
writeFileSync(file, [...inputBuf, input].join('\n'))
|
||||||
|
|
||||||
|
let exitCode: null | number = null
|
||||||
|
|
||||||
await withInkSuspended(async () => {
|
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()
|
const text = readFileSync(file, 'utf8').trimEnd()
|
||||||
|
|
||||||
if (text) {
|
if (!text) {
|
||||||
setInput('')
|
return
|
||||||
setInputBuf([])
|
|
||||||
submitRef.current(text)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
rmSync(file, { force: true })
|
setInput('')
|
||||||
|
setInputBuf([])
|
||||||
|
submitRef.current(text)
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { force: true, recursive: true })
|
||||||
|
}
|
||||||
}, [input, inputBuf, submitRef])
|
}, [input, inputBuf, submitRef])
|
||||||
|
|
||||||
const actions = useMemo(
|
const actions = useMemo(
|
||||||
|
|||||||
@@ -366,10 +366,9 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
|||||||
return voiceRecordToggle()
|
return voiceRecordToggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alt+G is the escape hatch for terminals that swallow Ctrl+G — VSCode and
|
// Ctrl+G, plus Alt+G fallback for VSCode/Cursor (they bind Ctrl+G to
|
||||||
// Cursor bind it to "Find Next" by default, so the keystroke never reaches
|
// "Find Next" before the TUI sees it; Alt+G arrives as meta+g).
|
||||||
// the embedded TUI. Alt+G arrives as `\x1bg` → meta+g across platforms.
|
if (ch.toLowerCase() === 'g' && (isAction(key, ch, 'g') || key.meta)) {
|
||||||
if (isAction(key, ch, 'g') || (key.meta && ch.toLowerCase() === 'g')) {
|
|
||||||
return cActions.openEditor()
|
return cActions.openEditor()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { tmpdir } from 'node:os'
|
||||||
import { delimiter, join } from 'node:path'
|
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'
|
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', () => {
|
describe('resolveEditor', () => {
|
||||||
let dir: string
|
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(() => {
|
beforeEach(() => {
|
||||||
dir = mkdtempSync(join(tmpdir(), 'editor-test-'))
|
dir = mkdtempSync(join(tmpdir(), 'editor-test-'))
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
// tmp dir is small; let the OS reap it
|
|
||||||
})
|
|
||||||
|
|
||||||
it('honors $VISUAL above all else', () => {
|
it('honors $VISUAL above all else', () => {
|
||||||
expect(resolveEditor({ EDITOR: 'vim', PATH: dir, VISUAL: 'helix' })).toBe('helix')
|
expect(resolveEditor({ EDITOR: 'vim', PATH: dir, VISUAL: 'helix' })).toBe('helix')
|
||||||
})
|
})
|
||||||
@@ -33,40 +30,30 @@ describe('resolveEditor', () => {
|
|||||||
expect(resolveEditor({ EDITOR: 'nvim', PATH: dir })).toBe('nvim')
|
expect(resolveEditor({ EDITOR: 'nvim', PATH: dir })).toBe('nvim')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('prefers system editor over nano over vi on $PATH', () => {
|
it('prefers `editor` over nano over vi on $PATH', () => {
|
||||||
exe('nano')
|
exe(dir, 'nano')
|
||||||
exe('vi')
|
exe(dir, 'vi')
|
||||||
const editor = exe('editor')
|
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', () => {
|
it('falls back to nano before vi when both exist', () => {
|
||||||
const nano = exe('nano')
|
exe(dir, 'vi')
|
||||||
exe('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', () => {
|
it('returns literal "vi" when $PATH is empty', () => {
|
||||||
const vi = exe('vi')
|
expect(resolveEditor({ PATH: '' })).toBe('vi')
|
||||||
|
|
||||||
expect(resolveEditor({ PATH: dir })).toBe(vi)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns literal "vi" when nothing on PATH and no env', () => {
|
it('walks multi-entry $PATH', () => {
|
||||||
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 a = mkdtempSync(join(tmpdir(), 'editor-a-'))
|
||||||
const b = mkdtempSync(join(tmpdir(), 'editor-b-'))
|
const b = mkdtempSync(join(tmpdir(), 'editor-b-'))
|
||||||
|
const expected = exe(b, 'editor')
|
||||||
|
|
||||||
writeFileSync(join(b, 'editor'), '#!/bin/sh\n')
|
expect(resolveEditor({ PATH: [a, b].join(delimiter) })).toBe(expected)
|
||||||
chmodSync(join(b, 'editor'), 0o755)
|
|
||||||
|
|
||||||
expect(resolveEditor({ PATH: [a, b].join(delimiter) })).toBe(join(b, 'editor'))
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,44 +2,13 @@ import { accessSync, constants } from 'node:fs'
|
|||||||
import { delimiter, join } from 'node:path'
|
import { delimiter, join } from 'node:path'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve which editor to launch when the user hits Ctrl+G / Alt+G.
|
* Editor fallback chain when neither $VISUAL nor $EDITOR is set. Mirrors
|
||||||
*
|
* prompt_toolkit's `Buffer.open_in_editor()` picker so the classic CLI and
|
||||||
* Order of preference:
|
* the TUI launch the same editor on a given box.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export function resolveEditor(env: NodeJS.ProcessEnv = process.env): string {
|
const FALLBACKS = ['editor', 'nano', 'pico', 'vi', 'emacs']
|
||||||
return (
|
|
||||||
env.VISUAL ||
|
|
||||||
env.EDITOR ||
|
|
||||||
findEditor(env.PATH ?? '', 'editor', 'nano', 'pico', 'vi', 'emacs') ||
|
|
||||||
'vi'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function findEditor(path: string, ...names: string[]): null | string {
|
const isExecutable = (path: string): boolean => {
|
||||||
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 {
|
|
||||||
try {
|
try {
|
||||||
accessSync(path, constants.X_OK)
|
accessSync(path, constants.X_OK)
|
||||||
|
|
||||||
@@ -48,3 +17,24 @@ function isExecutable(path: string): boolean {
|
|||||||
return false
|
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'
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user