SLOPSHOPPER

replay-theater

文件修改回放剧场:/replay 像看视频一样逐帧重播 Claude 对文件的每一次修改,随时一键回滚(纯本地,零 Token)

newpaneguardcommandtoasttimer
v0.1.0no licenseupdated 2026-10-23weizhongl/claude-code-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 │ ┃ 还没有录到任何文件修改。让 Claude │ ┃ 改个文件,再回来看。 ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 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: Replay Theater 已打开。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Replay Theater
还没有录到任何文件修改。让 Claude 改个文件,再回来看。
README

Claude Code Mods

十个给 Claude Code 用的 Mod。只能装在 Claude Code(终端和桌面 App 的 Code 页)。Codex、Anti-Gravity 没有 Mod 接口,装不了。

先装 switchboard(总控台),其余随意挑。

安装

终端里:

/plugin install switchboard --marketplace weizhongl/claude-code-mods

第一次会问 Add marketplace?,回 y,再选 user scope。其余 Mod 把名字换掉即可:usage-gauge、brain-dump、helper-desk、cache-clock、handoff、reflect、chat-inventory、screen-share、replay-theater。

在 shell 里:

claude plugin install switchboard --marketplace weizhongl/claude-code-mods

桌面 App(/plugin 在那里不能用):点输入框旁的 +,选 Plugins → Add marketplace,填 weizhongl/claude-code-mods,再逐个 Add plugin。

每个 Mod 怎么用

Mod怎么用注意
switchboard输入 /mods 打开总控台。每个 Mod 一行:On/Off、Hide UI Panel。输入框上方那一行条(额度、缓存倒计时、任务进度、👁 按钮)也是它画的:平时只占一行,别的 Mod 有事才在它旁边多出一行。条的右端有按钮:「收起」变成一小行(只剩几个百分比和倒计时),再点「隐藏」只留一个「📊 展开」按钮;也可以输入 /bar(或 /bar full、/bar mini、/bar off)切换,你的选择会记住。进度条在桌面版是细线,在终端里是小方块。也可以 /mods off 名字、/mods on 名字、/mods hide 名字、/mods show 名字。新放进同一个文件夹的 Mod 会自动出现。Off = 该 Mod 的钩子立刻停手;Hide = 后台照跑,只收起面板。
usage-gauge自动。显示在总控台那一行条里:对话上下文用了多少、5 小时(会话)额度、本周额度,各有短进度条、颜色(绿 / 黄 / 红)和重置时间,最后是本次对话花费。/gauge 看文字版。读的是引擎自己的数字(和状态栏同一份),零 Token。额度数字要等发过一条消息、引擎读到后才出现。
brain-dump直接打字或语音说一大段(默认 180 字以上、没有排版)。它会拦住,先问你 2–3 个问题;你在输入框直接回答(回「跳过」= 不补充),然后重写成 The Task / The Why / Guardrails / What Done Looks Like 再发给 Claude。输入框上方有 跳过提问 / 原话直接发 / 放弃 三个按钮,也可用 /dump skip、/dump raw、/dump cancel。消息开头加 >> 可以跳过拦截。会用到两次小的模型调用(提问用 haiku,重写用 sonnet)。
helper-desk自动。Claude 有待办清单时,一行条里显示「任务 n/m」和短进度条;Claude 派出子代理时,侧边栏出现 Helper Desk,每个子代理一张卡(进度、正在做什么、读入/输出 token、花费)。/helpers 手动打开。子代理花费是按公开标价估算;整段对话总花费取自引擎账本。零额外 Token。
cache-clock自动。第一条回复之后,一行条里显示「⏱ mm:ss」。点旁边的 Keep Warm 或输入 /keep warm,快冷之前会自动发一条极小的心跳续命。你发下一条真实消息,或连续保温满 2 小时,会自动关闭。拿不到服务器的缓存时间,所以倒计时是推算(订阅登入 60 分钟,API 5 分钟,并按真实缓存命中自动校正)。设置里的「缓存有效分钟数」可固定。
handoff对话累计到 250,000 token、或缓存变冷时,输入框上方提醒你。按「生成交接」或输入 /handoff,会写出一份详细的交接提示词(存在 ~/.claude/handoffs/,可直接发给同事),再按 Start Fresh Chat with Handoff 带去新对话。生成会花 Token,默认只提醒、不自动生成(设置里「触发后做什么」选 generate 才会自动生成)。桌面 App 不允许自动开新对话时,会改成复制到剪贴板。
reflect你在对话里说「以后永远不要…」「每次都要…」「记住…」这类话,输入框上方弹出小卡片,一键存进:内置 Memory / 项目 CLAUDE.md(没有就 AGENTS.md)/ 你指定的文件 / 仅本次忽略。只在句子里有规则关键词时才调用一次小模型判断。设置里可填默认的第二大脑文件路径。
chat-inventory自动。侧边栏「In this chat」:目标与待办、调用过的连接器(含次数)、用过的技能、读过和新建/修改过的文件(点文件在文件管理器里定位)。/inventory 手动打开。纯追踪,零 Token。
screen-share输入 /screen share 开或关,或点一行条右端的 👁 按钮。开启后,画面上的邮箱、电话、金额、API key、公司名、人名遮成灰条。只改画面,不改真实数据;不遮输入框里正在打的字。人名只遮设置里填的名单和「客户:xxx」「陈小姐」这类写法,没有真正的人名识别。
replay-theater自动录下 Claude 用 Edit / Write / NotebookEdit 对文件做的每次修改。输入 /replay 打开:选文件、播放/暂停、上一步/下一步、点步骤号跳转,看每一步的 diff。Revert 按钮按一次变成「再按一次确认」,第二次才会写回文件。/replay clear 清空。用 Bash 改文件(如 sed)录不到;单个文件超过 20 万字不录。记录只在当前会话。

目录

每个 Mod 一个文件夹:.claude-plugin/plugin.json、hooks/hooks.json、hooks/register.tsx,有状态的还有 types/index.d.ts。switchboard、screen-share、replay-theater 带自动测试(claude plugin test <文件夹>)。

各 Mod 里的 hooks/flag.ts 是同一小段代码(读总控台的开关),Mod 之间不能共享文件,所以每个放一份。

Source 4 files
hooks/register.tsx 262 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { countChanges, diffOps, toLines, unified } from './diff'
5import { flagOf } from './flag'
6import type { ReplayFrame } from '../types'
7
8const MOD = 'replay-theater'
9const PANE = 'replay-theater'
10const MAX_FRAMES = 300
11const MAX_CHARS = 200_000
12const STEP_MS = 1400
13const WINDOW = 24
14
15const frames = atom({ plugin: 'replay-theater', key: 'frames' } as const, [])
16const selected = atom({ plugin: 'replay-theater', key: 'selected' } as const, null)
17const index = atom({ plugin: 'replay-theater', key: 'index' } as const, 0)
18const isPlaying = atom({ plugin: 'replay-theater', key: 'isPlaying' } as const, false)
19const armed = atom({ plugin: 'replay-theater', key: 'armed' } as const, null)
20
21let timer: { cancel: () => void } | undefined
22
23const baseName = (path: string) => path.split(/[\\/]/).pop() ?? path
24
25const clock = (ms: number) => {
26  const d = new Date(ms)
27  const two = (n: number) => String(n).padStart(2, '0')
28
29  return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`
30}
31
32async function addFrame(
33  $: EngineInterface,
34  path: string,
35  tool: string,
36  before: string | null,
37  after: string | null,
38) {
39  if (before === after) return
40  if ((before?.length ?? 0) > MAX_CHARS || (after?.length ?? 0) > MAX_CHARS) {
41    $.ui.toast(`Replay:${baseName(path)} 太大,这一步没有录制`)
42
43    return
44  }
45  const at = await $.clock.now()
46  const frame: ReplayFrame = {
47    id: `${at}-${(await read($, frames)).length}`,
48    at,
49    path,
50    tool,
51    before,
52    after,
53    isTrimmed: false,
54  }
55  await update($, frames, list => [...list, frame].slice(-MAX_FRAMES))
56  if ((await read($, selected)) === null) await update($, selected, () => path)
57}
58
59async function stopPlay($: EngineInterface) {
60  timer?.cancel()
61  timer = undefined
62  await update($, isPlaying, () => false)
63}
64
65async function stepPlay($: EngineInterface) {
66  const all = await read($, frames)
67  const path = (await read($, selected)) ?? all[all.length - 1]?.path
68  const count = all.filter(one => one.path === path).length
69  const at = await read($, index)
70  if (at >= count - 1) {
71    await stopPlay($)
72
73    return
74  }
75  await update($, index, () => at + 1)
76}
77
78async function togglePlay($: EngineInterface, atEnd: boolean) {
79  if (await read($, isPlaying)) {
80    await stopPlay($)
81
82    return
83  }
84  if (atEnd) await update($, index, () => 0)
85  await update($, isPlaying, () => true)
86  timer = $.clock.every(STEP_MS, () => void stepPlay($).catch(() => undefined))
87}
88
89async function pick($: EngineInterface, path: string) {
90  await stopPlay($)
91  const count = (await read($, frames)).filter(one => one.path === path).length
92  await update($, selected, () => path)
93  await update($, index, () => Math.max(0, count - 1))
94}
95
96async function jump($: EngineInterface, to: number) {
97  await stopPlay($)
98  await update($, index, () => Math.max(0, to))
99}
100
101// First press arms, second press writes: a revert overwrites the file.
102async function revert(
103  $: EngineInterface,
104  mark: string,
105  path: string,
106  content: string | null,
107) {
108  if ((await read($, armed)) !== mark) {
109    await update($, armed, () => mark)
110
111    return
112  }
113  await update($, armed, () => null)
114  if (content === null) {
115    $.ui.toast('那一步之前这个文件还不存在,没办法回滚成「不存在」。')
116
117    return
118  }
119  const current = await $.fs.read(path).catch(() => null)
120  await $.fs.write(path, content)
121  await addFrame($, path, 'Revert', current, content)
122  const count = (await read($, frames)).filter(one => one.path === path).length
123  await update($, index, () => Math.max(0, count - 1))
124  $.ui.toast(`已回滚:${baseName(path)}`)
125}
126
127export const register: Register = on => {
128  on('session.start', async ($, e, next) => {
129    await $.command.register({
130      name: 'replay',
131      description: '打开回放剧场:逐帧重播文件修改,可回滚',
132      argumentHint: '[clear]',
133    })
134
135    return next(e)
136  })
137
138  on('command.run', { command: 'replay' }, async ($, e) => {
139    if (e.args.trim() === 'clear') {
140      await stopPlay($)
141      await update($, frames, () => [])
142      await update($, selected, () => null)
143      await update($, index, () => 0)
144
145      return { text: '回放记录已清空。' }
146    }
147    await $.ui.open({ id: PANE, title: 'Replay Theater', focus: true, closeOnEscape: true })
148
149    return { text: 'Replay Theater 已打开。' }
150  })
151
152  on('tool.call', async ($, e, next) => {
153    if (!(await flagOf($, MOD)).isOn) return next(e)
154    const path =
155      e.tool === 'Edit' || e.tool === 'Write'
156        ? e.file_path
157        : e.tool === 'NotebookEdit'
158          ? e.notebook_path
159          : undefined
160    if (path === undefined) return next(e)
161
162    const before = await $.fs.read(path).catch(() => null)
163    const ran = await next(e)
164    if (ran.deny === undefined && ran.isError !== true) {
165      const after = await $.fs.read(path).catch(() => null)
166      await addFrame($, path, e.tool, before, after)
167    }
168
169    return ran
170  }).catch(($, e, next) => next(e))
171
172  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
173    const { Box, Text, Button, Code } = $.ui.resolve(e)
174    const flag = await flagOf($, MOD)
175    if (!flag.isOn || flag.isHidden) {
176      return <Text dimColor>Replay Theater 已{flag.isOn ? '隐藏' : '关闭'}(/mods 可重新打开)</Text>
177    }
178
179    const all = await read($, frames)
180    if (all.length === 0) {
181      return <Text dimColor>还没有录到任何文件修改。让 Claude 改个文件,再回来看。</Text>
182    }
183
184    const paths = [...new Set(all.map(one => one.path))]
185    const path = (await read($, selected)) ?? paths[paths.length - 1] ?? ''
186    const mine = all.filter(one => one.path === path)
187    const at = Math.min(await read($, index), mine.length - 1)
188    const frame = mine[at]
189    const playing = await read($, isPlaying)
190    const arm = await read($, armed)
191    if (frame === undefined) return <Text dimColor>这个文件没有记录。</Text>
192
193    const ops = diffOps(toLines(frame.before), toLines(frame.after))
194    const { added, removed } = countChanges(ops)
195    const first = mine[0]
196    const from = Math.max(0, Math.min(at - Math.floor(WINDOW / 2), mine.length - WINDOW))
197    const toTo = `${frame.id}:to`
198    const toOrig = `${frame.id}:orig`
199
200    return (
201      <Box flexDirection="column" gap={1}>
202        <Box gap={1} flexWrap="wrap">
203          {paths.map(one => (
204            <Button
205              key={`file:${one}`}
206              label={baseName(one)}
207              variant={one === path ? 'primary' : 'secondary'}
208              onPress={() => pick($, one)}
209            />
210          ))}
211        </Box>
212        <Box gap={1} flexWrap="wrap">
213          <Button key="first" label="⏮" onPress={() => jump($, 0)} />
214          <Button key="prev" label="◀" onPress={() => jump($, at - 1)} />
215          <Button
216            key="play"
217            label={playing ? '⏸ Pause' : '▶ Play'}
218            variant="primary"
219            onPress={() => togglePlay($, at >= mine.length - 1)}
220          />
221          <Button key="next" label="▶" onPress={() => jump($, Math.min(mine.length - 1, at + 1))} />
222          <Button key="last" label="⏭" onPress={() => jump($, mine.length - 1)} />
223        </Box>
224        <Box gap={1} flexWrap="wrap">
225          {mine.slice(from, from + WINDOW).map((one, k) => (
226            <Button
227              key={`step:${one.id}`}
228              label={String(from + k + 1)}
229              variant={from + k === at ? 'primary' : 'secondary'}
230              onPress={() => jump($, from + k)}
231            />
232          ))}
233        </Box>
234        <Text bold>
235          第 {at + 1}/{mine.length} 步 · {frame.tool} · {clock(frame.at)} ·{' '}
236          <Text color="green">+{added}</Text> <Text color="red">−{removed}</Text>
237        </Text>
238        <Code
239          source={unified(frame.before, frame.after, baseName(path)) || '(这一步没有文字差异)'}
240          format="diff"
241          path={path}
242        />
243        <Box gap={1} flexWrap="wrap">
244          <Button
245            key="revert-to"
246            label={arm === toTo ? '再按一次:确认回滚到此版本' : '[Revert to this version] 回滚到此版本'}
247            variant={arm === toTo ? 'primary' : 'secondary'}
248            onPress={() => revert($, toTo, path, frame.after)}
249          />
250          {first?.before != null && (
251            <Button
252              key="revert-orig"
253              label={arm === toOrig ? '再按一次:确认回滚到最初版本' : '回滚到最初版本(第 1 步之前)'}
254              onPress={() => revert($, toOrig, path, first.before)}
255            />
256          )}
257        </Box>
258      </Box>
259    )
260  })
261}
262
hooks/diff.ts 114 lines
1// Pure: two texts -> a unified diff with 3 lines of context.
2
3type Op = { t: ' ' | '+' | '-'; line: string }
4
5const same = (lines: string[]): Op[] => lines.map(line => ({ t: ' ', line }))
6
7const lcsDiff = (a: string[], b: string[]): Op[] => {
8  const n = a.length
9  const m = b.length
10  if (n * m > 4_000_000) {
11    return [
12      ...a.map((line): Op => ({ t: '-', line })),
13      ...b.map((line): Op => ({ t: '+', line })),
14    ]
15  }
16  const table = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1))
17  for (let i = n - 1; i >= 0; i--) {
18    for (let j = m - 1; j >= 0; j--) {
19      table[i]![j] =
20        a[i] === b[j]
21          ? table[i + 1]![j + 1]! + 1
22          : Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
23    }
24  }
25  const ops: Op[] = []
26  let i = 0
27  let j = 0
28  while (i < n && j < m) {
29    if (a[i] === b[j]) {
30      ops.push({ t: ' ', line: a[i]! })
31      i++
32      j++
33    } else if (table[i + 1]![j]! >= table[i]![j + 1]!) {
34      ops.push({ t: '-', line: a[i]! })
35      i++
36    } else {
37      ops.push({ t: '+', line: b[j]! })
38      j++
39    }
40  }
41  while (i < n) ops.push({ t: '-', line: a[i++]! })
42  while (j < m) ops.push({ t: '+', line: b[j++]! })
43
44  return ops
45}
46
47export const toLines = (text: string | null): string[] =>
48  text === null || text === '' ? [] : text.split('\n')
49
50export const diffOps = (a: string[], b: string[]): Op[] => {
51  let head = 0
52  while (head < a.length && head < b.length && a[head] === b[head]) head++
53  let ea = a.length
54  let eb = b.length
55  while (ea > head && eb > head && a[ea - 1] === b[eb - 1]) {
56    ea--
57    eb--
58  }
59
60  return [
61    ...same(a.slice(0, head)),
62    ...lcsDiff(a.slice(head, ea), b.slice(head, eb)),
63    ...same(a.slice(ea)),
64  ]
65}
66
67export const countChanges = (ops: Op[]) => ({
68  added: ops.filter(op => op.t === '+').length,
69  removed: ops.filter(op => op.t === '-').length,
70})
71
72export const unified = (
73  before: string | null,
74  after: string | null,
75  path: string,
76): string => {
77  const ops = diffOps(toLines(before), toLines(after))
78  const changed = ops.flatMap((op, at) => (op.t === ' ' ? [] : [at]))
79  if (changed.length === 0) return ''
80
81  const CONTEXT = 3
82  const ranges: [number, number][] = []
83  for (const at of changed) {
84    const from = Math.max(0, at - CONTEXT)
85    const to = Math.min(ops.length - 1, at + CONTEXT)
86    const last = ranges[ranges.length - 1]
87    if (last !== undefined && from <= last[1] + 1) last[1] = to
88    else ranges.push([from, to])
89  }
90
91  // old/new line number of each op's start
92  const oldAt: number[] = []
93  const newAt: number[] = []
94  let o = 1
95  let n = 1
96  for (const op of ops) {
97    oldAt.push(o)
98    newAt.push(n)
99    if (op.t !== '+') o++
100    if (op.t !== '-') n++
101  }
102
103  const out = [`--- a/${path}`, `+++ b/${path}`]
104  for (const [from, to] of ranges) {
105    const slice = ops.slice(from, to + 1)
106    const oldCount = slice.filter(op => op.t !== '+').length
107    const newCount = slice.filter(op => op.t !== '-').length
108    out.push(`@@ -${oldAt[from]},${oldCount} +${newAt[from]},${newCount} @@`)
109    for (const op of slice) out.push(`${op.t}${op.line}`)
110  }
111
112  return out.join('\n')
113}
114
hooks/flag.ts 14 lines
1import type { EngineInterface } from 'claude-code'
2
3const FLAGS = { plugin: 'switchboard', key: 'flags' } as const
4
5export type Flag = { isOn: boolean; isHidden: boolean }
6
7// Switchboard owns the flags. A mod it has never heard of counts as on, shown.
8export async function flagOf($: EngineInterface, mod: string): Promise<Flag> {
9  const { value } = await $.state.get(FLAGS)
10  const own = value?.[mod]
11
12  return { isOn: own?.isOn ?? true, isHidden: own?.isHidden ?? false }
13}
14
types/index.d.ts 24 lines
1export type ReplayFrame = {
2  id: string
3  at: number
4  path: string
5  tool: string
6  before: string | null
7  after: string | null
8  isTrimmed: boolean
9}
10
11declare module 'claude-code' {
12  interface PluginState {
13    'replay-theater': {
14      frames: ReplayFrame[]
15      selected: string | null
16      index: number
17      isPlaying: boolean
18      armed: string | null
19    }
20    // Read-only view of Switchboard's value (it alone writes it).
21    switchboard: { flags: Record<string, { isOn: boolean; isHidden: boolean }> }
22  }
23}
24