SLOPSHOPPER

replay-theater

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…

newpanebandguardcommand
v0.1.0-mc.3Apache-2.0updated 2026-10-15aecheman27-gif/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 │ ┃ ╰──────────────────────────────────────────╯ │ › /replay │ ⎿ replay-theater: Replay Theater: 3 edits │ │ ▶ Replay: 3 edits (press r) [ Replay ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▶ Replay: 3 edits (press r) [ Replay ] ⟨Claude Code's own drawing⟩
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 ] │ ╰──────────────────────────────────────────────────────────╯
README

Replay Theater

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.
  • 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 304 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
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