SLOPSHOPPER

replay-theatre

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

newpaneguardcommandtimer
v0.1.0no licenseupdated 2026-10-10gauravruhela07/claude-mods/replay-theatre
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · replay-theatre
│ ┃ Replay theatre ✕ › fix the failing auth test and add an audit log call │ ┃ Scene 1/1 · Step 1/9 · 1x · ⏸ paused │ ┃ You: (no prompt) ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ╭──────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ ┃ │ ▸ Reading auth.ts │ │ ┃ │ auth.ts · 0.0s · ✓ ok │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ import { verify } from './jwt' export │ │ ┃ │ async functio… │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰──────────────────────────────────────────╯ │ ┃ › /replay │ ┃ ⎿ replay-theatre: Replay theatre: this session · 1 scene · 9 steps │ ┃ │ ┃ ●──────────────────────────────××××××─────── │ ┃ [ ⏮ ] [ ◀ ] [ ▶ Play ] [ ▶ ] [ ⏭ ] [ 1x ] [ │ ┃ keys: h ⏮ · j ◀ · k play · l ▶ · n ⏭ · s │ ┃ speed · r rest… │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Replay theatre
Scene 1/1 · Step 1/9 · 1x · ⏸ paused You: (no prompt) ╭──────────────────────────────────────────────────────────╮ │ ▸ Reading auth.ts │ │ auth.ts · 0.0s · ✓ ok │ │ import { verify } from './jwt' export async functio… │ ╰──────────────────────────────────────────────────────────╯ ●──────────────────────────────××××××─────────────────── [ ⏮ ] [ ◀ ] [ ▶ Play ] [ ▶ ] [ ⏭ ] [ 1x ] [ Restart ] keys: h ⏮ · j ◀ · k play · l ▶ · n ⏭ · s speed · r rest…
README

claude-mods

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.

ModWhat it doesHow to use it
savvy-progressA 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]`
skinsRe-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
filetreeA 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-taxHow 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-radiusFor 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
reflectClaude 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-theatreRecords 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

Install

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.

Try one from a clone, without installing

git clone https://github.com/gauravruhela07/claude-mods
claude --plugin-dir claude-mods/filetree

Notes

  • Cost: only 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 prices are first-party API list prices as of 2026-10-11, in 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.
  • blast-radius finds dependents with a text search (rg, falling back to grep) of import/require/from/include/use lines. It can miss aliased imports and Go's multi-line import blocks.
  • These mods use the Claude Code function-hooks API, which is early access and can change between releases. They were built and tested on Claude Code 2.1.29x.

Develop

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 filetree
Source 4 files
hooks/register.tsx 453 lines
1import { 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}
453
hooks/film.ts 314 lines
1import 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}
314
hooks/format.ts 178 lines
1import 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}
178
types/index.d.ts 73 lines
1/** 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