Replay Theater: a live timeline of everything Claude does in a turn (commands, reads, searches, edits) with diffs and output.

Changes in this copy (from anthropics/claude-code-playground,
claude-code/mods/replay-theater, Apache-2.0):
- Records every tool call in a turn, not only edits: commands with their output and exit status, reads, searches, web lookups, subagents and other tools. Failed calls are kept and marked.
- Live: steps appear as each call starts, with a running indicator, and fill in when it finishes; the pane and the band show "This turn · Live".
- A desktop-app view: a summary header with a diffstat, filter segments (All, Edits, Commands, Reads, Searches), each step as a rounded chip, five at a time, scrolled with the wheel and faded at the edges, and a detail card with the diff (real line numbers), the command and its output, or the file read.
- Paths shown relative to the session folder on Windows too; a themed band above the prompt.
The description below is the original mod's.
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. 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.After a rename, the hint above the prompt:

The pane, on step 1 of 5:

A Write step for a new file, capped at 12 lines:

Inline above the prompt in an 80-column terminal:

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 |
|---|---|
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 751 lines1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Replay Theater: records every tool call Claude makes in a turn (edits,
5// commands, reads, searches) and lets you step through them in a pane.
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", "NotebookEdit"]);
13
14// Module state. `pending` fills during a turn; `replay` is the last finished
15// turn's steps, the ones the pane shows.
16// `isLive` while the turn that fills `replay` is still running; `offset` is
17// the list window's first row; `follow` keeps the newest step selected.
18const LIST_WINDOW = 5;
19const state = {
20 pending: [], replay: [], index: 0, filter: "all", expanded: false,
21 isLive: false, offset: 0, follow: false, listRows: { top: 0, bottom: 0 },
22 isOpen: false, inBand: false, turns: 0, turnStartedAt: 0, turnMs: 0,
23};
24
25// ── Recording ─────────────────────────────────────────────────────────────
26
27// Paths are shown with forward slashes, relative to the session's folder.
28// Windows paths compare case-insensitively.
29function relPath(cwd, path) {
30 if (!path) return "(unknown file)";
31 const p = String(path).replace(/\\/g, "/");
32 const c = String(cwd || "").replace(/\\/g, "/").replace(/\/+$/, "");
33 if (c && p.toLowerCase().startsWith(c.toLowerCase() + "/")) return p.slice(c.length + 1);
34 return p;
35}
36
37function splitLines(text) {
38 if (text === undefined || text === null || text === "") return [];
39 const lines = String(text).split("\n");
40 if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
41 return lines;
42}
43
44// A line diff. Small inputs get an LCS diff, so unchanged lines show as
45// context; large ones fall back to all-removed then all-added.
46function diffLines(oldText, newText) {
47 const a = splitLines(oldText);
48 const b = splitLines(newText);
49 if (a.length > MAX_LCS_LINES || b.length > MAX_LCS_LINES) {
50 return [...a.map((t) => ({ op: "-", t })), ...b.map((t) => ({ op: "+", t }))];
51 }
52 const n = a.length, m = b.length;
53 const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
54 for (let i = n - 1; i >= 0; i--)
55 for (let j = m - 1; j >= 0; j--)
56 dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
57 const out = [];
58 let i = 0, j = 0;
59 while (i < n && j < m) {
60 if (a[i] === b[j]) { out.push({ op: " ", t: a[i] }); i++; j++; }
61 else if (dp[i + 1][j] >= dp[i][j + 1]) out.push({ op: "-", t: a[i++] });
62 else out.push({ op: "+", t: b[j++] });
63 }
64 while (i < n) out.push({ op: "-", t: a[i++] });
65 while (j < m) out.push({ op: "+", t: b[j++] });
66 return trimContext(out);
67}
68
69// Keep one line of context around each change, so a long file's Write shows
70// the changed lines, not the whole file. A skipped run becomes one "~" line
71// that remembers how many lines it stands for, so line numbers stay true.
72function trimContext(lines) {
73 if (!lines.some((l) => l.op !== " ")) return lines.slice(0, MAX_DIFF_LINES);
74 const isChange = (l) => l && l.op !== " ";
75 const out = [];
76 let skipped = 0;
77 lines.forEach((l, k) => {
78 if (isChange(l) || isChange(lines[k - 1]) || isChange(lines[k + 1])) {
79 if (skipped) out.push({ op: "~", t: "⋯", n: skipped });
80 out.push(l);
81 skipped = 0;
82 } else skipped++;
83 });
84 return out;
85}
86
87function countChanges(diff) {
88 let add = 0, del = 0;
89 for (const l of diff || []) { if (l.op === "+") add++; else if (l.op === "-") del++; }
90 return { add, del };
91}
92
93function lineOf(text, needle) {
94 const at = needle ? String(text).indexOf(needle) : -1;
95 return at < 0 ? 1 : String(text).slice(0, at).split("\n").length;
96}
97
98const firstLine = (s) => String(s ?? "").split("\n").find((l) => l.trim()) ?? "";
99const clip = (s, n) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
100
101// What every tool call is called and how it reads in the list. Edits get a
102// diff; everything else keeps its input summary and its output.
103function describe(cwd, e) {
104 const t = e.tool;
105 const file = e.file_path || e.notebook_path ? relPath(cwd, e.file_path || e.notebook_path) : undefined;
106 switch (t) {
107 case "Bash": return { kind: "command", title: e.description || clip(firstLine(e.command), 80), detail: e.command };
108 case "PowerShell": return { kind: "command", title: e.description || clip(firstLine(e.command), 80), detail: e.command, language: "powershell" };
109 case "Read": return { kind: "read", file, title: file };
110 case "Grep": return { kind: "search", title: e.pattern, detail: `Grep${e.path ? " in " + relPath(cwd, e.path) : ""}${e.glob ? " · " + e.glob : ""}` };
111 case "Glob": return { kind: "search", title: e.pattern, detail: `Glob${e.path ? " in " + relPath(cwd, e.path) : ""}` };
112 case "WebSearch": return { kind: "web", title: e.query, detail: "Web search" };
113 case "WebFetch": return { kind: "web", title: e.url, detail: "Fetch" };
114 case "Agent": case "Task": return { kind: "agent", title: e.description || "Subagent", detail: e.subagent_type ? `Agent · ${e.subagent_type}` : "Agent" };
115 default: {
116 if (EDIT_TOOLS.has(t)) return { kind: "edit", file, title: file };
117 const { tool, tool_use_id, ...input } = e;
118 return { kind: "other", title: t.replace(/^mcp__[^_]+__/, ""), detail: clip(JSON.stringify(input), 120), input };
119 }
120 }
121}
122
123// Edit steps, built before the call runs: a Write's "before" is the file as
124// it was. MultiEdit gives one step per edit.
125async function editSteps($, e, file) {
126 if (e.tool === "Edit") {
127 return [{ diff: diffLines(e.old_string, e.new_string), note: e.replace_all ? "replace all" : "", find: e.new_string }];
128 }
129 if (e.tool === "MultiEdit" && Array.isArray(e.edits)) {
130 return e.edits.map((ed, k) => ({ diff: diffLines(ed.old_string, ed.new_string), note: `part ${k + 1} of ${e.edits.length}`, find: ed.new_string }));
131 }
132 if (e.tool === "Write") {
133 let before = "", isNew = true;
134 try {
135 if (e.file_path && (await $.fs.exists(e.file_path))) { before = await $.fs.read(e.file_path); isNew = false; }
136 } catch { /* unreadable: show it as a new file */ }
137 return [{ diff: diffLines(before, e.content), note: isNew ? "new file" : "rewrite", startLine: 1 }];
138 }
139 return [{ diff: [], note: "" }];
140}
141
142function outputOf(r) {
143 if (!r) return "";
144 if (r.deny) return r.deny;
145 const res = r.result;
146 if (res && typeof res === "object" && "stdout" in res) return [res.stdout, res.stderr].filter(Boolean).join("\n");
147 return typeof r.text === "string" ? r.text : typeof res === "string" ? res : "";
148}
149
150async function record($, e, next) {
151 let cwd = "";
152 try { cwd = await $.session.cwd(); } catch { /* keep the full path */ }
153 const base = describe(cwd, e);
154 let parts = [{}];
155 if (base.kind === "edit") { try { parts = await editSteps($, e, base.file); } catch { parts = [{ diff: [] }]; } }
156 const startedAt = Date.now();
157
158 // Live: the steps go in as the call starts, marked running, and the pane
159 // shows this turn from its first call on.
160 const steps = parts.map(({ find, ...part }) => ({ tool: e.tool, ...base, ...part, isRunning: true, startedAt }));
161 try {
162 if (!state.isLive) { state.replay = state.pending; state.isLive = true; state.index = 0; state.offset = 0; state.filter = "all"; state.follow = true; }
163 state.pending.push(...steps);
164 if (state.follow) followLatest();
165 $.ui.invalidate("ui.render");
166 } catch { /* recording must never break the call */ }
167
168 let r;
169 try { r = await next(e); }
170 finally {
171 try {
172 const ms = Date.now() - startedAt;
173 const isError = !r || !!(r.deny || r.isError);
174 const output = outputOf(r);
175 const extra = {};
176 if (base.kind === "read" && r?.result?.file) {
177 extra.content = r.result.file.content;
178 extra.startLine = r.result.file.startLine;
179 extra.numLines = r.result.file.numLines;
180 }
181 if (base.kind === "search" && r?.result) extra.count = r.result.numFiles ?? r.result.numMatches;
182 // An edit's line numbers: where its new text landed in the file.
183 if (base.kind === "edit" && !isError && e.file_path) {
184 let after = "";
185 try { after = await $.fs.read(e.file_path); } catch { /* numbers from 1 */ }
186 parts.forEach((p, k) => { if (steps[k].startLine === undefined) steps[k].startLine = after ? lineOf(after, p.find) : 1; });
187 }
188 for (const s of steps) Object.assign(s, extra, { isError, output, ms, isRunning: false });
189 $.ui.invalidate("ui.render");
190 } catch { /* recording must never break the call */ }
191 }
192 return r;
193}
194
195// While nobody has picked a step, the selection rides the newest one.
196function followLatest() {
197 state.index = Math.max(0, state.replay.length - 1);
198 state.offset = Math.max(0, visibleSteps().length - LIST_WINDOW);
199}
200
201// ── Shared helpers ────────────────────────────────────────────────────────
202
203const plural = (n, one, many = one + "s") => `${n} ${n === 1 ? one : many}`;
204const baseName = (p) => String(p).split("/").pop();
205const dirName = (p) => (String(p).includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
206
207function duration(ms) {
208 if (!ms && ms !== 0) return "";
209 if (ms < 1000) return `${ms} ms`;
210 if (ms < 60000) return `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} s`;
211 return `${Math.floor(ms / 60000)} min ${Math.round((ms % 60000) / 1000)} s`;
212}
213
214const FILTERS = [
215 { id: "all", label: "All", test: () => true },
216 { id: "edit", label: "Edits", test: (s) => s.kind === "edit" },
217 { id: "command", label: "Commands", test: (s) => s.kind === "command" },
218 { id: "read", label: "Reads", test: (s) => s.kind === "read" },
219 { id: "search", label: "Searches", test: (s) => s.kind === "search" || s.kind === "web" },
220];
221
222function visibleSteps() {
223 const f = FILTERS.find((x) => x.id === state.filter) ?? FILTERS[0];
224 return state.replay.map((s, i) => ({ s, i })).filter(({ s }) => f.test(s));
225}
226
227function summaryText() {
228 const by = (k) => state.replay.filter((s) => s.kind === k).length;
229 const parts = [plural(state.replay.length, "step")];
230 if (by("edit")) parts.push(plural(by("edit"), "edit"));
231 if (by("command")) parts.push(plural(by("command"), "command"));
232 if (by("read")) parts.push(plural(by("read"), "read"));
233 return parts.join(" · ");
234}
235
236async function openReplay($) {
237 if (!state.replay.length) return false;
238 state.filter = "all";
239 state.expanded = false;
240 state.follow = state.isLive;
241 if (state.isLive) followLatest(); else { state.index = 0; state.offset = 0; }
242 state.isOpen = true;
243 // The pane only takes the keyboard while nothing else holds it. Hide the
244 // band first (it held the keys if its button was pressed), then open.
245 $.ui.invalidate("ui.render");
246 await $.clock.sleep(200);
247 const rows = Math.min(MAX_DIFF_LINES + 8, 22);
248 const placed = await $.ui.open({ id: PANE_ID, title: "Replay", focus: true, closeOnEscape: true, rows, columns: 58 });
249 // No room for a pane (a narrow terminal): draw the replay in the band.
250 state.inBand = placed?.isPlaced === false;
251 $.ui.invalidate("ui.render");
252 return true;
253}
254
255const EXT_LANG = { js: "javascript", mjs: "javascript", cjs: "javascript", ts: "typescript", tsx: "tsx", jsx: "jsx", py: "python", md: "markdown", json: "json", css: "css", html: "html", sh: "bash", go: "go", rs: "rust", ps1: "powershell" };
256
257// The step's diff as unified-diff hunks, so the desktop's diff viewer colors
258// and numbers it. A skipped run ("~") starts a new hunk past its lines.
259function unifiedDiff(diff, start = 1) {
260 const hunks = [];
261 let cur = null, oldAt = start, newAt = start;
262 for (const l of diff) {
263 if (l.op === "~") { cur = null; oldAt += l.n || 0; newAt += l.n || 0; continue; }
264 if (!cur) { cur = { oldStart: oldAt, newStart: newAt, oldLen: 0, newLen: 0, lines: [] }; hunks.push(cur); }
265 cur.lines.push(`${l.op}${l.t}`);
266 if (l.op !== "+") { cur.oldLen++; oldAt++; }
267 if (l.op !== "-") { cur.newLen++; newAt++; }
268 }
269 return hunks
270 .map((h) => [`@@ -${h.oldLen ? h.oldStart : h.oldStart - 1},${h.oldLen} +${h.newLen ? h.newStart : h.newStart - 1},${h.newLen} @@`, ...h.lines].join("\n"))
271 .join("\n");
272}
273
274// ── Desktop view ──────────────────────────────────────────────────────────
275// After Apple's source lists: a symbol, a primary and a secondary label per
276// row, one tinted selection, the accent kept for what is current. Text is
277// native and themed; SVG only draws small marks in grays that read on light
278// and dark alike.
279
280const DESKTOP_MAX_DIFF = 60;
281const DESKTOP_MAX_OUTPUT = 40;
282const ACCENT = "claude";
283const ACCENT_HEX = "#d97757";
284const GRAY = "#8e8e93";
285const GREEN = "#34c759", RED = "#ff453a", TRACK = "rgba(142, 142, 147, 0.35)";
286const SELECTED_BG = "rgba(142, 142, 147, 0.18)";
287const HOVER_BG = "rgba(142, 142, 147, 0.09)";
288
289// SF Symbols–style glyphs on a 16-unit grid, stroked so they sit with text.
290const GLYPHS = {
291 edit: `<path d="M10.5 2.5l3 3L6 13H3v-3z"/><path d="M9 4l3 3"/>`,
292 create: `<path d="M9.5 1.5H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V5z"/><path d="M9.5 1.5V5H13"/><path d="M8 7.5v4M6 9.5h4"/>`,
293 command: `<rect x="1.5" y="2.5" width="13" height="11" rx="2"/><path d="M4.5 6l2 2-2 2M8 10.5h3.5"/>`,
294 read: `<path d="M9.5 1.5H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V5z"/><path d="M9.5 1.5V5H13M5.5 8h5M5.5 10.5h5"/>`,
295 search: `<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/>`,
296 web: `<circle cx="8" cy="8" r="6.5"/><path d="M1.5 8h13M8 1.5c2 2 2.8 4.2 2.8 6.5S10 12.5 8 14.5C6 12.5 5.2 10.3 5.2 8S6 3.5 8 1.5z"/>`,
297 agent: `<path d="M8 1.5l1.6 4.9 4.9 1.6-4.9 1.6L8 14.5l-1.6-4.9L1.5 8l4.9-1.6z"/>`,
298 other: `<circle cx="8" cy="8" r="6.5"/><circle cx="5" cy="8" r=".6"/><circle cx="8" cy="8" r=".6"/><circle cx="11" cy="8" r=".6"/>`,
299 play: `<circle cx="8" cy="8" r="6.5"/><path d="M6.6 5.4v5.2L10.8 8z" fill="currentColor"/>`,
300};
301
302function glyphOf(step) {
303 if (step.kind === "edit") return step.note === "new file" ? "create" : "edit";
304 return GLYPHS[step.kind] ? step.kind : "other";
305}
306
307function symbol(name, color, size = 16, opacity = 1) {
308 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 16 16" fill="none" stroke="${color}" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" color="${color}" opacity="${opacity}">${GLYPHS[name]}</svg>`;
309}
310
311// A ring with a turning arc. Drawn as a plain image: its SMIL animation runs
312// there too, and an interactive frame would paint an opaque background.
313function spinner(size = 14) {
314 return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" fill="none" stroke="rgba(142, 142, 147, 0.3)" stroke-width="2"/><path d="M8 2a6 6 0 0 1 6 6" fill="none" stroke="${ACCENT_HEX}" stroke-width="2" stroke-linecap="round"><animateTransform attributeName="transform" type="rotate" from="0 8 8" to="360 8 8" dur="0.9s" repeatCount="indefinite"/></path></svg>`;
315}
316
317// The chip at a window's edge fades toward what is
318// scrolled out of sight: its border, its marks and its labels.
319const CHIP_BORDER = ["rgba(142, 142, 147, 0.32)", "rgba(142, 142, 147, 0.18)", "rgba(142, 142, 147, 0.08)"];
320const MARK_OPACITY = [1, 0.55, 0.25];
321
322function statusDot(isError) {
323 const c = isError ? RED : GREEN;
324 const mark = isError ? `<path d="M5.5 5.5l5 5M10.5 5.5l-5 5"/>` : `<path d="M5 8.2l2 2 4-4.4"/>`;
325 return `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16"><circle cx="8" cy="8" r="7" fill="${c}" opacity=".18"/><g fill="none" stroke="${c}" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">${mark}</g></svg>`;
326}
327
328// GitHub's diffstat: five cells split between added and removed lines.
329function diffstat(add, del, cell = 7) {
330 const total = add + del;
331 let green = total ? Math.round((5 * add) / total) : 0;
332 let red = total ? 5 - green : 0;
333 if (add && !green) { green = 1; red--; }
334 if (del && !red) { red = 1; green--; }
335 const gap = 2, w = cell * 5 + gap * 4;
336 const cells = Array.from({ length: 5 }, (_, i) => {
337 const fill = i < green ? GREEN : i < green + red ? RED : TRACK;
338 return `<rect x="${i * (cell + gap)}" y="0" width="${cell}" height="${cell}" rx="1.5" fill="${fill}"/>`;
339 }).join("");
340 return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${cell}" viewBox="0 0 ${w} ${cell}">${cells}</svg>`;
341}
342
343// What the row says on its right: a diffstat, a status, a count.
344function trailing(ui, s, n, isCurrent) {
345 const { Text, Svg, Box } = ui;
346 const items = [];
347 if (s.isRunning) {
348 items.push(Text({ key: `run${n}`, dimColor: true, children: "Running" }));
349 items.push(Svg({ key: `spin${n}`, source: spinner(), alt: "Running", width: 14, height: 14 }));
350 } else if (s.kind === "edit" && !s.isError) {
351 const c = countChanges(s.diff);
352 items.push(Text({ key: `add${n}`, color: "success", dimColor: !isCurrent, children: `+${c.add}` }));
353 items.push(Text({ key: `del${n}`, color: "error", dimColor: !isCurrent, children: `−${c.del}` }));
354 items.push(Svg({ key: `stat${n}`, source: diffstat(c.add, c.del), alt: `${c.add} added, ${c.del} removed`, width: 43, height: 7 }));
355 } else {
356 const note = s.kind === "read" && s.numLines ? plural(s.numLines, "line")
357 : s.kind === "search" && s.count !== undefined ? plural(s.count, "result")
358 : s.ms >= 1000 ? duration(s.ms) : "";
359 if (note) items.push(Text({ key: `note${n}`, dimColor: true, children: note }));
360 if (s.kind === "command" || s.isError) items.push(Svg({ key: `ok${n}`, source: statusDot(s.isError), alt: s.isError ? "Failed" : "Succeeded", width: 14, height: 14 }));
361 }
362 return Box({ key: `trail${n}`, flexDirection: "row", alignItems: "center", gap: 1, flexShrink: 0, children: items });
363}
364
365function secondaryOf(s) {
366 if (s.kind === "edit" || s.kind === "read") return dirName(s.file);
367 if (s.kind === "command") return s.title === s.detail ? "" : clip(firstLine(s.detail), 90);
368 return s.detail || "";
369}
370
371function bodyOf(ui, $, step) {
372 const { Box, Text, Button, Code } = ui;
373 const more = (total) => Button({
374 key: "more", label: `Show all ${total} lines`, plain: true, dimColor: true,
375 onPress: () => { state.expanded = true; $.ui.invalidate("ui.render"); },
376 });
377 const section = (key, label, children) => Box({ key, flexDirection: "column", gap: 0, children: [
378 Text({ key: `${key}-label`, dimColor: true, bold: true, children: label }), ...children,
379 ] });
380 const textBlock = (key, text, language) => {
381 const lines = splitLines(text);
382 const isClipped = !state.expanded && lines.length > DESKTOP_MAX_OUTPUT;
383 const shown = (isClipped ? lines.slice(0, DESKTOP_MAX_OUTPUT) : lines).join("\n").slice(0, 60000);
384 return [
385 shown ? Code({ key, source: shown, language, wrap: "wrap" }) : Text({ key, dimColor: true, italic: true, children: "No output" }),
386 isClipped ? more(lines.length) : null,
387 ].filter(Boolean);
388 };
389
390 if (step.isRunning) {
391 return [
392 step.kind === "command" ? Code({ key: "cmd", source: step.detail, language: step.language || "bash", wrap: "wrap" }) : null,
393 Text({ key: "running", dimColor: true, italic: true, children: "Running…" }),
394 ].filter(Boolean);
395 }
396 if (step.kind === "edit") {
397 if (step.isError) return [section("why", "Didn't apply", textBlock("err", step.output))];
398 if (!step.diff.length) return [Text({ key: "empty", dimColor: true, children: "No line changes." })];
399 const isClipped = !state.expanded && step.diff.length > DESKTOP_MAX_DIFF;
400 const ext = baseName(step.file).split(".").pop().toLowerCase();
401 return [
402 Code({ key: "diff", source: unifiedDiff(isClipped ? step.diff.slice(0, DESKTOP_MAX_DIFF) : step.diff, step.startLine), format: "diff", language: EXT_LANG[ext], path: step.file, wrap: "wrap" }),
403 isClipped ? more(step.diff.length) : null,
404 ].filter(Boolean);
405 }
406 if (step.kind === "command") {
407 return [
408 Code({ key: "cmd", source: step.detail, language: step.language || "bash", wrap: "wrap" }),
409 section("out", step.isError ? "Output · failed" : "Output", textBlock("output", step.output)),
410 ];
411 }
412 if (step.kind === "read" && step.content !== undefined) {
413 const lines = splitLines(step.content);
414 const isClipped = !state.expanded && lines.length > DESKTOP_MAX_OUTPUT;
415 const ext = baseName(step.file).split(".").pop().toLowerCase();
416 return [
417 Code({ key: "content", source: (isClipped ? lines.slice(0, DESKTOP_MAX_OUTPUT) : lines).join("\n") || " ", language: EXT_LANG[ext], path: step.file, startLine: step.startLine || 1, wrap: "wrap" }),
418 isClipped ? more(lines.length) : null,
419 ].filter(Boolean);
420 }
421 if (step.kind === "other" && step.input) {
422 return [
423 section("in", "Input", [Code({ key: "input", source: JSON.stringify(step.input, null, 2).slice(0, 20000), language: "json", wrap: "wrap" })]),
424 section("out", step.isError ? "Result · failed" : "Result", textBlock("output", step.output)),
425 ];
426 }
427 return [section("out", step.isError ? "Result · failed" : "Result", textBlock("output", step.output))];
428}
429
430function kindLabel(s) {
431 if (s.kind === "edit") return s.note === "new file" ? "New file" : s.note === "rewrite" ? "Rewrite" : s.note === "replace all" ? "Edit · replace all" : s.note ? `Edit · ${s.note}` : "Edit";
432 return { command: "Command", read: "Read", search: "Search", web: "Web", agent: "Agent" }[s.kind] || s.tool;
433}
434
435function desktopView($, e, go) {
436 const ui = $.ui.resolve(e);
437 const { Box, Text, Button, Svg, Markdown } = ui;
438 const total = state.replay.length;
439 const k = Math.max(0, Math.min(state.index, total - 1));
440 const step = state.replay[k];
441 const shown = visibleSteps();
442 const pos = shown.findIndex((v) => v.i === k);
443 const edits = state.replay.filter((s) => s.kind === "edit" && !s.isError);
444 const sum = edits.reduce((t, s) => { const c = countChanges(s.diff); return { add: t.add + c.add, del: t.del + c.del }; }, { add: 0, del: 0 });
445
446 const header = Box({
447 key: "header", flexDirection: "row", justifyContent: "space-between", alignItems: "flex-end", paddingX: 1,
448 children: [
449 Box({ key: "heading", flexDirection: "column", children: [
450 state.isLive
451 ? Box({ key: "title", flexDirection: "row", alignItems: "center", gap: 1, children: [
452 Markdown({ key: "t", text: "### This turn" }),
453 Svg({ key: "live", source: spinner(12), alt: "Live", width: 12, height: 12 }),
454 Text({ key: "live-label", color: ACCENT, children: "Live" }),
455 ] })
456 : Markdown({ key: "title", text: "### Last turn" }),
457 Text({ key: "sub", dimColor: true, children: `${summaryText()}${state.turnMs ? " · " + duration(state.turnMs) : ""}` }),
458 ] }),
459 edits.length ? Box({ key: "sum", flexDirection: "row", alignItems: "center", gap: 1, children: [
460 Text({ key: "add", color: "success", children: `+${sum.add}` }),
461 Text({ key: "del", color: "error", children: `−${sum.del}` }),
462 Svg({ key: "bar", source: diffstat(sum.add, sum.del, 9), alt: `${sum.add} lines added, ${sum.del} removed`, width: 53, height: 9 }),
463 ] }) : null,
464 ].filter(Boolean),
465 });
466
467 // A segmented control: only the kinds this turn has.
468 const segments = FILTERS.filter((f) => f.id === "all" || state.replay.some(f.test)).map((f) => {
469 const count = state.replay.filter(f.test).length;
470 const isOn = state.filter === f.id;
471 return Button({
472 key: `filter-${f.id}`, label: f.id === "all" ? f.label : `${f.label} ${count}`,
473 ...(isOn ? { variant: "secondary" } : { plain: true, dimColor: true }),
474 onPress: () => {
475 state.filter = f.id;
476 const first = visibleSteps()[0];
477 if (first && !f.test(state.replay[state.index])) state.index = first.i;
478 state.follow = false;
479 state.offset = 0;
480 revealSelection();
481 state.expanded = false;
482 $.ui.invalidate("ui.render");
483 },
484 });
485 });
486 const filters = segments.length > 2 ? Box({ key: "filters", flexDirection: "row", gap: 1, paddingX: 1, alignItems: "center", children: segments }) : null;
487 state.listRows = listRowsOf(!!filters, shown.length);
488
489 // The list: five chips at a time, the wheel moving the window (ui.scroll).
490 // No scrollbar; the chip at an edge with more beyond it fades instead.
491 const offset = clampOffset(state.offset, shown.length);
492 const windowed = shown.slice(offset, offset + LIST_WINDOW);
493 const hasAbove = offset > 0;
494 const hasBelow = offset + LIST_WINDOW < shown.length;
495 const fadeOf = (j) => Math.max(
496 hasAbove && j === 0 ? 2 : 0,
497 hasBelow && j === windowed.length - 1 ? 2 : 0,
498 );
499
500 const chips = windowed.map(({ s, i }, j) => {
501 const isCurrent = i === k;
502 const fade = isCurrent ? 0 : fadeOf(j);
503 const secondary = secondaryOf(s);
504 const tint = s.isError ? RED : isCurrent || s.isRunning ? ACCENT_HEX : GRAY;
505 return Box({
506 key: `row${i}`, flexDirection: "row", alignItems: "center", gap: 1, paddingX: 1,
507 borderStyle: "round", borderColor: isCurrent ? ACCENT : CHIP_BORDER[fade],
508 backgroundColor: isCurrent ? SELECTED_BG : undefined,
509 hover: isCurrent ? undefined : { backgroundColor: HOVER_BG, borderColor: CHIP_BORDER[0] },
510 children: [
511 Svg({ key: `icon${i}`, source: symbol(glyphOf(s), tint, 16, MARK_OPACITY[fade]), alt: kindLabel(s), width: 16, height: 16 }),
512 Box({ key: `label${i}`, flexDirection: "row", flexGrow: 1, flexShrink: 1, gap: 1, alignItems: "center", overflow: "hidden", children: [
513 Button({ key: `pick${i}`, label: s.kind === "edit" || s.kind === "read" ? baseName(s.file) : clip(String(s.title ?? s.tool), 70), plain: true, dimColor: fade > 0, onPress: () => go(i) }),
514 secondary ? Text({ key: `sec${i}`, dimColor: true, color: fade > 1 ? "inactive" : undefined, wrap: "truncate-end", children: secondary }) : null,
515 ].filter(Boolean) }),
516 trailing(ui, s, i, fade > 0 ? false : isCurrent),
517 ].filter(Boolean),
518 });
519 });
520 const list = Box({ key: "rows", flexDirection: "column", gap: 1, children: chips });
521
522 const stepGo = (d) => { const to = shown[pos + d]; if (to) go(to.i); };
523 // A matched pair, no key badges: the pane's wheel and the chips are the
524 // quick ways about; these two step in order.
525 const nav = Box({ key: "nav", flexDirection: "row", gap: 1, alignItems: "center", flexShrink: 0, children: [
526 Button({ key: "prev", label: "Previous", variant: "secondary", dimColor: pos <= 0, onPress: () => stepGo(-1) }),
527 Button({ key: "next", label: "Next", variant: "secondary", dimColor: pos >= shown.length - 1, onPress: () => stepGo(1) }),
528 ] });
529
530 const c = countChanges(step.diff);
531 const meta = [
532 kindLabel(step),
533 step.kind === "edit" || step.kind === "read" ? dirName(step.file) : "",
534 step.ms >= 1000 ? duration(step.ms) : "",
535 ].filter(Boolean).join(" · ");
536 const card = Box({
537 key: "card", flexDirection: "column", gap: 1, padding: 1, borderStyle: "round", borderColor: "subtle",
538 children: [
539 // The buttons have a row of their own, so a long title never pushes
540 // them out of the card.
541 Box({ key: "card-top", flexDirection: "row", justifyContent: "space-between", alignItems: "center", gap: 2, children: [
542 Text({ key: "pos", dimColor: true, children: `Step ${pos + 1} of ${shown.length}` }),
543 nav,
544 ] }),
545 Box({ key: "card-head", flexDirection: "row", children: [
546 Box({ key: "who", flexDirection: "row", gap: 1, alignItems: "flex-start", flexShrink: 1, children: [
547 Svg({ key: "icon", source: symbol(glyphOf(step), step.isError ? RED : ACCENT_HEX, 20), alt: kindLabel(step), width: 20, height: 20 }),
548 Box({ key: "names", flexDirection: "column", flexShrink: 1, children: [
549 Text({ key: "title", bold: true, wrap: "truncate-end", children: step.kind === "edit" || step.kind === "read" ? baseName(step.file) : String(step.title ?? step.tool) }),
550 Box({ key: "meta", flexDirection: "row", gap: 1, children: [
551 Text({ key: "kind", dimColor: true, children: meta }),
552 step.kind === "edit" && !step.isError ? Text({ key: "add", color: "success", children: `+${c.add}` }) : null,
553 step.kind === "edit" && !step.isError ? Text({ key: "del", color: "error", children: `−${c.del}` }) : null,
554 step.isError ? Text({ key: "failed", color: "error", children: "Failed" }) : null,
555 ].filter(Boolean) }),
556 ] }),
557 ] }),
558 ] }),
559 ...bodyOf(ui, $, step),
560 ],
561 });
562
563 return Box({
564 flexDirection: "column", gap: 1, padding: 1,
565 children: [header, filters, list, card].filter(Boolean),
566 });
567}
568
569function clampOffset(offset, length) {
570 return Math.max(0, Math.min(offset || 0, length - LIST_WINDOW));
571}
572
573// Keeps the selected step inside the list window.
574function revealSelection() {
575 const shown = visibleSteps();
576 const pos = shown.findIndex((v) => v.i === state.index);
577 if (pos < 0) return;
578 if (pos < state.offset) state.offset = pos;
579 else if (pos >= state.offset + LIST_WINDOW) state.offset = pos - LIST_WINDOW + 1;
580 state.offset = clampOffset(state.offset, shown.length);
581}
582
583// Where the list sits in the pane body, in rows, for telling a wheel tick over
584// the list from one over the card: the padding, the heading (title, summary),
585// the filter row when drawn, the gaps; then each chip (border, label, border)
586// and the gap after it.
587function listRowsOf(hasFilters, count) {
588 const top = 1 + 2 + 1 + (hasFilters ? 2 : 0);
589 return { top, bottom: top + Math.min(count, LIST_WINDOW) * 4 - 1 };
590}
591
592// ── Terminal view ─────────────────────────────────────────────────────────
593// One step at a time. Drawn in the Pane, or in the band above the prompt when
594// the surface can't place a pane.
595
596function terminalLines(step) {
597 if (step.kind === "edit") return step.diff;
598 const head = step.kind === "command" ? [{ op: "$", t: firstLine(step.detail) }] : [];
599 return [...head, ...splitLines(step.output).map((t) => ({ op: " ", t }))];
600}
601
602function replayView($, e, inBand) {
603 const { Box, Text, Button } = $.ui.resolve(e);
604 const maxDiff = inBand ? Math.max(3, Math.min(MAX_DIFF_LINES, (e.maxRows || 20) - 7)) : MAX_DIFF_LINES;
605 const total = state.replay.length;
606 if (!total) return Text({ dimColor: true, children: "Nothing to replay." });
607 const k = Math.max(0, Math.min(state.index, total - 1));
608 const step = state.replay[k];
609 const go = (to) => { state.index = Math.max(0, Math.min(to, total - 1)); state.follow = false; state.expanded = false; revealSelection(); $.ui.invalidate("ui.render"); };
610 const close = () => {
611 state.isOpen = false;
612 if (!state.inBand) $.ui.close({ id: PANE_ID });
613 state.inBand = false;
614 $.ui.invalidate("ui.render");
615 };
616
617 if (!inBand && e.surface === "desktop") return desktopView($, e, go);
618
619 const width = Math.max(40, (e.bodyColumns || 100) - 4);
620 const lines = terminalLines(step);
621 const shown = lines.slice(0, maxDiff);
622 const diffRows = shown.map((l, n) => {
623 const color = l.op === "+" ? "success" : l.op === "-" ? "error" : l.op === "$" ? ACCENT : undefined;
624 const line = `${l.op === "~" ? " " : l.op} ${l.t}`.slice(0, width);
625 return Text({ key: `d${n}`, color, dimColor: l.op === " " || l.op === "~", wrap: "truncate-end", children: line });
626 });
627 if (lines.length > maxDiff) diffRows.push(Text({ key: "more", dimColor: true, children: ` … ${lines.length - maxDiff} more lines` }));
628 if (!shown.length) diffRows.push(Text({ key: "empty", dimColor: true, children: " (nothing to show)" }));
629 const { add, del } = countChanges(step.diff);
630
631 return Box({
632 flexDirection: "column", borderStyle: "round", borderColor: "subtle", paddingX: 1,
633 children: [
634 Box({ flexDirection: "row", justifyContent: "space-between", children: [
635 Text({ bold: true, children: "Replay" }),
636 Text({ dimColor: true, children: `${k + 1} of ${total}` }),
637 ] }),
638 Text({ bold: true, color: step.isError ? "error" : ACCENT, wrap: "truncate-start", children: step.file ?? String(step.title ?? step.tool) }),
639 Box({ flexDirection: "row", gap: 2, children: [
640 Text({ dimColor: true, children: kindLabel(step) }),
641 step.kind === "edit" ? Text({ color: "success", children: `+${add}` }) : null,
642 step.kind === "edit" ? Text({ color: "error", children: `-${del}` }) : null,
643 ].filter(Boolean) }),
644 Box({ flexDirection: "column", marginTop: 1, children: diffRows }),
645 Box({ flexDirection: "row", gap: 2, marginTop: 1, children: [
646 Button({ key: "prev", label: "Prev", hotkey: "p", onPress: () => go(k - 1) }),
647 Button({ key: "next", label: "Next", hotkey: "n", autoFocus: true, onPress: () => go(k + 1) }),
648 Button({ key: "close", label: "Close", hotkey: "c", onPress: close }),
649 ] }),
650 ],
651 });
652}
653
654// The band above the prompt: what the last turn did, and a way in.
655function bandView($, e) {
656 const { Box, Text, Button, Svg } = $.ui.resolve(e);
657 const open = Button({ key: "open-replay", label: "Replay", variant: "secondary", hotkey: "r", onPress: () => openReplay($) });
658 const label = state.isLive ? "This turn" : "Last turn";
659 if (e.surface === "terminal") {
660 return Box({ flexDirection: "row", gap: 2, paddingX: 1, children: [
661 Text({ dimColor: true, children: `${label} · ${summaryText()}` }),
662 open,
663 ] });
664 }
665 return Box({ key: "band", flexDirection: "row", alignItems: "center", gap: 1, paddingX: 1, children: [
666 Svg ? Svg(state.isLive
667 ? { key: "icon", source: spinner(16), alt: "Live", width: 16, height: 16 }
668 : { key: "icon", source: symbol("play", GRAY), alt: "", width: 16, height: 16 }) : null,
669 Text({ key: "label", children: label }),
670 Text({ key: "summary", dimColor: true, children: summaryText() }),
671 Box({ key: "spacer", flexGrow: 1 }),
672 open,
673 ].filter(Boolean) });
674}
675
676export function register(on, options) {
677 // Slash command: /replay opens the pane.
678 on("session.start", async ($, e, next) => {
679 const r = await next(e);
680 await $.command.register({ name: "replay", description: "Replay: step through everything the last turn did" });
681 return r;
682 });
683
684 on("command.run", { command: "replay" }, async ($, e) => {
685 const opened = await openReplay($);
686 return { text: opened ? `Replay: ${summaryText()}` : "Replay: nothing happened in the last turn." };
687 });
688
689 // Record every main-loop tool call with what it returned. Never blocks it.
690 on("tool.call", async ($, e, next) => {
691 if (e.agentId) return next(e);
692 return record($, e, next);
693 });
694
695 // A turn starts empty; its first tool call makes it the live replay.
696 on("turn.start", ($, e, next) => {
697 if (!e.agentId) { state.pending = []; state.isLive = false; state.turnStartedAt = Date.now(); }
698 return next(e);
699 });
700
701 // At the end of a main-loop turn the live replay settles as the last turn's.
702 // The selection stays where it is, so a step being read isn't snatched away.
703 on("turn.complete", async ($, e, next) => {
704 const r = await next(e);
705 if (e.agentId || !state.isLive) return r;
706 state.isLive = false;
707 state.follow = false;
708 state.pending = [];
709 state.turnMs = state.turnStartedAt ? Date.now() - state.turnStartedAt : 0;
710 state.turns++;
711 $.ui.invalidate("ui.render");
712 return r;
713 });
714
715 // The wheel over the list moves its window, a chip a tick; anywhere else in
716 // the pane it scrolls the pane as usual.
717 on("ui.scroll", { requestId: PANE_ID }, ($, e, next) => {
718 const length = visibleSteps().length;
719 const row = e.pointer?.row;
720 const overList = row !== undefined && row >= state.listRows.top - 1 && row <= state.listRows.bottom;
721 if (e.component !== "Pane" || length <= LIST_WINDOW || !overList) return next(e);
722 const by = Math.sign(e.by) * Math.max(1, Math.round(Math.abs(e.by)));
723 const offset = clampOffset(state.offset + by, length);
724 if (offset !== state.offset) {
725 state.offset = offset;
726 state.follow = false;
727 $.ui.invalidate("ui.render");
728 }
729 return {};
730 });
731
732 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
733 if (state.isOpen && state.inBand) return replayView($, e, true);
734 if (!state.replay.length || state.isOpen) return next(e);
735 return bandView($, e);
736 });
737
738 // The pane.
739 on("ui.render", { component: "Pane" }, ($, e, next) => {
740 if (e.requestId !== PANE_ID) return next(e);
741 return replayView($, e, false);
742 });
743
744 // The person closed the pane (Escape): keep our flag in step.
745 on("ui.close", ($, e, next) => {
746 if (e.id === PANE_ID || e.requestId === PANE_ID) state.isOpen = false;
747 $.ui.invalidate("ui.render");
748 return next(e);
749 });
750}
751