Pane that records the session's tool calls and replays them step by step or on a timer.

A pane that records every tool call in the session with a timestamp and a pass or fail mark, then replays them step by step or on a timer with back, play, next, rewind and clear controls.
/plugin marketplace add namtrdev/claude-mods
/plugin install replay-theater@jackie-mods
Commands: /replay.
Built for Claude Code mods (hook modules). Nothing here runs outside your machine: the mod reads session events and draws its own interface.
You set these when you install the mod, and can change them later in the Claude Code config menu.
openPaneAtStart, default true): Open the Replay Theater pane when a session begins; off opens it only with /replay.playSpeedMs, default 800): Delay between steps while playing. Lower is faster.maxSteps, default 500): How many recent tool calls the recording holds.MIT, Copyright (c) 2026 Jackie.
Icons: Lucide (ISC). See THIRD_PARTY_NOTICES.md.
hooks/register.tsx 150 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { icon } from './icons'
5import type { IconName } from './icons'
6
7import type { Step } from '../types'
8
9const PANE = 'replay-theater'
10const steps = atom({ plugin: 'replay-theater', key: 'steps' } as const, [])
11const cursor = atom({ plugin: 'replay-theater', key: 'cursor' } as const, -1)
12const isPlaying = atom({ plugin: 'replay-theater', key: 'isPlaying' } as const, false)
13
14const summarize = (tool: string, e: object): string => {
15 const input = e as Record<string, unknown>
16 const detail = input.command ?? input.file_path ?? input.pattern ?? input.url ?? input.prompt ?? ''
17 return `${tool} ${String(detail).replace(/\s+/g, ' ').slice(0, 80)}`.trim()
18}
19
20export const register: Register = (on, options) => {
21 const speed = Math.max(100, Number(options.playSpeedMs ?? 800))
22 const maxSteps = Math.max(10, Number(options.maxSteps ?? 500))
23 let timer: { cancel: () => void } | undefined
24
25 on('session.start', async ($, e, next) => {
26 await $.command.register({
27 name: 'replay',
28 description: 'Open the Replay Theater pane',
29 })
30
31 if (options.openPaneAtStart !== false) {
32 void $.ui.open({ id: PANE, title: 'Replay Theater' })
33 }
34
35 return next(e)
36 })
37
38 on('command.run', { command: 'replay' }, async $ => {
39 await $.ui.open({ id: PANE, title: 'Replay Theater' })
40
41 return { text: 'Replay Theater opened.' }
42 })
43
44 on('tool.call', async ($, e, next) => {
45 const at = await $.clock.now()
46 const step: Step = {
47 id: e.tool_use_id,
48 at,
49 tool: e.tool,
50 summary: summarize(e.tool, e),
51 isError: false,
52 }
53 await update($, steps, list => [...list, step].slice(-maxSteps))
54 const ran = await next(e)
55 if (ran.deny !== undefined || ran.isError === true) {
56 await update($, steps, list =>
57 list.map(one => (one.id === step.id ? { ...one, isError: true } : one)),
58 )
59 }
60
61 return ran
62 })
63
64 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
65 const ui = $.ui.resolve(e)
66 const { Box, Button, Text } = ui
67 const hasSvg = ui.Svg !== undefined
68 const ctl = (key: string, name: IconName, label: string, glyphLabel: string, onPress: () => void) => (
69 <Box gap={1}>
70 {hasSvg && icon(ui, name, '#9ca3af', '')}
71 <Button key={key} label={hasSvg ? label : glyphLabel} onPress={onPress} />
72 </Box>
73 )
74 const [list, at, playing] = [await read($, steps), await read($, cursor), await read($, isPlaying)]
75 const room = Math.max(3, (e.viewport?.rows ?? 24) - 8)
76 const start = Math.max(0, Math.min(at - Math.floor(room / 2), list.length - room))
77 const t0 = list[0]?.at ?? 0
78
79 const play = async () => {
80 if (playing) {
81 timer?.cancel(); timer = undefined; await update($, isPlaying, () => false)
82 return
83 }
84 if (list.length === 0) return
85 await update($, isPlaying, () => true)
86 if (at >= list.length - 1) await update($, cursor, () => -1)
87 timer = $.clock.every(speed, async () => {
88 const [all, now] = [await read($, steps), await read($, cursor)]
89 if (now >= all.length - 1) {
90 timer?.cancel(); timer = undefined
91 await update($, isPlaying, () => false)
92 return
93 }
94 await update($, cursor, () => now + 1)
95 })
96 }
97
98 return (
99 <Box flexDirection="column">
100 <Box gap={1}>
101 {hasSvg && icon(ui, playing ? 'play' : 'pause', playing ? '#7dff9a' : '#9ca3af', '')}
102 <Text bold>
103 {hasSvg ? (playing ? 'Playing' : 'Paused') : playing ? '▶ Playing' : '■ Paused'} · step {Math.max(0, at + 1)}/{list.length}
104 </Text>
105 </Box>
106 {list.length === 0 && <Text dimColor>Nothing recorded yet.</Text>}
107 {list.slice(start, start + room).map((one, i) => {
108 const idx = start + i
109 const secs = ((one.at - t0) / 1000).toFixed(1).padStart(6)
110 return (
111 <Box gap={1}>
112 {hasSvg ? (
113 <Box width={2}>{idx === at && icon(ui, 'play', '#ffe066', '', 12)}</Box>
114 ) : (
115 <Text>{idx === at ? '▸' : ' '}</Text>
116 )}
117 {hasSvg ? (
118 icon(ui, one.isError ? 'x' : 'check', one.isError ? '#ff7a7a' : '#7dff9a', '', 14)
119 ) : (
120 <Text color={one.isError ? 'red' : 'green'}>{one.isError ? '✗' : '✓'}</Text>
121 )}
122 <Text bold={idx === at} dimColor={idx > at} color={one.isError ? 'red' : undefined}>
123 {secs}s {one.summary}
124 </Text>
125 </Box>
126 )
127 })}
128 <Box gap={2}>
129 {ctl('back', 'skip-back', 'Back', '◀ Back', () => update($, cursor, n => Math.max(-1, n - 1)))}
130 {ctl('play', playing ? 'pause' : 'play', playing ? 'Pause' : 'Play', playing ? 'Pause' : 'Play', play)}
131 {ctl('next', 'skip-forward', 'Next', 'Next ▶', () => update($, cursor, n => Math.min(list.length - 1, n + 1)))}
132 {ctl('rewind', 'rotate-ccw', 'Rewind', 'Rewind', async () => {
133 timer?.cancel()
134 timer = undefined
135 await update($, isPlaying, () => false)
136 await update($, cursor, () => -1)
137 })}
138 {ctl('clear', 'trash-2', 'Clear', 'Clear', async () => {
139 timer?.cancel()
140 timer = undefined
141 await update($, isPlaying, () => false)
142 await update($, steps, () => [])
143 await update($, cursor, () => -1)
144 })}
145 </Box>
146 </Box>
147 )
148 })
149}
150hooks/icons.tsx 30 lines1// Icons from Lucide (https://lucide.dev, ISC license), lucide-static 0.469.0.
2// Svg draws on the desktop, editor and mobile surfaces; the terminal has none, so a glyph stands in.
3const PATHS = {
4 'skip-back': '<polygon points="19 20 9 12 19 4 19 20" /> <line x1="5" x2="5" y1="19" y2="5" />',
5 'play': '<polygon points="6 3 20 12 6 21 6 3" />',
6 'pause': '<rect x="14" y="4" width="4" height="16" rx="1" /> <rect x="6" y="4" width="4" height="16" rx="1" />',
7 'skip-forward': '<polygon points="5 4 15 12 5 20 5 4" /> <line x1="19" x2="19" y1="5" y2="19" />',
8 'rotate-ccw': '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /> <path d="M3 3v5h5" />',
9 'trash-2': '<path d="M3 6h18" /> <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /> <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /> <line x1="10" x2="10" y1="11" y2="17" /> <line x1="14" x2="14" y1="11" y2="17" />',
10 'check': '<path d="M20 6 9 17l-5-5" />',
11 'x': '<path d="M18 6 6 18" /> <path d="m6 6 12 12" />',
12}
13
14export type IconName = keyof typeof PATHS
15
16export const svg = (name: IconName, color: string, size: number) =>
17 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${PATHS[name]}</svg>`
18
19// eslint-disable-next-line @typescript-eslint/no-explicit-any
20export type Ui = { Svg?: any; Text: any }
21
22export const icon = (ui: Ui, name: IconName, color: string, glyph: string, size = 16) => {
23 const { Svg, Text } = ui
24 return Svg === undefined ? (
25 <Text color={color}>{glyph}</Text>
26 ) : (
27 <Svg source={svg(name, color, size)} alt={name} width={size} height={size} />
28 )
29}
30types/index.d.ts 8 lines1export type Step = { id: string; at: number; tool: string; summary: string; isError: boolean }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'replay-theater': { steps: Step[]; cursor: number; isPlaying: boolean }
6 }
7}
8