Replay Theater: step through the file edits Claude made in the last turn, one diff at a time.

Anthropic's sample mod from anthropics/claude-code-playground, Apache License 2.0 (see
LICENSE). Ben AI added one change: the/modsswitchboard can turn it off or hide it.
A Claude Code mod that lets you step through the file edits Claude made in the last turn, one diff at a time. We're sharing it as an example of a mod that records tool calls without changing them and gives you a pane with buttons to review them afterwards.
While a turn runs, Replay Theater records each file edit Claude makes (the Edit, Write and MultiEdit tool calls): the file, and the text before and after. When the turn ends, a hint appears above the prompt:
▶ Replay: 5 edits (press r) [ Replay ]
Open it, and a pane shows one edit at a time: the file, the tool, a count of added and removed lines, and a short diff in red and green. A strip of numbered cells shows every step, with the current one highlighted, and the header reads "step 2 of 5". Prev, Next and Close buttons move through the steps.
Replay Theater only watches. It never blocks, changes or delays an edit.
The patterns it demonstrates:
tool.call and always passing the call on with next(e).turn.start and turn.complete, and skipping subagent turns./replay) registered from session.start and handled in command.run./replay next, /replay prev and /replay close drive the open view, mirroring its buttons.Pane with Button elements and hotkeys, with a fallback to the AbovePrompt band.After a rename, the hint above the prompt:

The pane, on step 1 of 5:

A Write step for a new file, capped at 12 lines:

Inline above the prompt in an 80-column terminal:

Replay Theater. Scrub through what Claude just did. After a turn, a pane opens with a timeline of every edit. Buttons step forward and back, and each step shows its diff. It uses
ui.renderonPane, withButtonelements, and it records each edit fromtool.call. It builds on the built-in diff mod. A developer would install it to review a long run in a minute, and it makes a strong demo clip. A bigger project, because the timeline needs its own state.
The build prompt, which picked this idea and two others by number:
implement 1,2,7. give me zips for them. test them in claude code and get me screenshots of what they look like when used.
/replay or the Replay button.Requirements:
claude plugin validate passes on 2.1.285.No environment variables or configuration.
Steps:
git clone https://github.com/anthropics/claude-code-playground.git
cd claude-code-playground/claude-code/mods
claude plugin validate ./replay-theater
claude --plugin-dir ./replay-theater
Or install it, with the other mods here, from the local marketplace in this folder (see the mods README):
claude plugin marketplace add ./
claude plugin install replay-theater@claude-code-playground-mods --scope user
/replay and press Enter.ctrl+x then Tab to focus the band above the prompt, then press r (or Enter on the Replay button).| Key | Does |
|---|---|
n | Next step |
p | Previous step |
c or Escape | Close the pane |
Mouse clicks on the buttons work too. The replay stays available until the next turn that edits files. A turn with no edits keeps the previous replay.
old_string and new_string, not the whole file. The file's line numbers aren't shown.| Name | Version | License (SPDX) | Source |
|---|---|---|---|
| None |
None.
Shared as-is as part of claude-code-playground. Not an official Anthropic product; no support or maintenance is implied. See the root README and LICENSE.
hooks/replay-theater.mjs 350 lines1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Replay Theater: records the file edits Claude makes in a turn, then lets you
5// step through them in a pane, one diff at a time.
6//
7// Claude Code 2.1.280 function hooks. Load with --plugin-dir.
8//
9// Changed by Ben AI (2026-10): added the /mods switchboard support below
10// (turn off and hide via ~/.claude/mods/switches.json). The rest is Anthropic's original.
11
12const PANE_ID = "replay-theater";
13const MAX_DIFF_LINES = 12;
14const MAX_LCS_LINES = 400;
15const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
16
17// Module state. `pending` fills during a turn; `replay` is the last finished
18// turn's steps, the ones the pane shows.
19const state = { pending: [], replay: [], index: 0, isOpen: false, inBand: false, turns: 0 };
20
21// The /mods switchboard turns this mod off in every chat with switches.json.
22// "replay-theater:hidden": true in the same file hides the hint band and closes the pane.
23const NAME = "replay-theater";
24const HIDE_KEY = `${NAME}:hidden`;
25const POLL_MS = 3000;
26const sw = { off: false, hidden: false, sid: "" };
27
28const homeOf = ($) => $.plugin.root.replace(/\\/g, "/").split("/.claude/")[0] || "";
29const switchesPath = ($) => `${homeOf($)}/.claude/mods/switches.json`;
30
31async function readSwitches($) {
32 const path = switchesPath($);
33 if (!(await $.fs.exists(path))) return {};
34 const json = JSON.parse(String(await $.fs.read(path)));
35 return json && typeof json === "object" && !Array.isArray(json) ? json : {};
36}
37
38async function readSwitch($) {
39 let nowOff = sw.off, nowHidden = sw.hidden;
40 try {
41 const json = await readSwitches($);
42 nowOff = json[NAME] === false;
43 nowHidden = json[HIDE_KEY] === true;
44 } catch { /* a broken or missing file changes nothing */ }
45 if (nowOff === sw.off && nowHidden === sw.hidden) return;
46 const closing = (nowOff && !sw.off) || (nowHidden && !sw.hidden);
47 sw.off = nowOff;
48 sw.hidden = nowHidden;
49 if (closing && state.isOpen) {
50 state.isOpen = false;
51 state.inBand = false;
52 await Promise.resolve($.ui.close({ id: PANE_ID })).catch(() => undefined);
53 }
54 $.ui.invalidate("ui.render");
55}
56
57// Read, change, write the whole file, so every chat sees the choice within 3 s.
58async function setHidden($, value) {
59 try {
60 const json = await readSwitches($).catch(() => ({}));
61 if (value) json[HIDE_KEY] = true;
62 else delete json[HIDE_KEY];
63 await $.fs.write(switchesPath($), `${JSON.stringify(json, null, 2)}\n`);
64 } catch { /* the in-memory flag still changes */ }
65 sw.hidden = value;
66}
67
68// $.fs has no delete, and Windows has no rm outside Git Bash: del through cmd there.
69async function removeFile($, path) {
70 const windows = (await $.env.get("OS")) === "Windows_NT";
71 await $.process.run(windows ? ["cmd", "/c", "del", "/f", "/q", path.replace(/\//g, "\\")] : ["rm", "-f", path]);
72}
73
74// The /mods switchboard asks for the pane by leaving an empty file for this chat.
75async function checkOpen($) {
76 if (!sw.sid) return;
77 const path = `${homeOf($)}/.claude/mods/.open-${NAME}-${sw.sid}`;
78 try {
79 if (!(await $.fs.exists(path))) return;
80 await removeFile($, path);
81 if (sw.off) return;
82 const opened = await openReplay($);
83 if (!opened) $.ui.toast("Replay Theater: no edits in the last turn yet.");
84 } catch { /* try again on the next poll */ }
85}
86
87async function poll($) {
88 await readSwitch($);
89 await checkOpen($);
90}
91
92function relPath(cwd, path) {
93 if (!path) return "(unknown file)";
94 if (cwd && path.startsWith(cwd + "/")) return path.slice(cwd.length + 1);
95 return path;
96}
97
98function splitLines(text) {
99 if (text === undefined || text === null || text === "") return [];
100 const lines = String(text).split("\n");
101 if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
102 return lines;
103}
104
105// A line diff. Small inputs get an LCS diff, so unchanged lines show as
106// context; large ones fall back to all-removed then all-added.
107function diffLines(oldText, newText) {
108 const a = splitLines(oldText);
109 const b = splitLines(newText);
110 if (a.length > MAX_LCS_LINES || b.length > MAX_LCS_LINES) {
111 return [...a.map((t) => ({ op: "-", t })), ...b.map((t) => ({ op: "+", t }))];
112 }
113 const n = a.length, m = b.length;
114 const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
115 for (let i = n - 1; i >= 0; i--)
116 for (let j = m - 1; j >= 0; j--)
117 dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
118 const out = [];
119 let i = 0, j = 0;
120 while (i < n && j < m) {
121 if (a[i] === b[j]) { out.push({ op: " ", t: a[i] }); i++; j++; }
122 else if (dp[i + 1][j] >= dp[i][j + 1]) out.push({ op: "-", t: a[i++] });
123 else out.push({ op: "+", t: b[j++] });
124 }
125 while (i < n) out.push({ op: "-", t: a[i++] });
126 while (j < m) out.push({ op: "+", t: b[j++] });
127 return trimContext(out);
128}
129
130// Keep one line of context around each change, so a long file's Write shows
131// the changed lines, not the whole file.
132function trimContext(lines) {
133 if (!lines.some((l) => l.op !== " ")) return lines.slice(0, MAX_DIFF_LINES);
134 const keep = lines.map((l, k) =>
135 l.op !== " " || lines[k - 1]?.op && lines[k - 1].op !== " " || lines[k + 1]?.op && lines[k + 1].op !== " ");
136 const out = [];
137 let skipped = false;
138 lines.forEach((l, k) => {
139 if (keep[k]) { if (skipped && out.length) out.push({ op: "~", t: "⋯" }); out.push(l); skipped = false; }
140 else skipped = true;
141 });
142 return out;
143}
144
145function countChanges(diff) {
146 let add = 0, del = 0;
147 for (const l of diff) { if (l.op === "+") add++; else if (l.op === "-") del++; }
148 return { add, del };
149}
150
151// Turns one tool call into one or more steps.
152async function stepsFor($, e) {
153 let cwd = "";
154 try { cwd = await $.session.cwd(); } catch { /* keep the full path */ }
155 const file = relPath(cwd, e.file_path);
156 if (e.tool === "Edit") {
157 return [{ tool: "Edit", file, diff: diffLines(e.old_string, e.new_string), note: e.replace_all ? "replace all" : "" }];
158 }
159 if (e.tool === "MultiEdit" && Array.isArray(e.edits)) {
160 return e.edits.map((ed, k) => ({
161 tool: "MultiEdit", file, diff: diffLines(ed.old_string, ed.new_string), note: `edit ${k + 1} of ${e.edits.length}`,
162 }));
163 }
164 if (e.tool === "Write") {
165 let before = "";
166 let isNew = true;
167 try {
168 if (e.file_path && (await $.fs.exists(e.file_path))) {
169 before = await $.fs.read(e.file_path);
170 isNew = false;
171 }
172 } catch { /* unreadable: show it as a new file */ }
173 return [{ tool: "Write", file, diff: diffLines(before, e.content), note: isNew ? "new file" : "rewrite" }];
174 }
175 return [];
176}
177
178function hintText() {
179 const n = state.replay.length;
180 return `▶ Replay: ${n} edit${n === 1 ? "" : "s"} (press r)`;
181}
182
183async function openReplay($) {
184 if (!state.replay.length) return false;
185 state.index = 0;
186 state.isOpen = true;
187 // The pane only takes the keyboard while nothing else holds it. Hide the
188 // band first (it held the keys if its button was pressed), then open.
189 $.ui.invalidate("ui.render");
190 await $.clock.sleep(200);
191 const rows = Math.min(MAX_DIFF_LINES + 8, 22);
192 const placed = await $.ui.open({ id: PANE_ID, title: "Replay Theater", focus: true, closeOnEscape: true, rows, columns: 58 });
193 // No room for a pane (a narrow terminal): draw the replay in the band.
194 state.inBand = placed?.isPlaced === false;
195 $.ui.invalidate("ui.render");
196 return true;
197}
198
199function goTo($, to) {
200 state.index = Math.max(0, Math.min(to, state.replay.length - 1));
201 $.ui.invalidate("ui.render");
202}
203
204function closeReplay($) {
205 state.isOpen = false;
206 if (!state.inBand) $.ui.close({ id: PANE_ID });
207 state.inBand = false;
208 $.ui.invalidate("ui.render");
209}
210
211// The replay view: one step at a time. Drawn in the Pane, or in the band
212// above the prompt when the surface can't place a pane.
213function replayView($, e, inBand) {
214 const { Box, Text, Button } = $.ui.resolve(e);
215 const maxDiff = inBand ? Math.max(3, Math.min(MAX_DIFF_LINES, (e.props.maxRows || 20) - 7)) : MAX_DIFF_LINES;
216 const total = state.replay.length;
217 if (!total) return Text({ dimColor: true, children: "No edits to replay." });
218 const k = Math.max(0, Math.min(state.index, total - 1));
219 const step = state.replay[k];
220 const width = Math.max(8, (e.props.bodyColumns || 100) - 4);
221 const { add, del } = countChanges(step.diff);
222 const shown = step.diff.slice(0, maxDiff);
223
224 const diffRows = shown.map((l, n) => {
225 const color = l.op === "+" ? "success" : l.op === "-" ? "error" : undefined;
226 const line = `${l.op === "~" ? " " : l.op} ${l.t}`.slice(0, width);
227 return Text({ key: `d${n}`, color, dimColor: l.op === " " || l.op === "~", wrap: "truncate-end", children: line });
228 });
229 if (step.diff.length > maxDiff) diffRows.push(Text({ key: "more", dimColor: true, children: ` … ${step.diff.length - maxDiff} more lines` }));
230 if (!shown.length) diffRows.push(Text({ key: "empty", dimColor: true, children: " (no line changes)" }));
231
232 // The step list: one cell per step, the current one highlighted.
233 const strip = state.replay.map((s, n) =>
234 Text({ key: `s${n}`, inverse: n === k, color: n === k ? "suggestion" : undefined, dimColor: n !== k, children: ` ${n + 1} ` }));
235
236 const go = (to) => goTo($, to);
237 const close = () => closeReplay($);
238
239 return Box({
240 flexDirection: "column", borderStyle: "round", borderColor: "promptBorder", paddingX: 1,
241 children: [
242 Box({ flexDirection: "row", justifyContent: "space-between", gap: 1, children: [
243 Text({ weight: "bold", color: "claude", wrap: "truncate-end", children: "▶ Replay Theater" }),
244 Text({ weight: "bold", wrap: "truncate-end", children: `step ${k + 1} of ${total}` }),
245 ] }),
246 Box({ flexDirection: "row", flexWrap: "wrap", children: strip }),
247 Text({ weight: "bold", color: "suggestion", wrap: "truncate-start", children: step.file }),
248 Box({ flexDirection: "row", gap: 2, children: [
249 Box({ key: "meta", flexShrink: 1, children: Text({ dimColor: true, wrap: "truncate-end", children: `${step.tool}${step.note ? " · " + step.note : ""}` }) }),
250 Text({ color: "success", children: `+${add}` }),
251 Text({ color: "error", children: `-${del}` }),
252 ] }),
253 Box({ flexDirection: "column", marginTop: 1, children: diffRows }),
254 Box({ flexDirection: "row", flexWrap: "wrap", gap: 2, marginTop: 1, children: [
255 Button({ key: "prev", label: "◀ Prev", hotkey: "p", onPress: () => go(k - 1) }),
256 Button({ key: "next", label: "Next ▶", hotkey: "n", autoFocus: true, onPress: () => go(k + 1) }),
257 Button({ key: "close", label: "Close", hotkey: "c", onPress: close }),
258 ] }),
259 ...(e.props.isFocused === false ? [Text({ key: "focus", dimColor: true, wrap: "truncate-end", children: "click to focus · /replay next, prev, close" })] : []),
260 ],
261 });
262}
263
264export function register(on, options) {
265 // Slash command: /replay opens the pane.
266 on("session.start", async ($, e, next) => {
267 const r = await next(e);
268 try {
269 sw.sid = await $.session.id();
270 await $.command.register({ name: "replay", description: "Replay Theater: step through the last turn's file edits. /replay next, prev or close drives the open view", argumentHint: "[next|prev|close]" });
271 await poll($).catch(() => undefined);
272 $.clock.every(POLL_MS, () => { void poll($).catch(() => undefined); });
273 } catch { /* the mod still records */ }
274 return r;
275 });
276
277 on("command.run", { command: "replay" }, async ($, e) => {
278 if (sw.off) return { text: "Replay Theater is off. Turn it on with /mods." };
279 const arg = String(e.args ?? "").trim().toLowerCase();
280 if (state.isOpen && (arg === "next" || arg === "prev" || arg === "close")) {
281 if (arg === "close") closeReplay($);
282 else goTo($, state.index + (arg === "next" ? 1 : -1));
283 return { text: arg === "close" ? "Replay Theater closed." : `Replay Theater: step ${state.index + 1} of ${state.replay.length}` };
284 }
285 if (sw.hidden) await setHidden($, false);
286 const opened = await openReplay($);
287 return { text: opened ? `Replay Theater: ${state.replay.length} edits` : "Replay Theater: no edits in the last turn." };
288 });
289
290 // Record each edit, then let it run. Never blocks the call.
291 on("tool.call", async ($, e, next) => {
292 if (!sw.off && EDIT_TOOLS.has(e.tool)) {
293 try {
294 const steps = await stepsFor($, e);
295 state.pending.push(...steps);
296 } catch { /* recording must never stop the edit */ }
297 }
298 return next(e);
299 });
300
301 on("turn.start", ($, e, next) => {
302 if (!e.agentId) state.pending = [];
303 return next(e);
304 });
305
306 // At the end of a main-loop turn, the pending edits become the replay.
307 on("turn.complete", async ($, e, next) => {
308 const r = await next(e);
309 if (sw.off || e.agentId || !state.pending.length) return r;
310 state.replay = state.pending;
311 state.pending = [];
312 state.index = 0;
313 state.turns++;
314 $.ui.invalidate("ui.render");
315 return r;
316 });
317
318 // The band above the prompt: the hint and a button that opens the pane.
319 // Local change (Ben AI, 2026-10-16): draw the other mods' bands first, so
320 // this band sits under them instead of replacing them.
321 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
322 if (sw.off || sw.hidden || e.props.hasSurvey) return next(e);
323 if (state.isOpen && state.inBand) return replayView($, e, true);
324 if (!state.replay.length || state.isOpen) return next(e);
325 const rest = await next(e);
326 const { Box, Text, Button } = $.ui.resolve(e);
327 const band = Box({
328 key: "replay-band", flexDirection: "row", gap: 2, paddingX: 1,
329 children: [
330 Box({ key: "hint", flexShrink: 1, children: Text({ color: "claude", weight: "bold", wrap: "truncate-end", children: hintText() }) }),
331 Button({ key: "open-replay", label: "Replay", hotkey: "r", onPress: () => openReplay($) }),
332 ],
333 });
334 return rest ? Box({ flexDirection: "column", children: [rest, band] }) : band;
335 });
336
337 // The pane.
338 on("ui.render", { component: "Pane" }, ($, e, next) => {
339 if (e.requestId !== PANE_ID || sw.off) return next(e);
340 return replayView($, e, false);
341 });
342
343 // The person closed the pane (Escape): keep our flag in step.
344 on("ui.close", ($, e, next) => {
345 if (e.id === PANE_ID || e.requestId === PANE_ID) state.isOpen = false;
346 $.ui.invalidate("ui.render");
347 return next(e);
348 });
349}
350