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

A one-line band above the Claude Code prompt, a roomy Cockpit panel beside the conversation, and seven community mods, installed as one plugin. The band shows only what you glance at. The panel has everything else: prompt-cache countdown, context, plan limits with their reset times, session cost, your next tasks and project shortcuts. Everything that spends tokens or redraws your screen starts off.
◔ 42m ctx 72.5k Next Write docs/specs/PLAN-2.md for the phase 2 slices Continue Details
The band is drawn with Claude's own text and buttons, so it takes the app's fonts and theme, truncates the task by itself, and cannot flash or resize. Details (or /cockpit) opens the panel as a tab in the right-hand side panel.
<img src="docs/images/band.png" alt="The band above the prompt in the Claude desktop app: working, context 704.8k at 70 percent, the next task, and a Details button" width="720">
<img src="docs/images/panel.png" alt="The Cockpit panel in the side panel: prompt cache with a green bar, context at 70 percent, plan limits with reset times, session cost, next up and shortcuts" width="320">
These are screenshots of the real desktop app, showing the dark theme.
| Plugin | What it does | Default here | Origin |
|---|---|---|---|
| cockpit | The band, the Cockpit panel, next-task list, Continue / What's next / Compact buttons, guards, and a cheat-sheet skill | on | this repo (MIT) |
| cache-tax | Shows the rewrite price before a cold send; opt-in keep-warm for the prompt cache | warns, never drops; keep-warm off | karanb192/cache-tax |
claude-reflect (reflect-mod) | Notices corrections and offers to save them as rules in CLAUDE.md | paused | BayramAnnakov/claude-reflect |
| skins | Redraws the transcript: tool rows, diff cards, table and code cards, spinner | off, its own band off | hellosverre/claude-skins |
| filetree | IDE-style file tree in the sidebar with git status and read/write activity | opens with /filetree (does not dock by itself) | data-goblin/claude-code-filetree |
| replay-theater | Step through the last turn's file edits, one diff at a time | on | anthropics/claude-code-playground |
| blast-radius | Holds rm -r, git reset --hard, git clean, force push and migrations until you press Proceed | on | anthropics/claude-code-playground |
| savvy-progress | Progress bar and agents panel for flows and subagents | idle until something reports | JohnnyVizz/claude-kit |
The seven community mods are vendored here at pinned commits, with small patches (listed in each folder's SOURCE.md) so they start quiet and stack above each other instead of replacing each other's band. Their original licences are kept.
Needs Claude Code 2.1.287 or later.
/plugin marketplace add cGradying/claude-code-cockpit
/plugin install cockpit@janvin-mods
or from a terminal:
claude plugin marketplace add cGradying/claude-code-cockpit
claude plugin install cockpit@janvin-mods --scope user
cockpit lists the seven mods as dependencies, so installing it installs them all. If a dependency does not install by itself, install it by name, for example claude plugin install skins@janvin-mods. Then restart Claude Code (or the desktop app) and open a new session.
To remove everything: /plugin, pick cockpit, uninstall, and do the same for each mod you no longer want. Each one is independent.
The band. One line above the prompt: the cache countdown (◔ 42m, green, then amber, then red), context, and the next task. The 5-hour and weekly limits join the line only once they pass 60%, and 9router down shows only if it was up and stopped. Buttons sit at the right; click them, or focus the band (ctrl+x then tab) and press the key.
| Button | Key | Does |
|---|---|---|
| Continue | c | Sends "continue with the next task" for the first open line of .claude/NEXT.md, and asks Claude to check it off when done. Hidden while a turn runs. |
| Details | d | Opens the Cockpit panel. |
| Compact | k | Appears past 70% context. Runs /compact and tells it to keep the plan, open tasks, PRs, decisions and open bugs. |
The Cockpit panel. One tab in the side panel with room for each number: prompt cache (time left, a bar, what happens if it lapses), context (tokens, percent, window size, Compact), plan limits (each with a bar and when it resets), this session (cost, 9router), your next five tasks (Continue, What's next?), and your project shortcuts. Bars are solid boxes sized in percent and follow the app's light or dark theme. Open it with Details or /cockpit.
Guards that run quietly. A shell command carrying a Claude or Anthropic co-author line is refused. A prompt containing a pasted API key is held once (press Enter again to send it anyway). Prompts about UI or design get a hidden nudge toward the impeccable skill if you have it.
Commands. /cockpit, /next, /keepwarm, /cache-tax, /reflect-pause, /reflect-queue, /skin, /filetree, /replay, /agents-info. Details in docs/COMMANDS.md; inside Claude Code, the cockpit:help skill prints the same cheat sheet.
Three things in the bundle can cost tokens or change your screen, so they start off:
/keepwarm. The cold-send guard only warns by default: it shows the rewrite price and still sends your message./reflect-pause turns it on. remember: <rule> works either way and needs no model./skin noir turns it on.How to change each: docs/CONFIGURATION.md.
NEXT.md, quick.json./filetree (set autoOpen to on to dock it at session start). It needs a wide window, and upstream says it does not load in WSL sessions of the desktop app.The code in cockpit/ is MIT, Copyright (c) 2026 cGradying (see LICENSE). Each vendored mod keeps its own licence file and authorship: the two Anthropic mods are Apache-2.0, the rest are MIT. See THIRD_PARTY_NOTICES.md. Thanks to the authors of all seven.
hooks/replay-theater.mjs 255 lines1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Replay Theater: records the file edits Claude makes in a turn, then lets you
5// step through them in a pane, one diff at a time.
6//
7// Claude Code 2.1.280 function hooks. Load with --plugin-dir.
8
9// cockpit patch: the cockpit accent, instead of magenta and cyan, and no border box.
10const CLAY = "#d97757";
11const PANE_ID = "replay-theater";
12const MAX_DIFF_LINES = 12;
13const MAX_LCS_LINES = 400;
14const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
15
16// Module state. `pending` fills during a turn; `replay` is the last finished
17// turn's steps, the ones the pane shows.
18const state = { pending: [], replay: [], index: 0, isOpen: false, inBand: false, turns: 0 };
19
20function relPath(cwd, path) {
21 if (!path) return "(unknown file)";
22 if (cwd && path.startsWith(cwd + "/")) return path.slice(cwd.length + 1);
23 return path;
24}
25
26function splitLines(text) {
27 if (text === undefined || text === null || text === "") return [];
28 const lines = String(text).split("\n");
29 if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
30 return lines;
31}
32
33// A line diff. Small inputs get an LCS diff, so unchanged lines show as
34// context; large ones fall back to all-removed then all-added.
35function diffLines(oldText, newText) {
36 const a = splitLines(oldText);
37 const b = splitLines(newText);
38 if (a.length > MAX_LCS_LINES || b.length > MAX_LCS_LINES) {
39 return [...a.map((t) => ({ op: "-", t })), ...b.map((t) => ({ op: "+", t }))];
40 }
41 const n = a.length, m = b.length;
42 const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
43 for (let i = n - 1; i >= 0; i--)
44 for (let j = m - 1; j >= 0; j--)
45 dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
46 const out = [];
47 let i = 0, j = 0;
48 while (i < n && j < m) {
49 if (a[i] === b[j]) { out.push({ op: " ", t: a[i] }); i++; j++; }
50 else if (dp[i + 1][j] >= dp[i][j + 1]) out.push({ op: "-", t: a[i++] });
51 else out.push({ op: "+", t: b[j++] });
52 }
53 while (i < n) out.push({ op: "-", t: a[i++] });
54 while (j < m) out.push({ op: "+", t: b[j++] });
55 return trimContext(out);
56}
57
58// Keep one line of context around each change, so a long file's Write shows
59// the changed lines, not the whole file.
60function trimContext(lines) {
61 if (!lines.some((l) => l.op !== " ")) return lines.slice(0, MAX_DIFF_LINES);
62 const keep = lines.map((l, k) =>
63 l.op !== " " || lines[k - 1]?.op && lines[k - 1].op !== " " || lines[k + 1]?.op && lines[k + 1].op !== " ");
64 const out = [];
65 let skipped = false;
66 lines.forEach((l, k) => {
67 if (keep[k]) { if (skipped && out.length) out.push({ op: "~", t: "⋯" }); out.push(l); skipped = false; }
68 else skipped = true;
69 });
70 return out;
71}
72
73function countChanges(diff) {
74 let add = 0, del = 0;
75 for (const l of diff) { if (l.op === "+") add++; else if (l.op === "-") del++; }
76 return { add, del };
77}
78
79// Turns one tool call into one or more steps.
80async function stepsFor($, e) {
81 let cwd = "";
82 try { cwd = await $.session.cwd(); } catch { /* keep the full path */ }
83 const file = relPath(cwd, e.file_path);
84 if (e.tool === "Edit") {
85 return [{ tool: "Edit", file, diff: diffLines(e.old_string, e.new_string), note: e.replace_all ? "replace all" : "" }];
86 }
87 if (e.tool === "MultiEdit" && Array.isArray(e.edits)) {
88 return e.edits.map((ed, k) => ({
89 tool: "MultiEdit", file, diff: diffLines(ed.old_string, ed.new_string), note: `edit ${k + 1} of ${e.edits.length}`,
90 }));
91 }
92 if (e.tool === "Write") {
93 let before = "";
94 let isNew = true;
95 try {
96 if (e.file_path && (await $.fs.exists(e.file_path))) {
97 before = await $.fs.read(e.file_path);
98 isNew = false;
99 }
100 } catch { /* unreadable: show it as a new file */ }
101 return [{ tool: "Write", file, diff: diffLines(before, e.content), note: isNew ? "new file" : "rewrite" }];
102 }
103 return [];
104}
105
106function hintText() {
107 const n = state.replay.length;
108 return `▶ Replay: ${n} edit${n === 1 ? "" : "s"} (press r)`;
109}
110
111async function openReplay($) {
112 if (!state.replay.length) return false;
113 state.index = 0;
114 state.isOpen = true;
115 // The pane only takes the keyboard while nothing else holds it. Hide the
116 // band first (it held the keys if its button was pressed), then open.
117 $.ui.invalidate("ui.render");
118 await $.clock.sleep(200);
119 const rows = Math.min(MAX_DIFF_LINES + 8, 22);
120 const placed = await $.ui.open({ id: PANE_ID, title: "Replay Theater", focus: true, closeOnEscape: true, rows, columns: 58 });
121 // No room for a pane (a narrow terminal): draw the replay in the band.
122 state.inBand = placed?.isPlaced === false;
123 $.ui.invalidate("ui.render");
124 return true;
125}
126
127// The replay view: one step at a time. Drawn in the Pane, or in the band
128// above the prompt when the surface can't place a pane.
129function replayView($, e, inBand) {
130 const { Box, Text, Button } = $.ui.resolve(e);
131 const maxDiff = inBand ? Math.max(3, Math.min(MAX_DIFF_LINES, (e.maxRows || 20) - 7)) : MAX_DIFF_LINES;
132 const total = state.replay.length;
133 if (!total) return Text({ dimColor: true, children: "No edits to replay." });
134 const k = Math.max(0, Math.min(state.index, total - 1));
135 const step = state.replay[k];
136 const width = Math.max(40, (e.bodyColumns || 100) - 4);
137 const { add, del } = countChanges(step.diff);
138 const shown = step.diff.slice(0, maxDiff);
139
140 const diffRows = shown.map((l, n) => {
141 const color = l.op === "+" ? "green" : l.op === "-" ? "red" : undefined;
142 const line = `${l.op === "~" ? " " : l.op} ${l.t}`.slice(0, width);
143 return Text({ key: `d${n}`, color, dimColor: l.op === " " || l.op === "~", wrap: "truncate-end", children: line });
144 });
145 if (step.diff.length > maxDiff) diffRows.push(Text({ key: "more", dimColor: true, children: ` … ${step.diff.length - maxDiff} more lines` }));
146 if (!shown.length) diffRows.push(Text({ key: "empty", dimColor: true, children: " (no line changes)" }));
147
148 // The step list: one cell per step, the current one highlighted.
149 const strip = state.replay.map((s, n) =>
150 Text({ key: `s${n}`, inverse: n === k, color: n === k ? CLAY : undefined, dimColor: n !== k, children: ` ${n + 1} ` }));
151
152 const go = (to) => { state.index = Math.max(0, Math.min(to, total - 1)); $.ui.invalidate("ui.render"); };
153 const close = () => {
154 state.isOpen = false;
155 if (!state.inBand) $.ui.close({ id: PANE_ID });
156 state.inBand = false;
157 $.ui.invalidate("ui.render");
158 };
159
160 return Box({
161 flexDirection: "column", paddingX: 1,
162 children: [
163 Box({ flexDirection: "row", justifyContent: "space-between", children: [
164 Text({ bold: true, color: CLAY, children: "▶ Replay Theater" }),
165 Text({ bold: true, children: `step ${k + 1} of ${total}` }),
166 ] }),
167 Box({ flexDirection: "row", children: strip }),
168 Text({ bold: true, wrap: "truncate-start", children: step.file }),
169 Box({ flexDirection: "row", gap: 2, children: [
170 Text({ dimColor: true, children: `${step.tool}${step.note ? " · " + step.note : ""}` }),
171 Text({ color: "green", children: `+${add}` }),
172 Text({ color: "red", children: `-${del}` }),
173 ] }),
174 Box({ flexDirection: "column", marginTop: 1, children: diffRows }),
175 Box({ flexDirection: "row", gap: 2, marginTop: 1, children: [
176 Button({ key: "prev", label: "◀ Prev", hotkey: "p", onPress: () => go(k - 1) }),
177 Button({ key: "next", label: "Next ▶", hotkey: "n", autoFocus: true, onPress: () => go(k + 1) }),
178 Button({ key: "close", label: "Close", hotkey: "c", onPress: close }),
179 ] }),
180 ],
181 });
182}
183
184export function register(on, options) {
185 // Slash command: /replay opens the pane.
186 on("session.start", async ($, e, next) => {
187 const r = await next(e);
188 await $.command.register({ name: "replay", description: "Replay Theater: step through the last turn's file edits" });
189 return r;
190 });
191
192 on("command.run", { command: "replay" }, async ($, e) => {
193 const opened = await openReplay($);
194 return { text: opened ? `Replay Theater: ${state.replay.length} edits` : "Replay Theater: no edits in the last turn." };
195 });
196
197 // Record each edit, then let it run. Never blocks the call.
198 on("tool.call", async ($, e, next) => {
199 if (EDIT_TOOLS.has(e.tool)) {
200 try {
201 const steps = await stepsFor($, e);
202 state.pending.push(...steps);
203 } catch { /* recording must never stop the edit */ }
204 }
205 return next(e);
206 });
207
208 on("turn.start", ($, e, next) => {
209 if (!e.agentId) state.pending = [];
210 return next(e);
211 });
212
213 // At the end of a main-loop turn, the pending edits become the replay.
214 on("turn.complete", async ($, e, next) => {
215 const r = await next(e);
216 if (e.agentId || !state.pending.length) return r;
217 state.replay = state.pending;
218 state.pending = [];
219 state.index = 0;
220 state.turns++;
221 $.ui.invalidate("ui.render");
222 return r;
223 });
224
225 // The band above the prompt: the hint and a button that opens the pane.
226 // cockpit patch: stack above whatever other plugins draw in this slot instead of replacing it.
227 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
228 const below = await next(e);
229 const { Box, Text, Button } = $.ui.resolve(e);
230 const stack = (mine) => Box({ flexDirection: "column", children: [mine, below].filter(Boolean) });
231 if (state.isOpen && state.inBand) return stack(replayView($, e, true));
232 if (!state.replay.length || state.isOpen) return below;
233 return stack(Box({
234 flexDirection: "row", gap: 2, paddingX: 1,
235 children: [
236 Text({ color: CLAY, bold: true, children: hintText() }),
237 Button({ key: "open-replay", label: "Replay", hotkey: "r", onPress: () => openReplay($) }),
238 ],
239 }));
240 });
241
242 // The pane.
243 on("ui.render", { component: "Pane" }, ($, e, next) => {
244 if (e.requestId !== PANE_ID) return next(e);
245 return replayView($, e, false);
246 });
247
248 // The person closed the pane (Escape): keep our flag in step.
249 on("ui.close", ($, e, next) => {
250 if (e.id === PANE_ID || e.requestId === PANE_ID) state.isOpen = false;
251 $.ui.invalidate("ui.render");
252 return next(e);
253 });
254}
255