SLOPSHOPPER

replay-theater

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

newpanebandguardcommandtoast
v0.2.0no licenseupdated 2026-10-23jz-wilson/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 │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ▶ Replay Theater step 1 of 3 │ ⏺ Read(src/auth.ts) │ ┃ │ 1 2 3 │ ⎿ Read 6 lines │ ┃ │ src/auth.ts │ ⏺ Update(src/auth.ts) │ ┃ │ Edit +2 -1 │ ⎿ Added 2 lines, removed 1 line │ ┃ │ │ ⏺ Bash(bun test) │ ┃ │ - if (!claims) throw new Error('invalid… │ ⎿ 3 pass, 1 fail │ ┃ │ + if (!claims || claims.exp < Date.now(… │ │ ┃ │ + await audit('refresh', claims.sub) │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ │ ┃ │ [ ◀ Prev ] [ Next ▶ ] [ Close ] │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ click to focus · /replay next, prev, cl… │ │ ┃ ╰──────────────────────────────────────────╯ › /replay │ ⎿ replay-theater: Replay Theater: 3 edits │ │ ⟨Claude Code's own drawing⟩ ▶ Replay: 3 edits (press r) [ Replay ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ▶ Replay: 3 edits (press r) [ Replay ]
Pane · Replay Theater
╭──────────────────────────────────────────────────────────╮ │ ▶ Replay Theater step 1 of 3 │ │ 1 2 3 │ │ src/auth.ts │ │ Edit +2 -1 │ │ │ │ - if (!claims) throw new Error('invalid token') │ │ + if (!claims || claims.exp < Date.now() / 1000) thr │ │ + await audit('refresh', claims.sub) │ │ │ │ [ ◀ Prev ] [ Next ▶ ] [ Close ] │ │ click to focus · /replay next, prev, close │ ╰──────────────────────────────────────────────────────────╯
README

Replay Theater

Anthropic's sample mod from anthropics/claude-code-playground, Apache License 2.0 (see LICENSE). Ben AI added one change: the /mods switchboard 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.

What this shows

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:

  • Recording from tool.call and always passing the call on with next(e).
  • Grouping work by turn with turn.start and turn.complete, and skipping subagent turns.
  • A slash command (/replay) registered from session.start and handled in command.run.
  • /replay next, /replay prev and /replay close drive the open view, mirroring its buttons.
  • A Pane with Button elements and hotkeys, with a fallback to the AbovePrompt band.

Demo

After a rename, the hint above the prompt:

The Replay hint above the prompt after a turn with five edits

The pane, on step 1 of 5:

The Replay Theater pane showing step 1 of 5

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

A Write step for a new file, with the diff capped at 12 lines

Inline above the prompt in an 80-column terminal:

The pane drawn inline in an 80-column terminal

How it was built

  • Model: built with Claude in Claude Code. The test runs and screenshots used Claude Sonnet 5. The mod itself doesn't call a model.
  • Prompt(s): the mod started as one of ten ideas Claude wrote for mods. This is the idea as written:

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.render on Pane, with Button elements, and it records each edit from tool.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.

  • Transcript: not shared. The build ran in an internal workspace.
  • Iterations:
  • The idea had the pane open by itself after each turn. The built mod shows a one-line hint above the prompt instead, and opens the pane when you ask for it with /replay or the Replay button.
  • A pane only takes the keyboard while nothing else holds it. When the band's Replay button is pressed, the band holds the keys, so the mod hides the band and waits briefly before it opens the pane.
  • A Write can replace a whole file, so the diff keeps only one line of context around each change, and caps each step at 12 lines.
  • Tested in Claude Code: a rename across three files made 5 edits, and the pane stepped through all of them with Prev, Next and Close. A Write of a new file showed as one step.

Run it

Requirements:

  • Claude Code 2.1.287 or later, where mods load by default. The mod was built and tested on 2.1.280, and claude plugin validate passes on 2.1.285.
  • A terminal. The pane is placed inline above the prompt, or docked to the right in the fullscreen layout.

No environment variables or configuration.

Steps:

  1. Clone this repository and go to this folder's parent:
   git clone https://github.com/anthropics/claude-code-playground.git
   cd claude-code-playground/claude-code/mods
  1. Check the plugin:
   claude plugin validate ./replay-theater
  1. Try it for one session:
   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
  1. Ask Claude for a change that edits files, for example "rename the function greet to welcome everywhere".
  2. When the turn ends, open the replay in one of two ways:
  3. Type /replay and press Enter.
  4. Press ctrl+x then Tab to focus the band above the prompt, then press r (or Enter on the Replay button).
  5. In the pane:
KeyDoes
nNext step
pPrevious step
c or EscapeClose 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.

Notes / limitations

  • The diff shows up to 12 lines per step, then "… N more lines". Unchanged lines are kept only next to a change, and a skipped run shows as "⋯".
  • For Edit, the diff is between old_string and new_string, not the whole file. The file's line numbers aren't shown.
  • For Write, the old text is read from disk just before the write. Files over 400 lines are shown as all removed, then all added, with no line matching.
  • Edits are recorded when the tool is called. An edit that you then deny, or that fails, still shows in the replay.
  • The replay is kept in memory for the current session only. It is lost when Claude Code restarts, or when the plugin reloads.
  • Edits made by subagents are included in the main turn's replay.
  • Claude Code 2.1.280 has no MultiEdit tool. The mod handles MultiEdit, one step per edit, for builds that have it. That path is untested.
  • If the terminal can't place a pane, the replay is drawn in the band above the prompt instead. That fallback is untested, because every width we tried (80 and 120 columns) placed the pane.
  • The band above the prompt is shared. If another mod also draws there, only one of them shows.

Dependencies

NameVersionLicense (SPDX)Source
None

Third-party notices

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.

Source 1 files
hooks/replay-theater.mjs 350 lines
1// 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