Live view of where the agent spends its time, plus a running summary timeline of the session.

A Claude Code mod that shows where the agent spends its time, live, and keeps a running timeline of what happened in the session.
The band sits above the prompt. The strip runs left to right through the agent's working time, each activity in its own color and glyph (█ Thinking, ▓ Reading, ▒ Editing, ▚ Shell, ▌ Web, ▐ Subagents, ▞ Asking you, ░ Other tools), so the glyph tells two activities apart where the colors look alike. The legend and the panel's bars use the same glyphs. Under it: what the agent is doing right now and for how long, then the running totals, largest first.
The band has two views:
To switch views, click the legend line, or focus the band (ctrl+x tab, or a click) and press v. You can also run /visual-recap view wall or /visual-recap view work, even while a turn is running. Your pick lasts until the session ends. The Band view setting picks the view each session starts in.
While the agent works, the strip's right edge pulses and a spinner turns beside the current action. When the agent stops, the strip stays as it was, dimmed, and the band says how long the turn took. In the terminal, dimmed means drawn in your theme's dim color, so the glyphs carry the activities until the next turn starts:
The panel (/visual-recap) docks beside the conversation in fullscreen mode, or opens above the prompt otherwise. It shows:
/clear. The timeline and totals carry on past them.The recap survives /exit: resume the session (claude --resume <id> or /resume) and it comes back, with the time you were away counted as waiting on you. The last 10 sessions are kept.
The images are mockups with sample data, drawn by docs/mockups.mjs.
At the prompt of a Claude Code terminal session:
/plugin install visual-recap --marketplace mascah/claude-plugins
Answer y to add the mascah marketplace, which lists my other plugins too, then pick a scope (user scope is first; press Enter).
From a shell:
claude plugin install visual-recap --marketplace mascah/claude-plugins
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with mascah/claude-plugins, then Add plugin.
In /config, under visual-recap:
| Setting | Default | What it does |
|---|---|---|
| Show band | on | Shows the strip and totals above the prompt. |
| Open panel at startup | off | Opens the panel when a session starts. |
| Palette | default | default uses the mod's own colors, the same on every theme. theme takes them from your Claude Code theme (including its light and colorblind themes), so they change when you switch themes. Built-in themes give Reading and Asking you the same color; their glyphs tell them apart. |
| Band view | wall | The view the band starts each session in: wall (wall clock) or work (work mix). Any other value means wall. |
| Turn summaries | on | Ask Haiku for a one-sentence description of each finished turn. Off: no model call is made, and the timeline lists the turn's actions instead. |
When a turn ends, the mod asks Haiku for a one-sentence description. It sends your prompt, the actions taken (file names, commands) and the start of the agent's reply. This uses your account and costs one small call per turn. If the call fails, the entry lists the turn's first actions instead. To skip the call entirely, turn off the Turn summaries setting: nothing is sent, and every entry lists the turn's actions.
See CONTRIBUTING.md.
MIT
hooks/register.tsx 492 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Agent, BandView, Cat, Mark, Span, Turn } from '../types'
5import { CATS, catOf, colorOf, fallbackSummary, fmt, labelOf, lanes, runs, strip, summaryPrompt, sweep, totals, workMix } from './recap'
6
7const PANE = 'visual-recap'
8const MAX_SPANS = 3000 // ponytail: oldest spans drop off; totals then undercount very long sessions
9const MAX_ACTIONS = 60
10const KEEP_SESSIONS = 10 // ponytail: $.store holds 4 MiB in all; older sessions' recaps are dropped
11
12const spans = atom({ plugin: 'visual-recap', key: 'spans' } as const, [] as Span[])
13const turns = atom({ plugin: 'visual-recap', key: 'turns' } as const, [] as Turn[])
14const now = atom({ plugin: 'visual-recap', key: 'now' } as const, 0)
15const startedAt = atom({ plugin: 'visual-recap', key: 'startedAt' } as const, 0)
16// Animation step while a turn runs; only the band reads it, so the panel redraws once a second, not four times.
17const marks = atom({ plugin: 'visual-recap', key: 'marks' } as const, [] as Mark[])
18const frame = atom({ plugin: 'visual-recap', key: 'frame' } as const, 0)
19// Each subagent's label and the main turn that spawned it, from agent.spawn; each of its runs looks it up at turn.start.
20const agents = atom({ plugin: 'visual-recap', key: 'agents' } as const, {} as Record<string, Agent>)
21// The band view picked with the legend, its letter or the command, until the session ends; null: the /config default.
22const view = atom({ plugin: 'visual-recap', key: 'view' } as const, null as BandView | null)
23
24const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
25const PULSE = ['▂', '▄', '▆', '█', '▆', '▄']
26
27type $ = EngineInterface
28
29async function snapshot($: $) {
30 const [sp, tu, n, st] = await Promise.all([read($, spans), read($, turns), read($, now), read($, startedAt)])
31 const to = Math.max(n, st, ...tu.map(t => t.end ?? 0), ...sp.map(s => s.end ?? s.start))
32 const from = st || Math.min(to, ...tu.map(t => t.start), ...sp.map(x => x.start))
33 const pieces = sweep(sp, tu, from, to)
34 const tot = totals(pieces)
35 const live = tu.find(t => t.end === undefined)
36 // The main loop first: a background subagent shows only while the main loop is idle.
37 const open = sp.filter(s => s.end === undefined)
38 const running = open.findLast(s => s.agentId === undefined) ?? (live ? undefined : open.at(-1))
39 let liveText: string | undefined
40 if (running) liveText = `${running.label} · ${fmt(to - running.start)}`
41 else if (live) {
42 const since = Math.max(live.start, ...sp.filter(s => s.agentId === undefined && s.start >= live.start).map(s => s.end ?? 0))
43 liveText = `Thinking · ${fmt(to - since)}`
44 }
45 return { sp, tu, to, st: from, pieces, tot, live, running, liveText }
46}
47
48function sortedCats(tot: Partial<Record<Cat | 'idle', number>>) {
49 return (Object.keys(CATS) as Cat[]).filter(c => (tot[c] ?? 0) >= 500).sort((a, b) => tot[b]! - tot[a]!)
50}
51
52// In the work mix the main loop's own Agent calls sit above a Subagents row of the subagents' work: name them apart.
53const mixLabel = (c: Cat) => (c === 'agents' ? 'Agent calls' : CATS[c].label)
54
55async function setView($: $, to: BandView | 'toggle', fallback: BandView) {
56 await update($, view, v => (to === 'toggle' ? ((v ?? fallback) === 'work' ? 'wall' : 'work') : to))
57}
58
59let ticker: { cancel: () => void } | undefined
60let ticks = 0
61
62async function setNow($: $) {
63 const t = await $.clock.now()
64 await update($, now, () => t)
65}
66
67function tick($: $) {
68 ticks++
69 void update($, frame, n => n + 1)
70 if (ticks % 4 === 0) void setNow($)
71}
72
73function startTicker($: $) {
74 ticker ??= $.clock.every(250, () => tick($))
75}
76
77function stopTicker() {
78 ticker?.cancel()
79 ticker = undefined
80}
81
82// Background subagents outlive the turn that spawned them, so a subagent's open run counts too.
83async function isBusy($: $) {
84 const [tu, sp] = await Promise.all([read($, turns), read($, spans)])
85 return tu.some(t => t.end === undefined) || sp.some(s => s.end === undefined)
86}
87
88async function stopTickerIfIdle($: $) {
89 if (!(await isBusy($))) stopTicker()
90}
91
92async function addMark($: $, kind: Mark['kind'], isAuto?: boolean) {
93 const at = await $.clock.now()
94 await update($, marks, l => [...l, { at, kind, ...(isAuto ? { isAuto } : {}) }].slice(-100))
95}
96
97async function summarize($: $, turnId: string, answer: string, useModel: boolean) {
98 const turn = (await read($, turns)).find(t => t.id === turnId)
99 if (!turn) return
100 let text = ''
101 if (useModel) {
102 const r = await $.model.complete({
103 model: 'haiku',
104 prompt: summaryPrompt(turn, answer),
105 maxTokens: 120,
106 effort: 'low',
107 timeoutMs: 30000,
108 })
109 text = r.isAnswered ? r.text.trim().split('\n')[0]! : ''
110 }
111 const summary = text || fallbackSummary(turn)
112 await update($, turns, l => l.map(t => (t.id === turnId ? { ...t, summary } : t)))
113}
114
115type Saved = { spans: Span[]; turns: Turn[]; marks: Mark[]; startedAt: number; now: number }
116
117// $.state lives as long as the process, so each session's recap is also kept in $.store for --resume.
118async function save($: $, id: string) {
119 const [sp, tu, mk, st, n] = await Promise.all([read($, spans), read($, turns), read($, marks), read($, startedAt), read($, now)])
120 const saved: Saved = {
121 // Labels and actions only matter while a span runs or a turn awaits its summary.
122 spans: sp.map(s => (s.end === undefined ? s : { ...s, id: '', label: '' })),
123 turns: tu.map(t => (t.summary === undefined ? t : { ...t, actions: [] })),
124 marks: mk,
125 startedAt: st,
126 now: n,
127 }
128 const ids = ((await $.store.get('sessions')) as string[] | undefined) ?? []
129 const keep = [...ids.filter(x => x !== id), id]
130 for (const old of keep.slice(0, -KEEP_SESSIONS)) await $.store.delete(`s:${old}`)
131 await $.store.set('sessions', keep.slice(-KEEP_SESSIONS))
132 await $.store.set(`s:${id}`, saved)
133}
134
135async function restore($: $): Promise<boolean> {
136 const saved = (await $.store.get(`s:${await $.session.id()}`)) as Saved | undefined
137 if (!saved) return false
138 // Whatever was still running when the process ended stops where the recap last saw it.
139 const end = saved.now
140 await update($, spans, () => saved.spans.map(s => (s.end === undefined ? { ...s, end } : s)))
141 await update($, turns, () =>
142 saved.turns.map(t => {
143 const done = t.end === undefined ? { ...t, end, isAborted: true } : t
144 return done.summary === undefined ? { ...done, summary: fallbackSummary(done) } : done
145 }),
146 )
147 await update($, marks, () => saved.marks)
148 await update($, startedAt, () => saved.startedAt)
149 return true
150}
151
152function openPane($: $) {
153 return $.ui.open({ id: PANE, title: 'Recap', columns: 56 })
154}
155
156export const register: Register = (on, options) => {
157 const showBand = options.showBand !== false
158 const summaries = options.summaries !== false
159 const color = (c: Cat) => colorOf(c, options.palette)
160 // An unrecognized value counts as unset, so the wall-clock view.
161 const defaultView: BandView = options.bandView === 'work' ? 'work' : 'wall'
162
163 on('session.start', async ($, e, next) => {
164 await $.command.register({
165 name: 'visual-recap',
166 description: 'Open the visual recap: time by activity and a summary timeline',
167 argumentHint: '[view wall|work]',
168 immediate: true,
169 })
170 if ((await read($, startedAt)) === 0) {
171 const t = await $.clock.now()
172 if (!(await restore($).catch(() => false))) await update($, startedAt, () => t)
173 await update($, now, () => t)
174 }
175 if (await isBusy($)) startTicker($)
176 if (options.openPanel === true) void openPane($)
177
178 return next(e)
179 })
180
181 on('command.run', { command: 'visual-recap' }, async ($, e) => {
182 const [sub, arg] = e.args.trim().split(/\s+/)
183 if (sub === 'view') {
184 if (arg !== 'wall' && arg !== 'work') return { text: 'Band views: wall (wall-clock time) or work (work mix). Try /visual-recap view work.' }
185 await setView($, arg, defaultView)
186 return { text: `Band view: ${arg === 'wall' ? 'wall clock' : 'work mix'}.` }
187 }
188 await openPane($)
189
190 return { text: 'Visual recap opened.' }
191 })
192
193 on('turn.start', async ($, e, next) => {
194 const t = await $.clock.now()
195 if (e.agentId === undefined) {
196 const turn: Turn = { id: e.turnId, start: t, prompt: e.text.slice(0, 500), actions: [] }
197 await update($, turns, l => [...l, turn].slice(-300))
198 } else {
199 // A background Agent call returns at once, so its span covers none of the subagent's work: time the subagent's own run.
200 // Claude Code runs this and agent.spawn side by side, so the span goes in before the label is looked up:
201 // a spawn that wrote the label before this read is found here, a later one patches the span itself.
202 const agentId = e.agentId
203 const span: Span = { id: `${agentId}:${t}`, cat: 'agents', label: 'Subagent', start: t, agentId }
204 await update($, spans, l => [...l, span].slice(-MAX_SPANS))
205 const a = (await read($, agents))[agentId]
206 if (a) await update($, spans, l => l.map(s => (s.id === span.id ? { ...s, label: a.label, turnId: a.turnId } : s)))
207 }
208 await update($, now, () => t)
209 startTicker($)
210
211 return next(e)
212 })
213
214 // The Agent call's own description labels the subagent, matched by the id its events carry.
215 // A nested subagent belongs to the main turn its parent belongs to.
216 on('agent.spawn', async ($, e, next) => {
217 const result = await next(e)
218 const agentId = result.agentId
219 if (agentId === undefined) return result
220 const turnId =
221 e.parentAgentId === undefined
222 ? (await read($, turns)).findLast(t => t.end === undefined)?.id
223 : (await read($, agents))[e.parentAgentId]?.turnId
224 const label = labelOf({ tool: 'Agent', description: e.description })
225 // ponytail: the last 200 subagents are remembered; an older one's later runs show as "Subagent"
226 await update($, agents, m => {
227 const rest = Object.entries(m).filter(([k]) => k !== agentId)
228 return Object.fromEntries([...rest, [agentId, { label, turnId }] as const].slice(-200))
229 })
230 await update($, spans, l => l.map(s => (s.agentId === agentId && s.end === undefined ? { ...s, label, turnId } : s)))
231
232 return result
233 })
234
235 // tool.call fires once the model has written the call's arguments, which can take far longer
236 // than running it (a Write's contents, a long heredoc). Count that writing as the tool's time too.
237 on('turn.step', async function* ($, e, next) {
238 if (e.agentId !== undefined) return yield* next(e)
239 let open: string | undefined
240 const close = async () => {
241 if (!open) return
242 const id = open
243 open = undefined
244 const end = await $.clock.now()
245 await update($, spans, l => l.map(s => (s.id === id ? { ...s, end } : s)))
246 }
247 try {
248 for await (const c of next(e)) {
249 if (open && c.kind !== 'input' && c.kind !== 'engine') await close()
250 if (c.kind === 'tool') {
251 open = `input:${c.id}`
252 const span: Span = { id: open, cat: catOf(c.name), label: `Writing ${c.name}`, start: await $.clock.now() }
253 await update($, spans, l => [...l, span].slice(-MAX_SPANS))
254 }
255 yield c
256 }
257 } finally {
258 await close()
259 }
260 })
261
262 on('tool.call', async ($, e, next) => {
263 const label = labelOf(e as unknown as Record<string, unknown>)
264 const action = e.agentId === undefined ? label : `↳ ${label}`
265 // A subagent's calls belong to the turn that spawned it, which may have ended since.
266 const turnId = e.agentId === undefined ? undefined : (await read($, agents))[e.agentId]?.turnId
267 await update($, turns, l => {
268 const i = turnId === undefined ? l.findLastIndex(t => t.end === undefined) : l.findLastIndex(t => t.id === turnId)
269 if (i < 0 || l[i]!.actions.length >= MAX_ACTIONS) return l
270 return l.with(i, { ...l[i]!, actions: [...l[i]!.actions, action] })
271 })
272 // Subagent calls happen inside the Agent call's span; timing them too would double count.
273 if (e.agentId !== undefined) return next(e)
274
275 const id = e.tool_use_id ?? crypto.randomUUID()
276 const start = await $.clock.now()
277 await update($, spans, l => [...l, { id, cat: catOf(e.tool), label, start }].slice(-MAX_SPANS))
278 try {
279 return await next(e)
280 } finally {
281 const end = await $.clock.now()
282 await update($, spans, l => l.map(s => (s.id === id ? { ...s, end } : s)))
283 await update($, now, () => end)
284 }
285 })
286
287 on('turn.complete', async ($, e, next) => {
288 const result = await next(e)
289 const t = await $.clock.now()
290 if (e.agentId === undefined) {
291 await update($, turns, l => l.map(x => (x.id === e.turnId ? { ...x, end: t, isAborted: e.isAborted } : x)))
292 await update($, spans, l => l.map(s => (s.end === undefined && s.agentId === undefined ? { ...s, end: t } : s)))
293 const answer = e.answer
294 $.clock.after(1, () =>
295 void summarize($, e.turnId, answer, summaries)
296 .catch(() => undefined)
297 .then(async () => save($, await $.session.id()))
298 .catch(() => undefined),
299 )
300 } else {
301 const agentId = e.agentId
302 await update($, spans, l => l.map(s => (s.end === undefined && s.agentId === agentId ? { ...s, end: t } : s)))
303 }
304 await update($, now, () => t)
305 await stopTickerIfIdle($)
306
307 return result
308 })
309
310 on('session.compact', async ($, e, next) => {
311 const result = await next(e)
312 // precompute only prepares a summary for later; a skip leaves the conversation as it was.
313 if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) {
314 await addMark($, 'compact', e.trigger === 'auto')
315 }
316
317 return result
318 })
319
320 // A /clear ends the conversation but not the process: the timeline carries on past a divider.
321 on('session.end', async ($, e, next) => {
322 if (e.reason === 'clear') await addMark($, 'clear')
323 // A subagent the session takes down with it never reports its end.
324 const t = await $.clock.now()
325 await update($, spans, l => l.map(s => (s.end === undefined ? { ...s, end: t } : s)))
326 stopTicker()
327 await update($, view, () => null)
328 await save($, e.sessionId).catch(() => undefined)
329 // /resume swaps in another session: start over so session.start loads that one's recap.
330 if (e.reason === 'resume') {
331 await update($, spans, () => [])
332 await update($, turns, () => [])
333 await update($, marks, () => [])
334 await update($, agents, () => ({}))
335 await update($, startedAt, () => 0)
336 }
337
338 return next(e)
339 })
340
341 // Wide and short: a strip of agent-active time, then the live action and a legend.
342 // Working: a pulsing leading edge and a spinner. Stopped: the same strip, dimmed.
343 // mono: a proportional face would give each category's glyph its own width.
344 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
345 if (!showBand || e.props.hasSurvey) return next(e)
346 const s = await snapshot($)
347 const isWorking = s.live !== undefined || s.running !== undefined
348 const f = isWorking ? await read($, frame) : 0
349 const isWork = ((await read($, view)) ?? defaultView) === 'work'
350 // The work mix: the main loop's strip, a strip of when subagents ran under it, on the wall-clock strip's axis.
351 const lane = isWork ? lanes(s.sp, s.tu, s.st, s.to) : undefined
352 const mix = isWork ? workMix(s.sp, s.tu, s.st, s.to) : undefined
353 const width = Math.max(10, e.props.bodyColumns) - (isWorking ? 1 : 0)
354 const cols = lane ? strip(lane.top, width, true) : strip(s.pieces, width, true)
355 if (!cols.length) return next(e)
356 const tot = mix?.main ?? s.tot
357 const { Box, Button, Text } = $.ui.resolve(e)
358 const nowCat: Cat = s.running?.cat ?? 'thinking'
359 const last = s.tu.at(-1)
360
361 return (
362 <Box flexDirection="column">
363 <Text wrap="truncate" mono>
364 {runs(cols).map(r => r.cat === 'gap' ? <Text>{' '.repeat(r.n)}</Text> : (
365 <Text color={color(r.cat)} dimColor={!isWorking}>{CATS[r.cat].glyph.repeat(r.n)}</Text>
366 ))}
367 {isWorking ? <Text color={color(nowCat)}>{PULSE[f % PULSE.length]}</Text> : null}
368 </Text>
369 {lane ? (
370 <Text wrap="truncate" mono>
371 {runs(strip(lane.low, width, true)).map(r => r.cat === 'gap' ? <Text>{' '.repeat(r.n)}</Text> : (
372 <Text color={color('agents')} dimColor={!isWorking}>{CATS.agents.glyph.repeat(r.n)}</Text>
373 ))}
374 </Text>
375 ) : null}
376 {/* The legend line is the view switch: a click, or its letter while the band has focus. A Button wraps, so the Box cuts it to a line. */}
377 <Box height={1} overflow="hidden">
378 <Button key="view" hotkey="v" plain onPress={() => void setView($, 'toggle', defaultView)}>
379 <Text wrap="truncate">
380 {isWorking ? (
381 <Text>
382 <Text color={color(nowCat)}>{SPIN[f % SPIN.length]}</Text>
383 <Text weight="bold"> {s.liveText} </Text>
384 </Text>
385 ) : last?.end !== undefined ? (
386 <Text dimColor>
387 ✓ {last.isAborted ? 'Interrupted after' : 'Done in'} {fmt(last.end - last.start)} · waiting on you{' '}
388 </Text>
389 ) : null}
390 {sortedCats(tot).map(c => (
391 <Text>
392 <Text color={color(c)}>{CATS[c].glyph}</Text>
393 <Text dimColor> {mix ? mixLabel(c) : CATS[c].label} {fmt(tot[c]!)} </Text>
394 </Text>
395 ))}
396 {mix && mix.subagents >= 500 ? (
397 <Text>
398 <Text color={color('agents')}>{CATS.agents.glyph}</Text>
399 <Text dimColor> Subagents {fmt(mix.subagents)} </Text>
400 </Text>
401 ) : null}
402 </Text>
403 </Button>
404 </Box>
405 </Box>
406 )
407 })
408
409 // Tall and narrow: totals as bars, then the timeline, newest first.
410 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
411 const { Box, Text } = $.ui.resolve(e)
412 const s = await snapshot($)
413 const mk = await read($, marks)
414 const width = Math.max(30, e.props.bodyColumns)
415 // The work mix: main-loop rows that add up to the main loop's active time, then the subagents' work on top.
416 const mix = workMix(s.sp, s.tu, s.st, s.to)
417 const tot = mix.main
418 const barW = Math.max(6, width - 25)
419 const cats = sortedCats(tot)
420 const active = Object.values(tot).reduce((n, ms) => n + ms, 0)
421 const sum = active || 1
422 const turnBar = (t: Turn) => {
423 const lo = t.start
424 const hi = t.end ?? s.to
425 // A subagent's run belongs to the turn that spawned it, not to whichever turn it overlaps.
426 const mine = s.sp.filter(x => x.start < hi && (x.end ?? s.to) > lo && (x.turnId === undefined || x.turnId === t.id))
427 return runs(strip(sweep(mine, [t], lo, hi), Math.min(24, width - 16)))
428 }
429
430 return (
431 <Box flexDirection="column">
432 <Text>
433 <Text weight="bold">Session {fmt(s.to - s.st)}</Text>
434 <Text dimColor> agent {fmt(active)}{mix.subagents >= 500 ? ` · subagents ${fmt(mix.subagents)}` : ''} · {s.tu.length} turns</Text>
435 </Text>
436 <Text wrap="truncate" dimColor={!s.liveText}>
437 {s.liveText ? `▶ ${s.liveText}` : 'Idle, waiting on you'}
438 </Text>
439 <Text> </Text>
440 {cats.length === 0 && mix.subagents < 500 && <Text dimColor>Nothing yet: time shows up as the agent works.</Text>}
441 {cats.map(c => {
442 const k = Math.round((tot[c]! / sum) * barW)
443 return (
444 <Text wrap="truncate">
445 {mixLabel(c).padEnd(11)}
446 <Text color={color(c)} mono>{CATS[c].glyph.repeat(k)}</Text>
447 <Text dimColor mono>{'·'.repeat(barW - k)}</Text>
448 {` ${fmt(tot[c]!).padStart(6)} ${String(Math.round((tot[c]! / sum) * 100)).padStart(3)}%`}
449 </Text>
450 )
451 })}
452 {/* Subagents' work adds on top of the main loop's, so it takes no share of it. */}
453 {mix.subagents >= 500 && (
454 <Text wrap="truncate">
455 <Text color={color('agents')}>{CATS.agents.label.padEnd(11 + barW)}</Text> {fmt(mix.subagents).padStart(6)}
456 </Text>
457 )}
458 {(s.tot.idle ?? 0) >= 1000 && <Text dimColor>{'Waiting on you'.padEnd(11 + barW)} {fmt(s.tot.idle!).padStart(6)}</Text>}
459 <Text> </Text>
460 <Text weight="bold">Timeline</Text>
461 {s.tu.length === 0 && <Text dimColor>No turns yet.</Text>}
462 {[...s.tu, ...mk]
463 .sort((a, b) => ('at' in b ? b.at : b.start) - ('at' in a ? a.at : a.start))
464 .map(t => 'at' in t ? (
465 <Box marginBottom={1}>
466 <Text dimColor wrap="truncate">
467 {`── ${t.kind === 'clear' ? 'Cleared' : t.isAuto ? 'Auto-compacted' : 'Compacted'} · +${fmt(t.at - s.st)} `.padEnd(width, '─')}
468 </Text>
469 </Box>
470 ) : (
471 <Box flexDirection="column" marginBottom={1}>
472 <Text wrap="truncate">
473 <Text dimColor>+{fmt(t.start - s.st).padEnd(7)} {fmt((t.end ?? s.to) - t.start).padStart(6)} </Text>
474 {turnBar(t).map(r => (
475 <Text color={color(r.cat)} mono>{CATS[r.cat].glyph.repeat(r.n)}</Text>
476 ))}
477 {t.isAborted ? <Text color="warning"> interrupted</Text> : null}
478 </Text>
479 {t.end === undefined ? (
480 <Text dimColor>
481 Working{t.prompt ? `: ${t.prompt.split('\n')[0]!.slice(0, 120)}` : '…'}
482 </Text>
483 ) : (
484 <Text dimColor={t.summary === undefined}>{t.summary ?? 'Summarizing…'}</Text>
485 )}
486 </Box>
487 ))}
488 </Box>
489 )
490 })
491}
492hooks/recap.ts 183 lines1import type { ThemeKey } from 'claude-code'
2
3import type { Cat, Span, Turn } from '../types'
4
5// color: the default palette. theme: the key the `theme` palette follows. glyph: told apart without color.
6export const CATS: Record<Cat, { label: string; color: string; theme: ThemeKey; glyph: string }> = {
7 thinking: { label: 'Thinking', color: '#C8601A', theme: 'claude', glyph: '█' },
8 reading: { label: 'Reading', color: '#37A8EC', theme: 'suggestion', glyph: '▓' },
9 editing: { label: 'Editing', color: '#68BE1F', theme: 'diffAdded', glyph: '▒' },
10 shell: { label: 'Shell', color: '#9F43C3', theme: 'bashBorder', glyph: '▚' },
11 web: { label: 'Web', color: '#D3316A', theme: 'ide', glyph: '▌' },
12 agents: { label: 'Subagents', color: '#5978FD', theme: 'planMode', glyph: '▐' },
13 asking: { label: 'Asking you', color: '#2FA88A', theme: 'permission', glyph: '▞' },
14 other: { label: 'Other tools', color: '#8A8F98', theme: 'inactive', glyph: '░' },
15}
16
17// The `palette` setting: `theme` follows the person's Claude Code theme; anything else is the default palette.
18export function colorOf(cat: Cat, palette: unknown): string {
19 return palette === 'theme' ? CATS[cat].theme : CATS[cat].color
20}
21
22export function catOf(tool: string): Cat {
23 if (/^(Read|Grep|Glob|LS|NotebookRead|LSP|ToolSearch)$/.test(tool)) return 'reading'
24 if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(tool)) return 'editing'
25 if (/^(Bash|BashOutput|KillShell|PowerShell|Monitor)$/.test(tool)) return 'shell'
26 if (/^(WebFetch|WebSearch)$|^mcp__claude-in-chrome/.test(tool)) return 'web'
27 if (/^(Agent|Task|SendMessage)$/.test(tool)) return 'agents'
28 if (/^(AskUserQuestion|ExitPlanMode)$/.test(tool)) return 'asking'
29 return 'other'
30}
31
32const LABEL_KEYS = ['description', 'file_path', 'notebook_path', 'pattern', 'command', 'url', 'query', 'path']
33
34export function labelOf(input: Record<string, unknown>): string {
35 const key = LABEL_KEYS.find(k => typeof input[k] === 'string' && input[k] !== '')
36 let arg = key ? String(input[key]).split('\n')[0]! : ''
37 if (key && /path/.test(key)) arg = arg.split('/').slice(-2).join('/')
38 return `${String(input.tool)}${arg ? ` ${arg}` : ''}`.slice(0, 100)
39}
40
41export type Piece<T extends string = Cat> = { t0: number; t1: number; cats: (T | 'idle')[] }
42
43// Splits [from, to] into pieces by what was running: main-loop tools win, else thinking
44// inside a turn, else a subagent's own run, else idle. Parallel tools share a piece.
45export function sweep(spans: readonly Span[], turns: readonly Turn[], from: number, to: number): Piece[] {
46 const ev: [number, number, string][] = []
47 for (const t of turns) ev.push([t.start, 1, 'turn'], [t.end ?? to, -1, 'turn'])
48 // A subagent's run (agentId set) is background: it fills only time the main loop leaves idle.
49 for (const s of spans) {
50 const k = s.agentId === undefined ? s.cat : 'subagent'
51 ev.push([s.start, 1, k], [s.end ?? to, -1, k])
52 }
53 ev.sort((a, b) => a[0] - b[0])
54 const open = new Map<string, number>()
55 const current = (): Piece['cats'] => {
56 const tools = [...open].filter(([k, n]) => k !== 'turn' && k !== 'subagent' && n > 0).map(([k]) => k as Cat)
57 if (tools.length) return tools
58 if ((open.get('turn') ?? 0) > 0) return ['thinking']
59 return (open.get('subagent') ?? 0) > 0 ? ['agents'] : ['idle']
60 }
61 const out: Piece[] = []
62 let prev = from
63 for (const [t, d, k] of ev) {
64 const at = Math.min(Math.max(t, from), to)
65 if (at > prev) {
66 out.push({ t0: prev, t1: at, cats: current() })
67 prev = at
68 }
69 open.set(k, (open.get(k) ?? 0) + d)
70 }
71 if (to > prev) out.push({ t0: prev, t1: to, cats: current() })
72 return out
73}
74
75export function totals(pieces: readonly Piece[]): Partial<Record<Cat | 'idle', number>> {
76 const tot: Partial<Record<Cat | 'idle', number>> = {}
77 for (const p of pieces) {
78 for (const c of p.cats) tot[c] = (tot[c] ?? 0) + (p.t1 - p.t0) / p.cats.length
79 }
80 return tot
81}
82
83// Seconds per column for the band: the smallest step of 1 s, 2 s, 4 s, ... at which `activeMs` fits in `width`.
84// It only moves when the bar fills, so between rescales blocks already drawn keep their columns.
85export function scaleFor(activeMs: number, width: number): number {
86 let ms = 1000
87 while (activeMs > width * ms) ms *= 2
88 return ms
89}
90
91// One category per column over agent-active time (idle squeezed out). Blocks (runs of one
92// category; parallel pieces split their time) end at column round(time before their end / scale),
93// so each gets a whole number of columns and no edge moves while the scale holds.
94// zoom: fill from the left at scaleFor(); otherwise fit the whole width.
95export function strip<T extends string>(pieces: readonly Piece<T>[], width: number, zoom = false): T[] {
96 const blocks: { cat: T; end: number }[] = []
97 let total = 0
98 for (const p of pieces) {
99 if (p.cats[0] === 'idle') continue
100 for (const cat of p.cats as T[]) {
101 total += (p.t1 - p.t0) / p.cats.length
102 if (blocks.at(-1)?.cat === cat) blocks.at(-1)!.end = total
103 else blocks.push({ cat, end: total })
104 }
105 }
106 if (total <= 0 || width <= 0) return []
107 const scale = zoom ? scaleFor(total, width) : total / width
108 const filled = zoom ? Math.max(1, Math.round(total / scale)) : width
109 const cols: T[] = []
110 blocks.forEach((b, i) => {
111 const to = i === blocks.length - 1 ? filled : Math.round(b.end / scale)
112 while (cols.length < to) cols.push(b.cat)
113 })
114 return cols
115}
116
117const isMain = (s: Span) => s.agentId === undefined
118
119// Work-mix totals: the main loop's own categories, adding up to its active time (no subagent fills its gaps),
120// and Subagents, every subagent run counted in full however many overlap.
121export function workMix(spans: readonly Span[], turns: readonly Turn[], from: number, to: number) {
122 const main = totals(sweep(spans.filter(isMain), turns, from, to))
123 delete main.idle
124 const subagents = spans.filter(s => !isMain(s)).reduce((n, s) => n + Math.max(0, Math.min(s.end ?? to, to) - Math.max(s.start, from)), 0)
125 return { main, subagents }
126}
127
128// The work-mix band's two rows, on the wall-clock strip's axis (time the main loop or any subagent was busy):
129// the main loop's categories on top, 'gap' where only subagents ran; below, 'agents' wherever a subagent ran.
130export function lanes(spans: readonly Span[], turns: readonly Turn[], from: number, to: number) {
131 const main = sweep(spans.filter(isMain), turns, from, to)
132 const sub = sweep(spans.filter(s => !isMain(s)), [], from, to)
133 const top: Piece<Cat | 'gap'>[] = []
134 const low: Piece<'agents' | 'gap'>[] = []
135 // Both sweeps cover [from, to] edge to edge: walk them together, cutting at either's edges.
136 for (let i = 0, j = 0, t = from; i < main.length && j < sub.length; ) {
137 const m = main[i]!, s = sub[j]!, t1 = Math.min(m.t1, s.t1)
138 if (m.cats[0] !== 'idle' || s.cats[0] !== 'idle') {
139 top.push({ t0: t, t1, cats: m.cats[0] === 'idle' ? ['gap'] : m.cats })
140 low.push({ t0: t, t1, cats: s.cats[0] === 'idle' ? ['gap'] : ['agents'] })
141 }
142 t = t1
143 if (m.t1 === t1) i++
144 if (s.t1 === t1) j++
145 }
146 return { top, low }
147}
148
149export function runs<T>(cols: readonly T[]): { cat: T; n: number }[] {
150 const out: { cat: T; n: number }[] = []
151 for (const cat of cols) {
152 const tail = out[out.length - 1]
153 if (tail?.cat === cat) tail.n++
154 else out.push({ cat, n: 1 })
155 }
156 return out
157}
158
159export function fmt(ms: number): string {
160 const s = Math.max(0, Math.round(ms / 1000))
161 if (s < 60) return `${s}s`
162 const m = Math.floor(s / 60)
163 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
164 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
165}
166
167export function summaryPrompt(turn: Turn, answer: string): string {
168 return [
169 'Summarize what an AI coding agent did in one turn of a session as ONE plain sentence,',
170 'at most 20 words, past tense, no preamble, no quotes.',
171 '',
172 `The user asked: ${turn.prompt || '(continuation, no new prompt)'}`,
173 'Actions, in order:',
174 ...(turn.actions.length ? turn.actions.map(a => `- ${a}`) : ['- (none)']),
175 `The agent's final reply (excerpt): ${answer.slice(0, 800)}`,
176 ].join('\n')
177}
178
179export function fallbackSummary(turn: Turn): string {
180 if (!turn.actions.length) return turn.prompt ? `Replied to: ${turn.prompt.slice(0, 80)}` : 'Replied.'
181 return `${turn.actions.length} actions: ${turn.actions.slice(0, 3).join(', ')}`
182}
183types/index.d.ts 28 lines1export type Cat = 'thinking' | 'reading' | 'editing' | 'shell' | 'web' | 'agents' | 'asking' | 'other'
2// A subagent's own run is a span too (agentId set), tied to the main turn that spawned it.
3export type Span = { id: string; cat: Cat; label: string; start: number; end?: number; agentId?: string; turnId?: string }
4export type Turn = {
5 id: string
6 start: number
7 end?: number
8 prompt: string
9 actions: string[]
10 summary?: string
11 isAborted?: boolean
12}
13
14// A subagent as agent.spawn named it: its label and the main turn that spawned it.
15export type Agent = { label: string; turnId?: string }
16
17// A divider in the timeline: where the conversation was cleared or compacted.
18export type Mark = { at: number; kind: 'clear' | 'compact'; isAuto?: boolean }
19
20// The band's two views: wall-clock time, or the work mix with subagents on a row of their own.
21export type BandView = 'wall' | 'work'
22
23declare module 'claude-code' {
24 interface PluginState {
25 'visual-recap': { spans: Span[]; turns: Turn[]; now: number; startedAt: number; frame: number; marks: Mark[]; agents: Record<string, Agent>; view: BandView | null }
26 }
27}
28