SLOPSHOPPER

replay-theater

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

newpaneguardcommandtoaststatus
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╭────────────────────────────────────────────╮ │ ┃ Step 1/3 edit src/auth.ts │ replay-theater │ │ ┃ [1] 2 3 ⏺ Read(src/auth.ts) │ replay-theater: 3 edits last turn. Run │ │ ┃ [ Prev ] [ Next ] [ Close ] ⎿ Read 6 lines │ /replay │ │ ┃ ──────────────────────────────────────────── ⏺ Update(src/auth.ts) ╰────────────────────────────────────────────╯ │ ┃ ────────── ⎿ Added 2 lines, removed 1 line │ ┃ - if (!claims) throw new Error('invalid tok… ⏺ Bash(bun test) │ ┃ + if (!claims || claims.exp < Date.now() / … ⎿ 3 pass, 1 fail │ ┃ + await audit('refresh', claims.sub) │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /replay │ ⎿ replay-theater: Replaying the last turn │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Replay Theater
Step 1/3 edit src/auth.ts [1] 2 3 [ Prev ] [ Next ] [ Close ] ────────────────────────────────────────────────────── - if (!claims) throw new Error('invalid token') + if (!claims || claims.exp < Date.now() / 1000) throw new … + await audit('refresh', claims.sub)
README

claude-code-mods-collection

A private reference collection of public Claude Code mods, with a security-oriented analysis.

  • ANALYSIS.md: what each mod can reach, which to install, which to avoid
  • SOURCES.md: upstream repo and pinned commit for every folder in 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.

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