SLOPSHOPPER

replay-theater

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

newpanebandguardcommandmodel
v0.1.0no licenseupdated 2026-10-09im-ksc/ks-claude-code-mods/mod/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 │ ┃ 3 edits in 3 files, +14 −1 │ ┃ ⏺ Read(src/auth.ts) │ ┃ › 1 auth.ts src ⎿ Read 6 lines │ ┃ 2 audit.ts src ⏺ Update(src/auth.ts) │ ┃ 3 cache.ts src ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Edit 1 of 3 · auth.ts ⎿ 3 pass, 1 fail │ ┃ │ ┃ [ Previous ] [ Next ] [ Reviewed ] [ Exp ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ╭────────────────────────────────────────╮ ✻ Worked for 42s · done 4:20 PM │ ┃ │− if (!claims) throw new Error('invalid…│ │ ┃ │+ if (!claims || claims.exp < Date.now(…│ › /replay │ ┃ │+ await audit('refresh', claims.sub) │ ⎿ replay-theater: Replay Theater: 3 edits │ ┃ ╰────────────────────────────────────────╯ │ 3 edits in the last turn [ Replay ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
3 edits in the last turn [ Replay ]
Pane · Replay Theater
3 edits in 3 files, +14 −1 › 1 auth.ts src +2 −1 2 audit.ts src +5 −0 3 cache.ts src +7 −0 Edit 1 of 3 · auth.ts [ Previous ] [ Next ] [ Reviewed ] [ Explain ] [ Close ╭────────────────────────────────────────────────────────╮ │− if (!claims) throw new Error('invalid token') │ │+ if (!claims || claims.exp < Date.now() / 1000) throw …│ │+ await audit('refresh', claims.sub) │ ╰────────────────────────────────────────────────────────╯
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. It also records Bash commands that change a file named in the command, such as sed -i or cat > file; files reached through a glob, find or a cd are missed. 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

Claude renames a function across three files. /replay opens the pane: n moves to the next edit, e asks for a plain-language explanation of it, v marks it reviewed, and c closes the pane.

Replay Theater stepping through a three-file rename

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
vMark the step reviewed and go to the next one (press again to unmark)
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 every changed line, under the buttons; the pane scrolls when it runs long. 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 2 files
hooks/replay-theater.mjs 352 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
9import { diffLines, countChanges, summary } from "./diff.mjs";
10
11const PANE_ID = "replay-theater";
12const MAX_TIMELINE = 8;
13const PANE_COLUMNS = 120;
14const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
15// Dark diff tints, so white text stays readable: line, then changed word.
16const ADD_LINE = "#0e1d14", ADD_WORD = "#183a23";
17const DEL_LINE = "#200e10", DEL_WORD = "#461b20";
18const EXPLAIN_ROWS = 8;
19
20// Module state. `pending` fills during a turn; `replay` is the last finished
21// turn's steps, the ones the pane shows.
22const state = { pending: [], replay: [], index: 0, isOpen: false, inBand: false, turns: 0 };
23
24function relPath(cwd, path) {
25  if (!path) return "(unknown file)";
26  if (cwd && path.startsWith(cwd + "/")) return path.slice(cwd.length + 1);
27  return path;
28}
29
30// Turns one tool call into one or more steps.
31async function stepsFor($, e) {
32  let cwd = "";
33  try { cwd = await $.session.cwd(); } catch { /* keep the full path */ }
34  const file = relPath(cwd, e.file_path);
35  if (e.tool === "Edit") {
36    return [{ tool: "Edit", file, diff: diffLines(e.old_string, e.new_string), note: e.replace_all ? "replace all" : "" }];
37  }
38  if (e.tool === "MultiEdit" && Array.isArray(e.edits)) {
39    return e.edits.map((ed, k) => ({
40      tool: "MultiEdit", file, diff: diffLines(ed.old_string, ed.new_string), note: `edit ${k + 1} of ${e.edits.length}`,
41    }));
42  }
43  if (e.tool === "Write") {
44    let before = "";
45    let isNew = true;
46    try {
47      if (e.file_path && (await $.fs.exists(e.file_path))) {
48        before = await $.fs.read(e.file_path);
49        isNew = false;
50      }
51    } catch { /* unreadable: show it as a new file */ }
52    return [{ tool: "Write", file, diff: diffLines(before, e.content), note: isNew ? "new file" : "rewrite" }];
53  }
54  return [];
55}
56
57// Words in a Bash command that look like file paths, made absolute. Globs,
58// variables and flags are skipped, so files reached that way aren't seen.
59function bashPaths(command, cwd) {
60  const words = String(command ?? "").split(/[\s;|&<>()]+/).map((w) => w.replace(/^['"]|['"]$/g, ""));
61  const paths = words.filter((w) => /[./]/.test(w) && !/^-|[$*?{}`'"=]/.test(w) && !/^\.+$/.test(w));
62  return [...new Set(paths.map((p) => (p.startsWith("/") || !cwd ? p : `${cwd}/${p}`)))].slice(0, 20);
63}
64
65// Each named file's text before a Bash command runs; null when it doesn't exist.
66async function snapshot($, paths) {
67  const texts = new Map();
68  for (const path of paths) {
69    try { texts.set(path, (await $.fs.exists(path)) ? await $.fs.read(path) : null); } catch { /* a folder or unreadable */ }
70  }
71  return texts;
72}
73
74// The files a Bash command changed, as steps.
75async function bashSteps($, cwd, before) {
76  const steps = [];
77  for (const [path, old] of before) {
78    try {
79      if (!(await $.fs.exists(path))) continue;
80      const now = await $.fs.read(path);
81      if (now !== old) steps.push({ tool: "Bash", file: relPath(cwd, path), diff: diffLines(old ?? "", now), note: old === null ? "new file" : "bash" });
82    } catch { /* unreadable */ }
83  }
84  return steps;
85}
86
87// A step's diff as unified-diff hunks; each "⋯" gap starts a new hunk.
88// Line numbers count from 1 within the edit, not the file.
89function unifiedDiff(lines) {
90  const hunks = [];
91  let h = null, a = 1, b = 1;
92  for (const l of lines) {
93    if (l.op === "~" || !h) {
94      h = { a, b, old: 0, new: 0, body: [] };
95      hunks.push(h);
96      if (l.op === "~") continue;
97    }
98    h.body.push(l.op + l.t);
99    if (l.op !== "+") { h.old++; a++; }
100    if (l.op !== "-") { h.new++; b++; }
101  }
102  return hunks.filter((x) => x.body.length)
103    .map((x) => `@@ -${x.a},${x.old} +${x.b},${x.new} @@\n${x.body.join("\n")}`).join("\n");
104}
105
106// The unslop skill's rules, for the explain prompt. Empty if it's missing.
107async function unslopRules($) {
108  try {
109    const text = await $.fs.read(`${await $.env.get("HOME")}/.claude/skills/unslop/SKILL.md`);
110    return text.replace(/^---[\s\S]*?---\s*/, "");
111  } catch { return ""; }
112}
113
114// Asks the model, over this conversation, what one edit's changed lines do.
115// The answer (or the failure) is kept on the step, so it's asked once.
116async function explain($, step) {
117  if (step.explain) return;
118  step.explain = { pending: true };
119  $.ui.invalidate("ui.render");
120  const rules = await unslopRules($);
121  const r = await $.model.fork({ prompt:
122    `Explain this edit to ${step.file} as simply as you can, for someone new to the code. ` +
123    `Use 2 to 5 short Markdown bullet points: what each changed line does and why it changed. No preamble.` +
124    (rules ? `\n\nWrite it following these rules:\n\n${rules}` : "") +
125    `\n\n\`\`\`diff\n${unifiedDiff(step.diff)}\n\`\`\`` })
126    .catch((err) => ({ isAnswered: false, reason: String(err?.message ?? err) }));
127  step.explain = r.isAnswered ? { text: r.text.trim() } : { error: `Couldn't explain (${r.reason}).` };
128  $.ui.invalidate("ui.render");
129}
130
131function hintText() {
132  const n = state.replay.length;
133  return `${n} edit${n === 1 ? "" : "s"} in the last turn`;
134}
135
136async function openReplay($) {
137  if (!state.replay.length) return false;
138  state.index = 0;
139  state.isOpen = true;
140  // The pane only takes the keyboard while nothing else holds it. Hide the
141  // band first (it held the keys if its button was pressed), then open.
142  $.ui.invalidate("ui.render");
143  await $.clock.sleep(200);
144  const rows = Math.min(60, 18 + Math.min(state.replay.length, MAX_TIMELINE) + Math.max(...state.replay.map((s) => s.diff.length)) + EXPLAIN_ROWS);
145  const placed = await $.ui.open({ id: PANE_ID, title: "Replay Theater", focus: true, closeOnEscape: true, rows, columns: PANE_COLUMNS });
146  // No room for a pane (a narrow terminal): draw the replay in the band.
147  state.inBand = placed?.isPlaced === false;
148  $.ui.invalidate("ui.render");
149  return true;
150}
151
152// The replay view: a summary, a timeline of every edit, then the current
153// edit's diff. Drawn in the Pane, or in the band above the prompt when the
154// surface can't place a pane (there without the timeline).
155function replayView($, e, inBand) {
156  const { Box, Text, Button, Markdown } = $.ui.resolve(e);
157  const total = state.replay.length;
158  if (!total) return Text({ dimColor: true, children: "No edits to replay." });
159  const k = Math.max(0, Math.min(state.index, total - 1));
160  const step = state.replay[k];
161  const last = k === total - 1;
162  const go = (to) => { state.index = Math.max(0, Math.min(to, total - 1)); $.ui.invalidate("ui.render"); };
163  const close = () => {
164    state.isOpen = false;
165    if (!state.inBand) $.ui.close({ id: PANE_ID });
166    state.inBand = false;
167    $.ui.invalidate("ui.render");
168  };
169  const split = (file) => {
170    const cut = file.lastIndexOf("/");
171    return cut < 0 ? { name: file, dir: "" } : { name: file.slice(cut + 1), dir: file.slice(0, cut) };
172  };
173
174  // Summary.
175  const sum = summary(state.replay);
176  const reviewed = state.replay.filter((s) => s.reviewed).length;
177  const header = Text({ key: "title", children: [
178    Text({ bold: true, children: sum.title }),
179    ", ",
180    Text({ color: "green", children: `+${sum.add}` }),
181    " ",
182    Text({ color: "red", children: `−${sum.del}` }),
183    reviewed ? `, ${reviewed} of ${total} reviewed` : "",
184  ] });
185
186  // Timeline: the current step gets a › pointer and a bold name, reviewed
187  // steps a green check and a dim name, the rest a dim number. Long turns show a window of steps. Names, folders and
188  // counts are padded to the widest in the window, so the columns line up.
189  const lo = Math.max(0, Math.min(k - Math.floor(MAX_TIMELINE / 2), total - MAX_TIMELINE));
190  const hi = Math.min(total, lo + MAX_TIMELINE);
191  const rows = state.replay.slice(lo, hi).map((s) => ({ ...split(s.file), c: countChanges(s.diff) }));
192  const widest = (f) => Math.max(...rows.map(f));
193  const nameW = widest((r) => r.name.length);
194  const dirW = widest((r) => r.dir.length);
195  const addW = widest((r) => String(r.c.add).length) + 1;
196  const delW = widest((r) => String(r.c.del).length) + 1;
197  // The label runs from the name to the counts: the row's width less the
198  // paddings, pointer, badge, counts, five gaps and two spare cells.
199  const cols = Math.min(e.props?.bodyColumns ?? e.viewport?.columns ?? 64, PANE_COLUMNS);
200  const labelW = Math.max(nameW + 1 + dirW, cols - 3 - 1 - 3 - addW - delW - 5 - 2);
201  const timeline = [];
202  if (lo > 0) timeline.push(Text({ key: "above", dimColor: true, children: `    ⋮ ${lo} earlier` }));
203  for (let n = lo; n < hi; n++) {
204    const { name, dir, c } = rows[n - lo];
205    const current = n === k;
206    const done = !!state.replay[n].reviewed;
207    const label = `${name.padEnd(nameW)} ${dir}`.padEnd(labelW);
208    timeline.push(Box({
209      key: `row${n}`, flexDirection: "row", gap: 1, paddingRight: 1,
210      children: [
211        Text({ children: current ? "›" : " " }),
212        done ? Text({ color: "green", children: " ✓ " }) : Text({ bold: current, dimColor: !current, children: ` ${n + 1} ` }),
213        // You're already on the current step, so its name isn't a button.
214        current ? Text({ bold: true, children: label })
215          : Button({ key: `go${n}`, label, plain: true, dimColor: done, onPress: () => go(n) }),
216        Box({ flexGrow: 1 }),
217        Text({ color: "green", children: `+${c.add}`.padStart(addW) }),
218        Text({ color: "red", children: `−${c.del}`.padStart(delW) }),
219      ],
220    }));
221  }
222  if (hi < total) timeline.push(Text({ key: "below", dimColor: true, children: `    ⋮ ${total - hi} more` }));
223
224  // The current edit's diff: whole lines tinted, changed words brighter.
225  const diffRows = step.diff.map((l, n) => {
226    if (l.op !== "+" && l.op !== "-") {
227      return Text({ key: `d${n}`, dimColor: true, wrap: "truncate-end", children: `  ${l.t}` });
228    }
229    const added = l.op === "+";
230    const segs = l.segs ?? [{ t: l.t, changed: false }];
231    return Box({
232      key: `d${n}`, flexDirection: "row", backgroundColor: added ? ADD_LINE : DEL_LINE,
233      children: [Text({ wrap: "truncate-end", children: [
234        Text({ bold: true, color: added ? "green" : "red", children: added ? "+ " : "− " }),
235        ...segs.map((g, i) => Text({
236          key: `w${i}`, backgroundColor: g.changed ? (added ? ADD_WORD : DEL_WORD) : undefined,
237          children: g.t,
238        })),
239      ] })],
240    });
241  });
242  if (!step.diff.length) diffRows.push(Text({ key: "empty", dimColor: true, children: "  (no line changes)" }));
243
244  return Box({
245    flexDirection: "column", paddingX: 1, gap: inBand ? 0 : 1,
246    children: [
247      header,
248      inBand ? null : Box({ flexDirection: "column", children: timeline }),
249      Box({ flexDirection: "row", gap: 1, children: [
250        Text({ key: "step-heading", dimColor: true, children: `Edit ${k + 1} of ${total} ·` }),
251        Text({ key: "step-name", bold: true, children: split(step.file).name }),
252        step.note ? Text({ key: "step-note", dimColor: true, children: step.note }) : null,
253      ] }),
254      // Buttons that don't fit wrap to another row, a blank line apart.
255      Box({ key: "buttons", flexDirection: "row", flexWrap: "wrap", columnGap: 2, rowGap: 1, children: [
256        Button({ key: "prev", label: "Previous", hotkey: "p", variant: "secondary", dimColor: k === 0, onPress: () => go(k - 1) }),
257        Button({ key: "next", label: last ? "Done" : "Next", hotkey: "n", variant: "secondary", onPress: () => (last ? close() : go(k + 1)) }),
258        // Marks the edit and moves on; on a reviewed edit, removes the mark.
259        Button({ key: "reviewed", label: step.reviewed ? "Unreview" : "Reviewed", hotkey: "v", variant: "primary", autoFocus: true,
260          onPress: () => { step.reviewed = !step.reviewed; go(step.reviewed ? k + 1 : k); } }),
261        Button({ key: "explain", label: step.explain?.pending ? "Explaining…" : step.explain?.text ? "Explained" : "Explain",
262          hotkey: "e", variant: "secondary", dimColor: !!step.explain?.text, onPress: () => { explain($, step); } }),
263        Button({ key: "close", label: "Close", hotkey: "c", role: "dismiss", dimColor: true, onPress: close }),
264      ] }),
265      Box({ key: "diff", flexDirection: "column", borderStyle: "round", borderColor: "gray", children: diffRows }),
266      step.explain?.text ? Box({ flexDirection: "column", children: [
267        Text({ key: "explain-label", dimColor: true, children: "Explanation" }),
268        Markdown({ key: "explain-text", text: step.explain.text }),
269      ] }) : null,
270      step.explain?.error ? Text({ key: "explain-error", wrap: "wrap", color: "red", children: step.explain.error }) : null,
271    ],
272  });
273}
274
275export function register(on, options) {
276  // Slash command: /replay opens the pane.
277  on("session.start", async ($, e, next) => {
278    const r = await next(e);
279    await $.command.register({ name: "replay", description: "Replay Theater: step through the last turn's file edits" });
280    return r;
281  });
282
283  on("command.run", { command: "replay" }, async ($, e) => {
284    const opened = await openReplay($);
285    return { text: opened ? `Replay Theater: ${state.replay.length} edits` : "Replay Theater: no edits in the last turn." };
286  });
287
288  // Record each edit, then let it run. Never blocks the call.
289  on("tool.call", async ($, e, next) => {
290    if (EDIT_TOOLS.has(e.tool)) {
291      try {
292        const steps = await stepsFor($, e);
293        state.pending.push(...steps);
294      } catch { /* recording must never stop the edit */ }
295    }
296    if (e.tool !== "Bash") return next(e);
297    // Bash: compare the files named in the command before and after it runs.
298    let cwd = "", before = new Map();
299    try {
300      cwd = await $.session.cwd();
301      before = await snapshot($, bashPaths(e.command, cwd));
302    } catch { /* recording must never stop the command */ }
303    const r = await next(e);
304    try { state.pending.push(...(await bashSteps($, cwd, before))); } catch { /* as above */ }
305    return r;
306  });
307
308  on("turn.start", ($, e, next) => {
309    if (!e.agentId) state.pending = [];
310    return next(e);
311  });
312
313  // At the end of a main-loop turn, the pending edits become the replay.
314  on("turn.complete", async ($, e, next) => {
315    const r = await next(e);
316    if (e.agentId || !state.pending.length) return r;
317    state.replay = state.pending;
318    state.pending = [];
319    state.index = 0;
320    state.turns++;
321    $.ui.invalidate("ui.render");
322    return r;
323  });
324
325  // The band above the prompt: the hint and a button that opens the pane.
326  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
327    if (state.isOpen && state.inBand) return replayView($, e, true);
328    if (!state.replay.length || state.isOpen) return next(e);
329    const { Box, Text, Button } = $.ui.resolve(e);
330    return Box({
331      flexDirection: "row", gap: 2, paddingX: 1,
332      children: [
333        Text({ color: "magenta", bold: true, children: hintText() }),
334        Button({ key: "open-replay", label: "Replay", hotkey: "r", onPress: () => openReplay($) }),
335      ],
336    });
337  });
338
339  // The pane.
340  on("ui.render", { component: "Pane" }, ($, e, next) => {
341    if (e.requestId !== PANE_ID) return next(e);
342    return replayView($, e, false);
343  });
344
345  // The person closed the pane (Escape): keep our flag in step.
346  on("ui.close", ($, e, next) => {
347    if (e.id === PANE_ID || e.requestId === PANE_ID) state.isOpen = false;
348    $.ui.invalidate("ui.render");
349    return next(e);
350  });
351}
352
hooks/diff.mjs 111 lines
1// Pure diff helpers for Replay Theater: line diffs, word highlights, counts.
2
3export const MAX_DIFF_LINES = 12;
4const MAX_LCS_LINES = 400;
5
6export function splitLines(text) {
7  if (text === undefined || text === null || text === "") return [];
8  const lines = String(text).split("\n");
9  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
10  return lines;
11}
12
13// LCS over two arrays: [op, item] pairs, " " kept, "-" from a, "+" from b.
14function lcs(a, b) {
15  const n = a.length, m = b.length;
16  const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
17  for (let i = n - 1; i >= 0; i--)
18    for (let j = m - 1; j >= 0; j--)
19      dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
20  const out = [];
21  let i = 0, j = 0;
22  while (i < n && j < m) {
23    if (a[i] === b[j]) { out.push([" ", a[i]]); i++; j++; }
24    else if (dp[i + 1][j] >= dp[i][j + 1]) out.push(["-", a[i++]]);
25    else out.push(["+", b[j++]]);
26  }
27  while (i < n) out.push(["-", a[i++]]);
28  while (j < m) out.push(["+", b[j++]]);
29  return out;
30}
31
32// A line diff. Lines shared at the start and end are set aside and the
33// middle gets an LCS diff, so unchanged lines show as context; a middle too
34// large for LCS falls back to all-removed then all-added.
35export function diffLines(oldText, newText) {
36  const a = splitLines(oldText);
37  const b = splitLines(newText);
38  let pre = 0, post = 0;
39  while (pre < a.length && pre < b.length && a[pre] === b[pre]) pre++;
40  while (post < a.length - pre && post < b.length - pre && a[a.length - 1 - post] === b[b.length - 1 - post]) post++;
41  const midA = a.slice(pre, a.length - post), midB = b.slice(pre, b.length - post);
42  const mid = midA.length > MAX_LCS_LINES || midB.length > MAX_LCS_LINES
43    ? [...midA.map((t) => ["-", t]), ...midB.map((t) => ["+", t])]
44    : lcs(midA, midB);
45  const pairs = [...a.slice(0, pre).map((t) => [" ", t]), ...mid, ...a.slice(a.length - post).map((t) => [" ", t])];
46  return addWords(trimContext(pairs.map(([op, t]) => ({ op, t }))));
47}
48
49// Keep one line of context around each change, so a long file's Write shows
50// the changed lines, not the whole file.
51function trimContext(lines) {
52  if (!lines.some((l) => l.op !== " ")) return lines.slice(0, MAX_DIFF_LINES);
53  const keep = lines.map((l, k) =>
54    l.op !== " " || lines[k - 1]?.op && lines[k - 1].op !== " " || lines[k + 1]?.op && lines[k + 1].op !== " ");
55  const out = [];
56  let skipped = false;
57  lines.forEach((l, k) => {
58    if (keep[k]) { if (skipped && out.length) out.push({ op: "~", t: "⋯" }); out.push(l); skipped = false; }
59    else skipped = true;
60  });
61  return out;
62}
63
64// Splits two lines into segments and marks the words that changed.
65export function wordDiff(oldLine, newLine) {
66  const tok = (s) => s.match(/\w+|\s+|[^\w\s]/g) ?? [];
67  const before = [], after = [];
68  const push = (list, t, changed) => {
69    const last = list[list.length - 1];
70    if (last && last.changed === changed) last.t += t; else list.push({ t, changed });
71  };
72  for (const [op, t] of lcs(tok(oldLine), tok(newLine))) {
73    if (op !== "+") push(before, t, op === "-");
74    if (op !== "-") push(after, t, op === "+");
75  }
76  return { before, after };
77}
78
79// In each run of removed lines followed by added lines, pairs them up in
80// order and gives each paired line `segs`, its words marked changed or not.
81function addWords(lines) {
82  for (let k = 0; k < lines.length; ) {
83    let d = k; while (lines[d]?.op === "-") d++;
84    let a = d; while (lines[a]?.op === "+") a++;
85    const pairs = Math.min(d - k, a - d);
86    for (let p = 0; p < pairs; p++) {
87      const { before, after } = wordDiff(lines[k + p].t, lines[d + p].t);
88      lines[k + p].segs = before;
89      lines[d + p].segs = after;
90    }
91    k = a > k ? a : k + 1;
92  }
93  return lines;
94}
95
96export function countChanges(diff) {
97  let add = 0, del = 0;
98  for (const l of diff) { if (l.op === "+") add++; else if (l.op === "-") del++; }
99  return { add, del };
100}
101
102const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
103
104// "4 edits in 4 files" and the turn's total added and removed lines.
105export function summary(steps) {
106  const files = new Set(steps.map((s) => s.file)).size;
107  let add = 0, del = 0;
108  for (const s of steps) { const c = countChanges(s.diff); add += c.add; del += c.del; }
109  return { title: `${plural(steps.length, "edit")} in ${plural(files, "file")}`, add, del };
110}
111