SLOPSHOPPER

replay-theater

Records the last turn's Edit and Write calls and steps through them one diff at a time with /replay.

newpanebandguardcommand
v0.1.0no licenseupdated 2026-10-02TLOBillyQ/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 │ ┃ 1 2 3 │ ┃ Edit /work/app/src/auth.ts ⏺ Read(src/auth.ts) │ ┃ step 1 of 3 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ - if (!claims) throw new Error('invalid t… ⎿ Added 2 lines, removed 1 line │ ┃ + if (!claims || claims.exp < Date.now() … ⏺ Bash(bun test) │ ┃ + await audit('refresh', claims.sub) ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ Prev ] [ Next ] [ Close ] ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /replay │ ⎿ replay-theater: Replaying │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

claude-mods

五个 Claude Code mod,打包成一个本地 marketplace my-mods。

Mod作用
token-weather在输入框上方用“天气预报”显示上下文窗口用量
blast-radius拦下有风险的 Bash 命令,先显示会影响什么,再等你选 Proceed 或 Cancel
replay-theater记录上一轮的文件编辑,逐个 diff 回放
context-bar在输入框上方用一张卡片显示 /context 的分类明细,可折叠
agents-panel侧边面板列出项目的 .claude/agents,每个带 run 按钮

需要 Claude Code 2.1.287 或更高版本(claude --version)。

安装

git clone https://github.com/TLOBillyQ/claude-mods.git
claude plugin marketplace add ./claude-mods
claude plugin install token-weather@my-mods --scope user
claude plugin install blast-radius@my-mods --scope user
claude plugin install replay-theater@my-mods --scope user
claude plugin install context-bar@my-mods --scope user
claude plugin install agents-panel@my-mods --scope user

装好后新开一个会话才会加载。

停用某一个:

claude plugin disable blast-radius@my-mods

拉取新版本后更新:

claude plugin marketplace update my-mods

只想临时试用、不安装:

claude --plugin-dir ./claude-mods/token-weather --plugin-dir ./claude-mods/blast-radius --plugin-dir ./claude-mods/replay-theater --plugin-dir ./claude-mods/context-bar --plugin-dir ./claude-mods/agents-panel

token-weather

每轮结束后读取上下文用量,在输入框上方画一行:天气图标、百分比、已用/总 token、最近 12 轮的迷你图,以及上一轮增加了多少。

用量预报
< 25%☀ Clear
25–49%☁ Cloudy
50–74%☂ Showers
75–89%☇ Storm
≥ 90%↯ Compact soon
☀  Clear  18% of context  36.1k / 200k   last turns ▁▂▅  ▲ +12.4k last turn

blast-radius

Claude 调用下面这些 Bash 命令时,mod 会先拦住,空跑一遍算出影响范围,再打开一个面板:

命令怎么算影响范围
rm -r / rm -rffind + du:会删哪些文件、总大小
git reset --hardgit status --porcelain:会丢掉哪些未提交改动
git cleangit clean -n:会删哪些未跟踪文件
强推(--force、--force-with-lease、+ref)git log HEAD..<upstream>:远端会丢哪些提交
数据库迁移(Django、Rails、Prisma、Alembic、Knex、Sequelize、Flyway)Django 用 showmigrations --plan 列出待执行迁移,其他只做提示

按 1 Proceed,命令照原样执行;按 2 Cancel,Claude 会收到拒绝和原因。关掉面板或按 Esc 等于 Cancel。终端太窄放不下面板时,同样的内容会画在输入框上方。

它只读命令文本,$(…)、别名、调用 rm 的脚本都拦不住。它是安全网,不是权限系统;要硬性禁止请用权限规则。

replay-theater

一轮对话里,mod 记录每次成功的 Edit、MultiEdit 和 Write(Write 会在写入前读取旧内容,所以 diff 是真实的)。回合结束后,输入框上方会出现提示:

↻ 3 edits across 2 files last turn  r: Replay  or /replay

运行 /replay(或聚焦提示条后按 r)打开回放面板:顶部是步骤条,下面是当前这一步的文件和 diff,p / n / c 对应 Prev / Next / Close,也可以点步骤条上的编号直接跳转。它只观察,不会阻止或修改任何编辑。

context-bar

把 /context 的分类明细做成输入框上方的一张卡片:

  • 标题行:◆ context,右边是已用 / 窗口、自动压缩阈值和一个百分比徽章(<50% 薄荷、<80% 柠檬杏、其余珊瑚红)
  • 一条连续的彩色条,每个类别有一个固定的中间亮度粉彩色,深色和浅色终端背景上都看得清;剩余空间是浅灰色,深墨色竖线标出压缩点。条按格宽用色块画,desktop 上也和卡片等宽
  • 图例:每个类别的 token 数和占比
╭──────────────────────────────────────────────────────────────╮
│ ◆ ▾ context                90k of 1M · compacts at 987k  9%  │
│ ██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│  │
│ ■ system prompt 4.2k 0%   ■ tools 17k 2%   ■ messages 0 0%   │
│ ■ free 897k                                                  │
╰──────────────────────────────────────────────────────────────╯

默认折叠,只显示标题行;点 ▸ context 展开,再点 ▾ context 收起。手动选择在当前会话的后续回复和热重载时保持;键盘可以 ctrl+x tab 聚焦后回车。/context-bar 显示或隐藏整张卡片。会话开始和每轮结束后刷新,用 summary 模式在本地估算,不额外发 token 计数请求;按需加载的 deferred 工具不占窗口,不显示。终端窄于 60 列时不画图例。

agents-panel

/agents-panel 在侧边打开面板,列出项目 .claude/agents/*.md 里定义的所有 agent:名字、模型、一行描述,右侧 ▶ run 按钮用该 agent 启动一个子 agent(提示词为“Run your default task on this project.”)。再次运行 /agents-panel 关闭。只读项目目录,不读 ~/.claude/agents;每次打开面板时重新读取。

开发

每个 mod 的结构:

<mod>/
├── .claude-plugin/plugin.json   清单,types 指向类型契约
├── hooks/hooks.json             指向 hooks 模块
├── hooks/<mod>.mjs              导出 register(on)
├── types/index.d.ts             $.state 的类型契约
└── tests/<mod>.test.ts

校验和测试:

claude plugin validate ./blast-radius
claude plugin test ./blast-radius
Source 2 files
hooks/replay-theater.mjs 210 lines
1// Replay Theater: record every Edit and Write in a turn, then step through them one diff at a time.
2
3const PANE = "replay-theater";
4const EDIT_TOOLS = new Set(["Edit", "MultiEdit", "Write"]);
5const CONTEXT = 2;
6const MAX_DIFF_LINES = 400;
7
8// Held by the host, so the last replay survives a hot reload of this file.
9const replay = { plugin: "replay-theater", key: "replay" };
10const at = { plugin: "replay-theater", key: "at" };
11const isHinted = { plugin: "replay-theater", key: "isHinted" };
12
13// The current turn's steps, before the turn completes.
14let pending = [];
15
16export function register(on) {
17  on("session.start", async ($, e, next) => {
18    const r = await next(e);
19    await $.command.register({ name: "replay", description: "Step through the last turn's file edits" });
20    return r;
21  });
22
23  on("turn.start", async ($, e, next) => {
24    if (!e.agentId) {
25      pending = [];
26      await $.state.set(isHinted, false);
27    }
28    return next(e);
29  });
30
31  on("tool.call", async ($, e, next) => {
32    if (!EDIT_TOOLS.has(e.tool)) return next(e);
33    const steps = await stepsFor($, e); // read before the edit lands
34    const r = await next(e); // the edit runs untouched
35    if (!r?.deny && !r?.isError) pending.push(...steps);
36    return r;
37  });
38
39  on("turn.complete", async ($, e, next) => {
40    const r = await next(e);
41    if (!e.agentId && pending.length) {
42      // one replay per turn
43      await $.state.set(replay, pending);
44      await $.state.set(at, 0);
45      await $.state.set(isHinted, true);
46      pending = [];
47    }
48    return r;
49  });
50
51  on("command.run", { command: "replay" }, async ($) => ({
52    text: (await openReplay($)) ? "Replaying" : "No edits in the last turn",
53  }));
54
55  on("ui.press", { plugin: "replay-theater" }, async ($, e, next) => {
56    const { value: steps = [] } = await $.state.get(replay);
57    const { value: i = 0 } = await $.state.get(at);
58    if (e.element === "replay") await openReplay($);
59    else if (e.element === "prev") await $.state.set(at, Math.max(0, i - 1));
60    else if (e.element === "next") await $.state.set(at, Math.min(steps.length - 1, i + 1));
61    else if (e.element === "close") await $.ui.close({ id: PANE });
62    else if (e.element.startsWith("step:")) await $.state.set(at, Number(e.element.slice(5)));
63    return next(e);
64  });
65
66  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
67    const { value: hinted = false } = await $.state.get(isHinted);
68    const { value: steps = [] } = await $.state.get(replay);
69    if (e.props.hasSurvey || e.props.isWorking || !hinted || !steps.length) return next(e);
70    const { Box, Text, Button } = $.ui.resolve(e);
71    const files = new Set(steps.map((s) => s.file)).size;
72    return Box({
73      flexDirection: "row",
74      paddingX: 1,
75      children: [
76        Text({ color: "magenta", children: "↻ " }),
77        Text({ children: `${plural(steps.length, "edit")} across ${plural(files, "file")} last turn  ` }),
78        Button({ key: "replay", label: "Replay", hotkey: "r", plain: true, onPress: () => {} }),
79        Text({ dimColor: true, children: "  or /replay" }),
80      ],
81    });
82  });
83
84  on("ui.render", { component: "Pane" }, async ($, e, next) => {
85    if (e.requestId !== PANE) return next(e);
86    const { value: steps = [] } = await $.state.get(replay);
87    const { value: i = 0 } = await $.state.get(at);
88    if (!steps.length) return next(e);
89    const { Box, Text, Button } = $.ui.resolve(e);
90    const step = steps[Math.min(i, steps.length - 1)];
91    const strip = steps.map((s, n) =>
92      Button({ key: `step:${n}`, label: ` ${n + 1} `, plain: true, dimColor: n !== i, onPress: () => {} }),
93    );
94    return Box({
95      flexDirection: "column",
96      paddingX: 1,
97      children: [
98        Box({ flexDirection: "row", gap: 1, children: strip }),
99        Text({ bold: true, color: "magenta", children: `${step.tool}  ${step.file}`, wrap: "truncate-start" }),
100        Text({ dimColor: true, children: `step ${i + 1} of ${steps.length}` }),
101        Box({ flexDirection: "column", marginY: 1, children: step.lines.map((l) => line(Text, l)) }),
102        Box({
103          flexDirection: "row",
104          gap: 2,
105          children: [
106            Button({ key: "prev", label: "Prev", hotkey: "p", onPress: () => {} }),
107            Button({ key: "next", label: "Next", hotkey: "n", onPress: () => {} }),
108            Button({ key: "close", label: "Close", hotkey: "c", onPress: () => {} }),
109          ],
110        }),
111      ],
112    });
113  });
114}
115
116async function openReplay($) {
117  const { value: steps = [] } = await $.state.get(replay);
118  if (!steps.length) return false;
119  await $.state.set(at, 0);
120  await $.state.set(isHinted, false);
121  await $.ui.open({ id: PANE, title: "Replay Theater", focus: true, closeOnEscape: true });
122  return true;
123}
124
125function line(Text, { op, text }) {
126  const color = op === "+" ? "green" : op === "-" ? "red" : undefined;
127  return Text({ color, dimColor: op === " " || op === "…", children: `${op} ${text}`, wrap: "truncate-end" });
128}
129
130// ---------- recording ----------
131
132async function stepsFor($, e) {
133  const file = String(e.file_path ?? "");
134  if (e.tool === "Write") {
135    const before = await $.fs.read(file).catch(() => "");
136    return [{ file, tool: before ? "Write" : "Create", lines: diff(before, String(e.content ?? "")) }];
137  }
138  const edits = e.tool === "MultiEdit" ? (e.edits ?? []) : [e];
139  return edits.map((edit) => ({
140    file,
141    tool: "Edit",
142    lines: diff(String(edit.old_string ?? ""), String(edit.new_string ?? "")),
143  }));
144}
145
146// ---------- diffing ----------
147
148export function diff(before, after) {
149  const a = before === "" ? [] : before.split("\n");
150  const b = after === "" ? [] : after.split("\n");
151  let start = 0;
152  while (start < a.length && start < b.length && a[start] === b[start]) start++;
153  let endA = a.length;
154  let endB = b.length;
155  while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
156    endA--;
157    endB--;
158  }
159  const ops = [
160    ...a.slice(0, start).map((text) => ({ op: " ", text })),
161    ...middle(a.slice(start, endA), b.slice(start, endB)),
162    ...a.slice(endA).map((text) => ({ op: " ", text })),
163  ];
164  return trim(ops);
165}
166
167function middle(a, b) {
168  if (a.length * b.length > MAX_DIFF_LINES * MAX_DIFF_LINES) {
169    return [...a.map((text) => ({ op: "-", text })), ...b.map((text) => ({ op: "+", text }))];
170  }
171  // Longest common subsequence, then walk it.
172  const lcs = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
173  for (let i = a.length - 1; i >= 0; i--) {
174    for (let j = b.length - 1; j >= 0; j--) {
175      lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
176    }
177  }
178  const out = [];
179  let i = 0;
180  let j = 0;
181  while (i < a.length || j < b.length) {
182    if (i < a.length && j < b.length && a[i] === b[j]) {
183      out.push({ op: " ", text: a[i++] });
184      j++;
185    } else if (i < a.length && (j === b.length || lcs[i + 1][j] >= lcs[i][j + 1])) {
186      out.push({ op: "-", text: a[i++] });
187    } else {
188      out.push({ op: "+", text: b[j++] });
189    }
190  }
191  return out;
192}
193
194// Keep CONTEXT unchanged lines around each change; fold the rest into "…".
195function trim(ops) {
196  const near = ops.map((o, n) =>
197    ops.slice(Math.max(0, n - CONTEXT), n + CONTEXT + 1).some((x) => x.op !== " "),
198  );
199  const out = [];
200  ops.forEach((o, n) => {
201    if (near[n]) out.push(o);
202    else if (out[out.length - 1]?.op !== "…") out.push({ op: "…", text: "" });
203  });
204  return out.length > MAX_DIFF_LINES ? [...out.slice(0, MAX_DIFF_LINES), { op: "…", text: `${out.length - MAX_DIFF_LINES} more lines` }] : out;
205}
206
207function plural(n, word) {
208  return `${n} ${word}${n === 1 ? "" : "s"}`;
209}
210
types/index.d.ts 13 lines
1export type ReplayLine = { op: " " | "-" | "+" | "…"; text: string };
2export type ReplayStep = { file: string; tool: string; lines: ReplayLine[] };
3
4declare module "claude-code" {
5  interface PluginState {
6    "replay-theater": {
7      replay: ReplayStep[];
8      at: number;
9      isHinted: boolean;
10    };
11  }
12}
13