From cfc5dd6aaa40b74152efcc16afd2fc633204d930 Mon Sep 17 00:00:00 2001 From: "CK iRonin.IT" Date: Sun, 5 Apr 2026 17:01:47 -0400 Subject: [PATCH] feat(stash): multi-item stash with browsable panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ctrl+S pushes/pops/browses a stash stack instead of a single slot: - Buffer has content: push to stash - Buffer empty + 1 item: pop immediately - Buffer empty + 2+ items: open panel browser Panel: ↑↓ navigate, Enter restore, D delete, Esc/Ctrl+S close. Status bar shows 📌 N count, 📌 N ▲ when panel open. --- cli.py | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/cli.py b/cli.py index 2b86f97458648..0c712f1ffcb78 100644 --- a/cli.py +++ b/cli.py @@ -6108,6 +6108,67 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): except Exception: return [("class:status-bar", f" {self._build_status_bar_text()} ")] + @staticmethod + def _fmt_stash_age(stashed_at: float) -> str: + """Return human-readable age string for a stash entry.""" + import time as _t + secs = int(_t.monotonic() - stashed_at) + if secs < 10: + return "just now" + if secs < 90: + return f"{secs}s ago" + mins = secs // 60 + if mins < 60: + return f"{mins} min ago" + 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) + + HDR_PREFIX = "╭─ 📌 Stash (" + n = len(stash_list) + title_mid = f"{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)) + + # Row inner width: W minus 2 border chars '│' on each side + INNER = W - 2 + + frags: list = [] + + def line(text: str, style: str = "") -> None: + frags.append((style, text + "\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} │" + if i == cursor: + frags.append(("class:subagent-selected", row + "\n")) + else: + frags.append(("class:subagent-border", "│")) + frags.append(("class:subagent-sub", f"{prefix}{preview} ")) + frags.append(("class:subagent-border", "│\n")) + + line(f"{FTR_PREFIX}{'─' * ftr_dashes}{FTR_SUFFIX}", "class:subagent-border") + return frags + def _normalize_model_for_provider(self, resolved_provider: str) -> bool: """Normalize provider-specific model IDs and routing.""" current_model = (self.model or "").strip()