SLOPSHOPPER

teckreplay

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

newpanebandguardcommand
v0.2.0Apache-2.0updated 2026-10-22laynem/claudemods/plugins/teckreplay
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · teckreplay
│ ┃ teckReplay ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ▶ teckReplay step 1 of 3 │ ⏺ Read(src/auth.ts) │ ┃ │ 1 2 3 │ ⎿ Read 6 lines │ ┃ │ src/auth.ts │ ⏺ Update(src/auth.ts) │ ┃ │ Edit +2 -1 │ ⎿ Added 2 lines, removed 1 line │ ┃ │ │ ⏺ Bash(bun test) │ ┃ │ - if (!claims) throw new Error('invalid… │ ⎿ 3 pass, 1 fail │ ┃ │ + if (!claims || claims.exp < Date.now(… │ │ ┃ │ + await audit('refresh', claims.sub) │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ │ ┃ │ [ ◀ Prev ] [ Next ▶ ] [ Close ] │ ✻ Worked for 42s · done 4:20 PM │ ┃ ╰──────────────────────────────────────────╯ │ › /replay │ ⎿ teckreplay: teckReplay: 3 edits │ │ ▶ Replay: 3 edits (press r) [ Replay ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▶ Replay: 3 edits (press r) [ Replay ]
Pane · teckReplay
╭──────────────────────────────────────────────────────────╮ │ ▶ teckReplay step 1 of 3 │ │ 1 2 3 │ │ src/auth.ts │ │ Edit +2 -1 │ │ │ │ - if (!claims) throw new Error('invalid token') │ │ + if (!claims || claims.exp < Date.now() / 1000) throw … │ │ + await audit('refresh', claims.sub) │ │ │ │ [ ◀ Prev ] [ Next ▶ ] [ Close ] │ ╰──────────────────────────────────────────────────────────╯
README

teckReplay Mod

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.

What it does

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).

Install

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.

Tests

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.

Changes from 0.1.0 (review findings)

Security

  1. Terminal escape injection (high). Diff lines come from file contents and from Claude's tool input, and were rendered as-is. A file containing 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 �.
  2. Trojan Source / hidden characters (medium). Bidi overrides (U+202A–202E, U+2066–2069), zero-width and other invisible characters could make a diff read differently from what the file holds, which defeats the point of reviewing it. They're replaced with � so they're visible.
  3. Secrets read and shown for denied writes (medium). Write recorded the old file by reading 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.
  4. The recorder could hold an edit forever (medium, DoS). Recording was awaited before 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.
  5. Unbounded memory and a stack overflow path (low). The whole diff of every step was kept for the session, including every line of large Writes. Now only the 12 shown lines are kept (each capped at 240 characters), texts over 2 MB aren't diffed, MultiEdit records at most 50 sub-edits, and a turn keeps at most 200 steps (the header says how many weren't recorded).

Correctness

  1. Out-of-order steps. Parallel edit calls pushed their steps after their own awaits, so an Edit could land before a Write that was called first. Each call now claims its slot synchronously.
  2. Stuck hidden hint. If $.ui.open threw, isOpen stayed true and the hint never came back. Opening now resets on failure.
  3. Double open. Pressing Replay twice quickly opened the pane twice. There's now an opening guard.
  4. Misleading diffs for big files. A one-line change in a file over 400 lines showed as the whole file removed and re-added. Common leading and trailing lines are now trimmed before diffing, so the changed region is exact.
  5. CRLF files. \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.
  6. Paths. Relative-path display only handled POSIX. Windows separators and drive-letter case are handled, and the "inside project" test fails closed (unsure → outside).
  7. Smaller things. The step strip is windowed (9 cells) so a long turn doesn't overflow; "1 edits" pluralization; 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.

Limitations

  • Symlinks aren't resolved: a link inside the project that points outside it still counts as inside. Doing better needs realpath, which would mean adding $.process access; the rewrite keeps the original's smaller set of host capabilities instead.
  • On a case-insensitive filesystem (macOS default), a path differing only in case from the session folder counts as outside, so its old text isn't read. That fails safe.
  • "denied" / "failed" labels depend on what next(e) returns or throws; if the host reports outcomes another way, edits show with no label.
  • Edits by subagents are included in the main turn's replay. A background subagent still editing after its parent turn ends lands in that turn's slot list, not the next.
  • In memory only; lost on restart or plugin reload.
  • MultiEdit and the band fallback remain untested in Claude Code, as in 0.1.0.

License

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.

Source 1 files
hooks/teckreplay.mjs 584 lines
1// 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