feat(cli,tui): show time since last final agent response on the status bar (#44265)

Adds an idle clock to the context/status bar in both the prompt_toolkit CLI
and the Ink TUI: once a turn completes, a dim '✓ <elapsed>' segment shows how
long the session has been idle since the last final agent response. Hidden
while a turn is live (the per-prompt elapsed timer covers that) and before
the first turn completes.

- cli.py: track _last_turn_finished_at when the agent thread exits, surface
  it via _format_idle_since() in the snapshot, render in both the wide
  fragments path and the plain-text fallback.
- ui-tui: stamp lastTurnEndedAt when busy flips false after a live turn,
  thread it through appStatus -> StatusRule, render via a ticking IdleSince
  segment sharing the duration breakpoint/width budget.
This commit is contained in:
Teknium 2026-06-11 06:06:19 -07:00 committed by GitHub
parent a2d7f538d4
commit 8972a151a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 186 additions and 2 deletions

29
cli.py
View file

@ -3426,6 +3426,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# 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
self._last_turn_finished_at: Optional[float] = None # time.time() when the last agent loop finished
# Initialize SQLite session store early so /title works before first message
self._session_db = None
try:
@ -3812,6 +3813,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
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``.
"""
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
@ -3835,6 +3849,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
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,
@ -4146,6 +4164,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
prompt_elapsed = snapshot.get("prompt_elapsed")
if prompt_elapsed:
parts.append(prompt_elapsed)
idle_since = snapshot.get("idle_since")
if idle_since:
parts.append(idle_since)
if yolo_active:
parts.append("⚠ YOLO")
return self._trim_status_bar_text("".join(parts), width)
@ -4247,6 +4268,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
if prompt_elapsed:
frags.append(("class:status-bar-dim", ""))
frags.append(("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:
frags.append(("class:status-bar-dim", ""))
frags.append(("class:status-bar-dim", idle_since))
if yolo_active:
frags.append(("class:status-bar-dim", ""))
frags.append(("class:status-bar-yolo", "⚠ YOLO"))
@ -10162,6 +10188,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
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
# Record when this agent loop finished so the status bar can show
# idle time since the last final response.
self._last_turn_finished_at = time.time()
# Proactively clean up async clients whose event loop is dead.
# The agent thread may have created AsyncOpenAI clients bound