diff --git a/cli.py b/cli.py index 641044bc924..fa9ac41b130 100644 --- a/cli.py +++ b/cli.py @@ -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 diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index f4c05060140..d93897d2609 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -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. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index a0d0882dcbb..d5cc9cee8c1 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -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", diff --git a/tests/hermes_cli/test_prompt_compose_command.py b/tests/hermes_cli/test_prompt_compose_command.py new file mode 100644 index 00000000000..eae36a5a1aa --- /dev/null +++ b/tests/hermes_cli/test_prompt_compose_command.py @@ -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 diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 1057578093f..f7ea42df537 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -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 => ({ const buildComposer = () => ({ enqueue: vi.fn(), hasSelection: false, + openEditor: vi.fn(async () => {}), paste: vi.fn(), queueRef: { current: [] as string[] }, selection: { copySelection: vi.fn(async () => '') }, diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index f570cf2b6ab..a4d21412c88 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -333,6 +333,7 @@ export interface SlashHandlerContext { composer: { enqueue: (text: string) => void hasSelection: boolean + openEditor: () => Promise paste: (quiet?: boolean) => void queueRef: MutableRefObject selection: SelectionApi diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 5c74eb3eb42..d87a1ec7513 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -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 . + 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', diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index d11e8e08dba..b0db1e1f945 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -833,6 +833,7 @@ export function useMainApp(gw: GatewayClient) { composer: { enqueue: composerActions.enqueue, hasSelection, + openEditor: composerActions.openEditor, paste, queueRef: composerRefs.queueRef, selection,