diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index b39e40bad9..1f2f938a93 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -284,7 +284,6 @@ const buildSession = () => ({ newSession: vi.fn(), resetVisibleHistory: vi.fn(), resumeById: vi.fn(), - setLastUserAt: vi.fn(), setSessionStartedAt: vi.fn() }) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index e0541af153..da9d0baede 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -237,7 +237,6 @@ export interface SlashHandlerContext { newSession: (msg?: string) => void resetVisibleHistory: (info?: null | SessionInfo) => void resumeById: (id: string) => void - setLastUserAt: StateSetter setSessionStartedAt: StateSetter } slashFlightRef: MutableRefObject @@ -300,11 +299,11 @@ export interface AppLayoutProgressProps { export interface AppLayoutStatusProps { cwdLabel: string goodVibesTick: number - lastUserAt: null | number sessionStartedAt: null | number showStickyPrompt: boolean statusColor: string stickyPrompt: string + turnStartedAt: null | number voiceLabel: string } diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index ef5d72e206..354d3c1975 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -178,7 +178,6 @@ export const sessionCommands: SlashCommand[] = [ void ctx.session.closeSession(prevSid) patchUiState({ sid: r.session_id }) ctx.session.setSessionStartedAt(Date.now()) - ctx.session.setLastUserAt(null) ctx.transcript.setHistoryItems([]) ctx.transcript.sys(`branched → ${r.title ?? ''}`) }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 5271246dda..a3d4740274 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { STARTUP_RESUME_ID } from '../config/env.js' import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js' -import { imageTokenMeta } from '../domain/messages.js' +import { fmtDuration, imageTokenMeta } from '../domain/messages.js' import { fmtCwdBranch } from '../domain/paths.js' import { type GatewayClient } from '../gatewayClient.js' import type { @@ -102,7 +102,7 @@ export function useMainApp(gw: GatewayClient) { const [voiceRecording, setVoiceRecording] = useState(false) const [voiceProcessing, setVoiceProcessing] = useState(false) const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) - const [lastUserAt, setLastUserAt] = useState(null) + const [turnStartedAt, setTurnStartedAt] = useState(null) const [goodVibesTick, setGoodVibesTick] = useState(0) const [bellOnComplete, setBellOnComplete] = useState(false) @@ -276,7 +276,6 @@ export function useMainApp(gw: GatewayClient) { rpc, scrollRef, setHistoryItems, - setLastUserAt, setLastUserMsg, setSessionStartedAt, setStickyPrompt, @@ -285,6 +284,26 @@ export function useMainApp(gw: GatewayClient) { sys }) + // Drive turnStartedAt from the busy edge and emit a one-shot "done in Xs" + // line on the idle edge. Covers agent turns and `!shell` alike — only + // suppresses when the block is under ~1s (too quick to matter). + useEffect(() => { + if (ui.busy && turnStartedAt === null) { + setTurnStartedAt(Date.now()) + + return + } + + if (!ui.busy && turnStartedAt !== null) { + const elapsed = Date.now() - turnStartedAt + setTurnStartedAt(null) + + if (elapsed >= 1000) { + sys(`done in ${fmtDuration(elapsed)}`) + } + } + }, [sys, turnStartedAt, ui.busy]) + useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid }) // ── Terminal tab title ───────────────────────────────────────────── @@ -376,7 +395,6 @@ export function useMainApp(gw: GatewayClient) { composerState, gw, maybeGoodVibes, - setLastUserAt, setLastUserMsg, slashRef, submitRef, @@ -500,7 +518,6 @@ export function useMainApp(gw: GatewayClient) { newSession: session.newSession, resetVisibleHistory: session.resetVisibleHistory, resumeById: session.resumeById, - setLastUserAt, setSessionStartedAt }, slashFlightRef, @@ -635,20 +652,20 @@ export function useMainApp(gw: GatewayClient) { () => ({ cwdLabel: fmtCwdBranch(cwd, gitBranch), goodVibesTick, - lastUserAt: ui.sid ? lastUserAt : null, sessionStartedAt: ui.sid ? sessionStartedAt : null, showStickyPrompt: !!stickyPrompt, statusColor: statusColorOf(ui.status, ui.theme.color), stickyPrompt, + turnStartedAt: ui.sid ? turnStartedAt : null, voiceLabel: voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` }), [ cwd, gitBranch, goodVibesTick, - lastUserAt, sessionStartedAt, stickyPrompt, + turnStartedAt, ui, voiceEnabled, voiceProcessing, diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 2738849e93..acd10135e1 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -44,7 +44,6 @@ export interface UseSessionLifecycleOptions { rpc: GatewayRpc scrollRef: RefObject setHistoryItems: StateSetter - setLastUserAt: StateSetter setLastUserMsg: StateSetter setSessionStartedAt: StateSetter setStickyPrompt: StateSetter @@ -62,7 +61,6 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { rpc, scrollRef, setHistoryItems, - setLastUserAt, setLastUserMsg, setSessionStartedAt, setStickyPrompt, @@ -84,18 +82,9 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { patchUiState({ bgTasks: new Set(), info: null, sid: null, usage: ZERO }) setHistoryItems([]) setLastUserMsg('') - setLastUserAt(null) setStickyPrompt('') composerActions.setPasteSnips([]) - }, [ - composerActions, - setHistoryItems, - setLastUserAt, - setLastUserMsg, - setStickyPrompt, - setVoiceProcessing, - setVoiceRecording - ]) + }, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording]) const resetVisibleHistory = useCallback( (info: null | SessionInfo = null) => { @@ -107,12 +96,11 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { setHistoryItems(info ? [introMsg(info)] : []) setStickyPrompt('') setLastUserMsg('') - setLastUserAt(null) composerActions.setPasteSnips([]) patchTurnState({ activity: [] }) patchUiState({ info, usage: usageFrom(info) }) }, - [composerActions, setHistoryItems, setLastUserAt, setLastUserMsg, setStickyPrompt] + [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt] ) const newSession = useCallback( diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 1e17ffdc72..f8a40f5a08 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -37,7 +37,6 @@ export function useSubmission(opts: UseSubmissionOptions) { composerState, gw, maybeGoodVibes, - setLastUserAt, setLastUserMsg, slashRef, submitRef, @@ -60,7 +59,6 @@ export function useSubmission(opts: UseSubmissionOptions) { turnController.clearStatusTimer() maybeGoodVibes(submitText) setLastUserMsg(text) - setLastUserAt(Date.now()) appendMessage({ role: 'user', text: displayText }) patchUiState({ busy: true, status: 'running…' }) turnController.bufRef = '' @@ -96,7 +94,7 @@ export function useSubmission(opts: UseSubmissionOptions) { }) .catch(() => startSubmit(text, expand(text))) }, - [appendMessage, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserAt, setLastUserMsg, sys] + [appendMessage, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys] ) const shellExec = useCallback( @@ -298,7 +296,6 @@ export interface UseSubmissionOptions { composerState: ComposerState gw: GatewayClient maybeGoodVibes: (text: string) => void - setLastUserAt: (value: null | number) => void setLastUserMsg: (value: string) => void slashRef: MutableRefObject<(cmd: string) => boolean> submitRef: MutableRefObject<(value: string) => void> diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 4c4faef0e2..da5507e28c 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -12,18 +12,24 @@ import type { Msg, Usage } from '../types.js' const FACE_TICK_MS = 2500 const HEART_COLORS = ['#ff5fa2', '#ff4d6d'] -function FaceTicker({ color }: { color: string }) { +function FaceTicker({ color, startedAt }: { color: string; startedAt?: null | number }) { const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000)) + const [now, setNow] = useState(() => Date.now()) useEffect(() => { - const id = setInterval(() => setTick(n => n + 1), FACE_TICK_MS) + const face = setInterval(() => setTick(n => n + 1), FACE_TICK_MS) + const clock = setInterval(() => setNow(Date.now()), 1000) - return () => clearInterval(id) + return () => { + clearInterval(face) + clearInterval(clock) + } }, []) return ( {FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}… + {startedAt ? ` · ${fmtDuration(now - startedAt)}` : ''} ) } @@ -68,19 +74,6 @@ function SessionDuration({ startedAt }: { startedAt: number }) { return fmtDuration(now - startedAt) } -export function IdleSinceLastMsg({ lastUserAt, t }: { lastUserAt: number; t: Theme }) { - const [now, setNow] = useState(() => Date.now()) - - useEffect(() => { - setNow(Date.now()) - const id = setInterval(() => setNow(Date.now()), 1000) - - return () => clearInterval(id) - }, [lastUserAt]) - - return {fmtDuration(now - lastUserAt)} -} - export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { const [active, setActive] = useState(false) const [color, setColor] = useState(t.color.amber) @@ -113,6 +106,7 @@ export function StatusRule({ bgCount, sessionStartedAt, showCost, + turnStartedAt, voiceLabel, t }: StatusRuleProps) { @@ -133,7 +127,7 @@ export function StatusRule({ {'─ '} - {busy ? : {status}} + {busy ? : {status}} │ {model} {ctxLabel ? │ {ctxLabel} : null} {bar ? ( @@ -306,6 +300,7 @@ interface StatusRuleProps { status: string statusColor: string t: Theme + turnStartedAt?: null | number usage: Usage voiceLabel?: string } diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 7b5b25ae83..ad854033ad 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -9,7 +9,7 @@ import { PLACEHOLDER } from '../content/placeholders.js' import type { Theme } from '../theme.js' import type { DetailsMode } from '../types.js' -import { GoodVibesHeart, IdleSinceLastMsg, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' +import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' import { FloatingOverlays, PromptZone } from './appOverlays.js' import { Banner, Panel, SessionPanel } from './branding.js' import { MessageLine } from './messageLine.js' @@ -194,6 +194,7 @@ const ComposerPane = memo(function ComposerPane({ status={ui.status} statusColor={status.statusColor} t={ui.theme} + turnStartedAt={status.turnStartedAt} usage={ui.usage} voiceLabel={status.voiceLabel} /> @@ -242,9 +243,7 @@ const ComposerPane = memo(function ComposerPane({ value={composer.input} /> - - {!ui.busy && status.lastUserAt ? : null} - +