From 1e7de177e80e05d56800aa49aac21c20e18a2293 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Apr 2026 11:17:34 -0500 Subject: [PATCH 1/4] feat(tui): show time-since-last-user-message alongside session total (#8541) StatusRule now renders `{sinceLastMsg}/{sinceSession}` (e.g. `12s/3m 45s`) when a user has submitted in the current session; falls back to the total alone otherwise. Wires `lastUserAt` through the state/session lifecycle: - useSubmission stamps `setLastUserAt(Date.now())` on send - useSessionLifecycle nulls it in reset/resetVisibleHistory - /branch slash nulls it on fork --- .../src/__tests__/createSlashHandler.test.ts | 1 + ui-tui/src/app/interfaces.ts | 2 ++ ui-tui/src/app/slash/commands/session.ts | 1 + ui-tui/src/app/useMainApp.ts | 18 +++++++++++++++++- ui-tui/src/app/useSessionLifecycle.ts | 16 ++++++++++++++-- ui-tui/src/app/useSubmission.ts | 5 ++++- ui-tui/src/components/appChrome.tsx | 12 ++++++++---- ui-tui/src/components/appLayout.tsx | 1 + 8 files changed, 48 insertions(+), 8 deletions(-) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 1f2f938a9..b39e40bad 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -284,6 +284,7 @@ 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 af13e047c..e0541af15 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -237,6 +237,7 @@ export interface SlashHandlerContext { newSession: (msg?: string) => void resetVisibleHistory: (info?: null | SessionInfo) => void resumeById: (id: string) => void + setLastUserAt: StateSetter setSessionStartedAt: StateSetter } slashFlightRef: MutableRefObject @@ -299,6 +300,7 @@ export interface AppLayoutProgressProps { export interface AppLayoutStatusProps { cwdLabel: string goodVibesTick: number + lastUserAt: null | number sessionStartedAt: null | number showStickyPrompt: boolean statusColor: string diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 354d3c197..ef5d72e20 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -178,6 +178,7 @@ 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 27401b418..5271246dd 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -102,6 +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 [goodVibesTick, setGoodVibesTick] = useState(0) const [bellOnComplete, setBellOnComplete] = useState(false) @@ -275,6 +276,7 @@ export function useMainApp(gw: GatewayClient) { rpc, scrollRef, setHistoryItems, + setLastUserAt, setLastUserMsg, setSessionStartedAt, setStickyPrompt, @@ -374,6 +376,7 @@ export function useMainApp(gw: GatewayClient) { composerState, gw, maybeGoodVibes, + setLastUserAt, setLastUserMsg, slashRef, submitRef, @@ -497,6 +500,7 @@ export function useMainApp(gw: GatewayClient) { newSession: session.newSession, resetVisibleHistory: session.resetVisibleHistory, resumeById: session.resumeById, + setLastUserAt, setSessionStartedAt }, slashFlightRef, @@ -631,13 +635,25 @@ 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, voiceLabel: voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` }), - [cwd, gitBranch, goodVibesTick, sessionStartedAt, stickyPrompt, ui, voiceEnabled, voiceProcessing, voiceRecording] + [ + cwd, + gitBranch, + goodVibesTick, + lastUserAt, + sessionStartedAt, + stickyPrompt, + ui, + voiceEnabled, + voiceProcessing, + voiceRecording + ] ) const appTranscript = useMemo( diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index acd10135e..2738849e9 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -44,6 +44,7 @@ export interface UseSessionLifecycleOptions { rpc: GatewayRpc scrollRef: RefObject setHistoryItems: StateSetter + setLastUserAt: StateSetter setLastUserMsg: StateSetter setSessionStartedAt: StateSetter setStickyPrompt: StateSetter @@ -61,6 +62,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { rpc, scrollRef, setHistoryItems, + setLastUserAt, setLastUserMsg, setSessionStartedAt, setStickyPrompt, @@ -82,9 +84,18 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { patchUiState({ bgTasks: new Set(), info: null, sid: null, usage: ZERO }) setHistoryItems([]) setLastUserMsg('') + setLastUserAt(null) setStickyPrompt('') composerActions.setPasteSnips([]) - }, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording]) + }, [ + composerActions, + setHistoryItems, + setLastUserAt, + setLastUserMsg, + setStickyPrompt, + setVoiceProcessing, + setVoiceRecording + ]) const resetVisibleHistory = useCallback( (info: null | SessionInfo = null) => { @@ -96,11 +107,12 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { setHistoryItems(info ? [introMsg(info)] : []) setStickyPrompt('') setLastUserMsg('') + setLastUserAt(null) composerActions.setPasteSnips([]) patchTurnState({ activity: [] }) patchUiState({ info, usage: usageFrom(info) }) }, - [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt] + [composerActions, setHistoryItems, setLastUserAt, setLastUserMsg, setStickyPrompt] ) const newSession = useCallback( diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index f8a40f5a0..1e17ffdc7 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -37,6 +37,7 @@ export function useSubmission(opts: UseSubmissionOptions) { composerState, gw, maybeGoodVibes, + setLastUserAt, setLastUserMsg, slashRef, submitRef, @@ -59,6 +60,7 @@ 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 = '' @@ -94,7 +96,7 @@ export function useSubmission(opts: UseSubmissionOptions) { }) .catch(() => startSubmit(text, expand(text))) }, - [appendMessage, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys] + [appendMessage, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserAt, setLastUserMsg, sys] ) const shellExec = useCallback( @@ -296,6 +298,7 @@ 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 2f5f807de..23c4a4e8e 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -55,7 +55,7 @@ function ctxBar(pct: number | undefined, w = 10) { return '█'.repeat(filled) + '░'.repeat(w - filled) } -function SessionDuration({ startedAt }: { startedAt: number }) { +function SessionDuration({ lastUserAt, startedAt }: { lastUserAt?: null | number; startedAt: number }) { const [now, setNow] = useState(() => Date.now()) useEffect(() => { @@ -65,7 +65,9 @@ function SessionDuration({ startedAt }: { startedAt: number }) { return () => clearInterval(id) }, [startedAt]) - return fmtDuration(now - startedAt) + const total = fmtDuration(now - startedAt) + + return lastUserAt ? `${fmtDuration(now - lastUserAt)}/${total}` : total } export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { @@ -98,6 +100,7 @@ export function StatusRule({ model, usage, bgCount, + lastUserAt, sessionStartedAt, showCost, voiceLabel, @@ -132,7 +135,7 @@ export function StatusRule({ {sessionStartedAt ? ( {' │ '} - + ) : null} {voiceLabel ? │ {voiceLabel} : null} @@ -287,8 +290,9 @@ interface StatusRuleProps { busy: boolean cols: number cwdLabel: string + lastUserAt?: null | number model: string - sessionStartedAt?: number | null + sessionStartedAt?: null | number showCost: boolean status: string statusColor: string diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index f13adf1bb..d711edca8 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -188,6 +188,7 @@ const ComposerPane = memo(function ComposerPane({ busy={ui.busy} cols={composer.cols} cwdLabel={status.cwdLabel} + lastUserAt={status.lastUserAt} model={ui.info?.model?.split('/').pop() ?? ''} sessionStartedAt={status.sessionStartedAt} showCost={ui.showCost} From 9910681b859fecf85f37a76d02ae6e88dd148ccc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Apr 2026 11:23:58 -0500 Subject: [PATCH 2/4] refactor(tui): move last-msg elapsed from status bar to prompt right-edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status bar ticker was too hot in peripheral vision. The moment the elapsed value matters is when the prompt returns — so surface it there. Dim `fmtDuration` next to the GoodVibesHeart, idle-only (hidden while busy), so quick turns and active streaming stay quiet. --- ui-tui/src/components/appChrome.tsx | 21 +++++++++++++++------ ui-tui/src/components/appLayout.tsx | 7 ++++--- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 23c4a4e8e..4c4faef0e 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -55,7 +55,7 @@ function ctxBar(pct: number | undefined, w = 10) { return '█'.repeat(filled) + '░'.repeat(w - filled) } -function SessionDuration({ lastUserAt, startedAt }: { lastUserAt?: null | number; startedAt: number }) { +function SessionDuration({ startedAt }: { startedAt: number }) { const [now, setNow] = useState(() => Date.now()) useEffect(() => { @@ -65,9 +65,20 @@ function SessionDuration({ lastUserAt, startedAt }: { lastUserAt?: null | number return () => clearInterval(id) }, [startedAt]) - const total = fmtDuration(now - startedAt) + return fmtDuration(now - startedAt) +} - return lastUserAt ? `${fmtDuration(now - lastUserAt)}/${total}` : total +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 }) { @@ -100,7 +111,6 @@ export function StatusRule({ model, usage, bgCount, - lastUserAt, sessionStartedAt, showCost, voiceLabel, @@ -135,7 +145,7 @@ export function StatusRule({ {sessionStartedAt ? ( {' │ '} - + ) : null} {voiceLabel ? │ {voiceLabel} : null} @@ -290,7 +300,6 @@ interface StatusRuleProps { busy: boolean cols: number cwdLabel: string - lastUserAt?: null | number model: string sessionStartedAt?: null | number showCost: boolean diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index d711edca8..7b5b25ae8 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, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' +import { GoodVibesHeart, IdleSinceLastMsg, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' import { FloatingOverlays, PromptZone } from './appOverlays.js' import { Banner, Panel, SessionPanel } from './branding.js' import { MessageLine } from './messageLine.js' @@ -188,7 +188,6 @@ const ComposerPane = memo(function ComposerPane({ busy={ui.busy} cols={composer.cols} cwdLabel={status.cwdLabel} - lastUserAt={status.lastUserAt} model={ui.info?.model?.split('/').pop() ?? ''} sessionStartedAt={status.sessionStartedAt} showCost={ui.showCost} @@ -243,7 +242,9 @@ const ComposerPane = memo(function ComposerPane({ value={composer.input} /> - + + {!ui.busy && status.lastUserAt ? : null} + From 2de1aad0286de37042bffd0edff3e33066920570 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Apr 2026 11:38:11 -0500 Subject: [PATCH 3/4] refactor(tui): turn elapsed lives in FaceTicker; emit done-in sys line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops `lastUserAt` plumbing and the right-edge idle ticker. Matches the claude-code / opencode convention: elapsed rides with the busy indicator (spinner verb), nothing at idle. - `turnStartedAt` driven by a useEffect on `ui.busy` — stamps on rising edge, clears on falling edge. Covers agent turns and !shell alike. - FaceTicker renders ` · {fmtDuration}` while busy; 1 s clock for the counter, existing 2500 ms cycle for face/verb rotation. - On busy → idle, if the block ran ≥ 1 s, emit a one-shot `done in {fmtDuration}` sys line (≡ claude-code's `thought for Ns`). --- .../src/__tests__/createSlashHandler.test.ts | 1 - ui-tui/src/app/interfaces.ts | 3 +- ui-tui/src/app/slash/commands/session.ts | 1 - ui-tui/src/app/useMainApp.ts | 31 ++++++++++++++----- ui-tui/src/app/useSessionLifecycle.ts | 16 ++-------- ui-tui/src/app/useSubmission.ts | 5 +-- ui-tui/src/components/appChrome.tsx | 29 +++++++---------- ui-tui/src/components/appLayout.tsx | 7 ++--- 8 files changed, 43 insertions(+), 50 deletions(-) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index b39e40bad..1f2f938a9 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 e0541af15..da9d0baed 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 ef5d72e20..354d3c197 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 5271246dd..a3d474027 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 2738849e9..acd10135e 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 1e17ffdc7..f8a40f5a0 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 4c4faef0e..da5507e28 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 7b5b25ae8..ad854033a 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} - + From f1f438e7f9ad09977b47528c32a740250f5ddbb8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Apr 2026 11:40:12 -0500 Subject: [PATCH 4/4] refactor(tui): drop done-in sys line; FaceTicker counter only The transcript line was noisy. Keep the one thing the issue really needs: live elapsed next to the busy verb. --- ui-tui/src/app/useMainApp.ts | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index a3d474027..28b2a26f9 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 { fmtDuration, imageTokenMeta } from '../domain/messages.js' +import { imageTokenMeta } from '../domain/messages.js' import { fmtCwdBranch } from '../domain/paths.js' import { type GatewayClient } from '../gatewayClient.js' import type { @@ -284,25 +284,13 @@ 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 + if (ui.busy) { + setTurnStartedAt(prev => prev ?? Date.now()) + } else { setTurnStartedAt(null) - - if (elapsed >= 1000) { - sys(`done in ${fmtDuration(elapsed)}`) - } } - }, [sys, turnStartedAt, ui.busy]) + }, [ui.busy]) useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid })