mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
a2b5627e6d
commit
654d61ab6f
1 changed files with 66 additions and 1 deletions
67
cli.py
67
cli.py
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue