Merge pull request #15821 from NousResearch/fix/tui-ctrl-g-editor

fix: external editor handoff in CLI/TUI
This commit is contained in:
brooklyn! 2026-04-25 20:37:05 -05:00 committed by GitHub
commit ff851ba7b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 175 additions and 29 deletions

View file

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

View file

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

View file

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