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

Part of laynem/claudemods, maintained by Layne. Version 0.2.0, a hardened rewrite of Replay Theater; installed it appears as teckreplay and opens with /replay.
A Claude Code mod that lets you step through the file edits Claude made in the last turn, one diff at a time. This is a clean rewrite of the replay-theater mod from claude-code-playground (0.1.0), with the security and robustness issues found in review fixed. Behavior and UI are the same unless noted below.
While a turn runs, it records each Edit, Write and MultiEdit call. When the turn ends, a hint appears above the prompt:
▶ Replay: 5 edits (press r) [ Replay ]
Open it with /replay, or ctrl+x then Tab to focus the band and press r. The pane shows one step at a time: the file, the tool, +added -removed counts, and up to 12 diff lines. n / p step, c or Escape closes. A file outside the project is shown in yellow with "(outside project)", and a step that was denied or failed is labeled when the mod can tell.
It only watches: every call is passed on unchanged with next(e).
Needs Claude Code 2.1.287 or later (mods load by default). It installs from the claudemods marketplace:
claude plugin marketplace add laynem/claudemods
claude plugin install teckreplay@claudemods
To try a local checkout for one session instead: claude --plugin-dir ./plugins/teckreplay.
node --test test/*.test.mjs
The tests run the mod against a small mock of the host ($.session, $.fs, $.clock, $.ui, $.command). They check the mod's own logic. They are not a substitute for trying it in Claude Code, which this rewrite has not been.
ESC sequences could clear or repaint the screen, spoof UI, set the window title, write the clipboard (OSC 52) or plant hyperlinks (OSC 8), if the renderer passes them through. Now every line and the file name are sanitized: C0 controls become visible Control Pictures (␛, ␍, ␀), DEL becomes ␡, and C1 controls become �.� so they're visible.file_path from disk at tool.call time, before the permission prompt, for any path. A Write to ~/.ssh/id_rsa or /etc/... that you then deny still put that file's contents on screen. Now the old text is only read inside the session folder, never from system paths (/dev, /proc, /sys) or sensitive-looking names (.env*, *.pem, *.key, id_rsa*, .npmrc, .netrc, .ssh/, .aws/, …). Paths are normalized first, so /proj/../etc/passwd counts as outside.next(e). A Write to a FIFO or a very slow file would block the edit indefinitely, and time inside $ calls doesn't count against the host's hook budget. Recording is now raced against a 1.5 s timeout; on timeout the step shows "not recorded (timed out)" and the edit goes ahead.$.ui.open threw, isOpen stayed true and the hint never came back. Opening now resets on failure.opening guard.\r was left on every line, which broke matching between CRLF and LF text and sent carriage returns to the terminal. Lines now split on \r?\n.ui.close only redraws for its own pane; band-button promises can't reject unhandled; binary content is noted instead of diffed; a minimum width of 10 columns instead of a forced 40.realpath, which would mean adding $.process access; the rewrite keeps the original's smaller set of host capabilities instead.next(e) returns or throws; if the host reports outcomes another way, edits show with no label.Apache-2.0. Original © 2026 Anthropic PBC, from claude-code-playground; modified in this rewrite (see "Changes from 0.1.0"). Renamed to teckReplay and maintained by Layne in laynem/claudemods (see SOURCE.md). Shared as-is; not an official Anthropic product.
hooks/teckreplay.mjs 584 lines1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// teckReplay 0.2.0 (formerly Replay Theater): a clean, hardened rewrite of the claude-code-playground
5// mod (modified from 0.1.0; see README.md, "Changes from 0.1.0").
6//
7// Modified by Layne, 2026: renamed to teckReplay for laynem/claudemods.
8//
9// Records the file edits Claude makes in a turn (Edit, Write, MultiEdit), then
10// lets you step through them in a pane, one diff at a time.
11//
12// It only watches. Every call is passed on, unchanged, with next(e), and the
13// time spent recording before that is capped (RECORD_TIMEOUT_MS).
14//
15// The host reads on(...) and $.noun.method(...) from source, so they are
16// spelled literally, and helpers that take $ are top-level functions. The mod
17// uses only $.session, $.fs (read-only), $.clock, $.ui and $.command.
18
19const PANE_ID = "teckreplay";
20const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
21
22const SHOW_LINES = 12; // diff rows kept and shown per step
23const LCS_MAX_LINES = 400; // changed region above this: no line matching
24const MAX_LINE_CHARS = 240; // each kept diff line is cut to this
25const MAX_PATH_CHARS = 300; // the file name is cut to this
26const MAX_INPUT_CHARS = 2_000_000; // texts bigger than this aren't diffed
27const MAX_STEPS = 200; // steps kept per turn
28const MAX_MULTI_EDITS = 50; // sub-edits recorded per MultiEdit call
29const RECORD_TIMEOUT_MS = 1500; // longest an edit waits on the recorder
30const OPEN_DELAY_MS = 200; // let the band release the keys first
31const STRIP_CELLS = 9; // step cells shown at once
32
33const TIMED_OUT = Symbol("timed-out");
34
35// Module state. `pending` collects one slot per edit call during a turn, in
36// call order; `replay` is the last finished turn's steps, the ones shown.
37const state = {
38 pending: [],
39 droppedCalls: 0,
40 replay: [],
41 dropped: 0,
42 index: 0,
43 isOpen: false,
44 opening: false,
45 inBand: false,
46};
47
48// ---- Safe text ---------------------------------------------------------------
49
50// File text is untrusted: a repo can hold any bytes, and so can what Claude
51// writes. Control characters are swapped for visible stand-ins, so nothing in
52// a diff can reach the terminal as an escape sequence (colors, cursor moves,
53// title or clipboard writes, hyperlinks). Bidi controls and invisible
54// characters are swapped too, so a diff can't be made to read differently
55// from what the file holds ("Trojan Source").
56const INVISIBLE = /[\u00AD\u061C\u180E\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF\uFFF9-\uFFFB]/g;
57
58function safeText(value, maxChars = MAX_LINE_CHARS) {
59 let s = String(value ?? "");
60 if (s.length > maxChars * 4) s = s.slice(0, maxChars * 4); // bound the work on huge lines
61 if (typeof s.toWellFormed === "function") s = s.toWellFormed(); // no lone surrogates
62 s = s
63 .replace(/\t/g, " ")
64 .replace(/[\u0000-\u001F]/g, (c) => String.fromCharCode(0x2400 + c.charCodeAt(0))) // ␛ ␍ ␀ …
65 .replace(/\u007F/g, "\u2421") // ␡
66 .replace(/[\u0080-\u009F]/g, "\uFFFD") // C1 controls (8-bit CSI, OSC, …)
67 .replace(INVISIBLE, "\uFFFD");
68 const chars = Array.from(s);
69 return chars.length > maxChars ? chars.slice(0, maxChars - 1).join("") + "…" : s;
70}
71
72function clip(text, columns) {
73 const chars = Array.from(text);
74 return chars.length > columns ? chars.slice(0, Math.max(1, columns - 1)).join("") + "…" : text;
75}
76
77// ---- Paths -------------------------------------------------------------------
78
79function isAbsolute(p) {
80 return /^(?:[A-Za-z]:)?[\\/]/.test(p);
81}
82
83// Lexical normalization: separators, ".", "..". Symlinks are not resolved.
84function normalizePath(p) {
85 let s = String(p).replace(/\\/g, "/");
86 let root = "";
87 const drive = /^[A-Za-z]:/.exec(s);
88 if (drive) {
89 root = drive[0].toUpperCase();
90 s = s.slice(2);
91 }
92 if (s.startsWith("/")) root += "/";
93 const parts = [];
94 for (const seg of s.split("/")) {
95 if (seg === "" || seg === ".") continue;
96 if (seg === "..") {
97 if (parts.length && parts[parts.length - 1] !== "..") parts.pop();
98 else if (!root) parts.push("..");
99 continue;
100 }
101 parts.push(seg);
102 }
103 return root + parts.join("/") || (root ? root : ".");
104}
105
106// Where an edit's file is: its absolute path, how to show it, and whether it
107// is inside the session folder. Anything unsure counts as outside.
108function locate(cwd, filePath) {
109 if (typeof filePath !== "string" || filePath === "") {
110 return { abs: "", display: "(unknown file)", inside: false };
111 }
112 const base = cwd ? normalizePath(cwd) : "";
113 const abs = normalizePath(isAbsolute(filePath) || !base ? filePath : `${base}/${filePath}`);
114 let inside = false;
115 let display = abs;
116 if (base && isAbsolute(base)) {
117 const fold = (x) => (/^[A-Z]:/.test(x) ? x.toLowerCase() : x); // Windows drives
118 const prefix = base.endsWith("/") ? base : base + "/";
119 if (fold(abs).startsWith(fold(prefix))) {
120 inside = true;
121 display = abs.slice(prefix.length);
122 }
123 }
124 return { abs, display: safeText(display, MAX_PATH_CHARS), inside };
125}
126
127const SYSTEM_PATH = /^\/(?:dev|proc|sys)(?:\/|$)/;
128const SENSITIVE_NAME =
129 /^(?:\.env(?:\..*)?|\.npmrc|\.pypirc|\.netrc|\.pgpass|\.git-credentials|credentials(?:\.json)?|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|.*\.(?:pem|key|p12|pfx|jks|keystore|kdbx))$/i;
130
131function isSensitive(abs) {
132 const name = abs.split("/").pop() ?? "";
133 return SENSITIVE_NAME.test(name) || /(^|\/)\.ssh\//.test(abs) || /(^|\/)\.aws\//.test(abs);
134}
135
136// ---- Diff --------------------------------------------------------------------
137
138function splitLines(text) {
139 if (text === "") return [];
140 const lines = text.split(/\r?\n/);
141 if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
142 return lines;
143}
144
145function countLines(text) {
146 if (!text) return 0;
147 let n = 1;
148 for (let k = text.indexOf("\n"); k !== -1; k = text.indexOf("\n", k + 1)) n++;
149 return text.endsWith("\n") ? n - 1 : n;
150}
151
152function kb(chars) {
153 return `${Math.round(chars / 1024)} KB`;
154}
155
156// Collects a diff without keeping it whole: counts every line, keeps (and
157// sanitizes) only the first SHOW_LINES.
158function makeSink() {
159 const sink = { kept: [], add: 0, del: 0, total: 0 };
160 sink.push = (op, text) => {
161 if (op === "+") sink.add++;
162 else if (op === "-") sink.del++;
163 sink.total++;
164 if (sink.kept.length < SHOW_LINES) sink.kept.push({ op, t: op === "~" ? "⋯" : safeText(text) });
165 };
166 return sink;
167}
168
169function finish(sink, note = "") {
170 return { diff: sink.kept, add: sink.add, del: sink.del, more: sink.total - sink.kept.length, note };
171}
172
173// Longest-common-subsequence line diff. Only called with at most
174// LCS_MAX_LINES lines a side, so the table is at most ~160k cells.
175function lcsOps(a, b) {
176 const n = a.length;
177 const m = b.length;
178 const w = m + 1;
179 const dp = new Uint16Array((n + 1) * w);
180 for (let i = n - 1; i >= 0; i--) {
181 for (let j = m - 1; j >= 0; j--) {
182 dp[i * w + j] = a[i] === b[j] ? dp[(i + 1) * w + j + 1] + 1 : Math.max(dp[(i + 1) * w + j], dp[i * w + j + 1]);
183 }
184 }
185 const ops = [];
186 let i = 0;
187 let j = 0;
188 while (i < n && j < m) {
189 if (a[i] === b[j]) {
190 ops.push({ op: " ", t: a[i] });
191 i++;
192 j++;
193 } else if (dp[(i + 1) * w + j] >= dp[i * w + j + 1]) ops.push({ op: "-", t: a[i++] });
194 else ops.push({ op: "+", t: b[j++] });
195 }
196 while (i < n) ops.push({ op: "-", t: a[i++] });
197 while (j < m) ops.push({ op: "+", t: b[j++] });
198 return ops;
199}
200
201// Keeps one line of context around each change; a skipped run shows as ⋯.
202function emitWithContext(sink, ops) {
203 const changed = (k) => ops[k] !== undefined && ops[k].op !== " ";
204 let skipped = false;
205 ops.forEach((l, k) => {
206 if (changed(k) || changed(k - 1) || changed(k + 1)) {
207 if (skipped) sink.push("~");
208 sink.push(l.op, l.t);
209 skipped = false;
210 } else {
211 skipped = true;
212 }
213 });
214}
215
216// The diff of one change. Common leading and trailing lines are trimmed
217// first, so a one-line change in a long file still gets a small, exact diff.
218function lineDiff(oldValue, newValue) {
219 const sink = makeSink();
220 const oldText = String(oldValue ?? "");
221 const newText = String(newValue ?? "");
222 if (oldText.length > MAX_INPUT_CHARS || newText.length > MAX_INPUT_CHARS) {
223 const r = finish(sink, `too large to diff (${kb(oldText.length)} → ${kb(newText.length)})`);
224 return { ...r, add: countLines(newText), del: countLines(oldText) };
225 }
226 if (oldText.includes("\u0000") || newText.includes("\u0000")) {
227 return finish(sink, "binary content");
228 }
229 const a = splitLines(oldText);
230 const b = splitLines(newText);
231 let start = 0;
232 while (start < a.length && start < b.length && a[start] === b[start]) start++;
233 let endA = a.length;
234 let endB = b.length;
235 while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
236 endA--;
237 endB--;
238 }
239 if (start === a.length && start === b.length) return finish(sink);
240
241 if (start > 1) sink.push("~");
242 if (start > 0) sink.push(" ", a[start - 1]);
243 const midA = a.slice(start, endA);
244 const midB = b.slice(start, endB);
245 if (midA.length > LCS_MAX_LINES || midB.length > LCS_MAX_LINES) {
246 for (const t of midA) sink.push("-", t);
247 for (const t of midB) sink.push("+", t);
248 } else {
249 emitWithContext(sink, lcsOps(midA, midB));
250 }
251 if (endA < a.length) sink.push(" ", a[endA]);
252 if (a.length - endA > 1) sink.push("~");
253 return finish(sink);
254}
255
256// ---- Recording ---------------------------------------------------------------
257
258async function withTimeout($, promise, ms) {
259 promise.catch(() => {}); // a late failure after the timeout is ignored
260 return Promise.race([promise, $.clock.sleep(ms).then(() => TIMED_OUT)]);
261}
262
263// The old text of a file about to be replaced by Write, or a reason why not.
264// Read only inside the session folder, never from a sensitive-looking file:
265// a Write is recorded before it is approved, and a denied Write to a secret
266// must not put the secret on screen.
267async function readBefore($, where) {
268 if (!where.abs) return { text: "", tag: "" };
269 if (!where.inside) return { text: "", tag: "outside project · old text not read" };
270 if (SYSTEM_PATH.test(where.abs)) return { text: "", tag: "system path · old text not read" };
271 if (isSensitive(where.abs)) return { text: "", tag: "sensitive file · old text not read" };
272 try {
273 if (!(await $.fs.exists(where.abs))) return { text: "", tag: "new file" };
274 const text = await $.fs.read(where.abs);
275 return { text: String(text ?? ""), tag: "rewrite" };
276 } catch {
277 return { text: "", tag: "old text unreadable" };
278 }
279}
280
281// One tool call → one or more steps.
282async function stepsFor($, e) {
283 let cwd = "";
284 try {
285 cwd = String((await $.session.cwd()) ?? "");
286 } catch {
287 // no folder: every path counts as outside
288 }
289 const where = locate(cwd, e.file_path);
290 const base = { file: where.display, outside: !where.inside };
291 const tags = (...xs) => xs.filter(Boolean).join(" · ");
292
293 if (e.tool === "Edit") {
294 const d = lineDiff(e.old_string, e.new_string);
295 return [{ ...base, tool: "Edit", ...d, note: tags(e.replace_all ? "replace all" : "", d.note) }];
296 }
297 if (e.tool === "MultiEdit") {
298 const edits = Array.isArray(e.edits) ? e.edits : [];
299 const steps = edits.slice(0, MAX_MULTI_EDITS).map((ed, k) => {
300 const d = lineDiff(ed?.old_string, ed?.new_string);
301 return { ...base, tool: "MultiEdit", ...d, note: tags(`edit ${k + 1} of ${edits.length}`, ed?.replace_all ? "replace all" : "", d.note) };
302 });
303 return steps.length ? steps : [{ ...base, tool: "MultiEdit", ...finish(makeSink()), note: "no edits" }];
304 }
305 if (e.tool === "Write") {
306 const before = await readBefore($, where);
307 const d = lineDiff(before.text, e.content);
308 return [{ ...base, tool: "Write", ...d, note: tags(before.tag, d.note) }];
309 }
310 return [];
311}
312
313// What a step shows if its recording didn't finish in time or failed.
314function placeholderStep(e, why) {
315 return {
316 file: safeText(typeof e.file_path === "string" ? e.file_path : "(unknown file)", MAX_PATH_CHARS),
317 outside: false,
318 tool: EDIT_TOOLS.has(e.tool) ? e.tool : "Edit",
319 ...finish(makeSink()),
320 note: why,
321 };
322}
323
324// ---- Opening and closing -----------------------------------------------------
325
326async function openReplay($) {
327 if (!state.replay.length) return false;
328 if (state.isOpen || state.opening) return true;
329 state.opening = true;
330 state.index = 0;
331 state.isOpen = true;
332 let opened = false;
333 try {
334 // A pane only takes the keyboard while nothing else holds it. Hide the
335 // band first (it holds the keys if its button was pressed), then open.
336 $.ui.invalidate("ui.render");
337 await $.clock.sleep(OPEN_DELAY_MS);
338 const placed = await $.ui.open({ id: PANE_ID, title: "teckReplay", focus: true, closeOnEscape: true, rows: SHOW_LINES + 9, columns: 60 });
339 // No room for a pane (a narrow terminal): draw the replay in the band.
340 state.inBand = placed?.isPlaced === false;
341 opened = true;
342 } catch {
343 state.isOpen = false; // never leave the hint hidden behind a pane that isn't there
344 state.inBand = false;
345 } finally {
346 state.opening = false;
347 try {
348 $.ui.invalidate("ui.render");
349 } catch {
350 // nothing to redraw
351 }
352 }
353 return opened;
354}
355
356async function closeReplay($) {
357 const hadPane = state.isOpen && !state.inBand;
358 state.isOpen = false;
359 state.inBand = false;
360 if (hadPane) {
361 try {
362 await $.ui.close({ id: PANE_ID });
363 } catch {
364 // already gone
365 }
366 }
367 $.ui.invalidate("ui.render");
368}
369
370function goTo($, to) {
371 const last = state.replay.length - 1;
372 state.index = Math.max(0, Math.min(to, last));
373 $.ui.invalidate("ui.render");
374}
375
376// ---- Drawing -----------------------------------------------------------------
377
378function plural(n, word) {
379 return `${n} ${word}${n === 1 ? "" : "s"}`;
380}
381
382function hintText() {
383 return `▶ Replay: ${plural(state.replay.length, "edit")} (press r)`;
384}
385
386function stripWindow(k, total) {
387 const from = Math.max(0, Math.min(k - Math.floor(STRIP_CELLS / 2), total - STRIP_CELLS));
388 return [from, Math.min(total, from + STRIP_CELLS)];
389}
390
391// The replay, one step at a time. Drawn in the Pane, or in the band above the
392// prompt when the surface can't place a pane.
393function drawReplay($, e, inBand) {
394 const { Box, Text, Button } = $.ui.resolve(e);
395 const total = state.replay.length;
396 if (!total) return Text({ dimColor: true, children: "No edits to replay." });
397
398 const k = Math.max(0, Math.min(state.index, total - 1));
399 const step = state.replay[k];
400 const columns = Math.max(10, (Number(e.bodyColumns) || 100) - 4);
401 const maxRows = inBand ? Math.max(3, Math.min(SHOW_LINES, (Number(e.maxRows) || 20) - 7)) : SHOW_LINES;
402 const shown = step.diff.slice(0, maxRows);
403 const more = step.diff.length - shown.length + step.more;
404
405 const diffRows = shown.map((l, n) =>
406 Text({
407 key: `d${n}`,
408 color: l.op === "+" ? "green" : l.op === "-" ? "red" : undefined,
409 dimColor: l.op === " " || l.op === "~",
410 wrap: "truncate-end",
411 children: clip(`${l.op === "~" ? " " : l.op} ${l.t}`, columns),
412 }),
413 );
414 if (more > 0) diffRows.push(Text({ key: "more", dimColor: true, children: ` … ${plural(more, "more line")}` }));
415 if (!shown.length && !step.note) diffRows.push(Text({ key: "empty", dimColor: true, children: " (no line changes)" }));
416
417 const [from, to] = stripWindow(k, total);
418 const strip = [];
419 if (from > 0) strip.push(Text({ key: "s-left", dimColor: true, children: "‹" }));
420 for (let n = from; n < to; n++) {
421 strip.push(Text({ key: `s${n}`, inverse: n === k, color: n === k ? "cyan" : undefined, dimColor: n !== k, children: ` ${n + 1} ` }));
422 }
423 if (to < total) strip.push(Text({ key: "s-right", dimColor: true, children: "›" }));
424
425 const status = step.slot?.status ?? "";
426 const meta = [Text({ key: "tool", dimColor: true, children: [step.tool, step.note].filter(Boolean).join(" · ") })];
427 meta.push(Text({ key: "add", color: "green", children: `+${step.add}` }));
428 meta.push(Text({ key: "del", color: "red", children: `-${step.del}` }));
429 if (status) meta.push(Text({ key: "status", color: "yellow", bold: true, children: status }));
430
431 const header = `step ${k + 1} of ${total}${state.dropped ? ` (+${state.dropped} not recorded)` : ""}`;
432
433 return Box({
434 flexDirection: "column",
435 borderStyle: "round",
436 borderColor: "magenta",
437 paddingX: 1,
438 children: [
439 Box({
440 flexDirection: "row",
441 justifyContent: "space-between",
442 children: [Text({ bold: true, color: "magenta", children: "▶ teckReplay" }), Text({ bold: true, children: header })],
443 }),
444 Box({ flexDirection: "row", children: strip }),
445 Text({
446 bold: true,
447 color: step.outside ? "yellow" : "cyan",
448 wrap: "truncate-start",
449 children: step.outside ? `${step.file} (outside project)` : step.file,
450 }),
451 Box({ flexDirection: "row", gap: 2, children: meta }),
452 Box({ flexDirection: "column", marginTop: 1, children: diffRows }),
453 Box({
454 flexDirection: "row",
455 gap: 2,
456 marginTop: 1,
457 children: [
458 Button({ key: "prev", label: "◀ Prev", hotkey: "p", onPress: () => goTo($, k - 1) }),
459 Button({ key: "next", label: "Next ▶", hotkey: "n", autoFocus: true, onPress: () => goTo($, k + 1) }),
460 Button({ key: "close", label: "Close", hotkey: "c", onPress: () => void closeReplay($) }),
461 ],
462 }),
463 ],
464 });
465}
466
467function drawHint($, e) {
468 const { Box, Text, Button } = $.ui.resolve(e);
469 return Box({
470 flexDirection: "row",
471 gap: 2,
472 paddingX: 1,
473 children: [
474 Text({ color: "magenta", bold: true, children: hintText() }),
475 Button({ key: "open-replay", label: "Replay", hotkey: "r", onPress: () => void openReplay($) }),
476 ],
477 });
478}
479
480// ---- Hooks -------------------------------------------------------------------
481
482export function register(on) {
483 on("session.start", async ($, e, next) => {
484 const result = await next(e);
485 try {
486 await $.command.register({ name: "replay", description: "teckReplay: step through the last turn's file edits" });
487 } catch {
488 // the band button still works
489 }
490 return result;
491 });
492
493 on("command.run", { command: "replay" }, async ($) => {
494 const opened = await openReplay($);
495 if (opened) return { text: `teckReplay: ${plural(state.replay.length, "edit")}` };
496 return { text: state.replay.length ? "teckReplay: couldn't open the pane." : "teckReplay: no edits in the last turn." };
497 });
498
499 // Record each edit, then let it run unchanged.
500 on("tool.call", async ($, e, next) => {
501 if (!EDIT_TOOLS.has(e.tool)) return next(e);
502
503 // Claim this call's slot before any await, so parallel calls keep their
504 // order, and a call that outlives its turn lands in that turn, not the next.
505 const slot = { steps: [placeholderStep(e, "not recorded")], status: "" };
506 const turn = state.pending;
507 if (turn.length < MAX_STEPS) turn.push(slot);
508 else state.droppedCalls++;
509
510 try {
511 const steps = await withTimeout($, stepsFor($, e), RECORD_TIMEOUT_MS);
512 slot.steps = steps === TIMED_OUT ? [placeholderStep(e, "not recorded (timed out)")] : steps;
513 } catch {
514 slot.steps = [placeholderStep(e, "not recorded (error)")];
515 }
516 for (const step of slot.steps) step.slot = slot;
517
518 // Note how the call ended, when that's knowable from here.
519 try {
520 const result = await next(e);
521 if (result && typeof result === "object" && "deny" in result) slot.status = "denied";
522 return result;
523 } catch (err) {
524 slot.status = "failed";
525 throw err;
526 }
527 });
528
529 on("turn.start", ($, e, next) => {
530 if (!e.agentId) {
531 state.pending = [];
532 state.droppedCalls = 0;
533 }
534 return next(e);
535 });
536
537 // At the end of a main-loop turn, the recorded edits become the replay. A
538 // turn with no edits keeps the previous replay.
539 on("turn.complete", async ($, e, next) => {
540 const result = await next(e);
541 if (e.agentId) return result;
542 const slots = state.pending;
543 let dropped = state.droppedCalls;
544 state.pending = [];
545 state.droppedCalls = 0;
546
547 const steps = [];
548 for (const slot of slots) {
549 for (const step of slot.steps) {
550 if (steps.length < MAX_STEPS) steps.push(step);
551 else dropped++;
552 }
553 }
554 if (!steps.length) return result;
555 state.replay = steps;
556 state.dropped = dropped;
557 state.index = 0;
558 $.ui.invalidate("ui.render");
559 return result;
560 });
561
562 // The band above the prompt: the hint and a button that opens the pane.
563 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
564 if (state.isOpen && state.inBand) return drawReplay($, e, true);
565 if (state.isOpen || !state.replay.length) return next(e);
566 return drawHint($, e);
567 });
568
569 on("ui.render", { component: "Pane" }, ($, e, next) => {
570 if (e.requestId !== PANE_ID) return next(e);
571 return drawReplay($, e, false);
572 });
573
574 // The pane was closed some other way (Escape): keep the flag in step.
575 on("ui.close", ($, e, next) => {
576 if (e.id === PANE_ID || e.requestId === PANE_ID) {
577 state.isOpen = false;
578 state.inBand = false;
579 $.ui.invalidate("ui.render");
580 }
581 return next(e);
582 });
583}
584