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

五个 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、最近 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
Claude 调用下面这些 Bash 命令时,mod 会先拦住,空跑一遍算出影响范围,再打开一个面板:
| 命令 | 怎么算影响范围 |
|---|---|
rm -r / rm -rf | find + du:会删哪些文件、总大小 |
git reset --hard | git status --porcelain:会丢掉哪些未提交改动 |
git clean | git 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的脚本都拦不住。它是安全网,不是权限系统;要硬性禁止请用权限规则。
一轮对话里,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 的分类明细做成输入框上方的一张卡片:
◆ context,右边是已用 / 窗口、自动压缩阈值和一个百分比徽章(<50% 薄荷、<80% 柠檬杏、其余珊瑚红)╭──────────────────────────────────────────────────────────────╮
│ ◆ ▾ 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 在侧边打开面板,列出项目 .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-radiushooks/replay-theater.mjs 210 lines1// 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}
210types/index.d.ts 13 lines1export 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