Merge pull request #15821 from NousResearch/fix/tui-ctrl-g-editor
fix: external editor handoff in CLI/TUI
This commit is contained in:
@@ -121,7 +121,7 @@ export interface ComposerActions {
|
||||
dequeue: () => string | undefined
|
||||
enqueue: (text: string) => void
|
||||
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
|
||||
openEditor: () => void
|
||||
openEditor: () => Promise<void>
|
||||
pushHistory: (text: string) => void
|
||||
replaceQueue: (index: number, text: string) => void
|
||||
setCompIdx: StateSetter<number>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
@@ -253,26 +254,36 @@ export function useComposerState({
|
||||
[handleResolvedPaste, onClipboardPaste, querier]
|
||||
)
|
||||
|
||||
const openEditor = useCallback(() => {
|
||||
const editor = process.env.EDITOR || process.env.VISUAL || 'vi'
|
||||
const file = join(mkdtempSync(join(tmpdir(), 'hermes-')), 'prompt.md')
|
||||
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'))
|
||||
process.stdout.write('\x1b[?1049l')
|
||||
const { status: code } = spawnSync(editor, [file], { stdio: 'inherit' })
|
||||
process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H')
|
||||
|
||||
if (code === 0) {
|
||||
let exitCode: null | number = null
|
||||
|
||||
await withInkSuspended(async () => {
|
||||
exitCode = spawnSync(cmd!, [...args, file], { stdio: 'inherit' }).status
|
||||
})
|
||||
|
||||
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(
|
||||
|
||||
@@ -366,8 +366,13 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||
return voiceRecordToggle()
|
||||
}
|
||||
|
||||
if (isAction(key, ch, 'g')) {
|
||||
return cActions.openEditor()
|
||||
// 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 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)
|
||||
|
||||
@@ -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 (Alt+G fallback for VSCode/Cursor)'],
|
||||
[action + '+L', 'new session (clear)'],
|
||||
[paste + '+V / /paste', 'paste text; /paste attaches clipboard image'],
|
||||
['Tab', 'apply completion'],
|
||||
|
||||
74
ui-tui/src/lib/editor.test.ts
Normal file
74
ui-tui/src/lib/editor.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
|
||||
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
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'editor-test-'))
|
||||
})
|
||||
|
||||
it('honors $VISUAL above all else', () => {
|
||||
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 })).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', () => {
|
||||
exe(dir, 'nano')
|
||||
exe(dir, 'vi')
|
||||
const expected = exe(dir, 'editor')
|
||||
|
||||
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 })).toEqual([expected])
|
||||
})
|
||||
|
||||
it('returns ["vi"] when $PATH is empty', () => {
|
||||
expect(resolveEditor({ PATH: '' })).toEqual(['vi'])
|
||||
})
|
||||
|
||||
it('walks multi-entry $PATH', () => {
|
||||
const a = mkdtempSync(join(tmpdir(), 'editor-a-'))
|
||||
const b = mkdtempSync(join(tmpdir(), 'editor-b-'))
|
||||
const expected = exe(b, 'editor')
|
||||
|
||||
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'])
|
||||
})
|
||||
})
|
||||
47
ui-tui/src/lib/editor.ts
Normal file
47
ui-tui/src/lib/editor.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { accessSync, constants } from 'node:fs'
|
||||
import { delimiter, join } from 'node:path'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const FALLBACKS = ['editor', 'nano', 'pico', 'vi', 'emacs']
|
||||
|
||||
const isExecutable = (path: string): boolean => {
|
||||
try {
|
||||
accessSync(path, constants.X_OK)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the editor invocation argv (without the file argument).
|
||||
*
|
||||
* 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,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): string[] => {
|
||||
const explicit = env.VISUAL ?? env.EDITOR
|
||||
|
||||
if (explicit?.trim()) {
|
||||
return explicit.trim().split(/\s+/)
|
||||
}
|
||||
|
||||
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 [found ?? 'vi']
|
||||
}
|
||||
Reference in New Issue
Block a user