SLOPSHOPPER

replay-theater

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

newpanebandguardcommand
v0.2.1no licenseupdated 2026-10-09yahyashareef48/claude-mods/plugins/replay-theater
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · replay-theater
│ ┃ Replay ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Replay 1 of 9 │ ⏺ Read(src/auth.ts) │ ┃ │ src/auth.ts │ ⎿ Read 6 lines │ ┃ │ Read │ ⏺ Update(src/auth.ts) │ ┃ │ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ import { verify } from './jwt' │ ⏺ Bash(bun test) │ ┃ │ │ ⎿ 3 pass, 1 fail │ ┃ │ export async function refresh(token: … │ │ ┃ │ const claims = await verify(token) │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ if (!claims) throw new Error('inval… │ │ ┃ │ return issue(claims.sub) │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ } │ │ ┃ │ │ › /replay │ ┃ │ [ Prev ] [ Next ] [ Close ] │ ⎿ replay-theater: Replay: 9 steps · 3 edits · 4 commands · 1 read │ ┃ ╰──────────────────────────────────────────╯ │ Last turn · 9 steps · 3 edits · 4 commands · 1 read [ Replay ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Last turn · 9 steps · 3 edits · 4 commands · 1 read [ Replay ]
Pane · Replay
╭──────────────────────────────────────────────────────────╮ │ Replay 1 of 9 │ │ src/auth.ts │ │ Read │ │ │ │ import { verify } from './jwt' │ │ │ │ export async function refresh(token: string) { │ │ const claims = await verify(token) │ │ if (!claims) throw new Error('invalid token') │ │ return issue(claims.sub) │ │ } │ │ │ │ [ Prev ] [ Next ] [ Close ] │ ╰──────────────────────────────────────────────────────────╯
README

Replay Theater

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.

What this shows

While a turn runs, Replay Theater records each file edit Claude makes (the Edit, Write and MultiEdit tool calls): the file, and the text before and after. When the turn ends, a hint appears above the prompt:

▶ Replay: 5 edits (press r)  [ Replay ]

Open it, and a pane shows one edit at a time: the file, the tool, a count of added and removed lines, and a short diff in red and green. A strip of numbered cells shows every step, with the current one highlighted, and the header reads "step 2 of 5". Prev, Next and Close buttons move through the steps.

Replay Theater only watches. It never blocks, changes or delays an edit.

The patterns it demonstrates:

  • Recording from tool.call and always passing the call on with next(e).
  • Grouping work by turn with turn.start and turn.complete, and skipping subagent turns.
  • A slash command (/replay) registered from session.start and handled in command.run.
  • A Pane with Button elements and hotkeys, with a fallback to the AbovePrompt band.

Demo

After a rename, the hint above the prompt:

The Replay hint above the prompt after a turn with five edits

The pane, on step 1 of 5:

The Replay Theater pane showing step 1 of 5

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

A Write step for a new file, with the diff capped at 12 lines

Inline above the prompt in an 80-column terminal:

The pane drawn inline in an 80-column terminal

How it was built

  • Model: built with Claude in Claude Code. The test runs and screenshots used Claude Sonnet 5. The mod itself doesn't call a model.
  • Prompt(s): the mod started as one of ten ideas Claude wrote for mods. This is the idea as written:

Replay Theater. Scrub through what Claude just did. After a turn, a pane opens with a timeline of every edit. Buttons step forward and back, and each step shows its diff. It uses ui.render on Pane, with Button elements, and it records each edit from tool.call. It builds on the built-in diff mod. A developer would install it to review a long run in a minute, and it makes a strong demo clip. A bigger project, because the timeline needs its own state.

The build prompt, which picked this idea and two others by number:

implement 1,2,7. give me zips for them. test them in claude code and get me screenshots of what they look like when used.

  • Transcript: not shared. The build ran in an internal workspace.
  • Iterations:
  • The idea had the pane open by itself after each turn. The built mod shows a one-line hint above the prompt instead, and opens the pane when you ask for it with /replay or the Replay button.
  • A pane only takes the keyboard while nothing else holds it. When the band's Replay button is pressed, the band holds the keys, so the mod hides the band and waits briefly before it opens the pane.
  • A Write can replace a whole file, so the diff keeps only one line of context around each change, and caps each step at 12 lines.
  • Tested in Claude Code: a rename across three files made 5 edits, and the pane stepped through all of them with Prev, Next and Close. A Write of a new file showed as one step.

Run it

Requirements:

  • Claude Code 2.1.287 or later, where mods load by default. The mod was built and tested on 2.1.280, and claude plugin validate passes on 2.1.285.
  • A terminal. The pane is placed inline above the prompt, or docked to the right in the fullscreen layout.

No environment variables or configuration.

Steps:

  1. Clone this repository and go to this folder's parent:
   git clone https://github.com/anthropics/claude-code-playground.git
   cd claude-code-playground/claude-code/mods
  1. Check the plugin:
   claude plugin validate ./replay-theater
  1. Try it for one session:
   claude --plugin-dir ./replay-theater

Or install it, with the other mods here, from the local marketplace in this folder (see the mods README):

   claude plugin marketplace add ./
   claude plugin install replay-theater@claude-code-playground-mods --scope user
  1. Ask Claude for a change that edits files, for example "rename the function greet to welcome everywhere".
  2. When the turn ends, open the replay in one of two ways:
  3. Type /replay and press Enter.
  4. Press ctrl+x then Tab to focus the band above the prompt, then press r (or Enter on the Replay button).
  5. In the pane:
KeyDoes
nNext step
pPrevious step
c or EscapeClose the pane

Mouse clicks on the buttons work too. The replay stays available until the next turn that edits files. A turn with no edits keeps the previous replay.

Notes / limitations

  • The diff shows up to 12 lines per step, then "… N more lines". Unchanged lines are kept only next to a change, and a skipped run shows as "⋯".
  • For Edit, the diff is between old_string and new_string, not the whole file. The file's line numbers aren't shown.
  • For Write, the old text is read from disk just before the write. Files over 400 lines are shown as all removed, then all added, with no line matching.
  • Edits are recorded when the tool is called. An edit that you then deny, or that fails, still shows in the replay.
  • The replay is kept in memory for the current session only. It is lost when Claude Code restarts, or when the plugin reloads.
  • Edits made by subagents are included in the main turn's replay.
  • Claude Code 2.1.280 has no MultiEdit tool. The mod handles MultiEdit, one step per edit, for builds that have it. That path is untested.
  • If the terminal can't place a pane, the replay is drawn in the band above the prompt instead. That fallback is untested, because every width we tried (80 and 120 columns) placed the pane.
  • The band above the prompt is shared. If another mod also draws there, only one of them shows.

Dependencies

NameVersionLicense (SPDX)Source
None

Third-party notices

None.


Shared as-is as part of claude-code-playground. Not an official Anthropic product; no support or maintenance is implied. See the root README and LICENSE.

Source 1 files
hooks/replay-theater.mjs 751 lines
1// 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