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

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.
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:
tool.call and always passing the call on with next(e).turn.start and turn.complete, and skipping subagent turns./replay) registered from session.start and handled in command.run.Pane with Button elements and hotkeys, with a fallback to the AbovePrompt band.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. 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.renderonPane, withButtonelements, and it records each edit fromtool.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.
/replay or the Replay button.Requirements:
claude plugin validate passes on 2.1.285.No environment variables or configuration.
Steps:
git clone https://github.com/anthropics/claude-code-playground.git
cd claude-code-playground/claude-code/mods
claude plugin validate ./replay-theater
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
/replay and press Enter.ctrl+x then Tab to focus the band above the prompt, then press r (or Enter on the Replay button).| Key | Does |
|---|---|
v | Mark the step reviewed and go to the next one (press again to unmark) |
n | Next step |
p | Previous step |
c or Escape | Close 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.
old_string and new_string, not the whole file. The file's line numbers aren't shown.| Name | Version | License (SPDX) | Source |
|---|---|---|---|
| None |
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.
hooks/replay-theater.mjs 352 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
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}
352hooks/diff.mjs 111 lines1// 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