diff --git a/cli.py b/cli.py index 6664a9aaec..9f3e8964c4 100644 --- a/cli.py +++ b/cli.py @@ -4319,7 +4319,7 @@ class HermesCLI: _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}Draft editor: Ctrl+G{_RST}") + _cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}") 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") else: @@ -9307,14 +9307,18 @@ class HermesCLI: """Ctrl+Enter (c-j) inserts a newline. Most terminals send c-j for Ctrl+Enter.""" event.current_buffer.insert_text('\n') - @kb.add( - 'c-g', - filter=Condition( - lambda: not self._clarify_state and not self._approval_state and not self._sudo_state and not self._secret_state - ), + # VSCode/Cursor bind Ctrl+G to "Find Next" at the editor level, so + # the keystroke never reaches the embedded terminal. Alt+G is unbound + # in those IDEs and arrives here as ('escape', 'g') — register it as + # 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): - """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) @kb.add('tab', eager=True) @@ -9778,6 +9782,11 @@ class HermesCLI: 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 # wrapping of long lines so the input area always fits its content. diff --git a/ui-tui/README.md b/ui-tui/README.md index 4d7090d5ac..2f95a47aa2 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -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) | | `Ctrl+C` | Interrupt active run, or clear the current draft, or exit if nothing is pending | | `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+V` / `Alt+V` | Paste text first, then fall back to image/path attachment when applicable | | `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. - 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. -- `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`. ## Rendering diff --git a/ui-tui/packages/hermes-ink/src/ink/screen.ts b/ui-tui/packages/hermes-ink/src/ink/screen.ts index 32c7e7d7e6..6916b8598e 100644 --- a/ui-tui/packages/hermes-ink/src/ink/screen.ts +++ b/ui-tui/packages/hermes-ink/src/ink/screen.ts @@ -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 * as warn from './warn.js' diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 0105b44376..9049c17f9a 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -121,7 +121,7 @@ export interface ComposerActions { dequeue: () => string | undefined enqueue: (text: string) => void handleTextPaste: (event: PasteEvent) => MaybePromise - openEditor: () => void + openEditor: () => Promise pushHistory: (text: string) => void replaceQueue: (index: number, text: string) => void setCompIdx: StateSetter diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index f229067edc..26dbc9796f 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -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( diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index d7ac30d932..d2b8bf2717 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -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) diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts index 4944a19a6a..9a079fd2c6 100644 --- a/ui-tui/src/content/hotkeys.ts +++ b/ui-tui/src/content/hotkeys.ts @@ -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'], diff --git a/ui-tui/src/lib/editor.test.ts b/ui-tui/src/lib/editor.test.ts new file mode 100644 index 0000000000..dc18be716a --- /dev/null +++ b/ui-tui/src/lib/editor.test.ts @@ -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']) + }) +}) diff --git a/ui-tui/src/lib/editor.ts b/ui-tui/src/lib/editor.ts new file mode 100644 index 0000000000..806ee693ff --- /dev/null +++ b/ui-tui/src/lib/editor.ts @@ -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'] +}