SLOPSHOPPER

replay-theater

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

newpanebandguardcommandprompt
v0.1.0no licenseupdated 2026-10-03navin0812/claude-mods/replay-theater
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · replay-theater
│ ┃ Replay Theater ✕ › fix the failing auth test and add an audit log call │ ┃ ▶ Replay Theater step 1 of 3 │ ┃ 1 2 3 ⏺ Read(src/auth.ts) │ ┃ /work/app/src/auth.ts ⎿ Read 6 lines │ ┃ Edit +2 -1 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ - if (!claims) throw new Error('invalid ⏺ Bash(bun test) │ ┃ token') ⎿ 3 pass, 1 fail │ ┃ + if (!claims || claims.exp < Date.now() / │ ┃ 1000) throw new Error('invalid token') ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ + await audit('refresh', claims.sub) │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ p: ◄ Prev n: Next ► c: Close │ › /replay │ ⎿ replay-theater: Replay Theater opened. │ │ Replay Theater: 3 edits · /replay r: Replay ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Replay Theater: 3 edits · /replay r: Replay
Pane · Replay Theater
▶ Replay Theater step 1 of 3 1 2 3 /work/app/src/auth.ts Edit +2 -1 - if (!claims) throw new Error('invalid token') + if (!claims || claims.exp < Date.now() / 1000) throw new Error('invalid token') + await audit('refresh', claims.sub) p: ◄ Prev n: Next ► c: Close
README

claude-mods

Claude Code mods.

/plugin marketplace add navin0812/claude-mods
/plugin install blast-radius@claude-mods
/plugin install replay-theater@claude-mods

blast-radius

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.

replay-theater

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.

Source 2 files
hooks/register.tsx 128 lines
1import { 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}
128
types/index.d.ts 8 lines
1export 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