SLOPSHOPPER

replay-theater

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

newpanebandguardcommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · replay-theater
│ ┃ Replay ✕ › fix the failing auth test and add an audit log call │ ┃ [1] │ ┃ ⏺ Read(src/auth.ts) │ ┃ src/auth.ts ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No line changes to show. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ Prev ] [ Next ] [ Close ] step 1 of 1 ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /replay │ ⎿ replay-theater: Replaying 1 edit in 1 file. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Replay
[1] src/auth.ts edit +0 −0 No line changes to show. [ Prev ] [ Next ] [ Close ] step 1 of 1
Source 3 files
hooks/register.tsx 193 lines
1// 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}
193
hooks/diff.ts 121 lines
1// 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}
121
types/index.d.ts 24 lines
1export 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