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

Variante di Replay Theater, la mod di esempio pubblicata da Anthropic in claude-code-playground con licenza Apache-2.0. Registra le modifiche ai file dell'ultimo turno (Edit, Write, MultiEdit) e le mostra in un pannello, un diff alla volta, senza bloccare né alterare le chiamate.
▶ Replay: N edits e il relativo bottone. Il bottone Replay è disegnato dalla mod next-steps (tasto 3) e la banda resta libera per le righe delle altre mod. La banda viene usata solo come ripiego quando il terminale non può collocare il pannello.Il resto del codice è invariato. La modifica è segnalata anche nel sorgente, in hooks/replay-theater.mjs.
/replay — apre il pannello sull'ultimo turno che ha modificato file.n / p — passo successivo / precedente.c o Esc — chiude il pannello.Limiti, dettagli di implementazione e schermate sono descritti nel README originale.
Apache-2.0 — vedi LICENSE. Copyright dell'opera originale: Anthropic, PBC. Non è un prodotto ufficiale Anthropic.
hooks/replay-theater.mjs 245 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 = { pending: [], replay: [], index: 0, isOpen: false, inBand: false, turns: 0 };
17
18function relPath(cwd, path) {
19 if (!path) return "(unknown file)";
20 if (cwd && path.startsWith(cwd + "/")) return path.slice(cwd.length + 1);
21 return path;
22}
23
24function splitLines(text) {
25 if (text === undefined || text === null || text === "") return [];
26 const lines = String(text).split("\n");
27 if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
28 return lines;
29}
30
31// A line diff. Small inputs get an LCS diff, so unchanged lines show as
32// context; large ones fall back to all-removed then all-added.
33function diffLines(oldText, newText) {
34 const a = splitLines(oldText);
35 const b = splitLines(newText);
36 if (a.length > MAX_LCS_LINES || b.length > MAX_LCS_LINES) {
37 return [...a.map((t) => ({ op: "-", t })), ...b.map((t) => ({ op: "+", t }))];
38 }
39 const n = a.length, m = b.length;
40 const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
41 for (let i = n - 1; i >= 0; i--)
42 for (let j = m - 1; j >= 0; j--)
43 dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
44 const out = [];
45 let i = 0, j = 0;
46 while (i < n && j < m) {
47 if (a[i] === b[j]) { out.push({ op: " ", t: a[i] }); i++; j++; }
48 else if (dp[i + 1][j] >= dp[i][j + 1]) out.push({ op: "-", t: a[i++] });
49 else out.push({ op: "+", t: b[j++] });
50 }
51 while (i < n) out.push({ op: "-", t: a[i++] });
52 while (j < m) out.push({ op: "+", t: b[j++] });
53 return trimContext(out);
54}
55
56// Keep one line of context around each change, so a long file's Write shows
57// the changed lines, not the whole file.
58function trimContext(lines) {
59 if (!lines.some((l) => l.op !== " ")) return lines.slice(0, MAX_DIFF_LINES);
60 const keep = lines.map((l, k) =>
61 l.op !== " " || lines[k - 1]?.op && lines[k - 1].op !== " " || lines[k + 1]?.op && lines[k + 1].op !== " ");
62 const out = [];
63 let skipped = false;
64 lines.forEach((l, k) => {
65 if (keep[k]) { if (skipped && out.length) out.push({ op: "~", t: "⋯" }); out.push(l); skipped = false; }
66 else skipped = true;
67 });
68 return out;
69}
70
71function countChanges(diff) {
72 let add = 0, del = 0;
73 for (const l of diff) { if (l.op === "+") add++; else if (l.op === "-") del++; }
74 return { add, del };
75}
76
77// Turns one tool call into one or more steps.
78async function stepsFor($, e) {
79 let cwd = "";
80 try { cwd = await $.session.cwd(); } catch { /* keep the full path */ }
81 const file = relPath(cwd, e.file_path);
82 if (e.tool === "Edit") {
83 return [{ tool: "Edit", file, diff: diffLines(e.old_string, e.new_string), note: e.replace_all ? "replace all" : "" }];
84 }
85 if (e.tool === "MultiEdit" && Array.isArray(e.edits)) {
86 return e.edits.map((ed, k) => ({
87 tool: "MultiEdit", file, diff: diffLines(ed.old_string, ed.new_string), note: `edit ${k + 1} of ${e.edits.length}`,
88 }));
89 }
90 if (e.tool === "Write") {
91 let before = "";
92 let isNew = true;
93 try {
94 if (e.file_path && (await $.fs.exists(e.file_path))) {
95 before = await $.fs.read(e.file_path);
96 isNew = false;
97 }
98 } catch { /* unreadable: show it as a new file */ }
99 return [{ tool: "Write", file, diff: diffLines(before, e.content), note: isNew ? "new file" : "rewrite" }];
100 }
101 return [];
102}
103
104function hintText() {
105 const n = state.replay.length;
106 return `▶ Replay: ${n} edit${n === 1 ? "" : "s"} (press r)`;
107}
108
109async function openReplay($) {
110 if (!state.replay.length) return false;
111 state.index = 0;
112 state.isOpen = true;
113 // The pane only takes the keyboard while nothing else holds it. Hide the
114 // band first (it held the keys if its button was pressed), then open.
115 $.ui.invalidate("ui.render");
116 await $.clock.sleep(200);
117 const rows = Math.min(MAX_DIFF_LINES + 8, 22);
118 const placed = await $.ui.open({ id: PANE_ID, title: "Replay Theater", focus: true, closeOnEscape: true, rows, columns: 58 });
119 // No room for a pane (a narrow terminal): draw the replay in the band.
120 state.inBand = placed?.isPlaced === false;
121 $.ui.invalidate("ui.render");
122 return true;
123}
124
125// The replay view: one step at a time. Drawn in the Pane, or in the band
126// above the prompt when the surface can't place a pane.
127function replayView($, e, inBand) {
128 const { Box, Text, Button } = $.ui.resolve(e);
129 const maxDiff = inBand ? Math.max(3, Math.min(MAX_DIFF_LINES, (e.maxRows || 20) - 7)) : MAX_DIFF_LINES;
130 const total = state.replay.length;
131 if (!total) return Text({ dimColor: true, children: "No edits to replay." });
132 const k = Math.max(0, Math.min(state.index, total - 1));
133 const step = state.replay[k];
134 const width = Math.max(40, (e.bodyColumns || 100) - 4);
135 const { add, del } = countChanges(step.diff);
136 const shown = step.diff.slice(0, maxDiff);
137
138 const diffRows = shown.map((l, n) => {
139 const color = l.op === "+" ? "green" : l.op === "-" ? "red" : undefined;
140 const line = `${l.op === "~" ? " " : l.op} ${l.t}`.slice(0, width);
141 return Text({ key: `d${n}`, color, dimColor: l.op === " " || l.op === "~", wrap: "truncate-end", children: line });
142 });
143 if (step.diff.length > maxDiff) diffRows.push(Text({ key: "more", dimColor: true, children: ` … ${step.diff.length - maxDiff} more lines` }));
144 if (!shown.length) diffRows.push(Text({ key: "empty", dimColor: true, children: " (no line changes)" }));
145
146 // The step list: one cell per step, the current one highlighted.
147 const strip = state.replay.map((s, n) =>
148 Text({ key: `s${n}`, inverse: n === k, color: n === k ? "cyan" : undefined, dimColor: n !== k, children: ` ${n + 1} ` }));
149
150 const go = (to) => { state.index = Math.max(0, Math.min(to, total - 1)); $.ui.invalidate("ui.render"); };
151 const close = () => {
152 state.isOpen = false;
153 if (!state.inBand) $.ui.close({ id: PANE_ID });
154 state.inBand = false;
155 $.ui.invalidate("ui.render");
156 };
157
158 return Box({
159 flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1,
160 children: [
161 Box({ flexDirection: "row", justifyContent: "space-between", children: [
162 Text({ bold: true, color: "magenta", children: "▶ Replay Theater" }),
163 Text({ bold: true, children: `step ${k + 1} of ${total}` }),
164 ] }),
165 Box({ flexDirection: "row", children: strip }),
166 Text({ bold: true, color: "cyan", wrap: "truncate-start", children: step.file }),
167 Box({ flexDirection: "row", gap: 2, children: [
168 Text({ dimColor: true, children: `${step.tool}${step.note ? " · " + step.note : ""}` }),
169 Text({ color: "green", children: `+${add}` }),
170 Text({ color: "red", children: `-${del}` }),
171 ] }),
172 Box({ flexDirection: "column", marginTop: 1, children: diffRows }),
173 Box({ flexDirection: "row", gap: 2, marginTop: 1, children: [
174 Button({ key: "prev", label: "◀ Prev", hotkey: "p", onPress: () => go(k - 1) }),
175 Button({ key: "next", label: "Next ▶", hotkey: "n", autoFocus: true, onPress: () => go(k + 1) }),
176 Button({ key: "close", label: "Close", hotkey: "c", onPress: close }),
177 ] }),
178 ],
179 });
180}
181
182export function register(on, options) {
183 // Slash command: /replay opens the pane.
184 on("session.start", async ($, e, next) => {
185 const r = await next(e);
186 await $.command.register({ name: "replay", description: "Replay Theater: step through the last turn's file edits" });
187 return r;
188 });
189
190 on("command.run", { command: "replay" }, async ($, e) => {
191 const opened = await openReplay($);
192 return { text: opened ? `Replay Theater: ${state.replay.length} edits` : "Replay Theater: no edits in the last turn." };
193 });
194
195 // Record each edit, then let it run. Never blocks the call.
196 on("tool.call", async ($, e, next) => {
197 if (EDIT_TOOLS.has(e.tool)) {
198 try {
199 const steps = await stepsFor($, e);
200 state.pending.push(...steps);
201 } catch { /* recording must never stop the edit */ }
202 }
203 return next(e);
204 });
205
206 on("turn.start", ($, e, next) => {
207 if (!e.agentId) state.pending = [];
208 return next(e);
209 });
210
211 // At the end of a main-loop turn, the pending edits become the replay.
212 on("turn.complete", async ($, e, next) => {
213 const r = await next(e);
214 if (e.agentId || !state.pending.length) return r;
215 state.replay = state.pending;
216 state.pending = [];
217 state.index = 0;
218 state.turns++;
219 $.ui.invalidate("ui.render");
220 return r;
221 });
222
223 // The band above the prompt: only the fallback view when no pane fits.
224 // Modified from the Anthropic sample: the hint and its Replay button are
225 // gone, because the next-steps mod draws the Replay button (key 3) in the
226 // band, and the other mods' rows must stay visible.
227 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
228 if (state.isOpen && state.inBand) return replayView($, e, true);
229 return next(e);
230 });
231
232 // The pane.
233 on("ui.render", { component: "Pane" }, ($, e, next) => {
234 if (e.requestId !== PANE_ID) return next(e);
235 return replayView($, e, false);
236 });
237
238 // The person closed the pane (Escape): keep our flag in step.
239 on("ui.close", ($, e, next) => {
240 if (e.id === PANE_ID || e.requestId === PANE_ID) state.isOpen = false;
241 $.ui.invalidate("ui.render");
242 return next(e);
243 });
244}
245