From 8972a151a44c1f792a63ece7fa678b4fc67beed3 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 11 Jun 2026 06:06:19 -0700 Subject: [PATCH] feat(cli,tui): show time since last final agent response on the status bar (#44265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 '✓ ' 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. --- cli.py | 29 ++++++++ tests/cli/test_cli_status_bar.py | 51 ++++++++++++++ .../__tests__/appChromeStatusRule.test.tsx | 68 +++++++++++++++++++ ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/app/useMainApp.ts | 11 ++- ui-tui/src/components/appChrome.tsx | 27 ++++++++ ui-tui/src/components/appLayout.tsx | 1 + 7 files changed, 186 insertions(+), 2 deletions(-) diff --git a/cli.py b/cli.py index b12a15f68ea..86875bcb60c 100644 --- a/cli.py +++ b/cli.py @@ -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 diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index 47a78c57044..c6a131a5131 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -676,3 +676,54 @@ class TestStatusBarWidthSource: mock_get_app.assert_not_called() mock_shutil.assert_not_called() assert len(text) > 0 + + +class TestIdleSinceLastTurn: + """Time-since-last-final-agent-response read-out on the status bar.""" + + def test_hidden_before_first_turn(self): + assert HermesCLI._format_idle_since(None, turn_live=False) == "" + + def test_hidden_while_turn_is_live(self): + assert HermesCLI._format_idle_since(time.time() - 30, turn_live=True) == "" + + def test_shows_compact_idle_time_after_turn(self): + label = HermesCLI._format_idle_since(time.time() - 42, turn_live=False) + assert label.startswith("✓ ") + assert label == "✓ 42s" + + def test_scales_to_minutes(self): + label = HermesCLI._format_idle_since(time.time() - 3 * 60, turn_live=False) + assert label == "✓ 3m" + + def test_snapshot_carries_idle_since(self): + cli_obj = _make_cli() + cli_obj._last_turn_finished_at = time.time() - 10 + cli_obj._prompt_start_time = None + cli_obj._prompt_duration = 5.0 + snapshot = cli_obj._get_status_bar_snapshot() + assert snapshot["idle_since"].startswith("✓ ") + + def test_snapshot_idle_empty_during_live_turn(self): + cli_obj = _make_cli() + cli_obj._last_turn_finished_at = time.time() - 10 + cli_obj._prompt_start_time = time.time() + cli_obj._prompt_duration = 0.0 + snapshot = cli_obj._get_status_bar_snapshot() + assert snapshot["idle_since"] == "" + + def test_wide_status_bar_text_includes_idle(self): + cli_obj = _attach_agent( + _make_cli(), + prompt_tokens=10_230, + completion_tokens=2_220, + total_tokens=12_450, + api_calls=7, + context_tokens=12_450, + context_length=200_000, + ) + cli_obj._last_turn_finished_at = time.time() - 42 + cli_obj._prompt_start_time = None + cli_obj._prompt_duration = 7.0 + text = cli_obj._build_status_bar_text(width=160) + assert "✓ 42s" in text diff --git a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx index 3381b4b8e4e..5bbd14bbdce 100644 --- a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +++ b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx @@ -260,3 +260,71 @@ describe('StatusRule credits notice render priority', () => { expect(textContent(element)).toContain('opus 4.8') }) }) + +describe('StatusRule idle-since read-out', () => { + // The IdleSince component uses hooks, so it can't be invoked outside a + // renderer — assert on the element tree instead (same reason the duration + // tests don't check SessionDuration's text). + const findComponentByName = (node: ReactNodeLike, name: string): React.ReactElement | null => { + if (node === null || node === undefined || typeof node === 'boolean') { + return null + } + + if (Array.isArray(node)) { + for (const child of node) { + const found = findComponentByName(child, name) + + if (found) { + return found + } + } + + return null + } + + if (!React.isValidElement(node)) { + return null + } + + if (typeof node.type === 'function' && node.type.name === name) { + return node + } + + return findComponentByName(node.props.children, name) + } + + it('shows time since the last final agent response when idle', () => { + const endedAt = Date.now() - 42_000 + const element = StatusRule({ + ...baseProps, + lastTurnEndedAt: endedAt, + sessionStartedAt: Date.now() - 60_000 + }) + + const idle = findComponentByName(element, 'IdleSince') + + expect(idle).not.toBeNull() + expect(idle!.props.endedAt).toBe(endedAt) + }) + + it('is hidden while a turn is busy', () => { + const element = StatusRule({ + ...baseProps, + busy: true, + lastTurnEndedAt: Date.now() - 42_000, + turnStartedAt: Date.now() + }) + + expect(findComponentByName(element, 'IdleSince')).toBeNull() + }) + + it('is hidden before the first turn completes', () => { + const element = StatusRule({ + ...baseProps, + lastTurnEndedAt: null, + sessionStartedAt: Date.now() - 60_000 + }) + + expect(findComponentByName(element, 'IdleSince')).toBeNull() + }) +}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 30c62e03590..f7297c151da 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -368,6 +368,7 @@ export interface AppLayoutProgressProps { export interface AppLayoutStatusProps { cwdLabel: string goodVibesTick: number + lastTurnEndedAt: null | number sessionStartedAt: null | number showStickyPrompt: boolean statusColor: string diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 3bd981b36cf..d11e8e08dba 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -173,6 +173,7 @@ export function useMainApp(gw: GatewayClient) { const [voiceRecordKey, setVoiceRecordKey] = useState(DEFAULT_VOICE_RECORD_KEY) const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) const [turnStartedAt, setTurnStartedAt] = useState(null) + const [lastTurnEndedAt, setLastTurnEndedAt] = useState(null) const [goodVibesTick, setGoodVibesTick] = useState(0) const [bellOnComplete, setBellOnComplete] = useState(false) @@ -500,10 +501,14 @@ export function useMainApp(gw: GatewayClient) { useEffect(() => { if (ui.busy) { setTurnStartedAt(prev => prev ?? Date.now()) - } else { + } else if (turnStartedAt != null) { + // Only stamp the idle marker when a turn was actually live — busy is + // also false on mount and we don't want a phantom "done" timestamp + // before the first turn has completed. + setLastTurnEndedAt(Date.now()) setTurnStartedAt(null) } - }, [ui.busy]) + }, [ui.busy, turnStartedAt]) useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid: ui.sid }) @@ -1090,6 +1095,7 @@ export function useMainApp(gw: GatewayClient) { // essentials and truncates this further on narrow terminals. cwdLabel: fmtCwdBranch(cwd, gitBranch, 28), goodVibesTick, + lastTurnEndedAt: ui.sid ? lastTurnEndedAt : null, sessionStartedAt: ui.sid ? sessionStartedAt : null, showStickyPrompt: !!stickyPrompt, statusColor: statusColorOf(ui.status, ui.theme.color), @@ -1103,6 +1109,7 @@ export function useMainApp(gw: GatewayClient) { cwd, gitBranch, goodVibesTick, + lastTurnEndedAt, sessionStartedAt, stickyPrompt, turnStartedAt, diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index a420d815341..007fd356355 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -341,6 +341,21 @@ function SessionDuration({ startedAt }: { startedAt: number }) { return fmtDuration(now - startedAt) } +function IdleSince({ endedAt }: { endedAt: number }) { + // Time since the last final agent response. Re-ticks every second like + // SessionDuration so the read-out stays live while the session idles. + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + setNow(Date.now()) + const id = setInterval(() => setNow(Date.now()), 1000) + + return () => clearInterval(id) + }, [endedAt]) + + return `✓ ${fmtDuration(now - endedAt)}` +} + const effortLabel = (effort?: string) => { const value = String(effort ?? '') .trim() @@ -400,6 +415,7 @@ export function StatusRule({ notice, usage, bgCount, + lastTurnEndedAt, liveSessionCount, sessionStartedAt, showCost, @@ -488,6 +504,10 @@ export function StatusRule({ const showBar = !!bar && fits(SEP + stringWidth(`[${bar}] ${pct != null ? `${pct}%` : ''}`)) const showDuration = segs.duration && !!sessionStartedAt && fits(SEP + MAX_DURATION_WIDTH) + // Idle clock — time since the last final agent response. Hidden while busy + // (the FaceTicker's elapsed tail covers the live turn) and before the first + // turn completes. Shares the duration breakpoint and width reservation. + const showIdle = segs.duration && !busy && lastTurnEndedAt != null && fits(SEP + stringWidth('✓ ') + MAX_DURATION_WIDTH) const showCompressions = segs.compressions && compressions > 0 && fits(SEP + stringWidth(`cmp ${compressions}`)) const showVoice = segs.voice && !!voiceLabel && fits(SEP + stringWidth(voiceLabel)) const showSessionCount = !!sessionCountText && fits(SEP + stringWidth(sessionCountText)) @@ -567,6 +587,12 @@ export function StatusRule({ ) : null} + {showIdle ? ( + + {' │ '} + + + ) : null} {showCompressions ? ( {' │ '} @@ -725,6 +751,7 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) interface StatusRuleProps { bgCount: number + lastTurnEndedAt?: null | number liveSessionCount: number busy: boolean cols: number diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index b93e2045c7e..d54f5c6da90 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -366,6 +366,7 @@ const StatusRulePane = memo(function StatusRulePane({ cols={composer.cols} cwdLabel={status.cwdLabel} indicatorStyle={ui.indicatorStyle} + lastTurnEndedAt={status.lastTurnEndedAt} liveSessionCount={ui.liveSessionCount} model={ui.info?.model ?? ''} modelFast={ui.info?.fast || ui.info?.service_tier === 'priority'}