326 methods lifted by AST (bodies identical; ast.dump-verified) into
hermes_cli/cli_{tui,status_bar,voice,model_switch,session,stream,modal,
terminal,info,loops}_mixin.py. cli.py-internal symbols resolve via lazy
'from cli import ...' inside each method (no import cycle; patch('cli.X')
keeps working). The three 'global' writers (_skill_commands, _cli_wake_owner)
now write the cli module attribute explicitly so the origin's readers still
see them. Dropped imports left unused in cli.py; kept display_hermes_home /
build_welcome_banner as re-exports (mixins + tests resolve them via cli).
Repointed two AST change-detector tests to cli_tui_mixin.py; one test
fixture now keeps 'cli' in sys.modules across its patch.dict scope.
1598 lines
74 KiB
Python
1598 lines
74 KiB
Python
"""Status bar, spinner, turn-summary, pet pane, and prompt-stash rendering for the interactive CLI
|
|
|
|
Mixin split out of ``cli.py``; bound onto ``HermesCLI`` via the MRO. cli.py-internal
|
|
symbols are imported LAZILY inside each method (``from cli import ...``) — the mixin
|
|
never imports ``cli`` at module load time (import cycle).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import errno
|
|
import shutil
|
|
import threading
|
|
import time
|
|
|
|
from agent.pet import render as pet_render
|
|
from hermes_cli.banner import _format_context_length
|
|
from typing import Any, Dict, Optional
|
|
|
|
|
|
class CLIStatusBarMixin:
|
|
"""Status bar, spinner, turn-summary, pet pane, and prompt-stash rendering for the interactive CLI"""
|
|
|
|
def _status_bar_context_style(self, percent_used: Optional[int]) -> str:
|
|
if percent_used is None:
|
|
return "class:status-bar-dim"
|
|
if percent_used >= 95:
|
|
return "class:status-bar-critical"
|
|
if percent_used > 80:
|
|
return "class:status-bar-bad"
|
|
if percent_used >= 50:
|
|
return "class:status-bar-warn"
|
|
return "class:status-bar-good"
|
|
|
|
def _cache_hit_rate(self, snapshot: dict, precision: int = 1) -> "tuple[float, str] | None":
|
|
"""Return (cache_pct, formatted_label) or None if no cache data.
|
|
|
|
Centralises the cache-hit-rate computation so both the plain-text
|
|
status bar and the prompt-toolkit fragment path share one formula.
|
|
Prefers the baseline-delta percentage computed in
|
|
``_get_status_bar_snapshot`` (resets on model switch / compression,
|
|
so it reflects the *current* cache regime); falls back to the
|
|
session-lifetime ratio when no delta is available.
|
|
"""
|
|
delta_pct = snapshot.get("cache_hit_pct")
|
|
if delta_pct is not None:
|
|
return float(delta_pct), f"◎ {float(delta_pct):.{precision}f}%"
|
|
cache_read = snapshot.get("session_cache_read_tokens", 0)
|
|
prompt_total = snapshot.get("session_prompt_tokens", 0)
|
|
if cache_read > 0 and prompt_total > 0:
|
|
cache_pct = cache_read / prompt_total * 100
|
|
return cache_pct, f"◎ {cache_pct:.{precision}f}%"
|
|
return None
|
|
|
|
def _cache_hit_rate_style(self, cache_pct: float) -> str:
|
|
"""Style for cache hit rate — higher is better (opposite of context %)."""
|
|
if cache_pct >= 70:
|
|
return "class:status-bar-good"
|
|
if cache_pct >= 40:
|
|
return "class:status-bar-warn"
|
|
return "class:status-bar-bad"
|
|
|
|
@staticmethod
|
|
def _battery_status_style(category: str) -> str:
|
|
"""Map a battery colour category to a status-bar style class."""
|
|
return {
|
|
"good": "class:status-bar-good",
|
|
"warn": "class:status-bar-warn",
|
|
"bad": "class:status-bar-bad",
|
|
"critical": "class:status-bar-critical",
|
|
}.get(category, "class:status-bar-dim")
|
|
|
|
def _handle_battery_command(self, cmd_original: str) -> None:
|
|
"""Toggle the status-bar battery read-out.
|
|
|
|
``/battery`` toggles, ``/battery on|off`` sets explicitly, and
|
|
``/battery status`` reports the current setting plus a live reading.
|
|
The choice is persisted to ``display.battery`` so it survives restarts.
|
|
"""
|
|
from cli import save_config_value
|
|
parts = (cmd_original or "").split()
|
|
arg = parts[1].strip().lower() if len(parts) > 1 else ""
|
|
|
|
try:
|
|
from agent.battery import format_battery, read_battery
|
|
reading = read_battery(use_cache=False)
|
|
except Exception:
|
|
reading = None
|
|
|
|
if arg in ("status", "show"):
|
|
state = "on" if self._battery_visible else "off"
|
|
if reading is not None and reading.available:
|
|
self._console_print(
|
|
f" Battery indicator {state} — currently {format_battery(reading)}"
|
|
)
|
|
elif reading is not None:
|
|
self._console_print(
|
|
f" Battery indicator {state} — no battery detected on this machine"
|
|
)
|
|
else:
|
|
self._console_print(f" Battery indicator {state}")
|
|
return
|
|
|
|
if arg in ("on", "true", "yes"):
|
|
target = True
|
|
elif arg in ("off", "false", "no"):
|
|
target = False
|
|
elif arg in ("", "toggle"):
|
|
target = not self._battery_visible
|
|
else:
|
|
self._console_print(" Usage: /battery [on|off|status]")
|
|
return
|
|
|
|
self._battery_visible = target
|
|
save_config_value("display.battery", target)
|
|
|
|
if target:
|
|
if reading is not None and not reading.available:
|
|
self._console_print(
|
|
" Battery indicator on — no battery detected, so nothing will show here"
|
|
)
|
|
elif reading is not None and reading.available:
|
|
self._console_print(
|
|
f" Battery indicator on — {format_battery(reading)}"
|
|
)
|
|
else:
|
|
self._console_print(" Battery indicator on")
|
|
else:
|
|
self._console_print(" Battery indicator off")
|
|
|
|
@staticmethod
|
|
def _compression_count_style(count: int) -> str:
|
|
"""Return a style class reflecting context compression pressure."""
|
|
if count >= 10:
|
|
return "class:status-bar-bad"
|
|
if count >= 5:
|
|
return "class:status-bar-warn"
|
|
return "class:status-bar-dim"
|
|
|
|
def _build_context_bar(self, percent_used: Optional[int], width: int = 10) -> str:
|
|
safe_percent = max(0, min(100, percent_used or 0))
|
|
filled = round((safe_percent / 100) * width)
|
|
return f"[{('█' * filled) + ('░' * max(0, width - filled))}]"
|
|
|
|
@staticmethod
|
|
def _format_prompt_elapsed(prompt_start_time: Optional[float], prompt_duration: float, live: bool = False) -> str:
|
|
"""Format per-prompt elapsed time for the status bar.
|
|
|
|
Always returns a string — shows 0s on fresh start before first turn.
|
|
Keeps seconds visible at all scales so it increments smoothly:
|
|
59s → 1m → 1m 1s → ... → 1m 59s → 2m → 2m 1s → ...
|
|
59m 59s → 1h → 1h 0m 1s → ...
|
|
23h 59m 59s → 1d → 1d 0h 1m → ...
|
|
|
|
Emoji prefix: ⏱ when turn is live, ⏲ when frozen or fresh start.
|
|
Uses width-1 (no variation selector) glyphs so the status bar stays
|
|
aligned in monospace terminals.
|
|
"""
|
|
if prompt_start_time is None and prompt_duration == 0.0:
|
|
return "⏲ 0s"
|
|
elapsed = time.time() - prompt_start_time if prompt_start_time is not None else prompt_duration
|
|
elapsed = max(0.0, elapsed)
|
|
|
|
days = int(elapsed // 86400)
|
|
remaining = elapsed % 86400
|
|
hours = int(remaining // 3600)
|
|
remaining = remaining % 3600
|
|
minutes = int(remaining // 60)
|
|
seconds = int(remaining % 60)
|
|
|
|
if days > 0:
|
|
time_str = f"{days}d {hours}h {minutes}m"
|
|
elif hours > 0:
|
|
time_str = f"{hours}h {minutes}m {seconds}s" if seconds else f"{hours}h {minutes}m"
|
|
elif minutes > 0:
|
|
time_str = f"{minutes}m {seconds}s" if seconds else f"{minutes}m"
|
|
else:
|
|
time_str = f"{int(elapsed)}s"
|
|
|
|
emoji = "⏱" if live else "⏲"
|
|
return f"{emoji} {time_str}"
|
|
|
|
@staticmethod
|
|
def _format_idle_since(last_finished_at: Optional[float], turn_live: bool) -> str:
|
|
"""Format time since the last final agent response for the status bar.
|
|
|
|
Returns an empty string while a turn is live (the per-prompt elapsed
|
|
timer covers that case) or before the first turn has completed.
|
|
Compact read-out: ``✓ 42s`` / ``✓ 3m`` / ``✓ 1h 12m``.
|
|
"""
|
|
from cli import format_duration_compact
|
|
if turn_live or last_finished_at is None:
|
|
return ""
|
|
idle = max(0.0, time.time() - last_finished_at)
|
|
return f"✓ {format_duration_compact(idle)}"
|
|
|
|
def _get_status_bar_snapshot(self) -> Dict[str, Any]:
|
|
# Prefer the agent's model name — it updates on fallback.
|
|
# self.model reflects the originally configured model and never
|
|
# changes mid-session, so the TUI would show a stale name after
|
|
# _try_activate_fallback() switches provider/model.
|
|
from cli import _reverse_alias_for_display, datetime, format_duration_compact
|
|
agent = getattr(self, "agent", None)
|
|
model_name = (getattr(agent, "model", None) or self.model or "unknown")
|
|
# Friendly display: prefer reverse-alias from config.yaml ``model_aliases:``
|
|
# before slash/length truncation. This turns long Palantir RIDs like
|
|
# ``ri.language-model-service..language-model.anthropic-claude-4-7-opus``
|
|
# into the user's chosen short name (e.g. ``opus-4.7``) in the status bar.
|
|
model_short = _reverse_alias_for_display(model_name)
|
|
if model_short == model_name:
|
|
model_short = model_name.split("/")[-1] if "/" in model_name else model_name
|
|
# Strip Palantir RID prefixes via the shared display formatter so
|
|
# this site and ``ModelSwitchResult`` confirmation can't drift.
|
|
from hermes_cli.model_switch import format_model_for_display
|
|
model_short = format_model_for_display(model_short)
|
|
if model_short.endswith(".gguf"):
|
|
model_short = model_short[:-5]
|
|
if len(model_short) > 26:
|
|
model_short = f"{model_short[:23]}..."
|
|
|
|
elapsed_seconds = max(0.0, (datetime.now() - self.session_start).total_seconds())
|
|
snapshot = {
|
|
"model_name": model_name,
|
|
"model_short": model_short,
|
|
"duration": format_duration_compact(elapsed_seconds),
|
|
"session_title": self._get_status_bar_session_title(),
|
|
"prompt_elapsed": self._format_prompt_elapsed(
|
|
getattr(self, "_prompt_start_time", None),
|
|
getattr(self, "_prompt_duration", 0.0),
|
|
live=getattr(self, "_prompt_start_time", None) is not None,
|
|
),
|
|
"idle_since": self._format_idle_since(
|
|
getattr(self, "_last_turn_finished_at", None),
|
|
turn_live=getattr(self, "_prompt_start_time", None) is not None,
|
|
),
|
|
"context_tokens": 0,
|
|
"context_length": None,
|
|
"context_percent": None,
|
|
"session_input_tokens": 0,
|
|
"session_output_tokens": 0,
|
|
"session_cache_read_tokens": 0,
|
|
"session_cache_write_tokens": 0,
|
|
"session_prompt_tokens": 0,
|
|
"session_completion_tokens": 0,
|
|
"session_total_tokens": 0,
|
|
"session_api_calls": 0,
|
|
"compressions": 0,
|
|
"active_background_tasks": 0,
|
|
"active_background_processes": 0,
|
|
"active_background_subagents": 0,
|
|
"battery_label": "",
|
|
"battery_category": "dim",
|
|
# Focus view badge (/focus). Persistent indicator so the reduced
|
|
# output mode is never invisible. Display-only.
|
|
"focus_label": "",
|
|
}
|
|
|
|
try:
|
|
from hermes_cli.focus_view import focus_statusbar_segment
|
|
|
|
snapshot["focus_label"] = focus_statusbar_segment(
|
|
bool(getattr(self, "_focus_view_enabled", False))
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# Battery read-out (first status-bar element when enabled). Reads are
|
|
# memoised for a few seconds inside agent.battery, so polling it on
|
|
# every status-bar repaint is cheap.
|
|
if getattr(self, "_battery_visible", False):
|
|
try:
|
|
from agent.battery import (
|
|
battery_category,
|
|
format_battery,
|
|
read_battery,
|
|
)
|
|
|
|
_batt = read_battery()
|
|
snapshot["battery_label"] = format_battery(_batt)
|
|
snapshot["battery_category"] = battery_category(_batt)
|
|
except Exception:
|
|
pass
|
|
|
|
# Count live /bg tasks. The dict entry is removed in the
|
|
# task thread's finally block, so len() reflects truly-running tasks.
|
|
# len() on a CPython dict is atomic; safe to read without a lock.
|
|
try:
|
|
bg_tasks = getattr(self, "_background_tasks", None)
|
|
if bg_tasks:
|
|
snapshot["active_background_tasks"] = len(bg_tasks)
|
|
except Exception:
|
|
pass
|
|
|
|
# Count live background terminal processes (terminal tool background
|
|
# sessions tracked by tools.process_registry). Cheap O(1) read.
|
|
try:
|
|
from tools.process_registry import process_registry
|
|
snapshot["active_background_processes"] = process_registry.count_running()
|
|
except Exception:
|
|
pass
|
|
|
|
# Count live background/async subagents (delegate_task batches and
|
|
# background single delegations tracked by tools.async_delegation).
|
|
# active_count() iterates an in-memory records dict under a lock —
|
|
# cheap and only counts records still in the "running" state.
|
|
try:
|
|
from tools.async_delegation import active_count as _async_active_count
|
|
snapshot["active_background_subagents"] = _async_active_count()
|
|
except Exception:
|
|
pass
|
|
|
|
# Standing /goal state (Ralph loop). GoalManager is cached on self and
|
|
# keeps its state in memory, so this is a cheap attribute read — no DB
|
|
# hit per repaint. Only an *active* goal earns a segment; paused/done
|
|
# goals stay out of the bar (matching the desktop's active-first row).
|
|
snapshot["goal_active"] = False
|
|
snapshot["goal_turns_used"] = 0
|
|
snapshot["goal_max_turns"] = 0
|
|
try:
|
|
goal_mgr = self._get_goal_manager()
|
|
if goal_mgr is not None and goal_mgr.is_active():
|
|
goal_state = goal_mgr.state
|
|
snapshot["goal_active"] = True
|
|
snapshot["goal_turns_used"] = int(getattr(goal_state, "turns_used", 0) or 0)
|
|
snapshot["goal_max_turns"] = int(getattr(goal_state, "max_turns", 0) or 0)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
if not agent:
|
|
return snapshot
|
|
|
|
snapshot["session_input_tokens"] = getattr(agent, "session_input_tokens", 0) or 0
|
|
snapshot["session_output_tokens"] = getattr(agent, "session_output_tokens", 0) or 0
|
|
snapshot["session_cache_read_tokens"] = getattr(agent, "session_cache_read_tokens", 0) or 0
|
|
snapshot["session_cache_write_tokens"] = getattr(agent, "session_cache_write_tokens", 0) or 0
|
|
snapshot["session_prompt_tokens"] = getattr(agent, "session_prompt_tokens", 0) or 0
|
|
snapshot["session_completion_tokens"] = getattr(agent, "session_completion_tokens", 0) or 0
|
|
snapshot["session_total_tokens"] = getattr(agent, "session_total_tokens", 0) or 0
|
|
snapshot["session_api_calls"] = getattr(agent, "session_api_calls", 0) or 0
|
|
|
|
compressor = getattr(agent, "context_compressor", None)
|
|
if compressor:
|
|
# last_prompt_tokens is parked at the -1 sentinel right after a
|
|
# compression, until the next real API call reports a prompt count
|
|
# (awaiting_real_usage_after_compression). The status bar must not
|
|
# render that sentinel verbatim — it produced "-1/200K" / "-1%".
|
|
# Clamp it to 0 so the one transitional turn reads as empty context.
|
|
context_tokens = getattr(compressor, "last_prompt_tokens", 0) or 0
|
|
if context_tokens < 0:
|
|
context_tokens = 0
|
|
# Durable-transcript view: on reasoning models a long tool loop
|
|
# replays the current turn's thinking + scaffolding on every
|
|
# request, so the LAST request's prompt_tokens can exceed the
|
|
# durable transcript by hundreds of K — all of which evaporates
|
|
# at the turn boundary. Rendering that raw figure makes the bar
|
|
# sawtooth (e.g. 850K mid-turn -> 600K next turn) and reads as a
|
|
# broken compaction. Anchor the display on the turn's FIRST
|
|
# response (minimal replay) plus a delta estimate of messages
|
|
# appended since, excluding stale thinking. Display-only: the
|
|
# compression trigger keeps using real last-request usage.
|
|
try:
|
|
from agent.model_metadata import anchored_context_tokens
|
|
|
|
_msgs = getattr(agent, "_session_messages", None)
|
|
_anchored = anchored_context_tokens(
|
|
_msgs if isinstance(_msgs, list) else [],
|
|
getattr(agent, "_turn_base_usage_anchor", None),
|
|
charge_stale_thinking=False,
|
|
)
|
|
if _anchored is not None and _anchored > 0:
|
|
context_tokens = _anchored
|
|
except Exception:
|
|
pass
|
|
context_length = getattr(compressor, "context_length", 0) or 0
|
|
if context_length < 0:
|
|
context_length = 0
|
|
snapshot["context_tokens"] = context_tokens
|
|
snapshot["context_length"] = context_length or None
|
|
snapshot["compressions"] = getattr(compressor, "compression_count", 0) or 0
|
|
if context_length:
|
|
snapshot["context_percent"] = max(0, min(100, round((context_tokens / context_length) * 100)))
|
|
|
|
# -- Cache-hit ratio (delta since last reset) --
|
|
# Reset baseline on model switch and on compression — both invalidate
|
|
# the prompt cache. Formula verified against live logs:
|
|
# hit = cache_read / prompt_tokens (prompt = input+cache_read+cache_write)
|
|
# see agent/conversation_loop.py:4314 cache=read/prompt (87%)
|
|
# and CanonicalUsage.prompt_tokens = input+read+write
|
|
try:
|
|
base_model = getattr(self, "_cache_hit_baseline_model", None)
|
|
base_prompt = int(getattr(self, "_cache_hit_baseline_prompt", 0) or 0)
|
|
base_read = int(getattr(self, "_cache_hit_baseline_read", 0) or 0)
|
|
base_comps = int(getattr(self, "_cache_hit_baseline_compressions", 0) or 0)
|
|
cur_model = snapshot.get("model_name") or model_name
|
|
cur_comps = int(snapshot.get("compressions", 0) or 0)
|
|
cur_prompt = int(snapshot.get("session_prompt_tokens", 0) or 0)
|
|
cur_read = int(snapshot.get("session_cache_read_tokens", 0) or 0)
|
|
if base_model is None:
|
|
self._cache_hit_baseline_model = cur_model
|
|
self._cache_hit_baseline_compressions = cur_comps
|
|
base_model = cur_model
|
|
base_comps = cur_comps
|
|
if cur_model != base_model:
|
|
self._cache_hit_baseline_model = cur_model
|
|
self._cache_hit_baseline_prompt = cur_prompt
|
|
self._cache_hit_baseline_read = cur_read
|
|
self._cache_hit_baseline_compressions = cur_comps
|
|
base_prompt = cur_prompt
|
|
base_read = cur_read
|
|
base_comps = cur_comps
|
|
if cur_comps != base_comps:
|
|
self._cache_hit_baseline_compressions = cur_comps
|
|
self._cache_hit_baseline_prompt = cur_prompt
|
|
self._cache_hit_baseline_read = cur_read
|
|
base_prompt = cur_prompt
|
|
base_read = cur_read
|
|
delta_prompt = cur_prompt - base_prompt
|
|
delta_read = cur_read - base_read
|
|
# A zero-read regime hides the segment entirely (no cache data
|
|
# is not the same as a 0% hit worth alarming about), and the pct
|
|
# stays a float so renderers control their own precision.
|
|
if delta_prompt > 0 and delta_read > 0:
|
|
pct = max(0.0, min(100.0, (delta_read / delta_prompt) * 100))
|
|
snapshot["cache_hit_pct"] = pct
|
|
snapshot["cache_hit_label"] = f"{pct:.0f}%"
|
|
elif cur_prompt > 0 and cur_read > 0 and base_prompt == 0 and base_read == 0:
|
|
pct = max(0.0, min(100.0, (cur_read / cur_prompt) * 100))
|
|
snapshot["cache_hit_pct"] = pct
|
|
snapshot["cache_hit_label"] = f"{pct:.0f}%"
|
|
else:
|
|
snapshot["cache_hit_pct"] = None
|
|
snapshot["cache_hit_label"] = ""
|
|
except Exception:
|
|
snapshot["cache_hit_pct"] = None
|
|
snapshot["cache_hit_label"] = ""
|
|
|
|
# -- Rolling avg latency / velocity (last 10 calls) --
|
|
# Reads the deque maintained in agent/conversation_loop.py (and
|
|
# agent_init). Codex app-server has no latency, so it stays hidden there.
|
|
try:
|
|
agent_obj = getattr(self, "agent", None)
|
|
lhist = list(getattr(agent_obj, "_api_latency_history", []) or []) if agent_obj else []
|
|
ohist = list(getattr(agent_obj, "_api_output_history", []) or []) if agent_obj else []
|
|
# Keep the two histories aligned (they are appended together).
|
|
n = min(len(lhist), len(ohist))
|
|
if n:
|
|
lhist = lhist[-n:]
|
|
ohist = ohist[-n:]
|
|
# Simple mean for latency; sum/sum for velocity (true throughput, not mean of ratios).
|
|
avg_lat = sum(lhist) / len(lhist) if lhist else None
|
|
total_out = sum(ohist)
|
|
total_lat = sum(lhist)
|
|
avg_vel = (total_out / total_lat) if total_lat > 0 else None
|
|
# Guard against NaN / inf from weird provider timings (e.g. -0.8s in logs).
|
|
if avg_lat is not None and (avg_lat != avg_lat or avg_lat < 0 or avg_lat > 1e6):
|
|
avg_lat = None
|
|
if avg_vel is not None and (avg_vel != avg_vel or avg_vel < 0 or avg_vel > 1e6):
|
|
avg_vel = None
|
|
snapshot["avg_latency"] = float(avg_lat) if avg_lat is not None else None
|
|
snapshot["avg_latency_label"] = f"{avg_lat:.1f}s" if avg_lat is not None else ""
|
|
snapshot["avg_velocity"] = float(avg_vel) if avg_vel is not None else None
|
|
snapshot["avg_velocity_label"] = f"{avg_vel:.0f} t/s" if avg_vel is not None else ""
|
|
else:
|
|
snapshot["avg_latency"] = None
|
|
snapshot["avg_latency_label"] = ""
|
|
snapshot["avg_velocity"] = None
|
|
snapshot["avg_velocity_label"] = ""
|
|
except Exception:
|
|
snapshot["avg_latency"] = None
|
|
snapshot["avg_latency_label"] = ""
|
|
snapshot["avg_velocity"] = None
|
|
snapshot["avg_velocity_label"] = ""
|
|
|
|
return snapshot
|
|
|
|
def _get_status_bar_session_title(self) -> str:
|
|
"""Return the current title without polling state.db on every repaint."""
|
|
pending = str(getattr(self, "_pending_title", None) or "").strip()
|
|
session_id = str(getattr(self, "session_id", "") or "")
|
|
if pending:
|
|
self._status_bar_title_session_id = session_id
|
|
self._status_bar_title_cache = pending
|
|
self._status_bar_title_checked_at = time.monotonic()
|
|
return pending
|
|
|
|
now = time.monotonic()
|
|
cached_session_id = getattr(self, "_status_bar_title_session_id", None)
|
|
checked_at = float(getattr(self, "_status_bar_title_checked_at", 0.0) or 0.0)
|
|
if cached_session_id == session_id and now - checked_at < 1.5:
|
|
return str(getattr(self, "_status_bar_title_cache", "") or "")
|
|
|
|
title = ""
|
|
db = getattr(self, "_session_db", None)
|
|
if db is not None and session_id:
|
|
try:
|
|
title = str(db.get_session_title(session_id) or "").strip()
|
|
except Exception:
|
|
title = ""
|
|
self._status_bar_title_session_id = session_id
|
|
self._status_bar_title_cache = title
|
|
self._status_bar_title_checked_at = now
|
|
return title
|
|
|
|
@staticmethod
|
|
def _status_bar_display_width(text: str) -> int:
|
|
"""Return terminal cell width for status-bar text.
|
|
|
|
len() is not enough for prompt_toolkit layout decisions because some
|
|
glyphs can render wider than one Python codepoint. Keeping the status
|
|
bar within the real display width prevents it from wrapping onto a
|
|
second line and leaving behind duplicate rows.
|
|
"""
|
|
try:
|
|
from prompt_toolkit.utils import get_cwidth
|
|
return get_cwidth(text or "")
|
|
except Exception:
|
|
return len(text or "")
|
|
|
|
@classmethod
|
|
def _trim_status_bar_text(cls, text: str, max_width: int) -> str:
|
|
"""Trim status-bar text to a single terminal row."""
|
|
if max_width <= 0:
|
|
return ""
|
|
try:
|
|
from prompt_toolkit.utils import get_cwidth
|
|
except Exception:
|
|
get_cwidth = None
|
|
|
|
if cls._status_bar_display_width(text) <= max_width:
|
|
return text
|
|
|
|
ellipsis = "..."
|
|
ellipsis_width = cls._status_bar_display_width(ellipsis)
|
|
if max_width <= ellipsis_width:
|
|
return ellipsis[:max_width]
|
|
|
|
out = []
|
|
width = 0
|
|
for ch in text:
|
|
ch_width = get_cwidth(ch) if get_cwidth else len(ch)
|
|
if width + ch_width + ellipsis_width > max_width:
|
|
break
|
|
out.append(ch)
|
|
width += ch_width
|
|
return "".join(out).rstrip() + ellipsis
|
|
|
|
@classmethod
|
|
def _right_align_status_title(cls, text: str, title: str, width: int) -> str:
|
|
"""Pin a bounded session-title badge to the far-right status-bar edge."""
|
|
title = str(title or "").strip()
|
|
if not title or width < 24:
|
|
return cls._trim_status_bar_text(text, width)
|
|
|
|
title_width = max(6, min(30, width // 3))
|
|
badge = f" {cls._trim_status_bar_text(title, title_width - 2)} "
|
|
suffix = f" ─{badge}"
|
|
left_width = max(0, width - cls._status_bar_display_width(suffix))
|
|
left = cls._trim_status_bar_text(text.rstrip(), left_width)
|
|
padding = " " * max(0, left_width - cls._status_bar_display_width(left))
|
|
return f"{left}{padding}{suffix}"
|
|
|
|
@classmethod
|
|
def _right_align_status_title_fragments(cls, frags, title: str, width: int):
|
|
"""Styled counterpart to :meth:`_right_align_status_title`."""
|
|
title = str(title or "").strip()
|
|
if not title or width < 24:
|
|
return frags
|
|
|
|
title_width = max(6, min(30, width // 3))
|
|
badge = f" {cls._trim_status_bar_text(title, title_width - 2)} "
|
|
suffix_width = cls._status_bar_display_width(" ─") + cls._status_bar_display_width(badge)
|
|
left_width = max(0, width - suffix_width)
|
|
trimmed = []
|
|
used = 0
|
|
for style, value in frags:
|
|
remaining = left_width - used
|
|
if remaining <= 0:
|
|
break
|
|
value_width = cls._status_bar_display_width(value)
|
|
if value_width <= remaining:
|
|
trimmed.append((style, value))
|
|
used += value_width
|
|
continue
|
|
clipped = cls._trim_status_bar_text(value, remaining)
|
|
if clipped:
|
|
trimmed.append((style, clipped))
|
|
used += cls._status_bar_display_width(clipped)
|
|
break
|
|
|
|
if used < left_width:
|
|
trimmed.append(("class:status-bar-dim", " " * (left_width - used)))
|
|
trimmed.extend([
|
|
("class:status-bar-dim", " ─"),
|
|
("class:status-bar-session-title", badge),
|
|
])
|
|
return trimmed
|
|
|
|
@staticmethod
|
|
def _get_tui_terminal_width(default: tuple[int, int] = (80, 24)) -> int:
|
|
"""Return the live prompt_toolkit width, falling back to ``shutil``.
|
|
|
|
The TUI layout can be narrower than ``shutil.get_terminal_size()`` reports,
|
|
especially on Termux/mobile shells, so prefer prompt_toolkit's width whenever
|
|
an app is active.
|
|
"""
|
|
try:
|
|
from prompt_toolkit.application import get_app
|
|
return get_app().output.get_size().columns
|
|
except Exception:
|
|
return shutil.get_terminal_size(default).columns
|
|
|
|
def _use_minimal_tui_chrome(self, width: Optional[int] = None) -> bool:
|
|
"""Hide low-value chrome on narrow/mobile terminals to preserve rows."""
|
|
if width is None:
|
|
width = self._get_tui_terminal_width()
|
|
return width < 64
|
|
|
|
@staticmethod
|
|
def _scrollback_box_width(width: Optional[int] = None) -> int:
|
|
"""Return the full viewport width for printed scrollback box rules.
|
|
|
|
Previously this clamped to ``max(32, min(width, 56))`` as a defense
|
|
against terminal-emulator reflow on column-shrink (#25975, salvaging
|
|
#24403). That clamp made response/reasoning borders look stubby on
|
|
any modern wide terminal. We now trust the prompt_toolkit
|
|
``_output_screen_diff`` monkey-patch landed in #26137 (salvaging
|
|
#25981) to keep chrome out of scrollback in the first place, and
|
|
accept that an aggressive column-shrink may visually reflow already
|
|
printed Panel borders — that's a cosmetic artifact of stamped
|
|
scrollback history, not a live-render bug.
|
|
|
|
A small floor (32 cols) is kept so the box still renders on tiny
|
|
terminals without negative ``'─' * (w - 2)`` math.
|
|
"""
|
|
if width is None:
|
|
try:
|
|
width = shutil.get_terminal_size((80, 24)).columns
|
|
except Exception:
|
|
width = 80
|
|
return max(32, int(width or 80))
|
|
|
|
def _agent_spacer_height(self, width: Optional[int] = None) -> int:
|
|
"""Return the spacer height shown above the status bar while the agent runs."""
|
|
if not getattr(self, "_agent_running", False):
|
|
return 0
|
|
return 0 if self._use_minimal_tui_chrome(width=width) else 1
|
|
|
|
def _spinner_widget_height(self, width: Optional[int] = None) -> int:
|
|
"""Return the visible height for the spinner/status text line above the status bar."""
|
|
spinner_line = self._render_spinner_text()
|
|
if not spinner_line:
|
|
return 0
|
|
if self._use_minimal_tui_chrome(width=width):
|
|
return 0
|
|
width = width or self._get_tui_terminal_width()
|
|
if width and width > 10:
|
|
import math
|
|
text_width = self._status_bar_display_width(spinner_line)
|
|
return max(1, math.ceil(text_width / width))
|
|
return 1
|
|
|
|
def _render_spinner_text(self) -> str:
|
|
"""Return the live spinner/status text exactly as rendered in the TUI."""
|
|
txt = getattr(self, "_spinner_text", "")
|
|
if not txt:
|
|
return ""
|
|
flow = self._spinner_token_flow()
|
|
t0 = getattr(self, "_tool_start_time", 0) or 0
|
|
if t0 > 0:
|
|
elapsed = time.monotonic() - t0
|
|
if elapsed >= 60:
|
|
_m, _s = int(elapsed // 60), int(elapsed % 60)
|
|
# Fixed-width timer to avoid status-line wrap jitter while
|
|
# scrolling/repainting (e.g. 01m05s, 12m09s).
|
|
elapsed_str = f"{_m:02d}m{_s:02d}s"
|
|
else:
|
|
# Keep width stable before the 60s rollover as well.
|
|
elapsed_str = f"{elapsed:5.1f}s"
|
|
if flow:
|
|
return f" {txt} ({elapsed_str} · {flow})"
|
|
return f" {txt} ({elapsed_str})"
|
|
if flow:
|
|
return f" {txt} ({flow})"
|
|
return f" {txt}"
|
|
|
|
def _spinner_token_flow(self) -> str:
|
|
"""Cumulative output tokens for the running turn, for the spinner."""
|
|
if not getattr(self, "_spinner_token_flow_enabled", False):
|
|
return ""
|
|
if not getattr(self, "_agent_running", False):
|
|
return ""
|
|
agent = getattr(self, "agent", None)
|
|
if agent is None:
|
|
return ""
|
|
try:
|
|
from agent.turn_summary import format_token_flow
|
|
|
|
produced = (getattr(agent, "session_output_tokens", 0) or 0) - (
|
|
getattr(self, "_turn_token_baseline", 0) or 0
|
|
)
|
|
return format_token_flow(produced)
|
|
except Exception:
|
|
return ""
|
|
|
|
def _turn_summary_is_active(self) -> bool:
|
|
"""Whether the per-turn summary line should render for this surface.
|
|
|
|
Gated off for: the config key, quiet/tool-progress-off mode, and any
|
|
non-interactive path (single query, ``-Q``, gateway/messaging) — those
|
|
surfaces either want machine-readable output or carry their own footer.
|
|
"""
|
|
if not getattr(self, "_turn_summary_enabled", False):
|
|
return False
|
|
if getattr(self, "tool_progress_mode", "all") == "off":
|
|
return False
|
|
agent = getattr(self, "agent", None)
|
|
if agent is not None and getattr(agent, "quiet_mode", False):
|
|
return False
|
|
return bool(getattr(self, "_interactive_turn", False))
|
|
|
|
def _turn_summary_begin(self) -> None:
|
|
"""Start per-turn accounting for the turn that is about to run."""
|
|
try:
|
|
from agent.turn_summary import TurnSummaryCollector
|
|
|
|
collector = getattr(self, "_turn_summary_collector", None)
|
|
if collector is None:
|
|
collector = TurnSummaryCollector()
|
|
self._turn_summary_collector = collector
|
|
collector.begin()
|
|
self._turn_summary_start = time.monotonic()
|
|
agent = getattr(self, "agent", None)
|
|
self._turn_token_baseline = (
|
|
getattr(agent, "session_output_tokens", 0) or 0
|
|
) if agent is not None else 0
|
|
except Exception:
|
|
self._turn_summary_collector = None
|
|
|
|
def _turn_summary_record(self, function_name, result, is_error: bool) -> None:
|
|
"""Feed one completed tool call into the active tally."""
|
|
collector = getattr(self, "_turn_summary_collector", None)
|
|
if collector is None:
|
|
return
|
|
try:
|
|
collector.record_tool(function_name, result=result, is_error=bool(is_error))
|
|
except Exception:
|
|
pass
|
|
|
|
def _turn_summary_emit(self) -> None:
|
|
"""Print the post-turn accounting line, when enabled for this surface."""
|
|
from cli import _DIM, _RST, _cprint, logger
|
|
collector = getattr(self, "_turn_summary_collector", None)
|
|
if collector is None or not self._turn_summary_is_active():
|
|
return
|
|
try:
|
|
started = getattr(self, "_turn_summary_start", 0.0) or 0.0
|
|
elapsed = max(0.0, time.monotonic() - started) if started else 0.0
|
|
line = collector.render(elapsed)
|
|
if line:
|
|
_cprint(f" {_DIM}{line}{_RST}")
|
|
except Exception:
|
|
logger.debug("Turn summary render failed", exc_info=True)
|
|
|
|
def _pet_clear_runtime(self) -> None:
|
|
"""Drop renderer + queued Kitty state. Caller holds ``_pet_lock``."""
|
|
self._pet_enabled = False
|
|
self._pet_renderer = None
|
|
self._pet_frames_cache.clear()
|
|
self._pet_kitty_cache.clear()
|
|
self._pet_kitty_pending = ""
|
|
self._pet_kitty_image_id = 0
|
|
|
|
def _pet_resolve_config(self) -> None:
|
|
"""(Re)resolve the active pet from config — picks up live enable/disable/
|
|
|
|
switch made via ``/pet`` or ``hermes pets`` without a restart, mirroring
|
|
the TUI's steady poll. Cheap and fail-open: any problem disables the pet.
|
|
"""
|
|
try:
|
|
from agent.pet import constants, store
|
|
from hermes_cli.config import load_config
|
|
|
|
cfg = load_config()
|
|
display = cfg.get("display", {}) if isinstance(cfg.get("display"), dict) else {}
|
|
pet_cfg = display.get("pet", {}) if isinstance(display.get("pet"), dict) else {}
|
|
|
|
from utils import is_truthy_value
|
|
|
|
enabled = is_truthy_value(pet_cfg.get("enabled"), default=False)
|
|
slug = str(pet_cfg.get("slug", "") or "")
|
|
scale = float(pet_cfg.get("scale", constants.DEFAULT_SCALE) or constants.DEFAULT_SCALE)
|
|
cols = constants.resolve_cols(scale, pet_cfg.get("unicode_cols", 0))
|
|
configured_mode = str(pet_cfg.get("render_mode", "auto") or "auto").lower()
|
|
# Placeholders only on kitty/Ghostty. WezTerm speaks kitty APC but
|
|
# not U+10EEEE — detect_terminal_graphics() still returns kitty
|
|
# there, which is why this gate is narrower.
|
|
use_kitty = configured_mode in ("", "auto", "kitty") and pet_render.supports_kitty_placeholders()
|
|
renderer_mode = "kitty" if use_kitty else "unicode"
|
|
|
|
if not enabled or configured_mode == "off":
|
|
with self._pet_lock:
|
|
self._pet_clear_runtime()
|
|
return
|
|
|
|
pet = store.resolve_active_pet(slug)
|
|
if pet is None or not pet.exists:
|
|
with self._pet_lock:
|
|
self._pet_clear_runtime()
|
|
return
|
|
|
|
with self._pet_lock:
|
|
# Rebuild only when the resolved pet, mode, or geometry changes.
|
|
if (
|
|
self._pet_renderer is None
|
|
or self._pet_slug != pet.slug
|
|
or self._pet_cols != cols
|
|
or self._pet_scale != scale
|
|
or self._pet_renderer.mode != renderer_mode
|
|
):
|
|
self._pet_renderer = pet_render.PetRenderer(
|
|
str(pet.spritesheet), mode=renderer_mode, scale=scale, unicode_cols=cols
|
|
)
|
|
self._pet_slug = pet.slug
|
|
self._pet_cols = cols
|
|
self._pet_scale = scale
|
|
self._pet_frames_cache.clear()
|
|
self._pet_kitty_cache.clear()
|
|
self._pet_kitty_pending = ""
|
|
self._pet_kitty_image_id = pet_render.kitty_image_id(pet.slug)
|
|
self._pet_frame_idx = 0
|
|
self._pet_enabled = True
|
|
except Exception:
|
|
with self._pet_lock:
|
|
self._pet_clear_runtime()
|
|
|
|
def _pet_flash(self, state: str, secs: float = 1.6) -> None:
|
|
"""Briefly force a transient reaction (wave/jump/failed) before resting."""
|
|
self._pet_event = state
|
|
self._pet_event_until = time.monotonic() + secs
|
|
|
|
def _on_reaction(self, kind: str) -> None:
|
|
"""User affection (ily / <3 / good bot), core-detected — the pet's share
|
|
of the vibe signal that plays hearts on the TUI/desktop. Flash a celebrate."""
|
|
if kind == "vibe":
|
|
self._pet_flash("jump")
|
|
|
|
def _pet_react_turn_end(self) -> None:
|
|
"""Flash the end-of-turn beat: failed on error, jump on a finished plan, else wave."""
|
|
if not self._pet_enabled:
|
|
return
|
|
from agent.pet.state import todos_all_done
|
|
|
|
if self._pet_turn_error:
|
|
self._pet_flash("failed")
|
|
return
|
|
try:
|
|
store = getattr(self.agent, "_todo_store", None)
|
|
done = todos_all_done(store.read()) if store else False
|
|
except Exception:
|
|
done = False
|
|
self._pet_flash("jump" if done else "wave")
|
|
|
|
def _derive_pet_state(self) -> str:
|
|
"""Map current CLI activity to a pet animation state.
|
|
|
|
A transient reaction beat (wave/jump/failed) wins while it's live;
|
|
otherwise the steady state comes from the shared
|
|
:func:`agent.pet.state.derive_pet_state` so the CLI can't drift from the
|
|
TUI/desktop priority order.
|
|
"""
|
|
if self._pet_event and time.monotonic() < self._pet_event_until:
|
|
return self._pet_event
|
|
self._pet_event = ""
|
|
from agent.pet.state import derive_pet_state
|
|
|
|
# A live blocking modal (approval / clarify / sudo / secret / slash
|
|
# confirm) means the agent is paused on the user → the `waiting` pose,
|
|
# which outranks the in-flight signals in derive_pet_state.
|
|
awaiting_input = bool(
|
|
self._approval_state
|
|
or self._clarify_state
|
|
or self._sudo_state
|
|
or self._secret_state
|
|
or getattr(self, "_slash_confirm_state", None)
|
|
)
|
|
|
|
return derive_pet_state(
|
|
awaiting_input=awaiting_input,
|
|
busy=getattr(self, "_agent_running", False),
|
|
reasoning=self._pet_reasoning,
|
|
).value
|
|
|
|
def _pet_frames_for(self, state: str) -> list:
|
|
"""Return (and cache) the half-block grids for one state."""
|
|
cached = self._pet_frames_cache.get(state)
|
|
if cached is not None:
|
|
return cached
|
|
renderer = self._pet_renderer
|
|
if renderer is None:
|
|
return []
|
|
try:
|
|
count = renderer.frame_count(state) or 1
|
|
grids = [renderer.cells(state, i, cols=self._pet_cols) for i in range(count)]
|
|
except Exception:
|
|
grids = []
|
|
self._pet_frames_cache[state] = grids
|
|
return grids
|
|
|
|
def _pet_kitty_payload_for(self, state: str) -> dict | None:
|
|
"""Return and cache a Kitty virtual-placeholder payload for *state*."""
|
|
with self._pet_lock:
|
|
cached = self._pet_kitty_cache.get(state)
|
|
if cached is not None:
|
|
return cached
|
|
renderer = self._pet_renderer
|
|
image_id = self._pet_kitty_image_id
|
|
if renderer is None or renderer.mode != "kitty":
|
|
return None
|
|
try:
|
|
# PNG encoding is outside _pet_lock: first visit of a state must
|
|
# not stall the prompt under the lock.
|
|
payload = renderer.kitty_payload(state, image_id=image_id)
|
|
except Exception:
|
|
payload = None
|
|
if payload is not None:
|
|
payload = {**payload, "image_id": image_id}
|
|
with self._pet_lock:
|
|
if self._pet_renderer is renderer and self._pet_kitty_image_id == image_id:
|
|
self._pet_kitty_cache[state] = payload
|
|
return payload
|
|
|
|
def _pet_queue_kitty_frame(self, state: str | None = None) -> None:
|
|
"""Queue one virtual Kitty frame for the next prompt_toolkit render.
|
|
|
|
No-op when the pet pane was never initialized (``__new__`` fixtures
|
|
and ``_force_full_redraw`` / resize recovery on a pet-less CLI).
|
|
"""
|
|
if not getattr(self, "_pet_enabled", False):
|
|
return
|
|
if state is None:
|
|
state = self._derive_pet_state()
|
|
payload = self._pet_kitty_payload_for(state)
|
|
if not payload or not payload.get("frames"):
|
|
return
|
|
with self._pet_lock:
|
|
if self._pet_renderer is not None and self._pet_renderer.mode == "kitty":
|
|
self._pet_kitty_pending = payload["frames"][self._pet_frame_idx % len(payload["frames"])]
|
|
|
|
def _pet_flush_kitty_frame(self, app) -> None:
|
|
"""Write a queued APC after prompt_toolkit has finished its screen diff."""
|
|
with self._pet_lock:
|
|
frame = self._pet_kitty_pending
|
|
self._pet_kitty_pending = ""
|
|
if not frame:
|
|
return
|
|
try:
|
|
# U=1/q=2 leaves the cursor and input stream untouched.
|
|
app.output.write_raw(frame)
|
|
app.output.flush()
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
def _pet_fragments(self):
|
|
"""Return prompt_toolkit FormattedText for the current pet frame, or []."""
|
|
with self._pet_lock:
|
|
if not self._pet_enabled or self._pet_renderer is None:
|
|
return []
|
|
state = self._derive_pet_state()
|
|
kitty = self._pet_renderer.mode == "kitty"
|
|
if kitty:
|
|
payload = self._pet_kitty_payload_for(state)
|
|
if not payload:
|
|
return []
|
|
color = pet_render.kitty_color_hex(payload["image_id"])
|
|
frags = []
|
|
for y, row in enumerate(payload["placeholder"]):
|
|
if y:
|
|
frags.append(("", "\n"))
|
|
frags.append((f"fg:{color}", row))
|
|
return frags
|
|
with self._pet_lock:
|
|
grids = self._pet_frames_for(state)
|
|
if not grids:
|
|
return []
|
|
grid = grids[self._pet_frame_idx % len(grids)]
|
|
|
|
frags = []
|
|
for y, row in enumerate(grid):
|
|
if y:
|
|
frags.append(("", "\n"))
|
|
for top, bottom in row:
|
|
tr, tg, tb, ta = top
|
|
br, bg, bb, ba = bottom
|
|
top_op = ta >= 32
|
|
bot_op = ba >= 32
|
|
if not top_op and not bot_op:
|
|
frags.append(("", " "))
|
|
elif top_op and bot_op:
|
|
frags.append((f"fg:#{tr:02x}{tg:02x}{tb:02x} bg:#{br:02x}{bg:02x}{bb:02x}", "▀"))
|
|
elif top_op:
|
|
# Upper half only — leave the lower half the terminal's bg
|
|
# instead of painting it black (cleaner on light themes).
|
|
frags.append((f"fg:#{tr:02x}{tg:02x}{tb:02x}", "▀"))
|
|
else:
|
|
frags.append((f"fg:#{br:02x}{bg:02x}{bb:02x}", "▄"))
|
|
return frags
|
|
|
|
def _pet_widget_height(self) -> int:
|
|
"""Visible rows for the pet window — 0 collapses it when no pet shows."""
|
|
with self._pet_lock:
|
|
if not self._pet_enabled or self._pet_renderer is None:
|
|
return 0
|
|
state = self._derive_pet_state()
|
|
kitty = self._pet_renderer.mode == "kitty"
|
|
if kitty:
|
|
payload = self._pet_kitty_payload_for(state)
|
|
return int(payload.get("rows", 0)) if payload else 0
|
|
with self._pet_lock:
|
|
grids = self._pet_frames_for(state)
|
|
if not grids or not grids[0]:
|
|
return 0
|
|
return len(grids[0])
|
|
|
|
def _pet_anim_loop(self) -> None:
|
|
"""Advance the frame + invalidate on a timer while a pet is enabled."""
|
|
while self._pet_anim_running:
|
|
time.sleep(self._PET_FRAME_INTERVAL)
|
|
if getattr(self, "_terminal_io_broken", False):
|
|
self._pet_anim_running = False
|
|
break
|
|
now = time.monotonic()
|
|
if now - self._pet_cfg_checked >= self._PET_CFG_INTERVAL:
|
|
self._pet_cfg_checked = now
|
|
self._pet_resolve_config()
|
|
if not self._pet_enabled:
|
|
continue
|
|
with self._pet_lock:
|
|
self._pet_frame_idx += 1
|
|
kitty = self._pet_renderer is not None and self._pet_renderer.mode == "kitty"
|
|
if kitty:
|
|
self._pet_queue_kitty_frame()
|
|
app = getattr(self, "_app", None)
|
|
if app is not None:
|
|
try:
|
|
app.invalidate()
|
|
except OSError as exc:
|
|
if getattr(exc, "errno", None) == errno.EIO:
|
|
self._mark_terminal_io_broken("pet_anim")
|
|
break
|
|
except Exception:
|
|
pass
|
|
|
|
def _pet_start_anim(self) -> None:
|
|
if self._pet_anim_running:
|
|
return
|
|
self._pet_resolve_config()
|
|
with self._pet_lock:
|
|
kitty = self._pet_enabled and self._pet_renderer is not None and self._pet_renderer.mode == "kitty"
|
|
if kitty:
|
|
self._pet_queue_kitty_frame()
|
|
self._pet_anim_running = True
|
|
self._pet_anim_thread = threading.Thread(target=self._pet_anim_loop, daemon=True)
|
|
self._pet_anim_thread.start()
|
|
|
|
def _pet_stop_anim(self) -> None:
|
|
self._pet_anim_running = False
|
|
thread = self._pet_anim_thread
|
|
if thread is not None:
|
|
thread.join(timeout=0.3)
|
|
self._pet_anim_thread = None
|
|
|
|
def _voice_record_key_label(self) -> str:
|
|
"""Return the configured voice push-to-talk key formatted for UI.
|
|
|
|
Shared helper so every voice-facing status line / placeholder /
|
|
recording hint advertises the SAME label as the registered
|
|
prompt_toolkit binding.
|
|
|
|
Cached at startup (see ``set_voice_record_key_cache``) rather
|
|
than re-read per render. Two reasons (Copilot round-13 on
|
|
#19835):
|
|
|
|
* The prompt_toolkit binding is registered once at session
|
|
start via ``@kb.add(_voice_key)``; re-reading config per
|
|
render meant the status bar could advertise a new shortcut
|
|
after a config edit while the actual binding was still the
|
|
startup chord — exactly the display/binding drift this PR
|
|
is trying to eliminate.
|
|
* The label is on the hot render path (status bar + composer
|
|
placeholder invalidated every 150ms during recording), so
|
|
reading config on every call added avoidable UI overhead.
|
|
"""
|
|
return getattr(self, "_voice_record_key_display_cache", None) or "Ctrl+B"
|
|
|
|
def set_voice_record_key_cache(self, raw_key: object) -> None:
|
|
"""Populate the voice label cache from a raw ``voice.record_key``.
|
|
|
|
Called at CLI startup after the prompt_toolkit binding is
|
|
registered so the cached label always matches the live binding.
|
|
"""
|
|
try:
|
|
from hermes_cli.voice import format_voice_record_key_for_status
|
|
self._voice_record_key_display_cache = format_voice_record_key_for_status(raw_key)
|
|
except Exception:
|
|
self._voice_record_key_display_cache = "Ctrl+B"
|
|
|
|
def _get_voice_status_fragments(self, width: Optional[int] = None):
|
|
"""Return the voice status bar fragments for the interactive TUI."""
|
|
width = width or self._get_tui_terminal_width()
|
|
compact = self._use_minimal_tui_chrome(width=width)
|
|
label = self._voice_record_key_label()
|
|
if self._voice_recording:
|
|
if compact:
|
|
return [("class:voice-status-recording", " ● REC ")]
|
|
return [("class:voice-status-recording", f" ● REC {label} to stop ")]
|
|
if self._voice_processing:
|
|
if compact:
|
|
return [("class:voice-status", " ◉ STT ")]
|
|
return [("class:voice-status", " ◉ Transcribing... ")]
|
|
if compact:
|
|
return [("class:voice-status", f" 🎤 {label} ")]
|
|
tts = " | TTS on" if self._voice_tts else ""
|
|
cont = " | Continuous" if self._voice_continuous else ""
|
|
return [("class:voice-status", f" 🎤 Voice mode{tts}{cont} — {label} to record ")]
|
|
|
|
@staticmethod
|
|
def _status_bar_goal_segment(snapshot: Dict[str, Any]) -> str:
|
|
"""Return the ``⊙ goal 3/20`` segment, or ``""`` when no goal is active.
|
|
|
|
Active-goal-only by design: paused/done goals don't occupy status-bar
|
|
real estate (they already print their own glyph lines in the thread).
|
|
"""
|
|
if not snapshot.get("goal_active"):
|
|
return ""
|
|
used = snapshot.get("goal_turns_used") or 0
|
|
max_turns = snapshot.get("goal_max_turns") or 0
|
|
if max_turns:
|
|
return f"⊙ goal {used}/{max_turns}"
|
|
return "⊙ goal"
|
|
|
|
def _get_status_bar_field_set(self) -> Optional[frozenset]:
|
|
"""Return the set of visible status-bar fields from config.
|
|
|
|
Reads ``display.status_bar.fields`` from the module-level
|
|
``CLI_CONFIG`` (no per-render YAML parse — the status bar repaints
|
|
every frame). Returns ``None`` when the user has not customized the
|
|
bar (use built-in defaults, i.e. show everything), or a
|
|
``frozenset`` of field names when the list is non-empty.
|
|
|
|
Available fields: model, context_detail, context_pct, cache_hit,
|
|
latency, tps, compressions, bg_tasks, bg_processes, bg_subagents,
|
|
goal, duration, prompt_elapsed, idle_since, focus, yolo, stash,
|
|
battery, title, total_tokens.
|
|
``total_tokens`` is opt-in only (never shown by default).
|
|
The field order is fixed; the config controls visibility only.
|
|
"""
|
|
from cli import CLI_CONFIG
|
|
if hasattr(self, "_status_bar_field_set_cache"):
|
|
return self._status_bar_field_set_cache
|
|
result = None
|
|
try:
|
|
display = CLI_CONFIG.get("display") if isinstance(CLI_CONFIG, dict) else None
|
|
status_bar = (display or {}).get("status_bar") if isinstance(display, dict) else None
|
|
fields = status_bar.get("fields") if isinstance(status_bar, dict) else None
|
|
if isinstance(fields, list) and fields:
|
|
result = frozenset(str(f) for f in fields)
|
|
except Exception:
|
|
result = None
|
|
self._status_bar_field_set_cache = result
|
|
return result
|
|
|
|
def _build_status_bar_text(self, width: Optional[int] = None) -> str:
|
|
"""Return a compact one-line session status string for the TUI footer."""
|
|
from cli import format_token_count_compact
|
|
try:
|
|
snapshot = self._get_status_bar_snapshot()
|
|
if width is None:
|
|
width = self._get_tui_terminal_width()
|
|
percent = snapshot["context_percent"]
|
|
percent_label = f"{percent}%" if percent is not None else "--"
|
|
duration_label = snapshot["duration"]
|
|
battery_label = snapshot.get("battery_label") or ""
|
|
battery_prefix = f"{battery_label} │ " if battery_label else ""
|
|
focus_label = snapshot.get("focus_label") or ""
|
|
session_title = snapshot.get("session_title") or ""
|
|
|
|
yolo_active = self._is_session_yolo_active()
|
|
goal_segment = self._status_bar_goal_segment(snapshot)
|
|
field_set = self._get_status_bar_field_set()
|
|
|
|
def _ok(name: str) -> bool:
|
|
return field_set is None or name in field_set
|
|
|
|
if not _ok("title"):
|
|
session_title = ""
|
|
|
|
if not _ok("goal"):
|
|
goal_segment = ""
|
|
if not _ok("focus"):
|
|
focus_label = ""
|
|
if width < 52:
|
|
segs = []
|
|
if _ok("model"):
|
|
segs.append(f"⚕ {snapshot['model_short']}")
|
|
if _ok("duration"):
|
|
segs.append(duration_label)
|
|
if goal_segment:
|
|
segs.append(goal_segment)
|
|
if focus_label:
|
|
segs.append(focus_label)
|
|
if yolo_active and _ok("yolo"):
|
|
segs.append("⚠ YOLO")
|
|
text = battery_prefix + " · ".join(segs) if segs else f"{battery_prefix}⚕ {snapshot['model_short']}"
|
|
return self._right_align_status_title(text, session_title, width)
|
|
if width < 76:
|
|
parts = []
|
|
if _ok("model"):
|
|
parts.append(f"⚕ {snapshot['model_short']}")
|
|
if _ok("context_pct"):
|
|
parts.append(percent_label)
|
|
cache = self._cache_hit_rate(snapshot, precision=0)
|
|
if cache and _ok("cache_hit"):
|
|
parts.append(cache[1])
|
|
if battery_label:
|
|
parts.insert(0, battery_label)
|
|
compressions = snapshot.get("compressions", 0)
|
|
if compressions and _ok("compressions"):
|
|
parts.append(f"🗜️ {compressions}")
|
|
bg_count = snapshot.get("active_background_tasks", 0)
|
|
if bg_count and _ok("bg_tasks"):
|
|
parts.append(f"▶ {bg_count}")
|
|
bg_proc_count = snapshot.get("active_background_processes", 0)
|
|
if bg_proc_count and _ok("bg_processes"):
|
|
parts.append(f"⚙ {bg_proc_count}")
|
|
bg_subagent_count = snapshot.get("active_background_subagents", 0)
|
|
if bg_subagent_count and _ok("bg_subagents"):
|
|
parts.append(f"⛓ {bg_subagent_count}")
|
|
if goal_segment:
|
|
parts.append(goal_segment)
|
|
if _ok("duration"):
|
|
parts.append(duration_label)
|
|
if focus_label:
|
|
parts.append(focus_label)
|
|
if yolo_active and _ok("yolo"):
|
|
parts.append("⚠ YOLO")
|
|
if not parts:
|
|
parts = [f"⚕ {snapshot['model_short']}"]
|
|
return self._right_align_status_title(" · ".join(parts), session_title, width)
|
|
|
|
parts = []
|
|
if _ok("model"):
|
|
parts.append(f"⚕ {snapshot['model_short']}")
|
|
if _ok("context_detail"):
|
|
if snapshot["context_length"]:
|
|
ctx_total = _format_context_length(snapshot["context_length"])
|
|
ctx_used = format_token_count_compact(snapshot["context_tokens"])
|
|
context_label = f"{ctx_used}/{ctx_total}"
|
|
else:
|
|
context_label = "ctx --"
|
|
parts.append(context_label)
|
|
if _ok("context_pct"):
|
|
parts.append(percent_label)
|
|
if battery_label:
|
|
parts.insert(0, battery_label)
|
|
compressions = snapshot.get("compressions", 0)
|
|
cache = self._cache_hit_rate(snapshot)
|
|
if cache and _ok("cache_hit"):
|
|
parts.append(cache[1])
|
|
_avg_lat = snapshot.get("avg_latency_label") or ""
|
|
if _avg_lat and _ok("latency"):
|
|
parts.append(f"◷ {_avg_lat}")
|
|
_avg_vel = snapshot.get("avg_velocity_label") or ""
|
|
if _avg_vel and _ok("tps"):
|
|
parts.append(f"↑ {_avg_vel}")
|
|
if compressions and _ok("compressions"):
|
|
parts.append(f"🗜️ {compressions}")
|
|
bg_count = snapshot.get("active_background_tasks", 0)
|
|
if bg_count and _ok("bg_tasks"):
|
|
parts.append(f"▶ {bg_count}")
|
|
bg_proc_count = snapshot.get("active_background_processes", 0)
|
|
if bg_proc_count and _ok("bg_processes"):
|
|
parts.append(f"⚙ {bg_proc_count}")
|
|
bg_subagent_count = snapshot.get("active_background_subagents", 0)
|
|
if bg_subagent_count and _ok("bg_subagents"):
|
|
parts.append(f"⛓ {bg_subagent_count}")
|
|
if goal_segment:
|
|
parts.append(goal_segment)
|
|
if _ok("duration"):
|
|
parts.append(duration_label)
|
|
prompt_elapsed = snapshot.get("prompt_elapsed")
|
|
if prompt_elapsed and _ok("prompt_elapsed"):
|
|
parts.append(prompt_elapsed)
|
|
idle_since = snapshot.get("idle_since")
|
|
if idle_since and _ok("idle_since"):
|
|
parts.append(idle_since)
|
|
if focus_label:
|
|
parts.append(focus_label)
|
|
if yolo_active and _ok("yolo"):
|
|
parts.append("⚠ YOLO")
|
|
# Session token total (Σ) — opt-in only via an explicit fields
|
|
# list, so default bars never widen.
|
|
total_tokens = snapshot.get("session_total_tokens", 0)
|
|
if total_tokens and field_set is not None and "total_tokens" in field_set:
|
|
parts.append(f"Σ{format_token_count_compact(total_tokens)}")
|
|
if not parts:
|
|
parts = [f"⚕ {snapshot['model_short']}"]
|
|
return self._right_align_status_title(" │ ".join(parts), session_title, width)
|
|
except Exception:
|
|
return f"⚕ {self.model if getattr(self, 'model', None) else 'Hermes'}"
|
|
|
|
def _get_status_bar_fragments(self):
|
|
from cli import format_token_count_compact
|
|
if not self._status_bar_visible or getattr(self, '_model_picker_state', None) or getattr(self, '_command_palette_state', None):
|
|
return []
|
|
try:
|
|
snapshot = self._get_status_bar_snapshot()
|
|
# Use prompt_toolkit's own terminal width when running inside the
|
|
# TUI — shutil.get_terminal_size() can return stale or fallback
|
|
# values (especially on SSH) that differ from what prompt_toolkit
|
|
# actually renders, causing the fragments to overflow to a second
|
|
# line and produce duplicated status bar rows over long sessions.
|
|
width = self._get_tui_terminal_width()
|
|
duration_label = snapshot["duration"]
|
|
yolo_active = self._is_session_yolo_active()
|
|
goal_segment = self._status_bar_goal_segment(snapshot)
|
|
battery_label = snapshot.get("battery_label") or ""
|
|
battery_style = self._battery_status_style(snapshot.get("battery_category", "dim"))
|
|
focus_label = snapshot.get("focus_label") or ""
|
|
session_title = snapshot.get("session_title") or ""
|
|
field_set = self._get_status_bar_field_set()
|
|
|
|
def _ok(name: str) -> bool:
|
|
return field_set is None or name in field_set
|
|
|
|
if not _ok("title"):
|
|
session_title = ""
|
|
|
|
if not _ok("goal"):
|
|
goal_segment = ""
|
|
if not _ok("focus"):
|
|
focus_label = ""
|
|
|
|
def _append(frag_list, sep, *pieces):
|
|
if frag_list:
|
|
frag_list.append(("class:status-bar-dim", sep))
|
|
frag_list.extend(pieces)
|
|
|
|
if width < 52:
|
|
frags = []
|
|
if _ok("model"):
|
|
frags.append(("class:status-bar", " ⚕ "))
|
|
frags.append(("class:status-bar-strong", snapshot["model_short"]))
|
|
if _ok("duration"):
|
|
_append(frags, " · ", ("class:status-bar-dim", duration_label))
|
|
if goal_segment:
|
|
_append(frags, " · ", ("class:status-bar-strong", goal_segment))
|
|
if focus_label:
|
|
_append(frags, " · ", ("class:status-bar-strong", focus_label))
|
|
if yolo_active and _ok("yolo"):
|
|
_append(frags, " · ", ("class:status-bar-yolo", "⚠ YOLO"))
|
|
if not frags:
|
|
frags = [
|
|
("class:status-bar", " ⚕ "),
|
|
("class:status-bar-strong", snapshot["model_short"]),
|
|
]
|
|
frags.append(("class:status-bar", " "))
|
|
else:
|
|
percent = snapshot["context_percent"]
|
|
percent_label = f"{percent}%" if percent is not None else "--"
|
|
if width < 76:
|
|
compressions = snapshot.get("compressions", 0)
|
|
bg_count = snapshot.get("active_background_tasks", 0)
|
|
bg_proc_count = snapshot.get("active_background_processes", 0)
|
|
bg_subagent_count = snapshot.get("active_background_subagents", 0)
|
|
frags = []
|
|
if _ok("model"):
|
|
frags.append(("class:status-bar", " ⚕ "))
|
|
frags.append(("class:status-bar-strong", snapshot["model_short"]))
|
|
if _ok("context_pct"):
|
|
_append(frags, " · ", (self._status_bar_context_style(percent), percent_label))
|
|
cache = self._cache_hit_rate(snapshot, precision=0)
|
|
if cache and _ok("cache_hit"):
|
|
_append(frags, " · ", (self._cache_hit_rate_style(cache[0]), cache[1]))
|
|
if compressions and _ok("compressions"):
|
|
_append(frags, " · ", (self._compression_count_style(compressions), f"🗜️ {compressions}"))
|
|
if bg_count and _ok("bg_tasks"):
|
|
_append(frags, " · ", ("class:status-bar-strong", f"▶ {bg_count}"))
|
|
if bg_proc_count and _ok("bg_processes"):
|
|
_append(frags, " · ", ("class:status-bar-strong", f"⚙ {bg_proc_count}"))
|
|
if bg_subagent_count and _ok("bg_subagents"):
|
|
_append(frags, " · ", ("class:status-bar-strong", f"⛓ {bg_subagent_count}"))
|
|
if goal_segment:
|
|
_append(frags, " · ", ("class:status-bar-strong", goal_segment))
|
|
if _ok("duration"):
|
|
_append(frags, " · ", ("class:status-bar-dim", duration_label))
|
|
if focus_label:
|
|
_append(frags, " · ", ("class:status-bar-strong", focus_label))
|
|
if yolo_active and _ok("yolo"):
|
|
_append(frags, " · ", ("class:status-bar-yolo", "⚠ YOLO"))
|
|
if not frags:
|
|
frags = [
|
|
("class:status-bar", " ⚕ "),
|
|
("class:status-bar-strong", snapshot["model_short"]),
|
|
]
|
|
frags.append(("class:status-bar", " "))
|
|
else:
|
|
bar_style = self._status_bar_context_style(percent)
|
|
compressions = snapshot.get("compressions", 0)
|
|
bg_count = snapshot.get("active_background_tasks", 0)
|
|
bg_proc_count = snapshot.get("active_background_processes", 0)
|
|
bg_subagent_count = snapshot.get("active_background_subagents", 0)
|
|
frags = []
|
|
if _ok("model"):
|
|
frags.append(("class:status-bar", " ⚕ "))
|
|
frags.append(("class:status-bar-strong", snapshot["model_short"]))
|
|
if _ok("context_detail"):
|
|
if snapshot["context_length"]:
|
|
ctx_total = _format_context_length(snapshot["context_length"])
|
|
ctx_used = format_token_count_compact(snapshot["context_tokens"])
|
|
context_label = f"{ctx_used}/{ctx_total}"
|
|
else:
|
|
context_label = "ctx --"
|
|
_append(frags, " │ ", ("class:status-bar-dim", context_label))
|
|
if _ok("context_pct"):
|
|
_append(
|
|
frags,
|
|
" │ ",
|
|
(bar_style, self._build_context_bar(percent)),
|
|
("class:status-bar-dim", " "),
|
|
(bar_style, percent_label),
|
|
)
|
|
cache = self._cache_hit_rate(snapshot)
|
|
if cache and _ok("cache_hit"):
|
|
_append(frags, " │ ", (self._cache_hit_rate_style(cache[0]), cache[1]))
|
|
_avg_lat = snapshot.get("avg_latency_label") or ""
|
|
if _avg_lat and _ok("latency"):
|
|
_append(frags, " │ ", ("class:status-bar-dim", f"◷ {_avg_lat}"))
|
|
_avg_vel = snapshot.get("avg_velocity_label") or ""
|
|
if _avg_vel and _ok("tps"):
|
|
_append(frags, " │ ", ("class:status-bar-dim", f"↑ {_avg_vel}"))
|
|
if compressions and _ok("compressions"):
|
|
_append(frags, " │ ", (self._compression_count_style(compressions), f"🗜️ {compressions}"))
|
|
if bg_count and _ok("bg_tasks"):
|
|
_append(frags, " │ ", ("class:status-bar-strong", f"▶ {bg_count}"))
|
|
if bg_proc_count and _ok("bg_processes"):
|
|
_append(frags, " │ ", ("class:status-bar-strong", f"⚙ {bg_proc_count}"))
|
|
if bg_subagent_count and _ok("bg_subagents"):
|
|
_append(frags, " │ ", ("class:status-bar-strong", f"⛓ {bg_subagent_count}"))
|
|
if goal_segment:
|
|
_append(frags, " │ ", ("class:status-bar-strong", goal_segment))
|
|
if _ok("duration"):
|
|
_append(frags, " │ ", ("class:status-bar-dim", duration_label))
|
|
# Position 7: per-prompt elapsed timer (live or frozen)
|
|
prompt_elapsed = snapshot.get("prompt_elapsed")
|
|
if prompt_elapsed and _ok("prompt_elapsed"):
|
|
_append(frags, " │ ", ("class:status-bar-dim", prompt_elapsed))
|
|
# Position 8: idle time since the last final agent response
|
|
idle_since = snapshot.get("idle_since")
|
|
if idle_since and _ok("idle_since"):
|
|
_append(frags, " │ ", ("class:status-bar-dim", idle_since))
|
|
# Persistent focus-view badge — so the reduced-output mode
|
|
# is never invisible (mirrors the YOLO badge convention).
|
|
if focus_label:
|
|
_append(frags, " │ ", ("class:status-bar-strong", focus_label))
|
|
if yolo_active and _ok("yolo"):
|
|
_append(frags, " │ ", ("class:status-bar-yolo", "⚠ YOLO"))
|
|
# Session token total (Σ) — opt-in only via an explicit
|
|
# fields list, so default bars never widen.
|
|
total_tokens = snapshot.get("session_total_tokens", 0)
|
|
if total_tokens and field_set is not None and "total_tokens" in field_set:
|
|
_append(frags, " │ ", ("class:status-bar-dim", f"Σ{format_token_count_compact(total_tokens)}"))
|
|
if not frags:
|
|
frags = [
|
|
("class:status-bar", " ⚕ "),
|
|
("class:status-bar-strong", snapshot["model_short"]),
|
|
]
|
|
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 and _ok("stash"):
|
|
# 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 and _ok("battery"):
|
|
frags[0:0] = [
|
|
("class:status-bar", " "),
|
|
(battery_style, battery_label),
|
|
("class:status-bar-dim", " │"),
|
|
]
|
|
|
|
frags = self._right_align_status_title_fragments(frags, session_title, width)
|
|
|
|
total_width = sum(self._status_bar_display_width(text) for _, text in frags)
|
|
if total_width > width:
|
|
plain_text = "".join(text for _, text in frags)
|
|
trimmed = self._trim_status_bar_text(plain_text, width)
|
|
return [("class:status-bar", trimmed)]
|
|
return frags
|
|
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.
|
|
|
|
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))
|
|
|
|
n = len(stash_list)
|
|
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 ─╯"
|
|
|
|
# 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 = "─╯"
|
|
|
|
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:
|
|
# 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} "
|
|
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:
|
|
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
|