SLOPSHOPPER

replay-theater

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

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. │ ┃ │ │ │ ┃ │ [ ◀ Prev ] [ Next ▶ ] [ 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) │ │ │ │ [ ◀ Prev ] [ Next ▶ ] [ Close ] │ ╰──────────────────────────────────────────────────────────╯
Source 1 files
hooks/replay-theater.mjs 252 lines
1// Copyright 2026 Anthropic PBC
2// Recolored 2026-10-06 for Dillon: lavender Ultracode family (#A99BF5 accent, #D6CFFF bright, #8A84A8 muted, white emphasis).
3// SPDX-License-Identifier: Apache-2.0
4//
5// Replay Theater: records the file edits Claude makes in a turn, then lets you
6// step through them in a pane, one diff at a time.
7//
8// Claude Code 2.1.280 function hooks. Load with --plugin-dir.
9
10const PANE_ID = "replay-theater";
11const MAX_DIFF_LINES = 12;
12const MAX_LCS_LINES = 400;
13const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
14
15// Module state. `pending` fills during a turn; `replay` is the last finished
16// turn's steps, the ones the pane shows.
17const state = { pending: [], replay: [], index: 0, isOpen: false, inBand: false, turns: 0 };
18
19function relPath(cwd, path) {
20  if (!path) return "(unknown file)";
21  if (cwd && path.startsWith(cwd + "/")) return path.slice(cwd.length + 1);
22  return path;
23}
24
25function splitLines(text) {
26  if (text === undefined || text === null || text === "") return [];
27  const lines = String(text).split("\n");
28  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
29  return lines;
30}
31
32// A line diff. Small inputs get an LCS diff, so unchanged lines show as
33// context; large ones fall back to all-removed then all-added.
34function diffLines(oldText, newText) {
35  const a = splitLines(oldText);
36  const b = splitLines(newText);
37  if (a.length > MAX_LCS_LINES || b.length > MAX_LCS_LINES) {
38    return [...a.map((t) => ({ op: "-", t })), ...b.map((t) => ({ op: "+", t }))];
39  }
40  const n = a.length, m = b.length;
41  const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
42  for (let i = n - 1; i >= 0; i--)
43    for (let j = m - 1; j >= 0; j--)
44      dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
45  const out = [];
46  let i = 0, j = 0;
47  while (i < n && j < m) {
48    if (a[i] === b[j]) { out.push({ op: " ", t: a[i] }); i++; j++; }
49    else if (dp[i + 1][j] >= dp[i][j + 1]) out.push({ op: "-", t: a[i++] });
50    else out.push({ op: "+", t: b[j++] });
51  }
52  while (i < n) out.push({ op: "-", t: a[i++] });
53  while (j < m) out.push({ op: "+", t: b[j++] });
54  return trimContext(out);
55}
56
57// Keep one line of context around each change, so a long file's Write shows
58// the changed lines, not the whole file.
59function trimContext(lines) {
60  if (!lines.some((l) => l.op !== " ")) return lines.slice(0, MAX_DIFF_LINES);
61  const keep = lines.map((l, k) =>
62    l.op !== " " || lines[k - 1]?.op && lines[k - 1].op !== " " || lines[k + 1]?.op && lines[k + 1].op !== " ");
63  const out = [];
64  let skipped = false;
65  lines.forEach((l, k) => {
66    if (keep[k]) { if (skipped && out.length) out.push({ op: "~", t: "⋯" }); out.push(l); skipped = false; }
67    else skipped = true;
68  });
69  return out;
70}
71
72function countChanges(diff) {
73  let add = 0, del = 0;
74  for (const l of diff) { if (l.op === "+") add++; else if (l.op === "-") del++; }
75  return { add, del };
76}
77
78// Turns one tool call into one or more steps.
79async function stepsFor($, e) {
80  let cwd = "";
81  try { cwd = await $.session.cwd(); } catch { /* keep the full path */ }
82  const file = relPath(cwd, e.file_path);
83  if (e.tool === "Edit") {
84    return [{ tool: "Edit", file, diff: diffLines(e.old_string, e.new_string), note: e.replace_all ? "replace all" : "" }];
85  }
86  if (e.tool === "MultiEdit" && Array.isArray(e.edits)) {
87    return e.edits.map((ed, k) => ({
88      tool: "MultiEdit", file, diff: diffLines(ed.old_string, ed.new_string), note: `edit ${k + 1} of ${e.edits.length}`,
89    }));
90  }
91  if (e.tool === "Write") {
92    let before = "";
93    let isNew = true;
94    try {
95      if (e.file_path && (await $.fs.exists(e.file_path))) {
96        before = await $.fs.read(e.file_path);
97        isNew = false;
98      }
99    } catch { /* unreadable: show it as a new file */ }
100    return [{ tool: "Write", file, diff: diffLines(before, e.content), note: isNew ? "new file" : "rewrite" }];
101  }
102  return [];
103}
104
105function hintText() {
106  const n = state.replay.length;
107  return `▶ Replay: ${n} edit${n === 1 ? "" : "s"} (press r)`;
108}
109
110async function openReplay($) {
111  if (!state.replay.length) return false;
112  state.index = 0;
113  state.isOpen = true;
114  // The pane only takes the keyboard while nothing else holds it. Hide the
115  // band first (it held the keys if its button was pressed), then open.
116  $.ui.invalidate("ui.render");
117  await $.clock.sleep(200);
118  const rows = Math.min(MAX_DIFF_LINES + 8, 22);
119  const placed = await $.ui.open({ id: PANE_ID, title: "Replay Theater", focus: true, closeOnEscape: true, rows, columns: 58 });
120  // No room for a pane (a narrow terminal): draw the replay in the band.
121  state.inBand = placed?.isPlaced === false;
122  $.ui.invalidate("ui.render");
123  return true;
124}
125
126// The replay view: one step at a time. Drawn in the Pane, or in the band
127// above the prompt when the surface can't place a pane.
128function replayView($, e, inBand) {
129    const { Box, Text, Button } = $.ui.resolve(e);
130    const maxDiff = inBand ? Math.max(3, Math.min(MAX_DIFF_LINES, (e.maxRows || 20) - 7)) : MAX_DIFF_LINES;
131    const total = state.replay.length;
132    if (!total) return Text({ dimColor: true, children: "No edits to replay." });
133    const k = Math.max(0, Math.min(state.index, total - 1));
134    const step = state.replay[k];
135    const width = Math.max(40, (e.bodyColumns || 100) - 4);
136    const { add, del } = countChanges(step.diff);
137    const shown = step.diff.slice(0, maxDiff);
138
139    const diffRows = shown.map((l, n) => {
140      const color = l.op === "+" ? "#D6CFFF" : l.op === "-" ? "#8A84A8" : undefined;
141      const line = `${l.op === "~" ? " " : l.op} ${l.t}`.slice(0, width);
142      return Text({ key: `d${n}`, color, dimColor: l.op === " " || l.op === "~", wrap: "truncate-end", children: line });
143    });
144    if (step.diff.length > maxDiff) diffRows.push(Text({ key: "more", dimColor: true, children: `  … ${step.diff.length - maxDiff} more lines` }));
145    if (!shown.length) diffRows.push(Text({ key: "empty", dimColor: true, children: "  (no line changes)" }));
146
147    // The step list: one cell per step, the current one highlighted.
148    const strip = state.replay.map((s, n) =>
149      Text({ key: `s${n}`, inverse: n === k, color: n === k ? "#D6CFFF" : undefined, dimColor: n !== k, children: ` ${n + 1} ` }));
150
151    const go = (to) => { state.index = Math.max(0, Math.min(to, total - 1)); $.ui.invalidate("ui.render"); };
152    const close = () => {
153      state.isOpen = false;
154      if (!state.inBand) $.ui.close({ id: PANE_ID });
155      state.inBand = false;
156      $.ui.invalidate("ui.render");
157    };
158
159    return Box({
160      flexDirection: "column", borderStyle: "round", borderColor: "#A99BF5", paddingX: 1,
161      children: [
162        Box({ flexDirection: "row", justifyContent: "space-between", children: [
163          Text({ bold: true, color: "#A99BF5", children: "▶ Replay Theater" }),
164          Text({ bold: true, children: `step ${k + 1} of ${total}` }),
165        ] }),
166        Box({ flexDirection: "row", children: strip }),
167        Text({ bold: true, color: "#D6CFFF", wrap: "truncate-start", children: step.file }),
168        Box({ flexDirection: "row", gap: 2, children: [
169          Text({ dimColor: true, children: `${step.tool}${step.note ? " · " + step.note : ""}` }),
170          Text({ color: "#D6CFFF", children: `+${add}` }),
171          Text({ color: "#8A84A8", children: `-${del}` }),
172        ] }),
173        Box({ flexDirection: "column", marginTop: 1, children: diffRows }),
174        Box({ flexDirection: "row", gap: 2, marginTop: 1, children: [
175          Button({ key: "prev", label: "◀ Prev", hotkey: "p", onPress: () => go(k - 1) }),
176          Button({ key: "next", label: "Next ▶", hotkey: "n", autoFocus: true, onPress: () => go(k + 1) }),
177          Button({ key: "close", label: "Close", hotkey: "c", onPress: close }),
178        ] }),
179      ],
180    });
181}
182
183export function register(on, options) {
184  // Slash command: /replay opens the pane.
185  on("session.start", async ($, e, next) => {
186    const r = await next(e);
187    await $.command.register({ name: "replay", description: "Replay Theater: step through the last turn's file edits" });
188    return r;
189  });
190
191  on("command.run", { command: "replay" }, async ($, e) => {
192    const opened = await openReplay($);
193    return { text: opened ? `Replay Theater: ${state.replay.length} edits` : "Replay Theater: no edits in the last turn." };
194  });
195
196  // Record each edit, then let it run. Never blocks the call.
197  on("tool.call", async ($, e, next) => {
198    if (EDIT_TOOLS.has(e.tool)) {
199      try {
200        const steps = await stepsFor($, e);
201        state.pending.push(...steps);
202      } catch { /* recording must never stop the edit */ }
203    }
204    return next(e);
205  });
206
207  on("turn.start", ($, e, next) => {
208    if (!e.agentId) state.pending = [];
209    return next(e);
210  });
211
212  // At the end of a main-loop turn, the pending edits become the replay.
213  on("turn.complete", async ($, e, next) => {
214    const r = await next(e);
215    if (e.agentId || !state.pending.length) return r;
216    state.replay = state.pending;
217    state.pending = [];
218    state.index = 0;
219    state.turns++;
220    $.ui.invalidate("ui.render");
221    return r;
222  });
223
224  // The band above the prompt: the hint and a button that opens the pane.
225  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
226    if (state.isOpen && state.inBand) return replayView($, e, true);
227    // Dillon change: no idle hint band (one less row above the prompt). Use /replay.
228    if (true) return next(e);
229    const { Box, Text, Button } = $.ui.resolve(e);
230    return Box({
231      flexDirection: "row", gap: 2, paddingX: 1,
232      children: [
233        Text({ color: "#A99BF5", bold: true, children: hintText() }),
234        Button({ key: "open-replay", label: "Replay", hotkey: "r", onPress: () => openReplay($) }),
235      ],
236    });
237  });
238
239  // The pane.
240  on("ui.render", { component: "Pane" }, ($, e, next) => {
241    if (e.requestId !== PANE_ID) return next(e);
242    return replayView($, e, false);
243  });
244
245  // The person closed the pane (Escape): keep our flag in step.
246  on("ui.close", ($, e, next) => {
247    if (e.id === PANE_ID || e.requestId === PANE_ID) state.isOpen = false;
248    $.ui.invalidate("ui.render");
249    return next(e);
250  });
251}
252