Step through the last turn's file edits, one diff at a time.

A private reference collection of public Claude Code mods, with a security-oriented analysis.
mods/Nothing here is installed or executed. All code is by its upstream authors under their own licences (MIT, one Apache-2.0); each folder keeps its original LICENSE.
hooks/register.tsx 182 lines1// Replay Theater: step through the last turn's file edits, one diff at a time.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4
5import type { ReplayStep } from '../types'
6
7const PANE = 'replay-theater'
8const MAX_DIFF_LINES = 400
9
10// Held by the host, so a replay survives a hot reload of this file.
11const replay = atom({ plugin: 'replay-theater', key: 'replay' } as const, [] as ReplayStep[])
12const pos = atom({ plugin: 'replay-theater', key: 'pos' } as const, 0)
13
14export const register: Register = on => {
15 let pending: ReplayStep[] = []
16 let cwd = ''
17 let toasted = false // one toast per session teaches /replay; later turns use the status line
18 let showing = false // the status line holds a count from the last turn
19
20 on('session.start', async ($, e, next) => {
21 const r = await next(e)
22 cwd = e.cwd
23 toasted = false
24 if (showing) {
25 showing = false
26 $.ui.status(undefined) // a new session starts without the last one's count
27 }
28 await $.command.register({ name: 'replay', description: "Step through the last turn's file edits" }).catch(() => {}) // a name Claude Code already has is refused: start anyway
29 return r
30 })
31
32 on('turn.start', ($, e, next) => {
33 pending = [] // a model turn begins: start a fresh replay
34 if (showing) {
35 showing = false
36 $.ui.status(undefined) // the count belongs to the last turn
37 }
38 return next(e)
39 })
40
41 // Observe only: the edit runs untouched.
42 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
43 const r = await next(e)
44 if (!r.deny && !r.isError) {
45 pending.push({ file: e.file_path, kind: 'edit', before: e.old_string, after: e.new_string })
46 }
47 return r
48 })
49
50 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
51 const before = await $.fs.read(e.file_path).catch(() => '') // read just before the write lands
52 const r = await next(e)
53 if (!r.deny && !r.isError) {
54 pending.push({ file: e.file_path, kind: 'write', before, after: e.content })
55 }
56 return r
57 })
58
59 on('turn.complete', async ($, e, next) => {
60 const r = await next(e)
61 if (!e.agentId && pending.length > 0) {
62 const steps = pending
63 pending = []
64 await update($, replay, () => steps) // one replay per turn
65 await update($, pos, () => 0)
66 const count = `${steps.length} edit${steps.length === 1 ? '' : 's'}`
67 if (!toasted) {
68 toasted = true
69 $.ui.toast(`replay-theater: ${count} last turn. Run /replay`)
70 } else {
71 showing = true
72 $.ui.status(`▶ /replay: ${count}`)
73 }
74 }
75 return r
76 })
77
78 on('command.run', { command: 'replay' }, async $ => ({
79 text: (await openReplay($)) ? 'Replaying the last turn' : 'No edits to replay yet',
80 }))
81
82 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
83 const { Box, Text, Button } = $.ui.resolve(e)
84 const steps = await read($, replay)
85 const at = Math.min(await read($, pos), Math.max(0, steps.length - 1))
86 const step = steps[at]
87
88 if (!step) {
89 return (
90 <Box flexDirection="column">
91 <Text dimColor>No edits to replay yet.</Text>
92 <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
93 </Box>
94 )
95 }
96
97 const width = Math.max(20, e.props.bodyColumns - 2)
98 return (
99 <Box flexDirection="column">
100 <Text>
101 <Text bold>{`Step ${at + 1}/${steps.length} `}</Text>
102 <Text dimColor>{`${step.kind === 'write' ? 'write' : 'edit'} `}</Text>
103 <Text color="cyan">{relative(step.file, cwd)}</Text>
104 </Text>
105 <Text>
106 {steps.map((_, i) => (
107 <Text color={i === at ? 'cyan' : undefined} bold={i === at} dimColor={i !== at}>
108 {i === at ? `[${i + 1}]` : ` ${i + 1} `}
109 </Text>
110 ))}
111 </Text>
112 <Box flexDirection="row" gap={1}>
113 <Button key="prev" label="Prev" hotkey="p" onPress={() => update($, pos, p => Math.max(0, p - 1))} />
114 <Button key="next" label="Next" hotkey="n" variant="primary" onPress={() => update($, pos, p => Math.min(steps.length - 1, p + 1))} />
115 <Button key="close" label="Close" hotkey="q" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
116 </Box>
117 <Text dimColor>{'─'.repeat(width)}</Text>
118 {diffLines(step).map(line => (
119 <Text color={line.sign === '+' ? 'green' : line.sign === '-' ? 'red' : undefined} dimColor={line.sign === ' '} wrap="truncate-end">
120 {`${line.sign} ${line.text}`}
121 </Text>
122 ))}
123 </Box>
124 )
125 })
126}
127
128async function openReplay($: EngineInterface) {
129 if ((await read($, replay)).length === 0) return false
130 await update($, pos, () => 0)
131 await $.ui.open({ id: PANE, title: 'Replay Theater', focus: true })
132 return true
133}
134
135type DiffLine = { sign: '+' | '-' | ' '; text: string }
136
137// A line diff (LCS) of the step's before and after text, capped for large files.
138export function diffLines(step: ReplayStep): DiffLine[] {
139 const a = lines(step.before)
140 const b = lines(step.after)
141 if (a.length + b.length > MAX_DIFF_LINES * 2) {
142 return [{ sign: ' ', text: `(large change: ${a.length} → ${b.length} lines, diff not drawn)` }]
143 }
144 const n = a.length
145 const m = b.length
146 const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0))
147 for (let i = n - 1; i >= 0; i--) {
148 for (let j = m - 1; j >= 0; j--) {
149 lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
150 }
151 }
152 const out: DiffLine[] = []
153 let i = 0
154 let j = 0
155 while (i < n && j < m) {
156 if (a[i] === b[j]) {
157 out.push({ sign: ' ', text: a[i]! })
158 i++
159 j++
160 } else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
161 out.push({ sign: '-', text: a[i++]! })
162 } else {
163 out.push({ sign: '+', text: b[j++]! })
164 }
165 }
166 while (i < n) out.push({ sign: '-', text: a[i++]! })
167 while (j < m) out.push({ sign: '+', text: b[j++]! })
168 return out
169}
170
171// A file's lines, without the empty one after a trailing newline.
172function lines(text: string) {
173 return text === '' ? [] : text.replace(/\n$/, '').split('\n')
174}
175
176// The path from the working directory, or its last two parts when it is outside.
177function relative(file: string, cwd: string) {
178 if (cwd && file.startsWith(`${cwd}/`)) return file.slice(cwd.length + 1)
179 const parts = file.split('/')
180 return parts.length > 3 ? `…/${parts.slice(-2).join('/')}` : file
181}
182types/index.d.ts 8 lines1export type ReplayStep = { file: string; kind: 'edit' | 'write'; before: string; after: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'replay-theater': { replay: ReplayStep[]; pos: number }
6 }
7}
8