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

View file

@ -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

View file

@ -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()
})
})

View file

@ -368,6 +368,7 @@ export interface AppLayoutProgressProps {
export interface AppLayoutStatusProps {
cwdLabel: string
goodVibesTick: number
lastTurnEndedAt: null | number
sessionStartedAt: null | number
showStickyPrompt: boolean
statusColor: string

View file

@ -173,6 +173,7 @@ export function useMainApp(gw: GatewayClient) {
const [voiceRecordKey, setVoiceRecordKey] = useState<ParsedVoiceRecordKey>(DEFAULT_VOICE_RECORD_KEY)
const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now())
const [turnStartedAt, setTurnStartedAt] = useState<null | number>(null)
const [lastTurnEndedAt, setLastTurnEndedAt] = useState<null | number>(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,

View file

@ -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({
<SessionDuration startedAt={sessionStartedAt!} />
</Text>
) : null}
{showIdle ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
<IdleSince endedAt={lastTurnEndedAt!} />
</Text>
) : null}
{showCompressions ? (
<Text color={t.color.muted} wrap="truncate-end">
{' │ '}
@ -725,6 +751,7 @@ export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps)
interface StatusRuleProps {
bgCount: number
lastTurnEndedAt?: null | number
liveSessionCount: number
busy: boolean
cols: number

View file

@ -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'}