feat(status-bar): per-prompt elapsed stopwatch

Adds a per-prompt elapsed timer to the CLI status bar (live ⏱ while the
turn runs, frozen ⏲ after completion, resets on next prompt).  Fills the
gap left by the KawaiiSpinner — the spinner only shows elapsed time while
actively animating, so it disappears between tool calls and after the
turn finishes.  Status bar is always pinned, so users can glance down
and see how long the current/last prompt has been running.

- New instance vars: _prompt_start_time, _prompt_duration
- Timer starts before agent_thread.start() and freezes once the thread
  has exited (both interrupt and normal-completion paths)
- _format_prompt_elapsed() formats s/m/h/d with seconds visible at all
  scales, trailing zeros hidden on exact boundaries, negative clamp
- Displayed in the wide (>=76 col) status bar as position 7, after the
  session duration timer
- Uses width-1 glyphs (⏱/⏲, no variation selector) to stay aligned in
  monospace terminals
This commit is contained in:
Stefan 2026-04-20 02:41:36 -07:00 committed by Teknium
parent a2b5627e6d
commit 654d61ab6f

67
cli.py
View file

@ -1921,6 +1921,10 @@ class HermesCLI:
self.conversation_history: List[Dict[str, Any]] = []
self.session_start = datetime.now()
self._resumed = False
# Per-prompt elapsed timer — started at the beginning of each chat turn,
# frozen when the agent thread completes, displayed in the status bar.
self._prompt_start_time: Optional[float] = None # time.time() when turn started
self._prompt_duration: float = 0.0 # frozen duration of last completed turn
# Initialize SQLite session store early so /title works before first message
self._session_db = None
try:
@ -2019,6 +2023,44 @@ class HermesCLI:
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}"
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
@ -2037,6 +2079,11 @@ class HermesCLI:
"model_name": model_name,
"model_short": model_short,
"duration": format_duration_compact(elapsed_seconds),
"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,
),
"context_tokens": 0,
"context_length": None,
"context_percent": None,
@ -2228,6 +2275,9 @@ class HermesCLI:
parts = [f"{snapshot['model_short']}", context_label, percent_label]
parts.append(duration_label)
prompt_elapsed = snapshot.get("prompt_elapsed")
if prompt_elapsed:
parts.append(prompt_elapsed)
return self._trim_status_bar_text("".join(parts), width)
except Exception:
return f"{self.model if getattr(self, 'model', None) else 'Hermes'}"
@ -2286,8 +2336,13 @@ class HermesCLI:
(bar_style, percent_label),
("class:status-bar-dim", ""),
("class:status-bar-dim", duration_label),
("class:status-bar", " "),
]
# Position 7: per-prompt elapsed timer (live or frozen)
prompt_elapsed = snapshot.get("prompt_elapsed")
if prompt_elapsed:
frags.append(("class:status-bar-dim", ""))
frags.append(("class:status-bar-dim", prompt_elapsed))
frags.append(("class:status-bar", " "))
total_width = sum(self._status_bar_display_width(text) for _, text in frags)
if total_width > width:
@ -8236,6 +8291,10 @@ class HermesCLI:
# Start agent in background thread (daemon so it cannot keep the
# process alive when the user closes the terminal tab — SIGHUP
# exits the main thread and daemon threads are reaped automatically).
# Start per-prompt elapsed timer — frozen after the agent thread
# finishes; reset on the next turn.
self._prompt_start_time = time.time()
self._prompt_duration = 0.0
agent_thread = threading.Thread(target=run_agent, daemon=True)
agent_thread.start()
@ -8313,6 +8372,12 @@ class HermesCLI:
# but guard against edge cases.
agent_thread.join(timeout=30)
# Freeze per-prompt elapsed timer once the agent thread has
# exited (or been abandoned as a daemon after interrupt).
if self._prompt_start_time is not None:
self._prompt_duration = max(0.0, time.time() - self._prompt_start_time)
self._prompt_start_time = None
# Proactively clean up async clients whose event loop is dead.
# The agent thread may have created AsyncOpenAI clients bound
# to a per-thread event loop; if that loop is now closed, those