diff --git a/cli.py b/cli.py index 0c712f1ffcb78..213a0be1fe449 100644 --- a/cli.py +++ b/cli.py @@ -4675,6 +4675,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): self._pet_turn_error: bool = False self._attached_images: list[Path] = [] self._image_counter = 0 + # Ctrl+S prompt stash โ€” park a half-written draft, send something + # else, bring the draft back. Session-scoped and in-memory only: + # drafts routinely contain secrets, so nothing is written to disk. + from hermes_cli.prompt_stash import PromptStash as _PromptStash + self._prompt_stash = _PromptStash() self.preloaded_skills: list[str] = [] self._startup_skills_line_shown = False self._active_session_lease = None @@ -6090,6 +6095,27 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): frags.append(("class:status-bar-yolo", "โš  YOLO")) frags.append(("class:status-bar", " ")) + # Stash indicator (๐Ÿ“Œ N) โ€” appended after all width tiers so the + # user always knows a parked draft exists, even on narrow + # terminals. Placed before the battery prepend so it stays at the + # right edge, and it is the first thing the width trim below drops + # if the bar genuinely cannot fit. + try: + stash_indicator = self._prompt_stash.indicator() + except Exception: + stash_indicator = "" + if stash_indicator: + # Insert before the trailing pad fragment so the bar keeps its + # one-cell right margin. + if frags and frags[-1] == ("class:status-bar", " "): + frags[-1:-1] = [ + ("class:status-bar-dim", " ยท "), + ("class:status-bar-strong", stash_indicator), + ] + else: + frags.append(("class:status-bar-dim", " ยท ")) + frags.append(("class:status-bar-strong", stash_indicator)) + # Battery is the first status-bar element when enabled: prepend it # ahead of the leading โš• marker in whichever width tier ran above. if battery_label: @@ -6123,42 +6149,60 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): return f"{mins // 60}h ago" def _render_stash_panel(self, stash_list: list, cursor: int, width: int) -> list: - """Return prompt_toolkit formatted_text fragments for the stash panel box.""" - W = min(width - 4, 80) + """Return prompt_toolkit formatted_text fragments for the stash panel box. + + Every horizontal measurement goes through ``_status_bar_display_width`` + (prompt_toolkit's ``get_cwidth``) rather than ``len()``. The header + contains ๐Ÿ“Œ, which is one Python codepoint but two terminal cells; the + original PR chased that off-by-one through three successive + "subtract 1 from len()" commits. Measuring in display cells fixes it + for real and keeps CJK previews from bleeding past the right border. + """ + cw = self._status_bar_display_width + W = max(12, min(width - 4, 80)) - HDR_PREFIX = "โ•ญโ”€ ๐Ÿ“Œ Stash (" n = len(stash_list) - title_mid = f"{n} item{'s' if n != 1 else ''}) " + hdr_prefix_str = f"โ•ญโ”€ ๐Ÿ“Œ Stash ({n} item{'s' if n != 1 else ''}) " HDR_SUFFIX = " Ctrl+S โ”€โ•ฎ" FTR_PREFIX = "โ•ฐ" FTR_SUFFIX = " โ†‘โ†“ Enter=restore D=delete Esc โ”€โ•ฏ" - # Header dashes fill between title and suffix - # HDR_PREFIX includes emoji (๐Ÿ“Œ = 2 wide) โ€” measure in display cols - hdr_fixed = 2 + len(HDR_PREFIX) - 2 + len(title_mid) + len(HDR_SUFFIX) - # ๐Ÿ“Œ is 2 wide, "โ•ญโ”€ " already counted title chars fine since we - # just need to fit in W columns - hdr_prefix_str = f"{HDR_PREFIX}{title_mid}" - hdr_dashes = max(0, W - len(hdr_prefix_str) - len(HDR_SUFFIX)) - ftr_dashes = max(0, W - len(FTR_PREFIX) - len(FTR_SUFFIX)) + # On narrow terminals the full hint text is wider than the box itself. + # Drop to compact affordances rather than letting the frame bleed past + # the right edge (which is what made the panel look broken). + if cw(hdr_prefix_str) + cw(HDR_SUFFIX) > W: + hdr_prefix_str = f"โ•ญโ”€ ๐Ÿ“Œ {n} " + HDR_SUFFIX = "โ”€โ•ฎ" + if cw(FTR_PREFIX) + cw(FTR_SUFFIX) > W: + FTR_SUFFIX = " โ†‘โ†“ โŽ D Esc โ”€โ•ฏ" + if cw(FTR_PREFIX) + cw(FTR_SUFFIX) > W: + FTR_SUFFIX = "โ”€โ•ฏ" - # Row inner width: W minus 2 border chars 'โ”‚' on each side + hdr_dashes = max(0, W - cw(hdr_prefix_str) - cw(HDR_SUFFIX)) + ftr_dashes = max(0, W - cw(FTR_PREFIX) - cw(FTR_SUFFIX)) + + # Row inner width: W minus the two 'โ”‚' border cells. INNER = W - 2 frags: list = [] def line(text: str, style: str = "") -> None: - frags.append((style, text + "\n")) + # Final guard: never emit a line wider than the box, whatever the + # label lengths worked out to. + frags.append((style, self._trim_status_bar_text(text, W) + "\n")) line(f"{hdr_prefix_str}{'โ”€' * hdr_dashes}{HDR_SUFFIX}", "class:subagent-border") for i, item in enumerate(stash_list): age = self._fmt_stash_age(item["stashed_at"]) # Row: " โ–บ [N] {age:<10} {preview} " - prefix = f" {'โ–บ' if i == cursor else ' '} [{i+1}] {age:<10} " - avail = max(0, INNER - len(prefix) - 1) - preview = item["preview"][:avail].ljust(avail) - row = f"โ”‚{prefix}{preview} โ”‚" + prefix = f" {'โ–บ' if i == cursor else ' '} [{i + 1}] {age:<10} " + if cw(prefix) > INNER - 2: + prefix = f" {'โ–บ' if i == cursor else ' '} [{i + 1}] " + avail = max(0, INNER - cw(prefix) - 1) + preview = self._trim_status_bar_text(item.get("preview") or "", avail) + preview = preview + " " * max(0, avail - cw(preview)) + row = self._trim_status_bar_text(f"โ”‚{prefix}{preview} โ”‚", W) if i == cursor: frags.append(("class:subagent-selected", row + "\n")) else: @@ -14640,6 +14684,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): spacer, *self._get_extra_tui_widgets(), getattr(self, "_pet_widget", None), + getattr(self, "_stash_panel_widget", None), status_bar, input_rule_top, image_bar, @@ -15214,6 +15259,105 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): """Ctrl+G (or Alt+G in VSCode/Cursor) opens the current draft in an external editor.""" cli_ref._open_external_editor(event.current_buffer) + # --- Ctrl+S prompt stash ------------------------------------------- + # Park a half-written draft, send something else, then bring the draft + # back. Suppressed while a modal prompt owns the composer (sudo / + # secret / approval / clarify) so Ctrl+S can't stash a password. + _stash_filter = Condition( + lambda: not cli_ref._clarify_state + and not cli_ref._approval_state + and not cli_ref._sudo_state + and not cli_ref._secret_state + and not cli_ref._slash_confirm_state + and not cli_ref._model_picker_state + ) + _stash_panel_filter = Condition( + lambda: cli_ref._prompt_stash.panel_open and bool(len(cli_ref._prompt_stash)) + ) + + def _restore_stash_payload(event, payload) -> None: + """Put a popped (text, images) payload back into the composer.""" + if not payload: + return + text, images = payload + buf = event.app.current_buffer + buf.text = text + buf.cursor_position = len(text) + if images: + # Restore attachments the draft was carrying. Extend rather + # than replace: the user may have attached something new since + # the stash was taken and silently dropping it would be data + # loss. + for img in images: + if img not in cli_ref._attached_images: + cli_ref._attached_images.append(img) + + @kb.add('c-s', filter=_stash_filter) + def handle_prompt_stash(event): + """Ctrl+S: stash the current draft, or restore/browse a stashed one. + + - Composer has content โ†’ push it onto the stash and clear the input. + - Composer empty, one stashed draft โ†’ pop it straight back. + - Composer empty, several stashed โ†’ open the browse panel. + - Browse panel open โ†’ close it. + + Pushing onto a stack (rather than a single slot) is what makes + repeated Ctrl+S safe: a second stash never silently overwrites the + first, both stay reachable in the panel. + """ + from hermes_cli.prompt_stash import ( + ACTION_OPEN_PANEL, + ACTION_RESTORED, + ACTION_STASHED, + resolve_ctrl_s, + ) + + buf = event.app.current_buffer + action, payload = resolve_ctrl_s( + cli_ref._prompt_stash, buf.text, cli_ref._attached_images + ) + + if action == ACTION_STASHED: + # reset() (not `text = ""`) so completion state, selection, and + # the undo stack are cleared along with the text. + buf.reset() + cli_ref._attached_images.clear() + elif action == ACTION_RESTORED: + _restore_stash_payload(event, payload) + elif action == ACTION_OPEN_PANEL: + pass # resolve_ctrl_s already flipped panel_open + + event.app.invalidate() + + @kb.add('up', filter=_stash_panel_filter, eager=True) + def handle_stash_panel_up(event): + cli_ref._prompt_stash.move_cursor(-1) + event.app.invalidate() + + @kb.add('down', filter=_stash_panel_filter, eager=True) + def handle_stash_panel_down(event): + cli_ref._prompt_stash.move_cursor(1) + event.app.invalidate() + + @kb.add('enter', filter=_stash_panel_filter, eager=True) + def handle_stash_panel_restore(event): + """Enter in the browse panel restores the highlighted draft.""" + payload = cli_ref._prompt_stash.restore_at_cursor() + _restore_stash_payload(event, payload) + event.app.invalidate() + + @kb.add('d', filter=_stash_panel_filter, eager=True) + @kb.add('D', filter=_stash_panel_filter, eager=True) + def handle_stash_panel_delete(event): + """D in the browse panel discards the highlighted draft.""" + cli_ref._prompt_stash.delete_at_cursor() + event.app.invalidate() + + @kb.add('escape', filter=_stash_panel_filter, eager=True) + def handle_stash_panel_close(event): + cli_ref._prompt_stash.close_panel() + event.app.invalidate() + @kb.add('tab', eager=True) def handle_tab(event): """Tab: accept completion, auto-suggestion, or start completions. @@ -16079,6 +16223,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): if cli_ref._voice_mode: _label = cli_ref._voice_record_key_label() return f"type or {_label} to record" + # Advertise a parked draft so the stash can never be silently + # forgotten โ€” the composer itself tells you how to get it back. + _stash_hint = "" + try: + _stash_hint = cli_ref._prompt_stash.placeholder_hint() + except Exception: + _stash_hint = "" + if _stash_hint: + return _stash_hint return "" input_area.control.input_processors.append(_PlaceholderProcessor(_get_placeholder)) @@ -16656,6 +16809,30 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): ), ) + # Stash browse panel โ€” appears just above the status bar when the user + # presses Ctrl+S on an empty composer with 2+ stashed drafts. + def _get_stash_panel_display(): + try: + _stash = cli_ref._prompt_stash + return cli_ref._render_stash_panel( + _stash.panel_rows(), + _stash.panel_cursor, + cli_ref._get_tui_terminal_width(), + ) + except Exception: + return [] + + self._stash_panel_widget = ConditionalContainer( + Window( + FormattedTextControl(_get_stash_panel_display), + wrap_lines=False, + ), + filter=Condition( + lambda: cli_ref._prompt_stash.panel_open + and bool(len(cli_ref._prompt_stash)) + ), + ) + # Allow wrapper CLIs to register extra keybindings. self._register_extra_tui_keybindings(kb, input_area=input_area) diff --git a/hermes_cli/prompt_stash.py b/hermes_cli/prompt_stash.py new file mode 100644 index 0000000000000..b0397defffd65 --- /dev/null +++ b/hermes_cli/prompt_stash.py @@ -0,0 +1,260 @@ +"""Ctrl+S prompt stash โ€” pure state machine for the classic CLI composer. + +Park a half-written prompt, send something else, then bring the draft back. +Mirrors Claude Code's ``ctrl + s to stash prompt`` affordance. + +The state machine lives here (no prompt_toolkit imports) so it can be unit +tested directly; ``cli.py`` owns only the keybinding and the rendering. + +Gesture +------- +- Buffer has content โ†’ push it onto the stash, clear the composer. +- Buffer empty, 1 item โ†’ pop it straight back into the composer. +- Buffer empty, 2+ items โ†’ open the browse panel (โ†‘โ†“ / Enter / D / Esc). + +Newest-first ordering: index 0 is always the most recently stashed draft, so +the common "undo my last Ctrl+S" case is a single keystroke. + +Nothing is written to disk. Drafts frequently contain credentials, prompts +under NDA, or pasted secrets, and a session-scoped stash keeps that material +in memory only. Callers that later want cross-restart persistence must route +through ``get_hermes_home()`` rather than hardcoding ``~/.hermes``. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Any, List, Optional, Sequence, Tuple + +# Single-line preview length for the browse panel. +PREVIEW_WIDTH = 60 + +# Cap the stack so a user leaning on Ctrl+S can't grow it without bound. +MAX_STASH_ITEMS = 20 + + +def build_preview(text: str, width: int = PREVIEW_WIDTH) -> str: + """Collapse a possibly multi-line draft into one preview line. + + Newlines and tabs become ``โŽ``/space so a 40-line draft still renders as a + single panel row, and the result is ellipsized to ``width`` display chars. + """ + if not text: + return "" + flat = text.replace("\r\n", "\n").replace("\r", "\n") + flat = flat.replace("\n", " โŽ ").replace("\t", " ") + flat = " ".join(flat.split()) + if width > 1 and len(flat) > width: + return flat[: width - 1] + "โ€ฆ" + return flat + + +@dataclass +class StashEntry: + """One parked draft: exact text plus any images that were attached.""" + + text: str + images: List[Any] = field(default_factory=list) + stashed_at: float = 0.0 + preview: str = "" + + def as_dict(self) -> dict: + """Render in the shape ``HermesCLI._render_stash_panel`` consumes.""" + return { + "text": self.text, + "images": list(self.images), + "stashed_at": self.stashed_at, + "preview": self.preview, + } + + +class PromptStash: + """Session-scoped stack of parked composer drafts. + + Pure state: no I/O, no prompt_toolkit, no global clock beyond + ``time.monotonic`` (injectable for tests via ``clock``). + """ + + def __init__(self, *, max_items: int = MAX_STASH_ITEMS, clock=None): + self._items: List[StashEntry] = [] + self._max_items = max(1, int(max_items)) + self._clock = clock or time.monotonic + self.panel_open = False + self.panel_cursor = 0 + + # ---------------------------------------------------------------- queries + + def __len__(self) -> int: + return len(self._items) + + def __bool__(self) -> bool: + # Explicit: an empty stash is falsey, but len() drives that anyway. + return bool(self._items) + + @property + def items(self) -> List[StashEntry]: + """Newest-first list of entries (a copy โ€” mutate via the API).""" + return list(self._items) + + def panel_rows(self) -> List[dict]: + """Entries as plain dicts for the panel renderer.""" + return [e.as_dict() for e in self._items] + + def indicator(self) -> str: + """Status-bar indicator, or ``""`` when the stash is empty. + + ``๐Ÿ“Œ 2`` when idle, ``๐Ÿ“Œ 2 โ–ฒ`` while the browse panel is open, so the + user can always tell a parked draft exists without opening anything. + """ + n = len(self._items) + if not n: + return "" + return f"๐Ÿ“Œ {n} โ–ฒ" if self.panel_open else f"๐Ÿ“Œ {n}" + + def placeholder_hint(self) -> str: + """Composer placeholder text advertising the stashed draft.""" + n = len(self._items) + if not n: + return "" + if n == 1: + return f"Ctrl+S to restore: {self._items[0].preview}" + return f"Ctrl+S to browse {n} stashed drafts" + + # --------------------------------------------------------------- mutators + + def stash(self, text: str, images: Optional[Sequence[Any]] = None) -> bool: + """Push a draft. Returns False (no-op) for a blank buffer. + + A buffer that is empty or whitespace-only is not worth parking and + must stay a no-op, otherwise Ctrl+S on an empty composer would push a + junk entry instead of triggering the restore half of the gesture. + Text is stored verbatim โ€” leading/trailing whitespace and newlines are + preserved so a restore round-trips byte-for-byte. + """ + has_images = bool(images) + if not (text or "").strip() and not has_images: + return False + + entry = StashEntry( + text=text or "", + images=list(images or []), + stashed_at=self._clock(), + preview=build_preview(text or "") or "(images only)", + ) + self._items.insert(0, entry) + # Drop the oldest entries past the cap. + del self._items[self._max_items:] + # A push invalidates any open browse session. + self.panel_open = False + self.panel_cursor = 0 + return True + + def pop(self, index: int = 0) -> Optional[Tuple[str, List[Any]]]: + """Remove and return ``(text, images)`` at ``index``, or None.""" + if not self._items or not (0 <= index < len(self._items)): + return None + entry = self._items.pop(index) + if not self._items: + self.panel_open = False + self.panel_cursor = self._clamp_cursor(self.panel_cursor) + return entry.text, list(entry.images) + + def peek(self, index: int = 0) -> Optional[StashEntry]: + """Return the entry at ``index`` without removing it.""" + if not self._items or not (0 <= index < len(self._items)): + return None + return self._items[index] + + def clear(self) -> None: + self._items.clear() + self.panel_open = False + self.panel_cursor = 0 + + # ------------------------------------------------------------ panel state + + def _clamp_cursor(self, value: int) -> int: + if not self._items: + return 0 + return max(0, min(int(value), len(self._items) - 1)) + + def open_panel(self) -> bool: + """Open the browse panel. False when there is nothing to browse.""" + if not self._items: + return False + self.panel_open = True + self.panel_cursor = 0 + return True + + def close_panel(self) -> None: + self.panel_open = False + self.panel_cursor = 0 + + def move_cursor(self, delta: int) -> int: + """Move the panel cursor, clamped to the list bounds.""" + self.panel_cursor = self._clamp_cursor(self.panel_cursor + int(delta)) + return self.panel_cursor + + def delete_at_cursor(self) -> bool: + """Delete the highlighted entry. False when there was nothing to drop.""" + if not self._items: + return False + idx = self._clamp_cursor(self.panel_cursor) + self._items.pop(idx) + if not self._items: + self.panel_open = False + self.panel_cursor = 0 + else: + self.panel_cursor = self._clamp_cursor(idx) + return True + + def restore_at_cursor(self) -> Optional[Tuple[str, List[Any]]]: + """Pop the highlighted entry and close the panel.""" + if not self._items: + return None + result = self.pop(self._clamp_cursor(self.panel_cursor)) + self.close_panel() + return result + + +# --------------------------------------------------------------------- gesture + +# Outcomes of a single Ctrl+S press. +ACTION_NOOP = "noop" +ACTION_STASHED = "stashed" +ACTION_RESTORED = "restored" +ACTION_OPEN_PANEL = "open_panel" +ACTION_CLOSE_PANEL = "close_panel" + + +def resolve_ctrl_s( + stash: PromptStash, + buffer_text: str, + images: Optional[Sequence[Any]] = None, +) -> Tuple[str, Optional[Tuple[str, List[Any]]]]: + """Decide what one Ctrl+S press does. Returns ``(action, payload)``. + + ``payload`` carries ``(text, images)`` for :data:`ACTION_RESTORED`, else + None. This is the whole decision table in one pure function so the + keybinding handler in ``cli.py`` stays a thin adapter. + """ + # Panel open โ†’ Ctrl+S is the "close it" escape hatch. + if stash.panel_open: + stash.close_panel() + return ACTION_CLOSE_PANEL, None + + # Something to park โ†’ park it. Never silently clobbers an existing stash: + # entries push onto a stack, so an earlier draft is still reachable. + if (buffer_text or "").strip() or images: + if stash.stash(buffer_text, images): + return ACTION_STASHED, None + return ACTION_NOOP, None + + # Empty buffer โ†’ restore half of the gesture. + count = len(stash) + if count == 0: + return ACTION_NOOP, None + if count == 1: + return ACTION_RESTORED, stash.pop(0) + stash.open_panel() + return ACTION_OPEN_PANEL, None diff --git a/tests/cli/test_prompt_stash.py b/tests/cli/test_prompt_stash.py new file mode 100644 index 0000000000000..dd6fa39e288ab --- /dev/null +++ b/tests/cli/test_prompt_stash.py @@ -0,0 +1,436 @@ +"""Tests for the Ctrl+S prompt stash state machine (hermes_cli.prompt_stash). + +Covers the pure state machine directly โ€” no prompt_toolkit, no TUI: + - stashing an empty/whitespace buffer is a no-op + - stash โ†’ restore round-trips exact text including newlines + - repeated stashes never silently clobber an earlier draft + - indicator / placeholder state + - browse-panel cursor, delete, and restore + - the resolve_ctrl_s decision table +""" + +from __future__ import annotations + +import pytest + +from hermes_cli.prompt_stash import ( + ACTION_CLOSE_PANEL, + ACTION_NOOP, + ACTION_OPEN_PANEL, + ACTION_RESTORED, + ACTION_STASHED, + MAX_STASH_ITEMS, + PromptStash, + StashEntry, + build_preview, + resolve_ctrl_s, +) + + +class _FakeClock: + """Deterministic monotonic clock for age assertions.""" + + def __init__(self, start: float = 1000.0): + self.now = start + + def __call__(self) -> float: + return self.now + + def advance(self, secs: float) -> None: + self.now += secs + + +@pytest.fixture +def stash(): + return PromptStash(clock=_FakeClock()) + + +# --------------------------------------------------------------- no-op cases + + +class TestStashNoOp: + """An empty or whitespace-only composer must not create a stash entry.""" + + @pytest.mark.parametrize("text", ["", " ", "\n", "\t\t", " \n \n ", None]) + def test_stash_blank_buffer_is_noop(self, stash, text): + assert stash.stash(text) is False + assert len(stash) == 0 + assert stash.indicator() == "" + + def test_pop_empty_stash_returns_none(self, stash): + assert stash.pop() is None + + def test_peek_empty_stash_returns_none(self, stash): + assert stash.peek() is None + + def test_open_panel_on_empty_stash_refused(self, stash): + assert stash.open_panel() is False + assert stash.panel_open is False + + def test_delete_on_empty_stash_is_noop(self, stash): + assert stash.delete_at_cursor() is False + + def test_restore_at_cursor_on_empty_stash(self, stash): + assert stash.restore_at_cursor() is None + + def test_images_only_draft_is_stashable(self, stash): + """Blank text but attached images is still worth parking.""" + assert stash.stash("", ["/tmp/a.png"]) is True + assert len(stash) == 1 + assert stash.peek().preview == "(images only)" + + +# ------------------------------------------------------------- round-tripping + + +class TestRoundTrip: + """Restore must return the draft byte-for-byte.""" + + @pytest.mark.parametrize( + "text", + [ + "hello", + "line one\nline two", + "line one\nline two\nline three\n", + "\n leading blank and indented\n", + "trailing spaces ", + " leading spaces", + "para one\n\npara two\n\n\npara three", + "tabs\there\tand\there", + "unicode รผnรฏcรถdรฉ ไธญๆ–‡ ๐ŸŽ‰ mixed", + "```python\ndef f():\n return 1\n```", + ], + ) + def test_stash_then_restore_round_trips_exactly(self, stash, text): + assert stash.stash(text) is True + result = stash.pop() + assert result is not None + restored, images = result + assert restored == text + assert images == [] + # Popping consumed the entry. + assert len(stash) == 0 + + def test_multiline_draft_preserves_every_newline(self, stash): + text = "a\nb\nc\nd\ne" + stash.stash(text) + restored, _ = stash.pop() + assert restored.count("\n") == 4 + assert restored.splitlines() == ["a", "b", "c", "d", "e"] + + def test_round_trip_through_resolve_ctrl_s(self, stash): + """The full gesture: Ctrl+S to park, Ctrl+S on empty to bring back.""" + draft = "a long prompt\nwith several lines\n" + action, payload = resolve_ctrl_s(stash, draft) + assert action == ACTION_STASHED + assert payload is None + assert len(stash) == 1 + + action, payload = resolve_ctrl_s(stash, "") + assert action == ACTION_RESTORED + assert payload == (draft, []) + assert len(stash) == 0 + + def test_images_round_trip(self, stash): + imgs = ["/tmp/one.png", "/tmp/two.png"] + stash.stash("with pics", imgs) + text, restored = stash.pop() + assert text == "with pics" + assert restored == imgs + # The stash must hold its own copy โ€” mutating the caller's list after + # stashing cannot corrupt the parked entry. + imgs.append("/tmp/three.png") + assert restored == ["/tmp/one.png", "/tmp/two.png"] + + +# --------------------------------------------------------------- no clobbering + + +class TestNoSilentClobber: + """A second Ctrl+S must not destroy the first draft.""" + + def test_second_stash_keeps_first(self, stash): + stash.stash("first draft") + stash.stash("second draft") + assert len(stash) == 2 + texts = [e.text for e in stash.items] + assert "first draft" in texts + assert "second draft" in texts + + def test_newest_first_ordering(self, stash): + stash.stash("oldest") + stash.stash("middle") + stash.stash("newest") + assert [e.text for e in stash.items] == ["newest", "middle", "oldest"] + # Default pop takes the most recent โ€” the "undo my last Ctrl+S" case. + assert stash.pop()[0] == "newest" + + def test_two_items_opens_panel_instead_of_guessing(self, stash): + """With 2+ drafts, Ctrl+S must not silently pick one.""" + stash.stash("first") + stash.stash("second") + action, payload = resolve_ctrl_s(stash, "") + assert action == ACTION_OPEN_PANEL + assert payload is None + assert stash.panel_open is True + # Nothing was consumed. + assert len(stash) == 2 + + def test_stash_cap_drops_oldest_not_newest(self): + s = PromptStash(max_items=3, clock=_FakeClock()) + for i in range(5): + s.stash(f"draft {i}") + assert len(s) == 3 + assert [e.text for e in s.items] == ["draft 4", "draft 3", "draft 2"] + + def test_default_cap_is_bounded(self, stash): + for i in range(MAX_STASH_ITEMS + 10): + stash.stash(f"d{i}") + assert len(stash) == MAX_STASH_ITEMS + + +# -------------------------------------------------------------- indicator state + + +class TestIndicatorState: + def test_empty_stash_has_no_indicator(self, stash): + assert stash.indicator() == "" + assert stash.placeholder_hint() == "" + assert bool(stash) is False + + def test_single_item_indicator(self, stash): + stash.stash("draft") + assert stash.indicator() == "๐Ÿ“Œ 1" + assert bool(stash) is True + + def test_count_grows_with_stash(self, stash): + stash.stash("a") + assert stash.indicator() == "๐Ÿ“Œ 1" + stash.stash("b") + assert stash.indicator() == "๐Ÿ“Œ 2" + stash.stash("c") + assert stash.indicator() == "๐Ÿ“Œ 3" + + def test_indicator_marks_open_panel(self, stash): + stash.stash("a") + stash.stash("b") + stash.open_panel() + assert stash.indicator() == "๐Ÿ“Œ 2 โ–ฒ" + stash.close_panel() + assert stash.indicator() == "๐Ÿ“Œ 2" + + def test_indicator_clears_after_restoring_last_item(self, stash): + stash.stash("only") + stash.pop() + assert stash.indicator() == "" + + def test_placeholder_hint_single_shows_preview(self, stash): + stash.stash("write the migration guide") + hint = stash.placeholder_hint() + assert "Ctrl+S" in hint + assert "write the migration guide" in hint + + def test_placeholder_hint_multi_shows_count(self, stash): + stash.stash("a") + stash.stash("b") + stash.stash("c") + assert stash.placeholder_hint() == "Ctrl+S to browse 3 stashed drafts" + + def test_clear_resets_all_state(self, stash): + stash.stash("a") + stash.stash("b") + stash.open_panel() + stash.clear() + assert len(stash) == 0 + assert stash.panel_open is False + assert stash.panel_cursor == 0 + assert stash.indicator() == "" + + +# ----------------------------------------------------------------- previewing + + +class TestBuildPreview: + def test_empty_text(self): + assert build_preview("") == "" + + def test_single_line_passthrough(self): + assert build_preview("hello world") == "hello world" + + def test_newlines_collapse_to_marker(self): + assert build_preview("a\nb") == "a โŽ b" + + def test_crlf_normalized(self): + assert build_preview("a\r\nb") == "a โŽ b" + + def test_preview_is_always_single_line(self): + preview = build_preview("x\n" * 30, width=200) + assert "\n" not in preview + + def test_long_text_ellipsized_to_width(self): + preview = build_preview("y" * 500, width=20) + assert len(preview) == 20 + assert preview.endswith("โ€ฆ") + + def test_whitespace_runs_collapsed(self): + assert build_preview("a b\t\tc") == "a b c" + + +class TestStashEntry: + def test_as_dict_shape_matches_panel_renderer(self, stash): + stash.stash("draft text") + row = stash.panel_rows()[0] + # _render_stash_panel indexes these exact keys. + assert set(row) >= {"text", "images", "stashed_at", "preview"} + assert row["text"] == "draft text" + assert row["preview"] == "draft text" + + def test_as_dict_copies_images(self): + imgs = ["/tmp/a.png"] + entry = StashEntry(text="t", images=imgs) + entry.as_dict()["images"].append("/tmp/b.png") + assert imgs == ["/tmp/a.png"] + + def test_stashed_at_uses_injected_clock(self): + clock = _FakeClock(start=500.0) + s = PromptStash(clock=clock) + s.stash("first") + clock.advance(60) + s.stash("second") + entries = s.items + assert entries[0].stashed_at == 560.0 # newest first + assert entries[1].stashed_at == 500.0 + + +# --------------------------------------------------------------- panel browsing + + +class TestPanelBrowsing: + @pytest.fixture + def three(self): + s = PromptStash(clock=_FakeClock()) + s.stash("oldest") + s.stash("middle") + s.stash("newest") + s.open_panel() + return s + + def test_open_panel_starts_at_top(self, three): + assert three.panel_open is True + assert three.panel_cursor == 0 + + def test_cursor_moves_and_clamps(self, three): + assert three.move_cursor(1) == 1 + assert three.move_cursor(1) == 2 + # Clamped at the bottom โ€” no wraparound, no IndexError. + assert three.move_cursor(1) == 2 + assert three.move_cursor(-5) == 0 + + def test_restore_at_cursor_picks_highlighted_entry(self, three): + three.move_cursor(1) # "middle" + result = three.restore_at_cursor() + assert result == ("middle", []) + assert three.panel_open is False + assert [e.text for e in three.items] == ["newest", "oldest"] + + def test_delete_at_cursor_removes_only_that_entry(self, three): + three.move_cursor(1) + assert three.delete_at_cursor() is True + assert [e.text for e in three.items] == ["newest", "oldest"] + assert three.panel_open is True + + def test_delete_last_row_reclamps_cursor(self, three): + three.move_cursor(2) # bottom row + three.delete_at_cursor() + assert three.panel_cursor == 1 # clamped into the shortened list + + def test_deleting_everything_closes_panel(self, three): + for _ in range(3): + three.delete_at_cursor() + assert len(three) == 0 + assert three.panel_open is False + assert three.panel_cursor == 0 + + def test_new_stash_closes_open_panel(self, three): + three.stash("brand new") + assert three.panel_open is False + assert three.panel_cursor == 0 + + def test_panel_rows_ordered_newest_first(self, three): + assert [r["text"] for r in three.panel_rows()] == [ + "newest", + "middle", + "oldest", + ] + + def test_pop_out_of_range_is_none(self, three): + assert three.pop(99) is None + assert three.pop(-1) is None + assert len(three) == 3 + + +# ------------------------------------------------------ resolve_ctrl_s table + + +class TestResolveCtrlS: + def test_empty_buffer_empty_stash_is_noop(self, stash): + assert resolve_ctrl_s(stash, "") == (ACTION_NOOP, None) + + def test_whitespace_buffer_empty_stash_is_noop(self, stash): + assert resolve_ctrl_s(stash, " \n ") == (ACTION_NOOP, None) + + def test_content_stashes(self, stash): + action, payload = resolve_ctrl_s(stash, "some draft") + assert (action, payload) == (ACTION_STASHED, None) + assert len(stash) == 1 + + def test_open_panel_then_ctrl_s_closes_it(self, stash): + stash.stash("a") + stash.stash("b") + stash.open_panel() + action, payload = resolve_ctrl_s(stash, "") + assert (action, payload) == (ACTION_CLOSE_PANEL, None) + assert stash.panel_open is False + + def test_close_panel_takes_priority_over_stashing(self, stash): + """With the panel open, Ctrl+S closes it rather than stashing text.""" + stash.stash("a") + stash.stash("b") + stash.open_panel() + action, _ = resolve_ctrl_s(stash, "text the user typed") + assert action == ACTION_CLOSE_PANEL + assert len(stash) == 2 # nothing new pushed + + def test_images_only_buffer_stashes(self, stash): + action, _ = resolve_ctrl_s(stash, "", ["/tmp/x.png"]) + assert action == ACTION_STASHED + + def test_whitespace_only_buffer_with_stash_restores(self, stash): + """Whitespace-only counts as empty, so the restore half fires.""" + stash.stash("real draft") + action, payload = resolve_ctrl_s(stash, " ") + assert action == ACTION_RESTORED + assert payload == ("real draft", []) + + def test_stash_pop_stash_pop_cycle(self, stash): + for text in ("one", "two\nlines", "three\n\nparas"): + assert resolve_ctrl_s(stash, text)[0] == ACTION_STASHED + action, payload = resolve_ctrl_s(stash, "") + assert action == ACTION_RESTORED + assert payload is not None + assert payload[0] == text + assert len(stash) == 0 + + +# --------------------------------------------------------------------- ages + + +class TestAgeFormatting: + def test_age_reflects_injected_clock(self): + clock = _FakeClock() + s = PromptStash(clock=clock) + s.stash("draft") + entry = s.peek() + assert entry is not None + clock.advance(120) + assert clock() - entry.stashed_at == 120 diff --git a/tests/cli/test_prompt_stash_cli.py b/tests/cli/test_prompt_stash_cli.py new file mode 100644 index 0000000000000..d4ec7120d8376 --- /dev/null +++ b/tests/cli/test_prompt_stash_cli.py @@ -0,0 +1,304 @@ +"""Tests for the Ctrl+S prompt stash wiring inside HermesCLI. + +The state machine itself is covered by tests/cli/test_prompt_stash.py. These +tests verify the cli.py side: + - HermesCLI.__init__ creates a PromptStash + - the layout hook makes room for the stash browse panel + - _render_stash_panel renders bounded, display-width-correct rows + - the status-bar indicator appears / disappears with stash contents + +Follows the prompt_toolkit-stub construction pattern from +tests/cli/test_cli_extension_hooks.py. +""" + +from __future__ import annotations + +import importlib +import sys +from unittest.mock import MagicMock, patch + +import pytest + + +def _make_cli(**kwargs): + """Create a HermesCLI with prompt_toolkit stubbed out.""" + _clean_config = { + "model": { + "default": "anthropic/claude-opus-4.6", + "base_url": "https://openrouter.ai/api/v1", + "provider": "auto", + }, + "display": {"compact": False, "tool_progress": "all"}, + "agent": {}, + "terminal": {"env_type": "local"}, + } + clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""} + prompt_toolkit_stubs = { + "prompt_toolkit": MagicMock(), + "prompt_toolkit.history": MagicMock(), + "prompt_toolkit.styles": MagicMock(), + "prompt_toolkit.patch_stdout": MagicMock(), + "prompt_toolkit.application": MagicMock(), + "prompt_toolkit.layout": MagicMock(), + "prompt_toolkit.layout.processors": MagicMock(), + "prompt_toolkit.filters": MagicMock(), + "prompt_toolkit.layout.dimension": MagicMock(), + "prompt_toolkit.layout.menus": MagicMock(), + "prompt_toolkit.widgets": MagicMock(), + "prompt_toolkit.key_binding": MagicMock(), + "prompt_toolkit.completion": MagicMock(), + "prompt_toolkit.formatted_text": MagicMock(), + "prompt_toolkit.auto_suggest": MagicMock(), + } + with patch.dict(sys.modules, prompt_toolkit_stubs), patch.dict( + "os.environ", clean_env, clear=False + ): + import cli as _cli_mod + + _cli_mod = importlib.reload(_cli_mod) + with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), patch.dict( + _cli_mod.__dict__, {"CLI_CONFIG": _clean_config} + ): + return _cli_mod.HermesCLI(**kwargs) + + +@pytest.fixture(scope="module") +def cli(): + return _make_cli() + + +class TestStashStateInit: + def test_cli_has_prompt_stash(self, cli): + assert hasattr(cli, "_prompt_stash") + + def test_stash_starts_empty(self, cli): + # Duck-typed rather than isinstance: _make_cli reloads the `cli` + # module, which re-imports hermes_cli.prompt_toolkit stubs and can + # yield a distinct-but-equivalent PromptStash class object. + stash = cli._prompt_stash + assert type(stash).__name__ == "PromptStash" + assert len(stash) == 0 + assert stash.panel_open is False + assert stash.indicator() == "" + assert stash.placeholder_hint() == "" + + def test_stash_is_per_instance_not_shared(self): + """Two CLIs must not share one stash โ€” drafts would leak across sessions.""" + a = _make_cli() + b = _make_cli() + a._prompt_stash.stash("only in a") + assert len(a._prompt_stash) == 1 + assert len(b._prompt_stash) == 0 + + +class TestKeybindingRegistration: + """The Ctrl+S binding must actually be registered on the TUI KeyBindings.""" + + def test_ctrl_s_source_binding_exists(self): + """cli.py registers a c-s handler (regression guard for PR #4771). + + The original PR's head lost its keybinding during a rebase, shipping + the panel renderer with no way to reach it. Assert on the source so + that cannot silently regress again. + """ + from pathlib import Path + + source = Path( + importlib.import_module("cli").__file__ or "" + ).read_text(encoding="utf-8") + assert "@kb.add('c-s'" in source + assert "def handle_prompt_stash(" in source + + def test_panel_navigation_bindings_exist(self): + from pathlib import Path + + source = Path( + importlib.import_module("cli").__file__ or "" + ).read_text(encoding="utf-8") + for handler in ( + "def handle_stash_panel_up(", + "def handle_stash_panel_down(", + "def handle_stash_panel_restore(", + "def handle_stash_panel_delete(", + "def handle_stash_panel_close(", + ): + assert handler in source, f"missing panel handler: {handler}" + + def test_extension_hook_still_a_noop(self, cli): + """The stash binding lives in run(), not in the wrapper extension hook.""" + from prompt_toolkit.key_binding import KeyBindings + + kb = KeyBindings() + assert cli._register_extra_tui_keybindings(kb, input_area=None) is None + assert kb.bindings == [] + + +class TestLayoutSlot: + def test_layout_includes_stash_panel_when_present(self, cli): + cli._stash_panel_widget = "stash-panel" + try: + children = cli._build_tui_layout_children( + sudo_widget="sudo", + secret_widget="secret", + approval_widget="approval", + clarify_widget="clarify", + spinner_widget="spinner", + spacer="spacer", + status_bar="status", + input_rule_top="top-rule", + image_bar="image-bar", + input_area="input-area", + input_rule_bot="bottom-rule", + voice_status_bar="voice-status", + completions_menu="completions-menu", + ) + assert "stash-panel" in children + # Panel sits directly above the status bar. + assert children.index("stash-panel") < children.index("status") + finally: + cli._stash_panel_widget = None + + def test_layout_omits_stash_panel_when_absent(self, cli): + cli._stash_panel_widget = None + children = cli._build_tui_layout_children( + sudo_widget="sudo", + secret_widget="secret", + approval_widget="approval", + clarify_widget="clarify", + spinner_widget="spinner", + spacer="spacer", + status_bar="status", + input_rule_top="top-rule", + image_bar="image-bar", + input_area="input-area", + input_rule_bot="bottom-rule", + voice_status_bar="voice-status", + completions_menu="completions-menu", + ) + assert None not in children + + +class TestRenderStashPanel: + """Contributor's panel renderer, now measured in display cells.""" + + @staticmethod + def _rows(cli, count=3, width=100): + stash = type(cli._prompt_stash)() + for i in range(count): + stash.stash(f"draft number {i}") + return cli._render_stash_panel(stash.panel_rows(), 0, width) + + def test_returns_fragments(self, cli): + frags = self._rows(cli) + assert frags + assert all(isinstance(f, tuple) and len(f) == 2 for f in frags) + + def test_header_and_footer_present(self, cli): + text = "".join(t for _, t in self._rows(cli)) + assert "๐Ÿ“Œ Stash" in text + assert "Ctrl+S" in text + assert "Enter=restore" in text + assert "D=delete" in text + + def test_row_per_entry(self, cli): + text = "".join(t for _, t in self._rows(cli, count=3)) + for i in range(3): + assert f"[{i + 1}]" in text + + def test_singular_plural_item_label(self, cli): + one = "".join(t for _, t in self._rows(cli, count=1)) + assert "(1 item)" in one + two = "".join(t for _, t in self._rows(cli, count=2)) + assert "(2 items)" in two + + @pytest.mark.parametrize("width", [16, 20, 30, 40, 60, 80, 120, 400]) + def test_no_line_exceeds_terminal_width(self, cli, width): + """Rows must never bleed past the terminal โ€” the bug the PR's three + follow-up commits kept failing to fix by tweaking len().""" + from prompt_toolkit.utils import get_cwidth + + text = "".join(t for _, t in self._rows(cli, count=3, width=width)) + for line in text.split("\n"): + if line: + assert get_cwidth(line) <= max(width, 12), ( + f"line {line!r} is {get_cwidth(line)} cells, width={width}" + ) + + def test_multiline_draft_renders_as_one_row(self, cli): + stash = type(cli._prompt_stash)() + stash.stash("first line\nsecond line\nthird line") + frags = cli._render_stash_panel(stash.panel_rows(), 0, 100) + text = "".join(t for _, t in frags) + # header + 1 entry row + footer = 3 rendered lines + assert len([ln for ln in text.split("\n") if ln]) == 3 + + def test_wide_glyph_preview_does_not_overflow(self, cli): + """CJK previews are 2 cells per char โ€” must still fit the box.""" + from prompt_toolkit.utils import get_cwidth + + stash = type(cli._prompt_stash)() + stash.stash("ไธญๆ–‡" * 80) + frags = cli._render_stash_panel(stash.panel_rows(), 0, 60) + for line in "".join(t for _, t in frags).split("\n"): + if line: + assert get_cwidth(line) <= 60 + + def test_cursor_row_is_styled_differently(self, cli): + stash = type(cli._prompt_stash)() + stash.stash("a") + stash.stash("b") + styles = {s for s, _ in cli._render_stash_panel(stash.panel_rows(), 1, 100)} + assert "class:subagent-selected" in styles + + def test_empty_list_still_renders_frame(self, cli): + frags = cli._render_stash_panel([], 0, 80) + text = "".join(t for _, t in frags) + assert "(0 items)" in text + + +class TestStatusBarIndicator: + def test_no_indicator_when_stash_empty(self, cli): + cli._prompt_stash.clear() + cli._status_bar_visible = True + text = "".join(t for _, t in cli._get_status_bar_fragments()) + assert "๐Ÿ“Œ" not in text + + def test_indicator_appears_after_stashing(self, cli): + cli._prompt_stash.clear() + cli._status_bar_visible = True + cli._prompt_stash.stash("a parked draft") + try: + text = "".join(t for _, t in cli._get_status_bar_fragments()) + assert "๐Ÿ“Œ 1" in text + finally: + cli._prompt_stash.clear() + + def test_indicator_count_tracks_stash_size(self, cli): + cli._prompt_stash.clear() + cli._status_bar_visible = True + cli._prompt_stash.stash("a") + cli._prompt_stash.stash("b") + try: + text = "".join(t for _, t in cli._get_status_bar_fragments()) + assert "๐Ÿ“Œ 2" in text + finally: + cli._prompt_stash.clear() + + def test_indicator_clears_after_restore(self, cli): + cli._prompt_stash.clear() + cli._status_bar_visible = True + cli._prompt_stash.stash("a") + cli._prompt_stash.pop() + text = "".join(t for _, t in cli._get_status_bar_fragments()) + assert "๐Ÿ“Œ" not in text + + +class TestFmtStashAge: + def test_age_buckets(self, cli): + import time + + now = time.monotonic() + assert cli._fmt_stash_age(now) == "just now" + assert cli._fmt_stash_age(now - 30).endswith("s ago") + assert "min ago" in cli._fmt_stash_age(now - 300) + assert cli._fmt_stash_age(now - 7200).endswith("h ago") diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index aa479f03c9198..8cb6faf40e0d3 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -107,6 +107,7 @@ When resuming a previous session (`hermes -c` or `hermes --resume `), a "Pre | `Ctrl+B` | Start/stop voice recording when voice mode is enabled (`voice.record_key`, default: `ctrl+b`) | | `Ctrl+G` | Open the current input buffer in `$EDITOR` (vim/nvim/nano/VS Code/etc.). Save and quit to send the edited text as the next prompt โ€” ideal for long, multi-paragraph prompts. | | `Ctrl+X Ctrl+E` | Emacs-style alternate binding for the external editor (same behavior as `Ctrl+G`). | +| `Ctrl+S` | **Stash the prompt.** Parks the current draft and clears the composer so you can send something else first. Press `Ctrl+S` again on an empty composer to bring the draft back (cursor at the end, attached images restored). Repeated presses build a stack rather than overwriting, so an earlier draft is never silently lost โ€” with two or more stashed, `Ctrl+S` opens a browse panel (`โ†‘`/`โ†“` to navigate, `Enter` to restore, `D` to discard, `Esc` or `Ctrl+S` to close). A `๐Ÿ“Œ N` badge in the status bar shows how many drafts are parked. Multi-line drafts round-trip exactly, including blank lines. The stash lives in memory for the session only โ€” nothing is written to disk, since drafts often contain secrets. | | `Ctrl+C` | Interrupt agent (double-press within 2s to force exit) | | `Ctrl+D` | Exit | | `Ctrl+Z` | Suspend Hermes to background (Unix only). Run `fg` in the shell to resume. |