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
@@ -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()
})
})
+1
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
+9 -2
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,
+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'}