No description.

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, /replay opens the edits. If you turn on the hint with /replay hint, it appears above the prompt after each turn with edits:
▶ Replay: 5 edits · /replay [ 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.The hint above the prompt is off by default. Toggle it with a subcommand, and Claude Code keeps your choice across sessions in the mod's store:
| Command | Does |
|---|---|
/replay hint | Toggle the hint |
/replay hint on | Show the hint |
/replay hint off | Hide the hint (default) |
Open the replay with /replay. While the hint shows, you can also click its Replay button, or press ctrl+x then Tab to focus it and press Enter. The button has no letter key: a mod's hotkey works only while its band or pane has focus, so a letter typed in the prompt goes into the prompt.
The pane takes the keyboard when it opens:
| Key | Does |
|---|---|
n or Enter | Next step. Enter presses the focused button, which is Next when the pane opens. |
p | Previous step |
1 to 9 | Jump to that step. Steps 10 and up have no key. |
q or Escape | Close the pane |
Tab | Move focus to the next button |
Up / Down | Scroll the pane when the diff is taller than the pane |
The buttons show their keys (p: ◀ Prev, n: Next ▶, q: Close), and the numbered step buttons use their digit. Mouse clicks work too. If you move focus back to the prompt (ctrl+x then Tab), the keys stop working until you focus the pane again the same way.
These keys differ from upstream, which used r to open the replay and c to close the pane.
Screenshots of the hint, the pane, a Write step and an 80-column terminal are in the upstream screenshots/ folder. They show the upstream keys, not the ones in Keys.
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.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 410 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;
12const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
13
14// Module state. `pending` fills during a turn; `replay` is the last finished
15// turn's steps, the ones the pane shows.
16const state = {
17 pending: [],
18 replay: [],
19 index: 0,
20 isOpen: false,
21 inBand: false,
22 turns: 0,
23 // The hint above the prompt. Off by default; `/replay hint` toggles it,
24 // and $.store keeps the choice across sessions.
25 showHint: false,
26};
27
28function relPath(cwd, path) {
29 if (!path) return "(unknown file)";
30 if (cwd && path.startsWith(cwd + "/")) return path.slice(cwd.length + 1);
31 return path;
32}
33
34function splitLines(text) {
35 if (text === undefined || text === null || text === "") return [];
36 const lines = String(text).split("\n");
37 if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
38 return lines;
39}
40
41// A line diff. Small inputs get an LCS diff, so unchanged lines show as
42// context; large ones fall back to all-removed then all-added.
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.map((t) => ({ op: "-", t })), ...b.map((t) => ({ op: "+", t }))];
48 }
49 const n = a.length,
50 m = b.length;
51 const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
52 for (let i = n - 1; i >= 0; i--)
53 for (let j = m - 1; j >= 0; j--)
54 dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
55 const out = [];
56 let i = 0,
57 j = 0;
58 while (i < n && j < m) {
59 if (a[i] === b[j]) {
60 out.push({ op: " ", t: a[i] });
61 i++;
62 j++;
63 } else if (dp[i + 1][j] >= dp[i][j + 1]) out.push({ op: "-", t: a[i++] });
64 else out.push({ op: "+", t: b[j++] });
65 }
66 while (i < n) out.push({ op: "-", t: a[i++] });
67 while (j < m) out.push({ op: "+", t: b[j++] });
68 return trimContext(out);
69}
70
71// Keep one line of context around each change, so a long file's Write shows
72// the changed lines, not the whole file.
73function trimContext(lines) {
74 if (!lines.some((l) => l.op !== " ")) return lines.slice(0, MAX_DIFF_LINES);
75 const keep = lines.map(
76 (l, k) =>
77 l.op !== " " ||
78 (lines[k - 1]?.op && lines[k - 1].op !== " ") ||
79 (lines[k + 1]?.op && lines[k + 1].op !== " "),
80 );
81 const out = [];
82 let skipped = false;
83 lines.forEach((l, k) => {
84 if (keep[k]) {
85 if (skipped && out.length) out.push({ op: "~", t: "⋯" });
86 out.push(l);
87 skipped = false;
88 } else skipped = true;
89 });
90 return out;
91}
92
93function countChanges(diff) {
94 let add = 0,
95 del = 0;
96 for (const l of diff) {
97 if (l.op === "+") add++;
98 else if (l.op === "-") del++;
99 }
100 return { add, del };
101}
102
103// Turns one tool call into one or more steps.
104async function stepsFor($, e) {
105 let cwd = "";
106 try {
107 cwd = await $.session.cwd();
108 } catch {
109 /* keep the full path */
110 }
111 const file = relPath(cwd, e.file_path);
112 if (e.tool === "Edit") {
113 return [
114 {
115 tool: "Edit",
116 file,
117 diff: diffLines(e.old_string, e.new_string),
118 note: e.replace_all ? "replace all" : "",
119 },
120 ];
121 }
122 if (e.tool === "MultiEdit" && Array.isArray(e.edits)) {
123 return e.edits.map((ed, k) => ({
124 tool: "MultiEdit",
125 file,
126 diff: diffLines(ed.old_string, ed.new_string),
127 note: `edit ${k + 1} of ${e.edits.length}`,
128 }));
129 }
130 if (e.tool === "Write") {
131 let before = "";
132 let isNew = true;
133 try {
134 if (e.file_path && (await $.fs.exists(e.file_path))) {
135 before = await $.fs.read(e.file_path);
136 isNew = false;
137 }
138 } catch {
139 /* unreadable: show it as a new file */
140 }
141 return [
142 {
143 tool: "Write",
144 file,
145 diff: diffLines(before, e.content),
146 note: isNew ? "new file" : "rewrite",
147 },
148 ];
149 }
150 return [];
151}
152
153function hintText() {
154 const n = state.replay.length;
155 return `▶ Replay: ${n} edit${n === 1 ? "" : "s"} · /replay`;
156}
157
158async function openReplay($) {
159 if (!state.replay.length) return false;
160 state.index = 0;
161 state.isOpen = true;
162 // The pane only takes the keyboard while nothing else holds it. Hide the
163 // band first (it held the keys if its button was pressed), then open.
164 $.ui.invalidate("ui.render");
165 await $.clock.sleep(200);
166 const rows = Math.min(MAX_DIFF_LINES + 8, 22);
167 const placed = await $.ui.open({
168 id: PANE_ID,
169 title: "Replay Theater",
170 focus: true,
171 closeOnEscape: true,
172 rows,
173 columns: 58,
174 });
175 // No room for a pane (a narrow terminal): draw the replay in the band.
176 state.inBand = placed?.isPlaced === false;
177 $.ui.invalidate("ui.render");
178 return true;
179}
180
181// The replay view: one step at a time. Drawn in the Pane, or in the band
182// above the prompt when the surface can't place a pane.
183function replayView($, e, inBand) {
184 const { Box, Text, Button } = $.ui.resolve(e);
185 const maxDiff = inBand
186 ? Math.max(3, Math.min(MAX_DIFF_LINES, (e.maxRows || 20) - 7))
187 : MAX_DIFF_LINES;
188 const total = state.replay.length;
189 if (!total) return Text({ dimColor: true, children: "No edits to replay." });
190 const k = Math.max(0, Math.min(state.index, total - 1));
191 const step = state.replay[k];
192 const width = Math.max(40, (e.bodyColumns || 100) - 4);
193 const { add, del } = countChanges(step.diff);
194 const shown = step.diff.slice(0, maxDiff);
195
196 const diffRows = shown.map((l, n) => {
197 const color = l.op === "+" ? "green" : l.op === "-" ? "red" : undefined;
198 const line = `${l.op === "~" ? " " : l.op} ${l.t}`.slice(0, width);
199 return Text({
200 key: `d${n}`,
201 color,
202 dimColor: l.op === " " || l.op === "~",
203 wrap: "truncate-end",
204 children: line,
205 });
206 });
207 if (step.diff.length > maxDiff)
208 diffRows.push(
209 Text({
210 key: "more",
211 dimColor: true,
212 children: ` … ${step.diff.length - maxDiff} more lines`,
213 }),
214 );
215 if (!shown.length)
216 diffRows.push(Text({ key: "empty", dimColor: true, children: " (no line changes)" }));
217
218 const go = (to) => {
219 state.index = Math.max(0, Math.min(to, total - 1));
220 $.ui.invalidate("ui.render");
221 };
222
223 // The step list: one button per step, the current one highlighted. A
224 // hotkey is one digit, so only steps 1 to 9 get one.
225 const strip = state.replay.map((s, n) =>
226 Button({
227 key: `s${n}`,
228 label: String(n + 1),
229 hotkey: n < 9 ? String(n + 1) : undefined,
230 variant: n === k ? "primary" : undefined,
231 dimColor: n !== k,
232 onPress: () => go(n),
233 }),
234 );
235 const close = () => {
236 state.isOpen = false;
237 if (!state.inBand) $.ui.close({ id: PANE_ID });
238 state.inBand = false;
239 $.ui.invalidate("ui.render");
240 };
241
242 return Box({
243 flexDirection: "column",
244 borderStyle: "round",
245 borderColor: "magenta",
246 paddingX: 1,
247 children: [
248 Box({
249 flexDirection: "row",
250 justifyContent: "space-between",
251 children: [
252 Text({ bold: true, color: "magenta", children: "▶ Replay Theater" }),
253 Text({ bold: true, children: `step ${k + 1} of ${total}` }),
254 ],
255 }),
256 Box({
257 flexDirection: "row",
258 flexWrap: "wrap",
259 columnGap: 1,
260 children: strip,
261 }),
262 Text({
263 bold: true,
264 color: "cyan",
265 wrap: "truncate-start",
266 children: step.file,
267 }),
268 Box({
269 flexDirection: "row",
270 gap: 2,
271 children: [
272 Text({
273 dimColor: true,
274 children: `${step.tool}${step.note ? " · " + step.note : ""}`,
275 }),
276 Text({ color: "green", children: `+${add}` }),
277 Text({ color: "red", children: `-${del}` }),
278 ],
279 }),
280 Box({ flexDirection: "column", marginTop: 1, children: diffRows }),
281 // Plain buttons draw their hotkey before the label: "p: ◀ Prev".
282 Box({
283 flexDirection: "row",
284 gap: 2,
285 marginTop: 1,
286 children: [
287 Button({
288 key: "prev",
289 label: "◀ Prev",
290 hotkey: "p",
291 plain: true,
292 onPress: () => go(k - 1),
293 }),
294 Button({
295 key: "next",
296 label: "Next ▶",
297 hotkey: "n",
298 plain: true,
299 autoFocus: true,
300 onPress: () => go(k + 1),
301 }),
302 Button({
303 key: "close",
304 label: "Close",
305 hotkey: "q",
306 plain: true,
307 role: "dismiss",
308 onPress: close,
309 }),
310 ],
311 }),
312 ],
313 });
314}
315
316export function register(on, options) {
317 // Slash command: /replay opens the pane; /replay hint [on|off] toggles the
318 // hint above the prompt.
319 on("session.start", async ($, e, next) => {
320 const r = await next(e);
321 state.showHint = (await $.store.get("showHint")) === true;
322 await $.command.register({
323 name: "replay",
324 description: "Replay Theater: step through the last turn's file edits",
325 argumentHint: "[hint [on|off]]",
326 });
327 return r;
328 });
329
330 on("command.run", { command: "replay" }, async ($, e) => {
331 const [sub, value] = e.args.trim().toLowerCase().split(/\s+/);
332 if (sub === "hint") {
333 state.showHint = value === "on" ? true : value === "off" ? false : !state.showHint;
334 await $.store.set("showHint", state.showHint);
335 $.ui.invalidate("ui.render");
336 return { text: `Replay Theater: hint ${state.showHint ? "on" : "off"}` };
337 }
338 if (sub) return { text: "Replay Theater: usage: /replay [hint [on|off]]" };
339 const opened = await openReplay($);
340 return {
341 text: opened
342 ? `Replay Theater: ${state.replay.length} edits`
343 : "Replay Theater: no edits in the last turn.",
344 };
345 });
346
347 // Record each edit, then let it run. Never blocks the call.
348 on("tool.call", async ($, e, next) => {
349 if (EDIT_TOOLS.has(e.tool)) {
350 try {
351 const steps = await stepsFor($, e);
352 state.pending.push(...steps);
353 } catch {
354 /* recording must never stop the edit */
355 }
356 }
357 return next(e);
358 });
359
360 on("turn.start", ($, e, next) => {
361 if (!e.agentId) state.pending = [];
362 return next(e);
363 });
364
365 // At the end of a main-loop turn, the pending edits become the replay.
366 on("turn.complete", async ($, e, next) => {
367 const r = await next(e);
368 if (e.agentId || !state.pending.length) return r;
369 state.replay = state.pending;
370 state.pending = [];
371 state.index = 0;
372 state.turns++;
373 $.ui.invalidate("ui.render");
374 return r;
375 });
376
377 // The band above the prompt: the hint and a button that opens the pane.
378 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
379 if (state.isOpen && state.inBand) return replayView($, e, true);
380 if (!state.showHint || !state.replay.length || state.isOpen) return next(e);
381 const { Box, Text, Button } = $.ui.resolve(e);
382 return Box({
383 flexDirection: "row",
384 gap: 2,
385 paddingX: 1,
386 children: [
387 Text({ color: "magenta", bold: true, children: hintText() }),
388 Button({
389 key: "open-replay",
390 label: "Replay",
391 onPress: () => openReplay($),
392 }),
393 ],
394 });
395 });
396
397 // The pane.
398 on("ui.render", { component: "Pane" }, ($, e, next) => {
399 if (e.requestId !== PANE_ID) return next(e);
400 return replayView($, e, false);
401 });
402
403 // The person closed the pane (Escape): keep our flag in step.
404 on("ui.close", ($, e, next) => {
405 if (e.id === PANE_ID || e.requestId === PANE_ID) state.isOpen = false;
406 $.ui.invalidate("ui.render");
407 return next(e);
408 });
409}
410