feat(cli): /prompt — compose your next prompt in $EDITOR (#50509)

* feat(cli): /prompt — compose your next prompt in $EDITOR

Adds /prompt (alias /compose): opens $VISUAL/$EDITOR on a temp markdown
file so you can hand-edit a multi-line prompt, then sends the saved buffer
as the next agent turn. Text after the command pre-seeds the buffer; an
empty save cancels. Reuses the one-shot _pending_agent_seed the interactive
loop already consumes (same mechanism as /blueprint), so no changes to the
input event loop or message pipeline. CLI-only.

* feat(tui): /prompt slash command opens $EDITOR (parity with CLI)

The TUI already opens $EDITOR via Ctrl+G (openEditor), but had no /prompt
slash command like the classic CLI. Wire openEditor into the slash handler
context and register /prompt (alias /compose) to call it; inline text after
the command is dropped into the composer first so it carries into the editor,
matching the CLI's /prompt <text>.
This commit is contained in:
Teknium 2026-06-21 20:21:33 -07:00 committed by GitHub
parent 95d53c3bcb
commit 9e96e70995
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 190 additions and 0 deletions

2
cli.py
View file

@ -7850,6 +7850,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
if retry_msg and hasattr(self, '_pending_input'):
# Re-queue the message so process_loop sends it to the agent
self._pending_input.put(retry_msg)
elif canonical == "prompt":
self._handle_prompt_compose_command(cmd_original)
elif canonical == "undo":
# Parse optional turn count: "/undo" → 1, "/undo 3" → 3.
_undo_n = 1

View file

@ -1960,6 +1960,79 @@ class CLICommandsMixin:
if self._apply_tui_skin_style():
print(" Prompt + TUI colors updated.")
def _compose_in_editor(self, initial_text: str = "") -> str:
"""Open ``$VISUAL``/``$EDITOR`` on a temp markdown file and return the
saved buffer (comment lines starting with ``#!`` stripped).
Returns the composed prompt text, or an empty string if the editor
could not be launched or the buffer was left empty. Factored out so
the read-back/strip logic is unit-testable without spawning an editor.
"""
import os
import shlex
import subprocess
import tempfile
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
if not editor:
editor = "notepad" if os.name == "nt" else "nano"
header = (
"#! Compose your prompt below. Lines starting with '#!' are ignored.\n"
"#! Save and quit to send; leave empty to cancel.\n\n"
)
fd, path = tempfile.mkstemp(suffix=".md", prefix="hermes_prompt_")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(header)
if initial_text:
fh.write(initial_text)
try:
subprocess.call([*shlex.split(editor), path])
except Exception:
# Fall back to a bare invocation (editor value may not be a
# simple argv-splittable string on some platforms).
subprocess.call(f"{editor} {shlex.quote(path)}", shell=True)
with open(path, "r", encoding="utf-8") as fh:
raw = fh.read()
finally:
try:
os.unlink(path)
except OSError:
pass
lines = [ln for ln in raw.splitlines() if not ln.startswith("#!")]
return "\n".join(lines).strip()
def _handle_prompt_compose_command(self, cmd_original: str) -> None:
"""Handle /prompt — compose the next prompt in $EDITOR and send it.
Opens the user's editor on a temporary markdown file (optionally
seeded with text passed after the command), then queues the saved
buffer as the next agent turn via the one-shot ``_pending_agent_seed``
the interactive loop already consumes (same path as /blueprint).
"""
from cli import _DIM, _RST, _cprint
initial = ""
parts = (cmd_original or "").strip().split(None, 1)
if len(parts) > 1:
initial = parts[1]
try:
composed = self._compose_in_editor(initial)
except Exception as exc:
_cprint(f" {_DIM}(>_<) Could not open editor: {exc}{_RST}")
return
if not composed:
_cprint(f" {_DIM}(._.) Empty prompt — nothing sent.{_RST}")
return
# One-shot seed: the interactive loop runs this as the next agent turn
# right after process_command() returns (see cli.py main loop).
self._pending_agent_seed = composed
def _handle_footer_command(self, cmd_original: str) -> None:
"""Toggle or inspect ``display.runtime_footer.enabled`` from the CLI.

View file

@ -78,6 +78,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
CommandDef("save", "Save the current conversation", "Session",
cli_only=True),
CommandDef("retry", "Retry the last message (resend to agent)", "Session"),
CommandDef("prompt", "Compose your next prompt in $EDITOR (markdown), then send it", "Session",
cli_only=True, args_hint="[initial text]", aliases=("compose",)),
CommandDef("undo", "Back up N user turns and re-prompt (default 1)", "Session",
args_hint="[N]"),
CommandDef("title", "Set a title for the current session", "Session",

View file

@ -0,0 +1,76 @@
"""Tests for the CLI `/prompt` editor-compose command.
`/prompt` opens `$VISUAL`/`$EDITOR` on a temp markdown file so the user can
hand-edit a multi-line prompt, then queues the saved buffer as the next
agent turn via the one-shot `_pending_agent_seed` (same path `/blueprint`
uses). These drive a fake editor subprocess to verify read-back, header
stripping, seeding, and the empty-buffer cancel path.
"""
import os
import stat
import tempfile
import pytest
from hermes_cli.cli_commands_mixin import CLICommandsMixin
from hermes_cli.commands import resolve_command
class _Stub(CLICommandsMixin):
def __init__(self):
self._pending_agent_seed = None
def _fake_editor(body: str, mode: str = "append") -> str:
"""Write a tiny shell 'editor' that mutates the file it is handed."""
f = tempfile.NamedTemporaryFile("w", suffix=".sh", delete=False)
if mode == "append":
f.write("#!/usr/bin/env bash\n")
f.write(f"cat >> \"$1\" <<'EOF'\n{body}\nEOF\n")
else: # clear
f.write("#!/usr/bin/env bash\n: > \"$1\"\n")
f.close()
os.chmod(f.name, os.stat(f.name).st_mode | stat.S_IEXEC)
return f.name
@pytest.fixture(autouse=True)
def _no_visual(monkeypatch):
monkeypatch.delenv("VISUAL", raising=False)
def test_command_registered():
cd = resolve_command("prompt")
assert cd and cd.name == "prompt"
assert resolve_command("compose").name == "prompt"
def test_compose_reads_and_strips_header(monkeypatch):
monkeypatch.setenv("EDITOR", _fake_editor("Refactor the auth module.\nUse pytest."))
out = _Stub()._compose_in_editor("")
assert "Refactor the auth module." in out
assert "Use pytest." in out
assert "#!" not in out # the instructional header is stripped
def test_prompt_sets_pending_seed(monkeypatch):
monkeypatch.setenv("EDITOR", _fake_editor("Write a haiku about caching."))
s = _Stub()
s._handle_prompt_compose_command("/prompt")
assert s._pending_agent_seed
assert "haiku about caching" in s._pending_agent_seed
def test_initial_text_is_seeded(monkeypatch):
# The fake editor appends, so the initial text leads the buffer.
monkeypatch.setenv("EDITOR", _fake_editor("rest of prompt"))
out = _Stub()._compose_in_editor("DRAFT: ")
assert out.startswith("DRAFT:")
def test_empty_buffer_does_not_seed(monkeypatch):
monkeypatch.setenv("EDITOR", _fake_editor("", mode="clear"))
s = _Stub()
s._handle_prompt_compose_command("/prompt")
assert s._pending_agent_seed is None

View file

@ -77,6 +77,22 @@ describe('createSlashHandler', () => {
expect(ctx.transcript.sys).toHaveBeenCalledWith('ui redrawn')
})
it('opens the editor locally for /prompt without slash worker fallback', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/prompt')).toBe(true)
expect(ctx.composer.openEditor).toHaveBeenCalledTimes(1)
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
it('routes /compose to the editor and seeds inline text', () => {
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/compose draft text')).toBe(true)
expect(ctx.composer.setInput).toHaveBeenCalledWith('draft text')
expect(ctx.composer.openEditor).toHaveBeenCalledTimes(1)
})
it('exits locally for /quit', () => {
const ctx = buildCtx()
@ -875,6 +891,7 @@ const buildCtx = (overrides: Partial<Ctx> = {}): Ctx => ({
const buildComposer = () => ({
enqueue: vi.fn(),
hasSelection: false,
openEditor: vi.fn(async () => {}),
paste: vi.fn(),
queueRef: { current: [] as string[] },
selection: { copySelection: vi.fn(async () => '') },

View file

@ -333,6 +333,7 @@ export interface SlashHandlerContext {
composer: {
enqueue: (text: string) => void
hasSelection: boolean
openEditor: () => Promise<void>
paste: (quiet?: boolean) => void
queueRef: MutableRefObject<string[]>
selection: SelectionApi

View file

@ -429,6 +429,24 @@ export const coreCommands: SlashCommand[] = [
run: (arg, ctx) => (arg ? ctx.transcript.sys('usage: /paste') : ctx.composer.paste())
},
{
aliases: ['compose'],
help: 'compose your next prompt in $EDITOR (same as Ctrl+G)',
name: 'prompt',
run: (arg, ctx) => {
if (arg) {
// The TUI editor opens with the current composer draft; there is no
// separate seed arg. Drop any inline text into the composer first so
// it carries into the editor, matching the CLI's /prompt <text>.
ctx.composer.setInput(arg)
}
void ctx.composer.openEditor().catch((err: unknown) => {
ctx.transcript.sys(`editor failed: ${String(err)}`)
})
}
},
{
help: 'configure IDE terminal keybindings for multiline + undo/redo',
name: 'terminal-setup',

View file

@ -833,6 +833,7 @@ export function useMainApp(gw: GatewayClient) {
composer: {
enqueue: composerActions.enqueue,
hasSelection,
openEditor: composerActions.openEditor,
paste,
queueRef: composerRefs.queueRef,
selection,