mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
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:
parent
95d53c3bcb
commit
9e96e70995
8 changed files with 190 additions and 0 deletions
2
cli.py
2
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
76
tests/hermes_cli/test_prompt_compose_command.py
Normal file
76
tests/hermes_cli/test_prompt_compose_command.py
Normal 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
|
||||
|
|
@ -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 () => '') },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -833,6 +833,7 @@ export function useMainApp(gw: GatewayClient) {
|
|||
composer: {
|
||||
enqueue: composerActions.enqueue,
|
||||
hasSelection,
|
||||
openEditor: composerActions.openEditor,
|
||||
paste,
|
||||
queueRef: composerRefs.queueRef,
|
||||
selection,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue