Records every Edit/Write in a turn, then steps through them one diff at a time in a pane (/replay).

Claude Code mods.
/plugin marketplace add navin0812/claude-mods
/plugin install blast-radius@claude-mods
/plugin install replay-theater@claude-mods
Holds Bash calls Claude makes that are destructive (rm -rf, git reset --hard, git clean -f, force push, DB migrations), opens a pane with the command, what it would do and the files affected, and runs it only on an explicit Proceed. Cancel, Esc, an interrupt or an error all deny the call.
Limits: only Bash calls Claude makes are held, not commands you run yourself with !; command detection splits on &&, ;, ||, | only.
Records every Edit and Write Claude makes during a turn (file, text before and after). When the turn ends a hint appears above the prompt; run /replay to step through the edits one diff at a time with a numbered strip and Prev, Next and Close buttons.
Limits: the r hotkey only fires while the band above the prompt has focus (ctrl+x tab or a click), so /replay is the reliable way in; the diff trims common head/tail lines rather than running a full LCS.
hooks/register.tsx 128 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Step } from '../types'
5
6const PANE = 'replay-theater'
7const steps = atom({ plugin: 'replay-theater', key: 'steps' } as const, [])
8const hint = atom({ plugin: 'replay-theater', key: 'hint' } as const, false)
9const cur = atom({ plugin: 'replay-theater', key: 'cur' } as const, 0)
10
11// ponytail: trim common head/tail lines, rest is -old +new; no LCS, noisy for big rewrites
12export function diff(before: string, after: string) {
13 const a = before === '' ? [] : before.split('\n')
14 const b = after === '' ? [] : after.split('\n')
15 let s = 0
16 while (s < a.length && s < b.length && a[s] === b[s]) s++
17 let e = 0
18 while (e < a.length - s && e < b.length - s && a[a.length - 1 - e] === b[b.length - 1 - e]) e++
19 const del = a.slice(s, a.length - e)
20 const add = b.slice(s, b.length - e)
21 return { del, add }
22}
23
24export const register: Register = on => {
25 on('session.start', async ($, e, next) => {
26 await $.command.register({ name: 'replay', description: "Step through the last turn's edits" })
27 return next(e)
28 })
29
30 on('command.run', { command: 'replay' }, async $ => {
31 if (!(await read($, steps)).length) return { text: 'No edits to replay.' }
32 await open($)
33 return { text: 'Replay Theater opened.' }
34 })
35
36 on('prompt.submit', async ($, e, next) => {
37 await update($, steps, () => [])
38 await update($, hint, () => false)
39 return next(e)
40 })
41
42 for (const tool of ['Edit', 'Write'] as const)
43 on('tool.call', { tool }, async ($, e: any, next) => {
44 let step: Step | undefined
45 try {
46 const before = tool === 'Edit' ? e.old_string : await $.fs.read(e.file_path).catch(() => '')
47 step = { file: e.file_path, tool, before, after: tool === 'Edit' ? e.new_string : e.content }
48 } catch {} // recording must never block the edit
49 const ran = await next(e)
50 if (step && !(ran as any)?.deny) await update($, steps, l => [...l, step!])
51 return ran
52 })
53
54 on('turn.complete', async ($, e, next) => {
55 if ((await read($, steps)).length) await update($, hint, () => true)
56 return next(e)
57 })
58
59 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
60 const list = await read($, steps)
61 if (e.props.hasSurvey || e.props.isWorking || !list.length || !(await read($, hint))) return next(e)
62 const { Box, Text, Button } = $.ui.resolve(e)
63 return (
64 <Box>
65 <Text dimColor>Replay Theater: {list.length} edit{list.length === 1 ? '' : 's'} · /replay </Text>
66 <Button plain hotkey="r" onPress={() => open($)}>
67 Replay
68 </Button>
69 </Box>
70 )
71 })
72
73 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
74 const { Box, Text, Button } = $.ui.resolve(e)
75 const list = await read($, steps)
76 if (!list.length) return <Text dimColor>No edits to replay.</Text>
77 const i = Math.min(await read($, cur), list.length - 1)
78 const st = list[i]
79 const { del, add } = diff(st.before, st.after)
80 const room = Math.max(1, (e.viewport?.rows ?? 24) - 10)
81 const rows = [...del.map(t => ({ t, c: 'red', p: '-' })), ...add.map(t => ({ t, c: 'green', p: '+' }))]
82 const go = (n: number) => update($, cur, () => Math.max(0, Math.min(list.length - 1, n)))
83
84 return (
85 <Box flexDirection="column">
86 <Box justifyContent="space-between">
87 <Text bold color="magenta">▶ Replay Theater</Text>
88 <Text bold>step {i + 1} of {list.length}</Text>
89 </Box>
90 <Box>
91 {list.map((_, n) =>
92 n === i ? (
93 <Text backgroundColor="blue" bold> {n + 1} </Text>
94 ) : (
95 <Text dimColor> {n + 1} </Text>
96 ),
97 )}
98 </Box>
99 <Text bold color="cyan">{st.file}</Text>
100 <Box>
101 <Text dimColor>{st.tool} </Text>
102 <Text color="green">+{add.length}</Text>
103 <Text> </Text>
104 <Text color="red">-{del.length}</Text>
105 </Box>
106 <Text> </Text>
107 {rows.slice(0, room).map(r => (
108 <Text color={r.c}>{r.p} {r.t}</Text>
109 ))}
110 {rows.length > room && <Text dimColor>+{rows.length - room} more lines</Text>}
111 <Text> </Text>
112 <Box>
113 <Button plain hotkey="p" onPress={() => go(i - 1)}>◄ Prev</Button>
114 <Text> </Text>
115 <Button plain hotkey="n" autoFocus onPress={() => go(i + 1)}>Next ►</Button>
116 <Text> </Text>
117 <Button plain hotkey="c" onPress={() => $.ui.close({ id: PANE })}>Close</Button>
118 </Box>
119 </Box>
120 )
121 })
122}
123
124async function open($: any) {
125 await update($, cur, () => 0)
126 await $.ui.open({ id: PANE, title: 'Replay Theater', focus: true, closeOnEscape: true })
127}
128types/index.d.ts 8 lines1export type Step = { file: string; tool: 'Edit' | 'Write'; before: string; after: string }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'replay-theater': { steps: Step[]; hint: boolean; cur: number }
6 }
7}
8