SLOPSHOPPER

replay-theater

No description.

newpanebandguardcommand
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. │ ┃ │ │ │ ┃ │ p: ◀ Prev n: Next ▶ q: Close │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰──────────────────────────────────────────╯ │ › /replay │ ⎿ replay-theater: Replay Theater: 3 edits │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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) throw … │ │ + await audit('refresh', claims.sub) │ │ │ │ p: ◀ Prev n: Next ▶ q: 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, /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:

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

Hint

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:

CommandDoes
/replay hintToggle the hint
/replay hint onShow the hint
/replay hint offHide the hint (default)

Keys

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:

KeyDoes
n or EnterNext step. Enter presses the focused button, which is Next when the pane opens.
pPrevious step
1 to 9Jump to that step. Steps 10 and up have no key.
q or EscapeClose the pane
TabMove focus to the next button
Up / DownScroll 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.

Demo

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.

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, type /replay and press Enter.
  3. Step through the edits with the keys in Keys. 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 410 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;
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