Replay Theater: step through the file edits Claude made in the last turn, one diff at a time. (Vendored from anthropic claude-code-playground @ 569c528…

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.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 304 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
9const PANE_ID = "replay-theater";
10const MAX_DIFF_LINES = 12;
11const MAX_LCS_LINES = 400;
12// Modified for my-claude-mods: at most this many steps wait in `pending`.
13const MAX_PENDING = 200;
14const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
15
16// Module state. `pending` fills during a turn; `replay` is the last finished
17// turn's steps, the ones the pane shows.
18const state = { pending: [], replay: [], index: 0, isOpen: false, inBand: false, turns: 0 };
19
20// Modified for my-claude-mods: either slash separates, and a Windows cwd (a
21// drive letter or \\server) matches whatever the case.
22function relPath(cwd, path) {
23 if (!path) return "(unknown file)";
24 const base = (cwd || "").replace(/\\/g, "/").replace(/\/+$/, "");
25 const full = path.replace(/\\/g, "/");
26 const head = full.slice(0, base.length);
27 const isSame = /^([A-Za-z]:|\/\/)/.test(base) ? head.toLowerCase() === base.toLowerCase() : head === base;
28 if (base && full[base.length] === "/" && isSame) return path.slice(base.length + 1);
29 return path;
30}
31
32function splitLines(text) {
33 if (text === undefined || text === null || text === "") return [];
34 const lines = String(text).split("\n");
35 if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
36 return lines;
37}
38
39// A line diff. Small inputs get an LCS diff, so unchanged lines show as
40// context; large ones fall back to all-removed then all-added.
41// Modified for my-claude-mods: the fallback keeps at most MAX_LCS_LINES lines
42// of each side, so a large Write doesn't hold the whole file in `pending`.
43function diffLines(oldText, newText) {
44 const a = splitLines(oldText);
45 const b = splitLines(newText);
46 if (a.length > MAX_LCS_LINES || b.length > MAX_LCS_LINES) {
47 return [...a.slice(0, MAX_LCS_LINES).map((t) => ({ op: "-", t })), ...b.slice(0, MAX_LCS_LINES).map((t) => ({ op: "+", t }))];
48 }
49 const n = a.length, m = b.length;
50 const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
51 for (let i = n - 1; i >= 0; i--)
52 for (let j = m - 1; j >= 0; j--)
53 dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
54 const out = [];
55 let i = 0, j = 0;
56 while (i < n && j < m) {
57 if (a[i] === b[j]) { out.push({ op: " ", t: a[i] }); i++; j++; }
58 else if (dp[i + 1][j] >= dp[i][j + 1]) out.push({ op: "-", t: a[i++] });
59 else out.push({ op: "+", t: b[j++] });
60 }
61 while (i < n) out.push({ op: "-", t: a[i++] });
62 while (j < m) out.push({ op: "+", t: b[j++] });
63 return trimContext(out);
64}
65
66// Keep one line of context around each change, so a long file's Write shows
67// the changed lines, not the whole file.
68function trimContext(lines) {
69 if (!lines.some((l) => l.op !== " ")) return lines.slice(0, MAX_DIFF_LINES);
70 const keep = lines.map((l, k) =>
71 l.op !== " " || lines[k - 1]?.op && lines[k - 1].op !== " " || lines[k + 1]?.op && lines[k + 1].op !== " ");
72 const out = [];
73 let skipped = false;
74 lines.forEach((l, k) => {
75 if (keep[k]) { if (skipped && out.length) out.push({ op: "~", t: "⋯" }); out.push(l); skipped = false; }
76 else skipped = true;
77 });
78 return out;
79}
80
81function countChanges(diff) {
82 let add = 0, del = 0;
83 for (const l of diff) { if (l.op === "+") add++; else if (l.op === "-") del++; }
84 return { add, del };
85}
86
87// Modified for my-claude-mods: one step, marked truncated (and its note says
88// so) when either side ran past MAX_LCS_LINES and diffLines cut it.
89function stepOf(tool, file, oldText, newText, note) {
90 const isTruncated = splitLines(oldText).length > MAX_LCS_LINES || splitLines(newText).length > MAX_LCS_LINES;
91 const cut = `first ${MAX_LCS_LINES} lines`;
92 return { tool, file, diff: diffLines(oldText, newText), note: isTruncated ? (note ? `${note} · ${cut}` : cut) : note, isTruncated };
93}
94
95// Turns one tool call into one or more steps.
96async function stepsFor($, e) {
97 let cwd = "";
98 try { cwd = await $.session.cwd(); } catch { /* keep the full path */ }
99 const file = relPath(cwd, e.file_path);
100 if (e.tool === "Edit") {
101 return [stepOf("Edit", file, e.old_string, e.new_string, e.replace_all ? "replace all" : "")];
102 }
103 if (e.tool === "MultiEdit" && Array.isArray(e.edits)) {
104 return e.edits.map((ed, k) => stepOf("MultiEdit", file, ed.old_string, ed.new_string, `edit ${k + 1} of ${e.edits.length}`));
105 }
106 if (e.tool === "Write") {
107 let before = "";
108 let isNew = true;
109 try {
110 if (e.file_path && (await $.fs.exists(e.file_path))) {
111 before = await $.fs.read(e.file_path);
112 isNew = false;
113 }
114 } catch { /* unreadable: show it as a new file */ }
115 return [stepOf("Write", file, before, e.content, isNew ? "new file" : "rewrite")];
116 }
117 return [];
118}
119
120function hintText() {
121 const n = state.replay.length;
122 return `▶ Replay: ${n} edit${n === 1 ? "" : "s"} (press r)`;
123}
124
125// Modified for my-claude-mods: while flightdeck's pane is open and drawn
126// (its `dock`), the hint row sits under its session log instead of the band.
127const FLIGHTDECK = "flightdeck";
128async function isDocked($) {
129 try {
130 const held = await $.state.get({ plugin: "flightdeck", key: "dock" });
131 return held?.value === true;
132 } catch {
133 return false;
134 }
135}
136
137function hintRow($, e) {
138 const { Box, Text, Button } = $.ui.resolve(e);
139 return Box({
140 flexDirection: "row", gap: 2, paddingX: 1,
141 children: [
142 Text({ color: "magenta", bold: true, children: hintText() }),
143 Button({ key: "open-replay", label: "Replay", hotkey: "r", onPress: () => openReplay($) }),
144 ],
145 });
146}
147
148async function openReplay($) {
149 if (!state.replay.length) return false;
150 state.index = 0;
151 state.isOpen = true;
152 // The pane only takes the keyboard while nothing else holds it. Hide the
153 // band first (it held the keys if its button was pressed), then open.
154 $.ui.invalidate("ui.render");
155 await $.clock.sleep(200);
156 const rows = Math.min(MAX_DIFF_LINES + 8, 22);
157 const placed = await $.ui.open({ id: PANE_ID, title: "Replay Theater", focus: true, closeOnEscape: true, rows, columns: 58 });
158 // No room for a pane (a narrow terminal): draw the replay in the band.
159 state.inBand = placed?.isPlaced === false;
160 $.ui.invalidate("ui.render");
161 return true;
162}
163
164// The replay view: one step at a time. Drawn in the Pane, or in the band
165// above the prompt when the surface can't place a pane.
166// Modified for my-claude-mods: the band's rows and both sites' columns are
167// read from `e.props`, where the render props live.
168function replayView($, e, inBand) {
169 const { Box, Text, Button } = $.ui.resolve(e);
170 const maxDiff = inBand ? Math.max(3, Math.min(MAX_DIFF_LINES, (e.props?.maxRows || 20) - 7)) : MAX_DIFF_LINES;
171 const total = state.replay.length;
172 if (!total) return Text({ dimColor: true, children: "No edits to replay." });
173 const k = Math.max(0, Math.min(state.index, total - 1));
174 const step = state.replay[k];
175 const width = Math.max(40, (e.props?.bodyColumns || 100) - 4);
176 const { add, del } = countChanges(step.diff);
177 const shown = step.diff.slice(0, maxDiff);
178
179 const diffRows = shown.map((l, n) => {
180 const color = l.op === "+" ? "green" : l.op === "-" ? "red" : undefined;
181 const line = `${l.op === "~" ? " " : l.op} ${l.t}`.slice(0, width);
182 return Text({ key: `d${n}`, color, dimColor: l.op === " " || l.op === "~", wrap: "truncate-end", children: line });
183 });
184 if (step.diff.length > maxDiff) diffRows.push(Text({ key: "more", dimColor: true, children: ` … ${step.diff.length - maxDiff} more lines` }));
185 if (!shown.length) diffRows.push(Text({ key: "empty", dimColor: true, children: " (no line changes)" }));
186
187 // The step list: one cell per step, the current one highlighted.
188 const strip = state.replay.map((s, n) =>
189 Text({ key: `s${n}`, inverse: n === k, color: n === k ? "cyan" : undefined, dimColor: n !== k, children: ` ${n + 1} ` }));
190
191 const go = (to) => { state.index = Math.max(0, Math.min(to, total - 1)); $.ui.invalidate("ui.render"); };
192 const close = () => {
193 state.isOpen = false;
194 if (!state.inBand) $.ui.close({ id: PANE_ID });
195 state.inBand = false;
196 $.ui.invalidate("ui.render");
197 };
198
199 return Box({
200 flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1,
201 children: [
202 Box({ flexDirection: "row", justifyContent: "space-between", children: [
203 Text({ bold: true, color: "magenta", children: "▶ Replay Theater" }),
204 Text({ bold: true, children: `step ${k + 1} of ${total}` }),
205 ] }),
206 Box({ flexDirection: "row", children: strip }),
207 Text({ bold: true, color: "cyan", wrap: "truncate-start", children: step.file }),
208 Box({ flexDirection: "row", gap: 2, children: [
209 Text({ dimColor: true, children: `${step.tool}${step.note ? " · " + step.note : ""}` }),
210 Text({ color: "green", children: `+${add}` }),
211 Text({ color: "red", children: `-${del}` }),
212 ] }),
213 Box({ flexDirection: "column", marginTop: 1, children: diffRows }),
214 Box({ flexDirection: "row", gap: 2, marginTop: 1, children: [
215 Button({ key: "prev", label: "◀ Prev", hotkey: "p", onPress: () => go(k - 1) }),
216 Button({ key: "next", label: "Next ▶", hotkey: "n", autoFocus: true, onPress: () => go(k + 1) }),
217 Button({ key: "close", label: "Close", hotkey: "c", onPress: close }),
218 ] }),
219 ],
220 });
221}
222
223export function register(on, options) {
224 // Slash command: /replay opens the pane.
225 on("session.start", async ($, e, next) => {
226 const r = await next(e);
227 await $.command.register({ name: "replay", description: "Replay Theater: step through the last turn's file edits" });
228 return r;
229 });
230
231 on("command.run", { command: "replay" }, async ($, e) => {
232 const opened = await openReplay($);
233 return { text: opened ? `Replay Theater: ${state.replay.length} edits` : "Replay Theater: no edits in the last turn." };
234 });
235
236 // Record each edit, then let it run. Never blocks the call.
237 // Modified for my-claude-mods: main-loop edits only (a subagent's carry
238 // `agentId`); the steps are read before the call (a Write's old file) but kept
239 // only once it succeeded (no deny, no error), and `pending` holds the newest
240 // MAX_PENDING of them.
241 on("tool.call", async ($, e, next) => {
242 if (!EDIT_TOOLS.has(e.tool) || e.agentId) return next(e);
243 let steps = [];
244 try { steps = await stepsFor($, e); } catch { /* recording must never stop the edit */ }
245 const r = await next(e);
246 if (steps.length && r && !r.deny && !r.isError) {
247 state.pending.push(...steps);
248 if (state.pending.length > MAX_PENDING) state.pending = state.pending.slice(-MAX_PENDING);
249 }
250 return r;
251 });
252
253 on("turn.start", ($, e, next) => {
254 if (!e.agentId) state.pending = [];
255 return next(e);
256 });
257
258 // At the end of a main-loop turn, the pending edits become the replay.
259 on("turn.complete", async ($, e, next) => {
260 const r = await next(e);
261 if (e.agentId || !state.pending.length) return r;
262 state.replay = state.pending;
263 state.pending = [];
264 state.index = 0;
265 state.turns++;
266 $.ui.invalidate("ui.render");
267 return r;
268 });
269
270 // The band above the prompt: the hint and a button that opens the pane.
271 // Modified for my-claude-mods: the row stacks above what the other mods and
272 // the engine draw here (`next(e)`), instead of replacing them; and it yields
273 // the band to a survey (`e.props.hasSurvey`).
274 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
275 if (e.props?.hasSurvey) return next(e);
276 const inBand = state.isOpen && state.inBand;
277 if (!inBand && (!state.replay.length || state.isOpen || (await isDocked($)))) return next(e);
278 const below = await next(e);
279 const { Box } = $.ui.resolve(e);
280 const mine = inBand ? replayView($, e, true) : hintRow($, e);
281 return Box({ flexDirection: "column", children: [mine, below] });
282 });
283
284 // The pane; and the hint under flightdeck's session log while it docks.
285 on("ui.render", { component: "Pane" }, async ($, e, next) => {
286 if (e.requestId === FLIGHTDECK) {
287 const above = await next(e);
288 if (!state.replay.length || state.isOpen || !(await isDocked($))) return above;
289 const { Box } = $.ui.resolve(e);
290 // beneath flightdeck's hook, `above` is the engine's own empty drawing: leave it out
291 return Box({ flexDirection: "column", children: [above?.type === "engine" ? null : above, hintRow($, e)] });
292 }
293 if (e.requestId !== PANE_ID) return next(e);
294 return replayView($, e, false);
295 });
296
297 // The person closed the pane (Escape): keep our flag in step.
298 on("ui.close", ($, e, next) => {
299 if (e.id === PANE_ID || e.requestId === PANE_ID) state.isOpen = false;
300 $.ui.invalidate("ui.render");
301 return next(e);
302 });
303}
304