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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -368,6 +368,7 @@ export interface AppLayoutProgressProps {
|
||||
export interface AppLayoutStatusProps {
|
||||
cwdLabel: string
|
||||
goodVibesTick: number
|
||||
lastTurnEndedAt: null | number
|
||||
sessionStartedAt: null | number
|
||||
showStickyPrompt: boolean
|
||||
statusColor: string
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user