Records what Claude does in the session and plays it back like a film in a pane: scene by scene, step by step.

Seven mods for Claude Code. Each one is a plugin of function hooks: it draws panes, status lines and toasts inside Claude Code, in the terminal and in the desktop app's Code tab.
| Mod | What it does | How to use it | |
|---|---|---|---|
| savvy-progress | A live band above the prompt while Claude works: what it is doing now ("Reading format.ts · 4s"), elapsed time, steps, files read and changed, errors, and a progress bar: the real task list when Claude keeps one, otherwise a time bar guessed from how long your past turns took (clearly labelled a guess). A one-line summary after each turn. | Shows by itself. `/savvy-progress [on\ | off]` |
| skins | Re-skins the spinner (glyphs, verbs, color), the turn's closing line and the prompt hints. Skins: matrix, pirate, zen, retro, space. Your choice is remembered. | /skin (picker), /skin matrix, /skin off | |
| filetree | A live file-tree pane of the project. Marks the files Claude read (R), changed (M) and created (A) this session, and opens the folders they live in. | /filetree, /filetree touched, /filetree all, /filetree close | |
| cache-tax | How well the prompt cache works, and the "tax": the money paid above the ideal where every input token is a cache read. Status line cache 94% · tax $0.12, a per-turn table, and a toast with the likely cause when the cache busts. | /cache-tax, /cache-tax reset | |
| blast-radius | For each file Claude edits, the other files that import it; a flag on wide-reaching shell commands (git reset --hard, rm -rf, force push, ...). Shows only, never blocks. | /blast-radius, /blast-radius session, /blast-radius clear | |
| reflect | Claude critiques its own recent work: what may be wrong or missed, risky assumptions, what to check next. One button puts the follow-ups in your prompt box (it never sends them). Optional auto mode, off by default. | /reflect [focus], /reflect show, `/reflect auto on\ | off` |
| replay-theatre | Records the session and plays it back like a film: scene by scene, step by step, with play/pause, step, speed and a timeline. | /replay, /replay last, /replay copy; keys k play, j/l step, h/n scene, s speed, r restart |
In a Claude Code terminal session, install any mod by name:
/plugin install savvy-progress --marketplace gauravruhela07/claude-mods
Answer y to add the marketplace, then pick a scope (user scope loads it in every session). Repeat for each mod you want: skins, filetree, cache-tax, blast-radius, reflect, replay-theatre.
A mod installed from a terminal at the user scope also loads in the desktop app's Code tab.
git clone https://github.com/gauravruhela07/claude-mods
claude --plugin-dir claude-mods/filetree
reflect calls the model, and only when you run /reflect or turn auto mode on. It asks over the session's own transcript, so most of it is served from the prompt cache.cache-tax/hooks/prices.ts. Claude Code does not report 5-minute and 1-hour cache writes apart, so all writes use the 5-minute price.rg, falling back to grep) of import/require/from/include/use lines. It can miss aliased imports and Go's multi-line import blocks.Each mod folder holds .claude-plugin/plugin.json, hooks/ (the hooks module), types/index.d.ts (its state contract) and tests/.
claude plugin validate filetree
claude plugin test filetreehooks/register.tsx 453 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Playback, Position, Recording, Source, Step } from '../types'
5import {
6 EMPTY,
7 addStep,
8 asSaved,
9 closeScene,
10 countAgentCall,
11 finishStep,
12 fit,
13 holdMs,
14 nextFrame,
15 nextScene,
16 nextSpeed,
17 nextStep,
18 openScene,
19 prevScene,
20 prevStep,
21 runs,
22 script,
23 startOf,
24 theEnd,
25 timeline,
26 totals,
27} from './film'
28import type { Cell } from './film'
29import { GLYPH, clip, describe, kindOf, plural, targetOf, took } from './format'
30
31const PANE = 'replay'
32const TITLE = 'Replay theatre'
33const STORE_KEY = 'last-session'
34const NOTHING = 'Nothing to replay yet.'
35const USAGE =
36 'Usage: /replay opens the theatre, /replay last plays the previous session, /replay copy copies a Markdown script. Nothing changed.'
37
38const STOPPED: Playback = { source: 'live', scene: 0, step: 0, isPlaying: false, speed: 1, since: 0, isEnded: false }
39
40const recording = atom({ plugin: 'replay-theatre', key: 'recording' } as const, EMPTY)
41const previous = atom({ plugin: 'replay-theatre', key: 'previous' } as const, null)
42const playback = atom({ plugin: 'replay-theatre', key: 'playback' } as const, STOPPED)
43
44type Dollar = EngineInterface
45
46/** The film a source names: this session's, or the previous one (empty when none was loaded). */
47const filmOf = async ($: Dollar, source: Source): Promise<Recording> =>
48 source === 'last' ? ((await read($, previous)) ?? EMPTY) : read($, recording)
49
50/** The previous session's film: the one kept at session start, else the store's when it is another session's. */
51const loadPrevious = async ($: Dollar): Promise<Recording | null> => {
52 const held = await read($, previous)
53
54 if (held !== null) return held
55
56 const saved = asSaved(await $.store.get(STORE_KEY))
57
58 if (saved === null || saved.sessionId === (await $.session.id())) return null
59
60 const film: Recording = { scenes: saved.scenes }
61 await update($, previous, () => film)
62
63 return film
64}
65
66/** Moves the playhead by hand; the frame starts holding anew. */
67const move = async ($: Dollar, to: (film: Recording, at: Position) => Position | null) => {
68 const held = await read($, playback)
69 const film = await filmOf($, held.source)
70 const t = await $.clock.now()
71
72 await update($, playback, pb => {
73 const at = fit(film, pb)
74 const next = at === null ? null : to(film, at)
75
76 return next === null ? pb : { ...pb, ...next, since: t, isEnded: false }
77 })
78}
79
80const togglePlay = async ($: Dollar) => {
81 const held = await read($, playback)
82 const film = await filmOf($, held.source)
83 const t = await $.clock.now()
84
85 await update($, playback, pb =>
86 pb.isEnded ? { ...pb, ...startOf(film), isPlaying: true, isEnded: false, since: t } : { ...pb, isPlaying: !pb.isPlaying, since: t },
87 )
88}
89
90const restart = async ($: Dollar) => {
91 const held = await read($, playback)
92 const film = await filmOf($, held.source)
93 const t = await $.clock.now()
94
95 await update($, playback, pb => ({ ...pb, ...startOf(film), since: t, isEnded: false }))
96}
97
98/** One 100 ms beat of the projector: moves on once the frame has held long enough. */
99const beat = async ($: Dollar) => {
100 const held = await read($, playback)
101
102 if (!held.isPlaying) return
103
104 const film = await filmOf($, held.source)
105 const t = await $.clock.now()
106 const at = fit(film, held)
107
108 if (at !== null && t - held.since < holdMs(film, at, held.speed)) return
109
110 const to = at === null ? null : nextFrame(film, at)
111
112 await update($, playback, pb => {
113 const isSame = pb.isPlaying && pb.scene === held.scene && pb.step === held.step && pb.since === held.since
114
115 if (!isSame) return pb
116
117 return to === null ? { ...pb, isPlaying: false, isEnded: true } : { ...pb, ...to, since: t }
118 })
119}
120
121/** Opens the theatre on a film, at the start, paused; says why when the pane waits undrawn. */
122const open = async ($: Dollar, source: Source, film: Recording): Promise<string> => {
123 const t = await $.clock.now()
124
125 await update($, playback, pb => ({ ...pb, source, ...startOf(film), isPlaying: false, isEnded: false, since: t }))
126 const placed = await $.ui.open({ id: PANE, title: TITLE, focus: true, rows: 20 })
127
128 return placed.isPlaced ? '' : ` (The pane waits: ${placed.reason})`
129}
130
131const CELL_STYLE: Record<Cell, { color?: 'claude' | 'error' | 'subtle'; dimColor?: boolean; bold?: boolean }> = {
132 '●': { color: 'claude', bold: true },
133 '×': { color: 'error' },
134 '│': { color: 'subtle' },
135 '─': { dimColor: true },
136}
137
138export const register: Register = on => {
139 on('session.start', async ($, e, next) => {
140 await $.command.register({
141 name: 'replay',
142 description: 'Play back what Claude did this session, scene by scene, in a pane',
143 argumentHint: '[last|copy]',
144 immediate: true,
145 })
146
147 try {
148 // A new session keeps the previous one's film before its own first save replaces it;
149 // a resumed session (same id, nothing recorded yet) picks its own film back up.
150 const saved = asSaved(await $.store.get(STORE_KEY))
151 const id = await $.session.id()
152
153 if (saved !== null && saved.sessionId !== id) {
154 await update($, previous, () => ({ scenes: saved.scenes }))
155 } else if (saved !== null && (await read($, recording)).scenes.length === 0) {
156 await update($, recording, () => ({ scenes: saved.scenes }))
157 }
158 } catch {
159 // The store is a nicety: the theatre works on this session's film without it.
160 }
161
162 $.clock.every(100, () => {
163 void beat($).catch(() => {})
164 })
165
166 return next(e)
167 })
168
169 on('turn.start', async ($, e, next) => {
170 try {
171 const t = await $.clock.now()
172 await update($, recording, film => openScene(film, e.turnId, e.text, t))
173 } catch {
174 // Recording never stands in the turn's way.
175 }
176
177 return next(e)
178 })
179
180 on('tool.call', async ($, e, next) => {
181 const tool = String(e.tool)
182 const input = e as unknown as Record<string, unknown>
183
184 if (e.agentId !== undefined) {
185 const agentId = e.agentId
186
187 try {
188 const t = await $.clock.now()
189 const film = await read($, recording)
190
191 if (countAgentCall(film, agentId, t) !== film) {
192 await update($, recording, was => countAgentCall(was, agentId, t))
193 }
194 } catch {
195 // A subagent call that cannot be matched is simply not counted.
196 }
197
198 return next(e)
199 }
200
201 const id = e.tool_use_id
202 let startedAt = 0
203
204 try {
205 startedAt = await $.clock.now()
206 const step: Step = {
207 id,
208 tool,
209 label: clip(describe(tool, input), 120),
210 target: targetOf(tool, input),
211 kind: kindOf(tool),
212 startedAt,
213 durationMs: 0,
214 isRunning: true,
215 ok: true,
216 result: '',
217 agentId: null,
218 agentCalls: 0,
219 }
220 await update($, recording, film => addStep(film, step))
221 } catch {
222 // Recording never stands in the call's way.
223 }
224
225 const ran = await next(e)
226
227 try {
228 const t = await $.clock.now()
229 const ok = ran.deny === undefined && ran.isError !== true
230 const text = ran.deny ?? ran.text ?? (typeof ran.result === 'string' ? ran.result : '')
231 await update($, recording, film =>
232 finishStep(film, id, { durationMs: Math.max(0, t - startedAt), ok, result: clip(text, 120) }),
233 )
234 } catch {
235 // The step stays as it started.
236 }
237
238 return ran
239 })
240
241 on('turn.complete', async ($, e, next) => {
242 if (e.agentId !== undefined) return next(e)
243
244 try {
245 const t = await $.clock.now()
246 const film = await update($, recording, was => closeScene(was, e.answer, e.isAborted, t))
247 const sessionId = await $.session.id()
248 await $.store.set(STORE_KEY, { sessionId, savedAt: t, scenes: film.scenes })
249 } catch {
250 // The film stays in $.state even when the store refuses it.
251 }
252
253 return next(e)
254 })
255
256 on('command.run', { command: 'replay' }, async ($, e) => {
257 const arg = e.args.trim().toLowerCase()
258
259 if (arg !== '' && arg !== 'last' && arg !== 'copy') return { text: USAGE }
260
261 if (arg === 'copy') {
262 const held = await read($, playback)
263 const isLast = held.source === 'last' && (await read($, previous)) !== null
264 const film = await filmOf($, isLast ? 'last' : 'live')
265
266 if (film.scenes.length === 0) return { text: NOTHING }
267
268 const which = isLast ? 'the previous session' : 'this session'
269 const copied = await $.ui.copy({ text: script(film, `Replay of ${which}`) })
270 const sum = totals(film)
271
272 return {
273 text: copied.isCopied
274 ? `Copied the script of ${which} as Markdown: ${plural(sum.scenes, 'scene')}, ${plural(sum.steps, 'step')}.`
275 : `Could not copy the script (${copied.reason}).`,
276 }
277 }
278
279 if (arg === 'last') {
280 const film = await loadPrevious($)
281
282 if (film === null || film.scenes.length === 0) return { text: `${NOTHING} No earlier session was recorded.` }
283
284 const waits = await open($, 'last', film)
285 const sum = totals(film)
286
287 return {
288 text: `Replay theatre: the previous session · ${plural(sum.scenes, 'scene')} · ${plural(sum.steps, 'step')}. Press k to play.${waits}`,
289 }
290 }
291
292 const film = await read($, recording)
293
294 if (film.scenes.length === 0) return { text: NOTHING }
295
296 const waits = await open($, 'live', film)
297 const sum = totals(film)
298
299 return {
300 text: `Replay theatre: this session · ${plural(sum.scenes, 'scene')} · ${plural(sum.steps, 'step')}. Press k to play.${waits}`,
301 }
302 })
303
304 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
305 const { Box, Text, Button } = $.ui.resolve(e)
306 const pb = await read($, playback)
307 const film = await filmOf($, pb.source)
308 const width = Math.max(24, e.props.bodyColumns)
309 const at = fit(film, pb)
310
311 if (at === null) {
312 return (
313 <Box flexDirection="column">
314 <Text dimColor>{NOTHING}</Text>
315 </Box>
316 )
317 }
318
319 const scene = film.scenes[at.scene]
320
321 if (scene === undefined) {
322 return (
323 <Box flexDirection="column">
324 <Text dimColor>{NOTHING}</Text>
325 </Box>
326 )
327 }
328
329 const step = at.step < 0 ? undefined : scene.steps[at.step]
330 const isSceneEnd = at.step === scene.steps.length - 1 || scene.steps.length === 0
331 const state = pb.isPlaying ? '▶ playing' : '⏸ paused'
332 const where = `Step ${at.step < 0 ? '–' : at.step + 1}/${scene.steps.length}`
333 const header = `Scene ${at.scene + 1}/${film.scenes.length} · ${where} · ${pb.speed}x · ${state}${pb.source === 'last' ? ' · previous session' : ''}`
334 const inner = Math.max(10, width - 4)
335 const rows = []
336
337 rows.push(
338 <Box key="header" flexDirection="row">
339 <Text color={pb.isPlaying ? 'claude' : 'subtle'}>{clip(header, width)}</Text>
340 </Box>,
341 <Box key="prompt" flexDirection="row">
342 <Text bold>{clip(`You: ${scene.prompt || '(no prompt)'}`, width)}</Text>
343 </Box>,
344 )
345
346 // The stage keeps one height (4 rows before, the card, 2 answer rows, the end row), so the
347 // controls below it stay put while the film plays.
348 for (let n = 4; n >= 1; n--) {
349 const i = at.step - n
350 const was = i >= 0 ? scene.steps[i] : undefined
351 const mark = was === undefined ? '' : was.isRunning ? '…' : was.ok ? '✓' : '✗'
352 const line = was === undefined ? ' ' : clip(` ${GLYPH[was.kind]} ${was.label} · ${took(was.durationMs)} ${mark}`, width)
353
354 rows.push(
355 <Box key={`before-${n}`} flexDirection="row">
356 <Text dimColor>{line}</Text>
357 </Box>,
358 )
359 }
360
361 if (step === undefined) {
362 const length = scene.endedAt === null ? 'still running' : took(scene.endedAt - scene.startedAt)
363 const kinds = (Object.keys(GLYPH) as (keyof typeof GLYPH)[])
364 .map(kind => ({ kind, n: scene.steps.filter(one => one.kind === kind).length }))
365 .filter(one => one.n > 0)
366 .map(one => `${GLYPH[one.kind]} ${one.n}`)
367 const failed = scene.steps.filter(one => !one.ok && !one.isRunning).length
368 const mix = kinds.length === 0 ? 'no tool calls' : `${kinds.join(' ')}${failed > 0 ? ` · ${failed} failed` : ''}`
369
370 rows.push(
371 <Box key="card" flexDirection="column" borderStyle="round" paddingX={1}>
372 <Text bold color="claude">
373 {clip(`Scene ${at.scene + 1} of ${film.scenes.length}`, inner)}
374 </Text>
375 <Text>{clip(`${plural(scene.steps.length, 'step')} · ${length}${scene.aborted ? ' · stopped' : ''}`, inner)}</Text>
376 <Text dimColor>{clip(mix, inner)}</Text>
377 </Box>,
378 )
379 } else {
380 const extra = step.agentCalls > 0 ? ` · ${plural(step.agentCalls, 'agent call')}` : ''
381 const meta = `${step.target === '' ? step.tool : step.target} · ${step.isRunning ? 'running' : took(step.durationMs)}${extra} ·`
382 const verdict = step.isRunning ? ' … running' : step.ok ? ' ✓ ok' : ' ✗ failed'
383
384 rows.push(
385 <Box key="card" flexDirection="column" borderStyle="round" paddingX={1}>
386 <Box flexDirection="row">
387 <Text color="claude">{GLYPH[step.kind]} </Text>
388 <Text bold>{clip(step.label, inner - 2)}</Text>
389 </Box>
390 <Box flexDirection="row">
391 <Text color="subtle">{clip(meta, inner - verdict.length)}</Text>
392 <Text color={step.isRunning ? 'warning' : step.ok ? 'success' : 'error'}>{verdict}</Text>
393 </Box>
394 <Text dimColor>{clip(step.result || '(no output)', inner)}</Text>
395 </Box>,
396 )
397 }
398
399 const answer = !isSceneEnd
400 ? ' '
401 : scene.endedAt === null
402 ? 'Claude is still working…'
403 : `Claude: ${scene.aborted ? '(stopped) ' : ''}${scene.answer || '(no answer)'}`
404
405 rows.push(
406 <Box key="answer" flexDirection="row" height={2} overflow="hidden">
407 <Text wrap="wrap" dimColor={scene.endedAt === null}>
408 {clip(answer, width * 2 - 4)}
409 </Text>
410 </Box>,
411 <Box key="end" flexDirection="row">
412 <Text bold color="claude">
413 {pb.isEnded ? clip(theEnd(film), width) : ' '}
414 </Text>
415 </Box>,
416 )
417
418 rows.push(
419 <Box key="timeline" flexDirection="row">
420 {runs(timeline(film, at, width)).map(run => (
421 <Text {...CELL_STYLE[run.mark]}>{run.text}</Text>
422 ))}
423 </Box>,
424 <Box key="controls" flexDirection="row" flexWrap="wrap" columnGap={1}>
425 <Button key="prev-scene" label="⏮" hotkey="h" onPress={() => move($, prevScene)} />
426 <Button key="prev-step" label="◀" hotkey="j" onPress={() => move($, prevStep)} />
427 <Button
428 key="play"
429 label={pb.isPlaying ? '⏸ Pause' : '▶ Play'}
430 hotkey="k"
431 variant="primary"
432 onPress={() => togglePlay($)}
433 />
434 <Button key="next-step" label="▶" hotkey="l" onPress={() => move($, nextStep)} />
435 <Button key="next-scene" label="⏭" hotkey="n" onPress={() => move($, nextScene)} />
436 <Button key="speed" label={`${pb.speed}x`} hotkey="s" onPress={() => update($, playback, was => ({ ...was, speed: nextSpeed(was.speed) }))} />
437 <Button key="restart" label="Restart" hotkey="r" onPress={() => restart($)} />
438 </Box>,
439 )
440
441 if (e.surface === 'terminal') {
442 const keys = 'keys: h ⏮ · j ◀ · k play · l ▶ · n ⏭ · s speed · r restart'
443 rows.push(
444 <Box key="keys" flexDirection="row">
445 <Text dimColor>{clip(e.props.isFocused ? keys : `${keys} · ctrl+x tab to focus`, width)}</Text>
446 </Box>,
447 )
448 }
449
450 return <Box flexDirection="column">{rows}</Box>
451 })
452}
453hooks/film.ts 314 lines1import type { Position, Recording, SavedSession, Scene, Speed, Step } from '../types'
2import { GLYPH, clip, plural, took } from './format'
3
4export const MAX_STEPS = 500
5export const TITLE_MS = 1500
6export const STEP_MIN_MS = 600
7export const STEP_MAX_MS = 3000
8
9export const EMPTY: Recording = { scenes: [] }
10
11// ---------------------------------------------------------------- recording
12
13const stepCount = (scenes: readonly Scene[]): number => scenes.reduce((n, scene) => n + scene.steps.length, 0)
14
15/** Keeps at most MAX_STEPS steps, dropping the oldest scenes first. */
16export const cap = (rec: Recording): Recording => {
17 let scenes = rec.scenes
18 let total = stepCount(scenes)
19
20 if (total <= MAX_STEPS) return rec
21
22 while (total > MAX_STEPS && scenes.length > 1) {
23 total -= scenes[0]?.steps.length ?? 0
24 scenes = scenes.slice(1)
25 }
26
27 const only = scenes[0]
28
29 if (total > MAX_STEPS && only !== undefined) {
30 scenes = [{ ...only, steps: only.steps.slice(total - MAX_STEPS) }]
31 }
32
33 return { scenes }
34}
35
36const isOpen = (scene: Scene | undefined): scene is Scene => scene !== undefined && scene.endedAt === null
37
38/** A new scene for a turn that starts; a scene left open (no turn.complete) is closed at `t`. */
39export const openScene = (rec: Recording, id: string, prompt: string, t: number): Recording => {
40 const scenes = rec.scenes.map(scene => (isOpen(scene) ? { ...scene, endedAt: t } : scene))
41 const scene: Scene = { id, prompt: clip(prompt, 200), startedAt: t, endedAt: null, answer: '', aborted: false, steps: [] }
42
43 return cap({ scenes: [...scenes, scene] })
44}
45
46/** The step joins the open scene; with none open (a reload mid-turn), a scene without a prompt. */
47export const addStep = (rec: Recording, step: Step): Recording => {
48 const last = rec.scenes[rec.scenes.length - 1]
49 const scenes = isOpen(last)
50 ? [...rec.scenes.slice(0, -1), { ...last, steps: [...last.steps, step] }]
51 : [
52 ...rec.scenes,
53 { id: `scene-${step.startedAt}`, prompt: '', startedAt: step.startedAt, endedAt: null, answer: '', aborted: false, steps: [step] },
54 ]
55
56 return cap({ scenes })
57}
58
59const mapStep = (rec: Recording, isIt: (step: Step) => boolean, change: (step: Step) => Step): Recording => {
60 for (let s = rec.scenes.length - 1; s >= 0; s--) {
61 const scene = rec.scenes[s]
62
63 if (scene === undefined) continue
64
65 const i = scene.steps.findIndex(isIt)
66
67 if (i < 0) continue
68
69 const steps = scene.steps.map((step, j) => (j === i ? change(step) : step))
70 const scenes = rec.scenes.map((one, k) => (k === s ? { ...scene, steps } : one))
71
72 return { scenes }
73 }
74
75 return rec
76}
77
78export const finishStep = (
79 rec: Recording,
80 id: string,
81 done: { durationMs: number; ok: boolean; result: string },
82): Recording => mapStep(rec, step => step.id === id, step => ({ ...step, ...done, isRunning: false }))
83
84/**
85 * A subagent's tool call, counted on the Agent step that spawned it: the step it
86 * was matched to before, else the latest Agent step running at `t` with no agent yet.
87 */
88export const countAgentCall = (rec: Recording, agentId: string, t: number): Recording => {
89 const known = mapStep(rec, step => step.agentId === agentId, step => ({ ...step, agentCalls: step.agentCalls + 1 }))
90
91 if (known !== rec) return known
92
93 const isCandidate = (step: Step) =>
94 step.kind === 'agent' &&
95 step.agentId === null &&
96 step.startedAt <= t &&
97 (step.isRunning || step.startedAt + step.durationMs >= t)
98 let latest: Step | undefined
99
100 for (const scene of rec.scenes) {
101 for (const step of scene.steps) {
102 if (isCandidate(step) && (latest === undefined || step.startedAt >= latest.startedAt)) latest = step
103 }
104 }
105
106 if (latest === undefined) return rec
107
108 const id = latest.id
109
110 return mapStep(rec, step => step.id === id, step => ({ ...step, agentId, agentCalls: step.agentCalls + 1 }))
111}
112
113export const closeScene = (rec: Recording, answer: string, aborted: boolean, t: number): Recording => {
114 const last = rec.scenes[rec.scenes.length - 1]
115
116 if (!isOpen(last)) return rec
117
118 const steps = last.steps.map(step => (step.isRunning ? { ...step, isRunning: false, durationMs: Math.max(0, t - step.startedAt) } : step))
119
120 return { scenes: [...rec.scenes.slice(0, -1), { ...last, endedAt: t, answer: clip(answer, 300), aborted, steps }] }
121}
122
123const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
124
125/** What `$.store` held under `last-session`, when it is a saved session. */
126export const asSaved = (value: unknown): SavedSession | null => {
127 if (!isRecord(value) || !Array.isArray(value.scenes)) return null
128
129 const scenes = (value.scenes as unknown[]).filter(
130 (scene): scene is Scene => isRecord(scene) && Array.isArray(scene.steps) && typeof scene.prompt === 'string',
131 )
132
133 return {
134 sessionId: typeof value.sessionId === 'string' ? value.sessionId : '',
135 savedAt: typeof value.savedAt === 'number' ? value.savedAt : 0,
136 scenes,
137 }
138}
139
140// ---------------------------------------------------------------- playback
141
142const firstStop = (scene: Scene | undefined): number => (scene !== undefined && scene.steps.length > 0 ? 0 : -1)
143const lastStop = (scene: Scene | undefined): number => (scene === undefined ? -1 : scene.steps.length - 1)
144
145/** The start: scene 1, step 1 (or its title card when it has no steps). */
146export const startOf = (rec: Recording): Position => ({ scene: 0, step: firstStop(rec.scenes[0]) })
147
148/** The position made to fit the recording (scenes may have been dropped); null when it is empty. */
149export const fit = (rec: Recording, at: Position): Position | null => {
150 if (rec.scenes.length === 0) return null
151
152 const scene = Math.min(Math.max(0, at.scene), rec.scenes.length - 1)
153 const step = Math.min(Math.max(-1, at.step), lastStop(rec.scenes[scene]))
154
155 return { scene, step }
156}
157
158/** One step on, by hand: the next step, or the next scene's first. */
159export const nextStep = (rec: Recording, at: Position): Position | null => {
160 if (at.step < lastStop(rec.scenes[at.scene])) return { scene: at.scene, step: at.step + 1 }
161 if (at.scene + 1 < rec.scenes.length) return { scene: at.scene + 1, step: firstStop(rec.scenes[at.scene + 1]) }
162
163 return null
164}
165
166/** One step back, by hand: the step before, or the previous scene's last. */
167export const prevStep = (rec: Recording, at: Position): Position | null => {
168 if (at.step > firstStop(rec.scenes[at.scene])) return { scene: at.scene, step: at.step - 1 }
169 if (at.scene > 0) return { scene: at.scene - 1, step: lastStop(rec.scenes[at.scene - 1]) }
170
171 return null
172}
173
174export const nextScene = (rec: Recording, at: Position): Position | null =>
175 at.scene + 1 < rec.scenes.length ? { scene: at.scene + 1, step: firstStop(rec.scenes[at.scene + 1]) } : null
176
177export const prevScene = (rec: Recording, at: Position): Position | null => {
178 const scene = Math.max(0, at.scene - 1)
179 const to = { scene, step: firstStop(rec.scenes[scene]) }
180
181 return to.scene === at.scene && to.step === at.step ? null : to
182}
183
184/** The frame playback shows next: the next step, or the next scene's title card; null at the end. */
185export const nextFrame = (rec: Recording, at: Position): Position | null => {
186 if (at.step < lastStop(rec.scenes[at.scene])) return { scene: at.scene, step: at.step + 1 }
187 if (at.scene + 1 < rec.scenes.length) return { scene: at.scene + 1, step: -1 }
188
189 return null
190}
191
192/** How long a frame holds: a title card 1500 ms, a step its real time within 600–3000 ms; over the speed. */
193export const holdMs = (rec: Recording, at: Position, speed: Speed): number => {
194 const step = at.step < 0 ? undefined : rec.scenes[at.scene]?.steps[at.step]
195 const ms = step === undefined ? TITLE_MS : Math.min(STEP_MAX_MS, Math.max(STEP_MIN_MS, step.durationMs))
196
197 return ms / speed
198}
199
200export const nextSpeed = (speed: Speed): Speed => (speed === 1 ? 2 : speed === 2 ? 4 : 1)
201
202export const totals = (rec: Recording) => {
203 let steps = 0
204 let failed = 0
205
206 for (const scene of rec.scenes) {
207 steps += scene.steps.length
208 failed += scene.steps.filter(step => !step.ok && !step.isRunning).length
209 }
210
211 return { scenes: rec.scenes.length, steps, failed }
212}
213
214export const theEnd = (rec: Recording): string => {
215 const sum = totals(rec)
216
217 return `The end · ${plural(sum.scenes, 'scene')} · ${plural(sum.steps, 'step')} · ${sum.failed} failed`
218}
219
220// ---------------------------------------------------------------- timeline
221
222export type Cell = '●' | '│' | '×' | '─'
223
224const RANK: Record<Cell, number> = { '●': 4, '×': 3, '│': 2, '─': 1 }
225
226/**
227 * The whole session across `width` cells: every step (or step-less scene) a unit
228 * scaled to the width, `│` where a scene starts, `×` on a failed step, `●` at the
229 * playhead, `─` elsewhere. Where several units share a cell, the strongest mark wins.
230 */
231export const timeline = (rec: Recording, at: Position | null, width: number): Cell[] => {
232 type Unit = { isSceneStart: boolean; isFailed: boolean; isHere: boolean }
233 const units: Unit[] = []
234
235 rec.scenes.forEach((scene, s) => {
236 if (scene.steps.length === 0) {
237 units.push({ isSceneStart: true, isFailed: false, isHere: at !== null && at.scene === s })
238
239 return
240 }
241
242 scene.steps.forEach((step, i) => {
243 const isHere = at !== null && at.scene === s && (at.step === i || (at.step < 0 && i === 0))
244 units.push({ isSceneStart: i === 0, isFailed: !step.ok && !step.isRunning, isHere })
245 })
246 })
247
248 const cells: Cell[] = Array.from({ length: Math.max(0, width) }, () => '─')
249 const total = units.length
250
251 if (total === 0 || width <= 0) return cells
252
253 units.forEach((unit, u) => {
254 const from = Math.floor((u * width) / total)
255 const to = Math.max(from + 1, Math.floor(((u + 1) * width) / total))
256
257 for (let c = from; c < to && c < width; c++) {
258 const isFirst = c === from
259 const mark: Cell =
260 isFirst && unit.isHere ? '●' : isFirst && unit.isSceneStart ? '│' : unit.isFailed ? '×' : '─'
261
262 if (RANK[mark] > RANK[cells[c] ?? '─']) cells[c] = mark
263 }
264 })
265
266 return cells
267}
268
269/** Runs of the same mark, so each run is one Text. */
270export const runs = (cells: readonly Cell[]): { mark: Cell; text: string }[] => {
271 const out: { mark: Cell; text: string }[] = []
272
273 for (const cell of cells) {
274 const last = out[out.length - 1]
275
276 if (last !== undefined && last.mark === cell) last.text += cell
277 else out.push({ mark: cell, text: cell })
278 }
279
280 return out
281}
282
283// ---------------------------------------------------------------- script
284
285const tick = (text: string): string => text.replace(/`/g, "'")
286
287/** The session as a Markdown script: a heading per scene, numbered steps with ✓ or ✗. */
288export const script = (rec: Recording, title: string): string => {
289 const sum = totals(rec)
290 const lines = [`# ${title}`, '', `${plural(sum.scenes, 'scene')} · ${plural(sum.steps, 'step')} · ${sum.failed} failed`]
291
292 rec.scenes.forEach((scene, s) => {
293 lines.push('', `## Scene ${s + 1}: ${clip(scene.prompt, 60) || '(no prompt)'}`, '')
294 lines.push(`> You: ${scene.prompt || '(no prompt)'}`, '')
295
296 if (scene.steps.length === 0) lines.push('_No tool calls._')
297
298 scene.steps.forEach((step, i) => {
299 const mark = step.isRunning ? '…' : step.ok ? '✓' : '✗'
300 const target = step.target === '' ? '' : ` \`${tick(step.target)}\``
301 const agents = step.agentCalls > 0 ? `, ${plural(step.agentCalls, 'agent call')}` : ''
302 const result = step.result === '' ? '' : `: ${step.result}`
303
304 lines.push(`${i + 1}. ${mark} ${GLYPH[step.kind]} ${step.label}${target} (${took(step.durationMs)}${agents})${result}`)
305 })
306
307 if (scene.endedAt !== null) {
308 lines.push('', `**Claude:** ${scene.aborted ? '(stopped) ' : ''}${scene.answer || '(no answer)'}`)
309 }
310 })
311
312 return `${lines.join('\n')}\n`
313}
314hooks/format.ts 178 lines1import type { StepKind } from '../types'
2
3type Input = Record<string, unknown>
4
5const str = (input: Input, key: string): string => {
6 const value = input[key]
7
8 return typeof value === 'string' ? value : ''
9}
10
11/** One line, at most `max` characters, an ellipsis where it was cut. */
12export const clip = (text: string, max: number): string => {
13 const line = text.replace(/\s+/g, ' ').trim()
14
15 if (max <= 1) return line.slice(0, Math.max(0, max))
16
17 return line.length > max ? `${line.slice(0, max - 1)}…` : line
18}
19
20export const base = (path: string): string => path.split('/').filter(Boolean).pop() ?? path
21
22export const host = (url: string): string => {
23 const match = /^[a-z]+:\/\/([^/?#]+)/i.exec(url)
24
25 return match?.[1] ?? url
26}
27
28export const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
29
30/** A real duration as a person reads it: "0.4s", "12s", "3m 5s". */
31export const took = (ms: number): string => {
32 const safe = Math.max(0, ms)
33
34 if (safe < 10_000) return `${(safe / 1000).toFixed(1)}s`
35
36 const seconds = Math.round(safe / 1000)
37
38 if (seconds < 60) return `${seconds}s`
39
40 const minutes = Math.floor(seconds / 60)
41
42 if (minutes < 60) return `${minutes}m ${seconds % 60}s`
43
44 return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
45}
46
47const file = (input: Input): string =>
48 base(str(input, 'file_path') || str(input, 'notebook_path') || str(input, 'path'))
49
50/** Turns a tool call into a short line a person reads: "Reading register.tsx". */
51export const describe = (tool: string, input: Input): string => {
52 switch (tool) {
53 case 'Read':
54 return `Reading ${file(input)}`
55 case 'Edit':
56 case 'MultiEdit':
57 case 'NotebookEdit':
58 return `Editing ${file(input)}`
59 case 'Write':
60 return `Writing ${file(input)}`
61 case 'Bash':
62 case 'PowerShell':
63 return str(input, 'description') || `Running ${clip(str(input, 'command'), 60)}`
64 case 'Grep':
65 return `Searching for "${str(input, 'pattern')}"`
66 case 'Glob':
67 return `Finding ${str(input, 'pattern')}`
68 case 'WebFetch':
69 return `Fetching ${host(str(input, 'url'))}`
70 case 'WebSearch':
71 return `Searching the web: ${str(input, 'query')}`
72 case 'Agent':
73 case 'Task':
74 return `Agent: ${str(input, 'description') || 'working'}`
75 case 'Skill':
76 return `Using skill ${str(input, 'skill')}`
77 case 'TodoWrite':
78 case 'TaskCreate':
79 case 'TaskUpdate':
80 return 'Updating the plan'
81 case 'EnterPlanMode':
82 case 'ExitPlanMode':
83 return 'Planning'
84 case 'ToolSearch':
85 return 'Loading tools'
86 }
87
88 if (tool.startsWith('mcp__')) {
89 const [, server = '', ...rest] = tool.split('__')
90 const name = rest.join('__').replace(/_/g, ' ')
91 const isOpaque = /^[0-9a-f-]{20,}$/i.test(server)
92
93 return isOpaque ? name : `${server}: ${name}`
94 }
95
96 return tool
97}
98
99/** What the call worked on: a file's basename, the command, the url's host. */
100export const targetOf = (tool: string, input: Input): string => {
101 const path = file(input)
102
103 if (path !== '') return path
104
105 switch (tool) {
106 case 'Bash':
107 case 'PowerShell':
108 return clip(str(input, 'command'), 120)
109 case 'WebFetch':
110 return host(str(input, 'url'))
111 case 'WebSearch':
112 return clip(str(input, 'query'), 120)
113 case 'Grep':
114 case 'Glob':
115 return clip(str(input, 'pattern'), 120)
116 case 'Agent':
117 case 'Task':
118 return str(input, 'subagent_type') || clip(str(input, 'description'), 120)
119 case 'Skill':
120 return str(input, 'skill')
121 case 'ToolSearch':
122 return clip(str(input, 'query'), 120)
123 }
124
125 if (tool.startsWith('mcp__')) {
126 const server = tool.split('__')[1] ?? ''
127
128 return /^[0-9a-f-]{20,}$/i.test(server) ? '' : server
129 }
130
131 return ''
132}
133
134const KINDS: Record<string, StepKind> = {
135 Read: 'read',
136 NotebookRead: 'read',
137 LS: 'read',
138 ReadMcpResourceTool: 'read',
139 Edit: 'edit',
140 MultiEdit: 'edit',
141 Write: 'edit',
142 NotebookEdit: 'edit',
143 Bash: 'run',
144 PowerShell: 'run',
145 BashOutput: 'run',
146 KillShell: 'run',
147 Monitor: 'run',
148 Grep: 'search',
149 Glob: 'search',
150 ToolSearch: 'search',
151 LSP: 'search',
152 WebFetch: 'web',
153 WebSearch: 'web',
154 Agent: 'agent',
155 Task: 'agent',
156 SendMessage: 'agent',
157 TodoWrite: 'plan',
158 TaskCreate: 'plan',
159 TaskUpdate: 'plan',
160 TaskList: 'plan',
161 TaskGet: 'plan',
162 EnterPlanMode: 'plan',
163 ExitPlanMode: 'plan',
164}
165
166export const kindOf = (tool: string): StepKind => KINDS[tool] ?? 'other'
167
168export const GLYPH: Record<StepKind, string> = {
169 read: '▸',
170 edit: '✎',
171 run: '$',
172 search: '?',
173 web: '@',
174 agent: '◆',
175 plan: '≡',
176 other: '•',
177}
178types/index.d.ts 73 lines1/** What kind of work a step was, which picks its glyph in the theatre. */
2export type StepKind = 'read' | 'edit' | 'run' | 'search' | 'web' | 'agent' | 'plan' | 'other'
3
4/** One main-loop tool call, as the theatre plays it back. */
5export type Step = {
6 /** The tool_use_id of the call. */
7 id: string
8 tool: string
9 /** The call in plain words: "Reading register.tsx". */
10 label: string
11 /** File basename, command, url host, pattern; "" when the tool has none. */
12 target: string
13 kind: StepKind
14 startedAt: number
15 /** Real time the call took; 0 while it runs. */
16 durationMs: number
17 isRunning: boolean
18 ok: boolean
19 /** The first 120 characters of what the model read back, on one line. */
20 result: string
21 /** For an Agent step: the subagent matched to it, and the tool calls it made. */
22 agentId: string | null
23 agentCalls: number
24}
25
26/** One main-loop turn: the prompt, the steps, the answer. */
27export type Scene = {
28 id: string
29 /** The prompt, clipped to 200 characters. */
30 prompt: string
31 startedAt: number
32 /** null while the turn still runs. */
33 endedAt: number | null
34 /** The final answer, clipped to 300 characters. */
35 answer: string
36 aborted: boolean
37 steps: Step[]
38}
39
40/** A whole session's film. */
41export type Recording = { scenes: Scene[] }
42
43/** What `$.store` keeps under `last-session`. */
44export type SavedSession = { sessionId: string; savedAt: number; scenes: Scene[] }
45
46export type Speed = 1 | 2 | 4
47
48/** Which film the theatre shows: this session's, or the one before it. */
49export type Source = 'live' | 'last'
50
51/** Where the playhead stands: a scene, and a step in it (-1 is the scene's title card). */
52export type Position = { scene: number; step: number }
53
54export type Playback = Position & {
55 source: Source
56 isPlaying: boolean
57 speed: Speed
58 /** When the current frame began to hold, in `$.clock.now()` milliseconds. */
59 since: number
60 /** True once playback ran off the last frame. */
61 isEnded: boolean
62}
63
64declare module 'claude-code' {
65 interface PluginState {
66 'replay-theatre': {
67 recording: Recording
68 previous: Recording | null
69 playback: Playback
70 }
71 }
72}
73