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:
parent
cfc5dd6aaa
commit
a55a52c72f
213
cli.py
213
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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. |
|
||||
|
|
|
|||
Loading…
Reference in New Issue