Records every edit Claude makes in a turn and lets you step through the diffs with /replay.

hooks/register.tsx 193 lines1// Replay Theater: records every edit Claude makes during a turn (the file
2// before and after), and when the turn ends offers a replay: /replay opens a
3// pane that steps through the edits one diff at a time.
4// It only observes: no edit is blocked or changed.
5
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { ReplayStep } from '../types'
10import { diffLines, fit } from './diff'
11
12const PANE = 'replay-theater'
13const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
14const MAX_STEPS = 200
15
16const pending = atom({ plugin: 'replay-theater', key: 'pending' } as const, [])
17const replay = atom({ plugin: 'replay-theater', key: 'replay' } as const, [])
18const index = atom({ plugin: 'replay-theater', key: 'index' } as const, 0)
19const showHint = atom({ plugin: 'replay-theater', key: 'showHint' } as const, false)
20const cwd = atom({ plugin: 'replay-theater', key: 'cwd' } as const, '')
21
22export const register: Register = on => {
23 on('session.start', async ($, e, next) => {
24 const result = await next(e)
25 await update($, cwd, () => e.cwd)
26 try {
27 await $.command.register({ name: 'replay', description: "Step through the last turn's file edits" })
28 } catch (error) {
29 $.ui.log(`replay-theater: could not register /replay (${String(error)})`, { to: 'debug' })
30 }
31 return result
32 })
33
34 on('turn.start', async ($, e, next) => {
35 await update($, pending, () => [])
36 await update($, showHint, () => false)
37 return next(e)
38 })
39
40 on('tool.call', async ($, e, next) => {
41 const tool = String(e.tool)
42 if (!EDIT_TOOLS.has(tool)) return next(e)
43 const input = e as unknown as Record<string, unknown>
44 const path = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : undefined
45 if (path === undefined) return next(e)
46
47 const before = await readText($, path)
48 const ran = await next(e)
49 if (ran.deny !== undefined || ran.isError === true) return ran
50
51 try {
52 const step = await stepFor($, tool, path, before, input)
53 if (step !== undefined) await update($, pending, list => [...list, step].slice(-MAX_STEPS))
54 } catch (error) {
55 $.ui.log(`replay-theater: could not record ${path} (${String(error)})`, { to: 'debug' })
56 }
57 return ran
58 })
59
60 on('turn.complete', async ($, e, next) => {
61 const result = await next(e)
62 if (e.agentId !== undefined) return result
63 const steps = await read($, pending)
64 if (steps.length > 0) {
65 await update($, replay, () => steps)
66 await update($, index, () => 0)
67 await update($, showHint, () => true)
68 await update($, pending, () => [])
69 }
70 return result
71 })
72
73 on('command.run', { command: 'replay' }, async $ => {
74 const steps = await read($, replay)
75 if (steps.length === 0) return { text: 'No edits to replay yet: they are recorded as Claude makes them.' }
76 await openReplay($)
77 return { text: `Replaying ${summary(steps)}.` }
78 })
79
80 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
81 const below = await next(e)
82 const steps = await read($, replay)
83 if (e.props.hasSurvey || e.props.isWorking || !(await read($, showHint)) || steps.length === 0) return below
84 const { Box, Text, Button } = $.ui.resolve(e)
85 return (
86 <Box flexDirection="column">
87 {below}
88 <Box key="replay-hint" flexDirection="row" paddingX={1} gap={1}>
89 <Text color="magenta">↺</Text>
90 <Text>Last turn: {summary(steps)}</Text>
91 <Button key="open-replay" label="Replay" hotkey="r" onPress={() => openReplay($)} />
92 <Button key="hide-replay" label="Hide" plain dimColor onPress={() => update($, showHint, () => false)} />
93 </Box>
94 </Box>
95 )
96 })
97
98 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
99 const { Box, Text, Button, Code } = $.ui.resolve(e)
100 const steps = await read($, replay)
101 if (steps.length === 0) return <Text dimColor>Nothing to replay.</Text>
102 const at = Math.min(await read($, index), steps.length - 1)
103 const step = steps[at]!
104 const go = (to: number) => update($, index, () => Math.max(0, Math.min(steps.length - 1, to)))
105
106 return (
107 <Box flexDirection="column" width={e.props.bodyColumns}>
108 <Text>{strip(steps.length, at, e.props.bodyColumns)}</Text>
109 <Box flexDirection="row" marginTop={1} justifyContent="space-between">
110 <Text bold wrap="truncate-start">
111 {step.path}
112 </Text>
113 <Text dimColor>
114 {' '}
115 {step.kind} <Text color="green">+{step.added}</Text> <Text color="red">−{step.removed}</Text>
116 </Text>
117 </Box>
118 <Box marginTop={1} flexDirection="column">
119 {step.diff === '' ? (
120 <Text dimColor>No line changes to show.</Text>
121 ) : (
122 <Code key={`diff-${at}`} source={step.diff} format="diff" path={step.path} />
123 )}
124 </Box>
125 <Box flexDirection="row" marginTop={1} gap={1}>
126 <Button key="prev" label="Prev" hotkey="p" onPress={() => go(at - 1)} />
127 <Button key="next" label="Next" hotkey="n" variant="primary" onPress={() => go(at + 1)} />
128 <Button key="close" label="Close" hotkey="c" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
129 <Text dimColor>
130 {' '}
131 step {at + 1} of {steps.length}
132 </Text>
133 </Box>
134 </Box>
135 )
136 })
137}
138
139async function openReplay($: EngineInterface) {
140 await update($, showHint, () => false)
141 await $.ui.open({ id: PANE, title: 'Replay', focus: true, closeOnEscape: true, rows: 50 })
142}
143
144async function readText($: EngineInterface, path: string): Promise<string | undefined> {
145 try {
146 return await $.fs.read(path)
147 } catch {
148 return undefined // missing (a new file) or too large to read
149 }
150}
151
152async function stepFor(
153 $: EngineInterface,
154 tool: string,
155 path: string,
156 before: string | undefined,
157 input: Record<string, unknown>,
158): Promise<ReplayStep | undefined> {
159 const shown = relative(path, await read($, cwd))
160 if (tool === 'NotebookEdit') {
161 const source = typeof input.new_source === 'string' ? input.new_source : ''
162 const d = diffLines('', source)
163 return { path: shown, kind: 'notebook', diff: fit(d.hunks), added: d.added, removed: 0 }
164 }
165 const after = await readText($, path)
166 if (after === undefined) return undefined
167 const d = diffLines(before ?? '', after)
168 const kind = before === undefined ? 'new file' : tool === 'Write' ? 'rewrite' : 'edit'
169 return { path: shown, kind, diff: fit(d.hunks), added: d.added, removed: d.removed }
170}
171
172function summary(steps: readonly ReplayStep[]): string {
173 const files = new Set(steps.map(s => s.path)).size
174 return `${steps.length} ${steps.length === 1 ? 'edit' : 'edits'} in ${files} ${files === 1 ? 'file' : 'files'}`
175}
176
177/** "‹ 3 4 [5] 6 7 ›": the steps around the current one, as many as fit. */
178export function strip(count: number, at: number, columns: number): string {
179 const room = Math.max(3, Math.floor((columns - 4) / 5))
180 let lo = Math.max(0, at - Math.floor(room / 2))
181 const hi = Math.min(count, lo + room)
182 lo = Math.max(0, hi - room)
183 const cells: string[] = []
184 for (let i = lo; i < hi; i++) cells.push(i === at ? `[${i + 1}]` : ` ${i + 1} `)
185 return `${lo > 0 ? '‹ ' : ' '}${cells.join(' ')}${hi < count ? ' ›' : ''}`
186}
187
188function relative(path: string, root: string): string {
189 const p = path.replace(/\\/g, '/')
190 const r = root.replace(/\\/g, '/').replace(/\/+$/, '')
191 return r !== '' && p.toLowerCase().startsWith(r.toLowerCase() + '/') ? p.slice(r.length + 1) : p
192}
193hooks/diff.ts 121 lines1// A small line diff: common prefix and suffix are trimmed first (edits are
2// usually local), the middle is diffed with an LCS table, and the result is
3// written as unified-diff hunks with three lines of context.
4
5export type DiffResult = { hunks: string; added: number; removed: number }
6
7type Op = { kind: ' ' | '-' | '+'; text: string; a: number; b: number }
8
9const CONTEXT = 3
10const MAX_CELLS = 2_000_000
11
12export function diffLines(before: string, after: string): DiffResult {
13 const a = split(before)
14 const b = split(after)
15 let start = 0
16 while (start < a.length && start < b.length && a[start] === b[start]) start++
17 let endA = a.length
18 let endB = b.length
19 while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
20 endA--
21 endB--
22 }
23
24 const ops: Op[] = []
25 for (let i = 0; i < start; i++) ops.push({ kind: ' ', text: a[i]!, a: i, b: i })
26 ops.push(...middle(a, b, start, endA, start, endB))
27 for (let i = endA, j = endB; i < a.length; i++, j++) ops.push({ kind: ' ', text: a[i]!, a: i, b: j })
28
29 const added = ops.filter(o => o.kind === '+').length
30 const removed = ops.filter(o => o.kind === '-').length
31 return { hunks: toHunks(ops), added, removed }
32}
33
34function split(text: string): string[] {
35 if (text === '') return []
36 const lines = text.replace(/\r\n/g, '\n').split('\n')
37 if (lines[lines.length - 1] === '') lines.pop()
38 return lines
39}
40
41function middle(a: string[], b: string[], a0: number, a1: number, b0: number, b1: number): Op[] {
42 const n = a1 - a0
43 const m = b1 - b0
44 if (n === 0 && m === 0) return []
45 if (n * m > MAX_CELLS) {
46 // Too big to align: show it as one block replaced.
47 const out: Op[] = []
48 for (let i = a0; i < a1; i++) out.push({ kind: '-', text: a[i]!, a: i, b: b0 })
49 for (let j = b0; j < b1; j++) out.push({ kind: '+', text: b[j]!, a: a1, b: j })
50 return out
51 }
52 // lcs[i][j] = length of the LCS of a[a0+i..a1) and b[b0+j..b1)
53 const lcs: Uint32Array[] = []
54 for (let i = 0; i <= n; i++) lcs.push(new Uint32Array(m + 1))
55 for (let i = n - 1; i >= 0; i--) {
56 for (let j = m - 1; j >= 0; j--) {
57 lcs[i]![j] = a[a0 + i] === b[b0 + j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
58 }
59 }
60 const out: Op[] = []
61 let i = 0
62 let j = 0
63 while (i < n || j < m) {
64 if (i < n && j < m && a[a0 + i] === b[b0 + j]) {
65 out.push({ kind: ' ', text: a[a0 + i]!, a: a0 + i, b: b0 + j })
66 i++
67 j++
68 } else if (i < n && (j === m || lcs[i + 1]![j]! >= lcs[i]![j + 1]!)) {
69 // Removals first, as unified diffs write them.
70 out.push({ kind: '-', text: a[a0 + i]!, a: a0 + i, b: b0 + j })
71 i++
72 } else {
73 out.push({ kind: '+', text: b[b0 + j]!, a: a0 + i, b: b0 + j })
74 j++
75 }
76 }
77 return out
78}
79
80function toHunks(ops: Op[]): string {
81 const changed = ops.map((o, i) => (o.kind === ' ' ? -1 : i)).filter(i => i >= 0)
82 if (changed.length === 0) return ''
83 const groups: [number, number][] = []
84 for (const i of changed) {
85 const lo = Math.max(0, i - CONTEXT)
86 const hi = Math.min(ops.length - 1, i + CONTEXT)
87 const last = groups[groups.length - 1]
88 if (last && lo <= last[1] + 1) last[1] = hi
89 else groups.push([lo, hi])
90 }
91 const out: string[] = []
92 for (const [lo, hi] of groups) {
93 const slice = ops.slice(lo, hi + 1)
94 const aLines = slice.filter(o => o.kind !== '+').length
95 const bLines = slice.filter(o => o.kind !== '-').length
96 const firstA = slice.find(o => o.kind !== '+')
97 const firstB = slice.find(o => o.kind !== '-')
98 // An empty side names the line it follows (0 for the top), as unified diffs do.
99 const aStart = firstA ? firstA.a + 1 : slice[0]!.a
100 const bStart = firstB ? firstB.b + 1 : slice[0]!.b
101 out.push(`@@ -${aStart},${aLines} +${bStart},${bLines} @@`)
102 for (const o of slice) out.push(`${o.kind}${o.text.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')}`)
103 }
104 return out.join('\n')
105}
106
107/** Cuts hunks to fit a Code element, at a hunk boundary, saying how many were left out. */
108export function fit(hunks: string, max = 9000): string {
109 if (hunks.length <= max) return hunks
110 const parts = hunks.split(/\n(?=@@ )/)
111 const kept: string[] = []
112 let size = 0
113 for (const p of parts) {
114 if (size + p.length + 1 > max) break
115 kept.push(p)
116 size += p.length + 1
117 }
118 if (kept.length === 0) return parts[0]!.slice(0, max).replace(/\n[^\n]*$/, '')
119 return kept.join('\n')
120}
121types/index.d.ts 24 lines1export type ReplayStep = {
2 /** File path, relative to the project when inside it. */
3 path: string
4 kind: 'edit' | 'new file' | 'rewrite' | 'notebook'
5 /** Unified-diff hunks (no file headers), or '' when there is nothing to show. */
6 diff: string
7 added: number
8 removed: number
9}
10
11declare module 'claude-code' {
12 interface PluginState {
13 'replay-theater': {
14 /** Edits recorded during the running turn. */
15 pending: ReplayStep[]
16 /** The last finished turn's edits: what /replay steps through. */
17 replay: ReplayStep[]
18 index: number
19 showHint: boolean
20 cwd: string
21 }
22 }
23}
24