SLOPSHOPPER

visual-recap

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

panebandguardcommandmodeltimer
v0.3.0MITupdated 2026-10-26mascah/visual-recap
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · visual-recap
│ ┃ Recap ✕ › fix the failing auth test and add an audit log call │ ┃ Session 0s agent 0s · 0 turns │ ┃ Idle, waiting on you ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Nothing yet: time shows up as the agent ⏺ Update(src/auth.ts) │ ┃ works. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Timeline ⎿ 3 pass, 1 fail │ ┃ No turns yet. │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /visual-recap │ ⎿ visual-recap: Visual recap opened. │ │ ▚ v: view ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▚ v: view
Pane · Recap
Session 0s agent 0s · 0 turns Idle, waiting on you Nothing yet: time shows up as the agent works. Timeline No turns yet.
README

visual-recap

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 above the prompt, mid-turn: a colored strip of working time, the running command, and totals per activity

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:

  • Wall clock (the default) answers "what is the agent's time going to right now". Each second goes to one activity, the main agent first: its tools, else Thinking, else Subagents when only a subagent is working.
  • Work mix answers "what kind of work has been done". The top strip is the main agent alone; the strip under it marks when any subagent was running, on the same time scale. The totals are the main agent's, plus a Subagents total of every subagent's own run time. The main agent's own Agent tool calls are listed as Agent calls.

The band in the work-mix view: the main agent's strip, a second strip marking when a subagent ran, and totals with Subagents

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 band after a turn: the same strip dimmed and "Done in 2m40s · waiting on you"

The panel docked beside the conversation: time per activity as bars, then a timeline of turns with descriptions

The panel (/visual-recap) docks beside the conversation in fullscreen mode, or opens above the prompt otherwise. It shows:

  • Session length, how much of it the main agent was working, and how long subagents worked.
  • The work mix: the main agent's time per activity as bars, with percentages of its working time. Under them, a Subagents row with every subagent's run time added up, with no percentage, since subagents work alongside the main agent and can add up to more than the session. Then the time spent waiting on you.
  • A timeline of turns, newest first. Each turn has when it started (from session start), how long it took, a strip of that turn alone, and a one-line description of what happened. A running turn shows your prompt as Working: …; a finished one shows Summarizing… for a second or two until its description arrives; one you stopped with Esc is marked interrupted.
  • Dividers where the conversation was compacted or cleared with /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.

Install

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.

Settings

In /config, under visual-recap:

SettingDefaultWhat it does
Show bandonShows the strip and totals above the prompt.
Open panel at startupoffOpens the panel when a session starts.
Palettedefaultdefault 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 viewwallThe view the band starts each session in: wall (wall clock) or work (work mix). Any other value means wall.
Turn summariesonAsk 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.

How time is counted

  • Time inside a tool call goes to that tool's activity, starting from when the model begins writing the call: writing a file's contents counts as Editing, composing a long command as Shell. Parallel calls split their shared time. A call's time includes any permission prompt it waits on.
  • Time inside a turn with no tool being written or running counts as Thinking: the model's reasoning, its reply text, and waiting for a response to start.
  • Time between turns counts as Waiting on you. The band's strip leaves it out, so a long break doesn't flatten the picture:

Wall clock with idle gaps above; the band strip below, with the gaps removed

  • The band's strip always shows the whole session. It fills from the left at a fixed scale, starting at one second per cell, so blocks already drawn stay put while the newest one grows. When the bar is full, the scale doubles and every block halves in one step. Each block gets a whole number of cells, as close to its share of the time as it can be. A turn's strip in the panel always spans its full width.
  • A subagent is timed from when it starts until it finishes, including a background one that outlives the turn that launched it. A background subagent keeps the band working after that turn ends.
  • In the wall-clock view each second counts once. While a main-agent turn is open, a second goes to the main agent's tools, else Thinking, even if a subagent is running. Only when no turn is open and a subagent is running does it count as Subagents, not as waiting on you.
  • In the work mix, and in the panel, the main agent's rows add up to its own working time. The Subagents total adds every subagent's run time on top, counting overlapping subagents each in full. Two subagents that each run for the whole of a 5-minute turn add 10 minutes.

Turn descriptions use an extra model call

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.

Contributing

See CONTRIBUTING.md.

License

MIT

Source 3 files
hooks/register.tsx 492 lines
1import { 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}
492
hooks/recap.ts 183 lines
1import 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}
183
types/index.d.ts 28 lines
1export 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