feat(cli): complete the Ctrl+S prompt stash — keybinding, state machine, tests

Finishes the input stash started in the preceding commit from PR #4771.
That PR shipped only the panel renderer: its `@kb.add('c-s')` handler and
stash-state initialization were lost in a rebase, so the panel predicate
read undefined `_stash_panel_open` / `_stash_list` and the feature was
unreachable. This adds the missing half and the tests the PR never had.

Resolves the review feedback on #4771:

- Rebuilt the stash on current main's keybinding setup. The `c-s` key was
  unbound repo-wide, so there is no conflict.
- Extracted the state machine into `hermes_cli/prompt_stash.py` as pure
  functions (no prompt_toolkit import) so it is directly unit testable —
  the PR was cli.py-only with zero tests.
- Dropped the PR's unrelated changes: delegation `supervisor_model` /
  `execution_model` config aliases, and stale reverts of the banner
  builder, worktree pruning, logging setup, and MCP toolset validation
  that its 14k-commit-old base dragged along.
- Fixed the 📌 double-width measurement for real. Three commits in the PR
  ("subtract 1 from len()", "use bare len()", "subtract 1 again") were
  chasing this by tweaking `len()`; all horizontal math now goes through
  `_status_bar_display_width` (prompt_toolkit `get_cwidth`), which also
  keeps CJK previews inside the border. Narrow terminals fall back to
  compact header/footer labels instead of overflowing — caught by a
  parametrized width test, not by eyeballing.

Gesture (the contributor's design, kept):

- Composer has content → push onto the stash, clear the input.
- Composer empty, one stashed → pop it straight back.
- Composer empty, 2+ stashed → open the browse panel (↑↓ / Enter / D / Esc).
- Panel open → Ctrl+S closes 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, and with
2+ parked the panel asks rather than guessing which to restore. A `📌 N`
status-bar badge and a composer placeholder advertise the parked draft so
it cannot be silently forgotten.

Deliberate departures from the PR:

- No auto-restore after the agent responds, and no `display.stash_auto_restore`
  config key. The PR itself had already defaulted this to false as
  "avoids surprising the user"; a keystroke the user pressed should not
  cause text to reappear on its own, so the dead default is dropped
  rather than carried as config surface.
- Nothing is persisted to disk. Drafts routinely contain pasted
  credentials and NDA material, so the stash is session-scoped and
  in-memory only. Any future persistence must route through
  `get_hermes_home()`.
- Suppressed while a modal prompt owns the composer (sudo / secret /
  approval / clarify / slash-confirm / model picker) so Ctrl+S can never
  stash a password.
- Restoring images extends `_attached_images` instead of replacing it, so
  an attachment added since the stash was taken is not silently dropped.
- `buf.reset()` on stash (not `text = ""`) clears completion state,
  selection, and undo stack with the text.

Tests: 95 new tests across two files — 66 on the state machine (empty
buffer is a no-op, exact round-trips including newlines/tabs/CJK/fences,
no-clobber ordering, cap eviction, indicator states, panel cursor
clamping and deletion, the full resolve_ctrl_s decision table) and 29 on
the cli.py wiring (per-instance stash, keybinding registration guard,
layout slot, panel bounded at 8 widths, status-bar indicator lifecycle).
The keybinding-registration test asserts the `c-s` handler exists in
source specifically so the rebase loss that broke #4771 cannot recur.

Verified: 153 passed, 0 failed across the two new files plus
tests/cli/test_cli_init.py and tests/cli/test_cli_extension_hooks.py.
ruff check clean; check-windows-footguns clean.

Docs: Ctrl+S added to the CLI keybindings table.

Co-authored-by: CK iRonin.IT <cyprian@ironin.pl>
This commit is contained in:
teknium1 2026-07-26 14:34:38 -07:00 committed by Teknium
parent cfc5dd6aaa
commit a55a52c72f
5 changed files with 1196 additions and 18 deletions

213
cli.py
View File

@ -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)

260
hermes_cli/prompt_stash.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -107,6 +107,7 @@ When resuming a previous session (`hermes -c` or `hermes --resume <id>`), 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. |