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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user