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
This commit is contained in:
Brooklyn Nicholson
2026-04-20 11:17:34 -05:00
parent b7e71fb727
commit 1e7de177e8
8 changed files with 48 additions and 8 deletions

View File

@@ -284,6 +284,7 @@ const buildSession = () => ({
newSession: vi.fn(),
resetVisibleHistory: vi.fn(),
resumeById: vi.fn(),
setLastUserAt: vi.fn(),
setSessionStartedAt: vi.fn()
})

View File

@@ -237,6 +237,7 @@ export interface SlashHandlerContext {
newSession: (msg?: string) => void
resetVisibleHistory: (info?: null | SessionInfo) => void
resumeById: (id: string) => void
setLastUserAt: StateSetter<null | number>
setSessionStartedAt: StateSetter<number>
}
slashFlightRef: MutableRefObject<number>
@@ -299,6 +300,7 @@ export interface AppLayoutProgressProps {
export interface AppLayoutStatusProps {
cwdLabel: string
goodVibesTick: number
lastUserAt: null | number
sessionStartedAt: null | number
showStickyPrompt: boolean
statusColor: string

View File

@@ -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 ?? ''}`)
})

View File

@@ -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 | number>(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(

View File

@@ -44,6 +44,7 @@ export interface UseSessionLifecycleOptions {
rpc: GatewayRpc
scrollRef: RefObject<null | ScrollBoxHandle>
setHistoryItems: StateSetter<Msg[]>
setLastUserAt: StateSetter<null | number>
setLastUserMsg: StateSetter<string>
setSessionStartedAt: StateSetter<number>
setStickyPrompt: StateSetter<string>
@@ -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(

View File

@@ -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>

View File

@@ -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 ? (
<Text color={t.color.dim}>
{' │ '}
<SessionDuration startedAt={sessionStartedAt} />
<SessionDuration lastUserAt={lastUserAt} startedAt={sessionStartedAt} />
</Text>
) : null}
{voiceLabel ? <Text color={t.color.dim}> {voiceLabel}</Text> : 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

View File

@@ -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}