mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
Merge pull request #15821 from NousResearch/fix/tui-ctrl-g-editor
fix: external editor handoff in CLI/TUI
This commit is contained in:
commit
ff851ba7b9
9 changed files with 175 additions and 29 deletions
23
cli.py
23
cli.py
|
|
@ -4319,7 +4319,7 @@ class HermesCLI:
|
||||||
|
|
||||||
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
|
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
|
||||||
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
|
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
|
||||||
_cprint(f" {_DIM}Draft editor: Ctrl+G{_RST}")
|
_cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}")
|
||||||
if _is_termux_environment():
|
if _is_termux_environment():
|
||||||
_cprint(f" {_DIM}Attach image: /image {_termux_example_image_path()} or start your prompt with a local image path{_RST}\n")
|
_cprint(f" {_DIM}Attach image: /image {_termux_example_image_path()} or start your prompt with a local image path{_RST}\n")
|
||||||
else:
|
else:
|
||||||
|
|
@ -9307,14 +9307,18 @@ class HermesCLI:
|
||||||
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
|
"""Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter."""
|
||||||
event.current_buffer.insert_text('\n')
|
event.current_buffer.insert_text('\n')
|
||||||
|
|
||||||
@kb.add(
|
# VSCode/Cursor bind Ctrl+G to "Find Next" at the editor level, so
|
||||||
'c-g',
|
# the keystroke never reaches the embedded terminal. Alt+G is unbound
|
||||||
filter=Condition(
|
# in those IDEs and arrives here as ('escape', 'g') — register it as
|
||||||
lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state
|
# a fallback so the editor handoff works inside Cursor/VSCode too.
|
||||||
),
|
_editor_filter = Condition(
|
||||||
|
lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@kb.add('c-g', filter=_editor_filter)
|
||||||
|
@kb.add('escape', 'g', filter=_editor_filter)
|
||||||
def handle_open_in_editor(event):
|
def handle_open_in_editor(event):
|
||||||
"""Ctrl+G opens the current draft in an external editor."""
|
"""Ctrl+G (or Alt+G in VSCode/Cursor) opens the current draft in an external editor."""
|
||||||
cli_ref._open_external_editor(event.current_buffer)
|
cli_ref._open_external_editor(event.current_buffer)
|
||||||
|
|
||||||
@kb.add('tab', eager=True)
|
@kb.add('tab', eager=True)
|
||||||
|
|
@ -9778,6 +9782,11 @@ class HermesCLI:
|
||||||
completer=_completer,
|
completer=_completer,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
# Keep prompt_toolkit on its simple tempfile path. Setting
|
||||||
|
# buffer.tempfile = "prompt.md" triggers its complex-tempfile branch,
|
||||||
|
# which tries to mkdir() the mkdtemp() directory again and raises
|
||||||
|
# EEXIST. The suffix keeps markdown highlighting without that bug.
|
||||||
|
input_area.buffer.tempfile_suffix = '.md'
|
||||||
|
|
||||||
# Dynamic height: accounts for both explicit newlines AND visual
|
# Dynamic height: accounts for both explicit newlines AND visual
|
||||||
# wrapping of long lines so the input area always fits its content.
|
# wrapping of long lines so the input area always fits its content.
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ Current input behavior is split across `app.tsx`, `components/textInput.tsx`, an
|
||||||
| `\` + `Enter` | Append the line to the multiline buffer (fallback for terminals without modifier support) |
|
| `\` + `Enter` | Append the line to the multiline buffer (fallback for terminals without modifier support) |
|
||||||
| `Ctrl+C` | Interrupt active run, or clear the current draft, or exit if nothing is pending |
|
| `Ctrl+C` | Interrupt active run, or clear the current draft, or exit if nothing is pending |
|
||||||
| `Ctrl+D` | Exit |
|
| `Ctrl+D` | Exit |
|
||||||
| `Ctrl+G` | Open `$EDITOR` with the current draft |
|
| `Cmd/Ctrl+G` / `Alt+G` | Open `$EDITOR` with the current draft (use `Alt+G` in VSCode/Cursor — they bind the primary keystroke to Find Next) |
|
||||||
| `Ctrl+L` | New session (same as `/clear`) |
|
| `Ctrl+L` | New session (same as `/clear`) |
|
||||||
| `Ctrl+V` / `Alt+V` | Paste text first, then fall back to image/path attachment when applicable |
|
| `Ctrl+V` / `Alt+V` | Paste text first, then fall back to image/path attachment when applicable |
|
||||||
| `Tab` | Apply the active completion |
|
| `Tab` | Apply the active completion |
|
||||||
|
|
@ -169,7 +169,7 @@ Notes:
|
||||||
- If you load a queued item into the input and resubmit plain text, that queue item is replaced, removed from the queue preview, and promoted to send next. If the agent is still busy, the edited item is moved to the front of the queue and sent after the current run completes.
|
- If you load a queued item into the input and resubmit plain text, that queue item is replaced, removed from the queue preview, and promoted to send next. If the agent is still busy, the edited item is moved to the front of the queue and sent after the current run completes.
|
||||||
- Completion requests are debounced by 60 ms. Input starting with `/` uses `complete.slash`. A trailing token that starts with `./`, `../`, `~/`, `/`, or `@` uses `complete.path`.
|
- Completion requests are debounced by 60 ms. Input starting with `/` uses `complete.slash`. A trailing token that starts with `./`, `../`, `~/`, `/`, or `@` uses `complete.path`.
|
||||||
- Text pastes are inserted inline directly into the draft. Nothing is newline-flattened.
|
- Text pastes are inserted inline directly into the draft. Nothing is newline-flattened.
|
||||||
- `Ctrl+G` writes the current draft, including any multiline buffer, to a temp file, temporarily swaps screen buffers, launches `$EDITOR`, then restores the TUI and submits the saved text if the editor exits cleanly.
|
- `Cmd/Ctrl+G` (or `Alt+G` in VSCode/Cursor, which intercept the primary keystroke for Find Next) writes the current draft, including any multiline buffer, to a temp file, suspends Ink, launches `$EDITOR`, then restores the TUI and submits the saved text if the editor exits cleanly.
|
||||||
- Input history is stored in `~/.hermes/.hermes_history` or under `HERMES_HOME`.
|
- Input history is stored in `~/.hermes/.hermes_history` or under `HERMES_HOME`.
|
||||||
|
|
||||||
## Rendering
|
## Rendering
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ export interface ComposerActions {
|
||||||
dequeue: () => string | undefined
|
dequeue: () => string | undefined
|
||||||
enqueue: (text: string) => void
|
enqueue: (text: string) => void
|
||||||
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
|
handleTextPaste: (event: PasteEvent) => MaybePromise<ComposerPasteResult | null>
|
||||||
openEditor: () => void
|
openEditor: () => Promise<void>
|
||||||
pushHistory: (text: string) => void
|
pushHistory: (text: string) => void
|
||||||
replaceQueue: (index: number, text: string) => void
|
replaceQueue: (index: number, text: string) => void
|
||||||
setCompIdx: StateSetter<number>
|
setCompIdx: StateSetter<number>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
|
|
||||||
import { useStdin } from '@hermes/ink'
|
import { useStdin, withInkSuspended } from '@hermes/ink'
|
||||||
import { useStore } from '@nanostores/react'
|
import { useStore } from '@nanostores/react'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { useCompletion } from '../hooks/useCompletion.js'
|
||||||
import { useInputHistory } from '../hooks/useInputHistory.js'
|
import { useInputHistory } from '../hooks/useInputHistory.js'
|
||||||
import { useQueue } from '../hooks/useQueue.js'
|
import { useQueue } from '../hooks/useQueue.js'
|
||||||
import { isUsableClipboardText, readClipboardText } from '../lib/clipboard.js'
|
import { isUsableClipboardText, readClipboardText } from '../lib/clipboard.js'
|
||||||
|
import { resolveEditor } from '../lib/editor.js'
|
||||||
import { readOsc52Clipboard } from '../lib/osc52.js'
|
import { readOsc52Clipboard } from '../lib/osc52.js'
|
||||||
import { isRemoteShellSession } from '../lib/terminalSetup.js'
|
import { isRemoteShellSession } from '../lib/terminalSetup.js'
|
||||||
import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js'
|
import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js'
|
||||||
|
|
@ -253,26 +254,36 @@ export function useComposerState({
|
||||||
[handleResolvedPaste, onClipboardPaste, querier]
|
[handleResolvedPaste, onClipboardPaste, querier]
|
||||||
)
|
)
|
||||||
|
|
||||||
const openEditor = useCallback(() => {
|
const openEditor = useCallback(async () => {
|
||||||
const editor = process.env.EDITOR || process.env.VISUAL || 'vi'
|
const dir = mkdtempSync(join(tmpdir(), 'hermes-'))
|
||||||
const file = join(mkdtempSync(join(tmpdir(), 'hermes-')), 'prompt.md')
|
const file = join(dir, 'prompt.md')
|
||||||
|
const [cmd, ...args] = resolveEditor()
|
||||||
|
|
||||||
writeFileSync(file, [...inputBuf, input].join('\n'))
|
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()
|
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,8 +366,13 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
|
||||||
return voiceRecordToggle()
|
return voiceRecordToggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAction(key, ch, 'g')) {
|
// Cmd/Ctrl+G, plus Alt+G fallback for VSCode/Cursor (they bind the
|
||||||
return cActions.openEditor()
|
// 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)
|
// 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][] = [
|
export const HOTKEYS: [string, string][] = [
|
||||||
...copyHotkeys,
|
...copyHotkeys,
|
||||||
[action + '+D', 'exit'],
|
[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)'],
|
[action + '+L', 'new session (clear)'],
|
||||||
[paste + '+V / /paste', 'paste text; /paste attaches clipboard image'],
|
[paste + '+V / /paste', 'paste text; /paste attaches clipboard image'],
|
||||||
['Tab', 'apply completion'],
|
['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']
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue