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
7 changed files with 186 additions and 2 deletions
+27
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
+1
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'}