SLOPSHOPPER

replay-theater

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

newpanebandguardcommand
v0.1.0+cockpit.1NOASSERTIONupdated 2026-10-10cGradying/claude-code-cockpit/replay-theater
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · replay-theater
│ ┃ Replay Theater ✕ › fix the failing auth test and add an audit log call │ ┃ ▶ Replay Theater step 1 of 3 │ ┃ 1 2 3 ⏺ Read(src/auth.ts) │ ┃ src/auth.ts ⎿ Read 6 lines │ ┃ Edit +2 -1 ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ - if (!claims) throw new Error('invalid t… ⏺ Bash(bun test) │ ┃ + if (!claims || claims.exp < Date.now() … ⎿ 3 pass, 1 fail │ ┃ + 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 │ ⎿ replay-theater: Replay Theater: 3 edits │ │ ▶ Replay: 3 edits (press r) [ Replay ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▶ Replay: 3 edits (press r) [ Replay ] ⟨Claude Code's own drawing⟩
Pane · Replay Theater
▶ Replay Theater 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 ne… + await audit('refresh', claims.sub) [ ◀ Prev ] [ Next ▶ ] [ Close ]
README

claude-code-cockpit

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.

What is inside

PluginWhat it doesDefault hereOrigin
cockpitThe band, the Cockpit panel, next-task list, Continue / What's next / Compact buttons, guards, and a cheat-sheet skillonthis repo (MIT)
cache-taxShows the rewrite price before a cold send; opt-in keep-warm for the prompt cachewarns, never drops; keep-warm offkaranb192/cache-tax
claude-reflect (reflect-mod)Notices corrections and offers to save them as rules in CLAUDE.mdpausedBayramAnnakov/claude-reflect
skinsRedraws the transcript: tool rows, diff cards, table and code cards, spinneroff, its own band offhellosverre/claude-skins
filetreeIDE-style file tree in the sidebar with git status and read/write activityopens with /filetree (does not dock by itself)data-goblin/claude-code-filetree
replay-theaterStep through the last turn's file edits, one diff at a timeonanthropics/claude-code-playground
blast-radiusHolds rm -r, git reset --hard, git clean, force push and migrations until you press Proceedonanthropics/claude-code-playground
savvy-progressProgress bar and agents panel for flows and subagentsidle until something reportsJohnnyVizz/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.

Install

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.

A quick tour

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.

ButtonKeyDoes
ContinuecSends "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.
DetailsdOpens the Cockpit panel.
CompactkAppears 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.

Quiet by default

Three things in the bundle can cost tokens or change your screen, so they start off:

  • Keep-warm (cache-tax) sends a real model request about every 50 minutes while armed. It is off; you arm it with /keepwarm. The cold-send guard only warns by default: it shows the rewrite price and still sends your message.
  • Correction checking (claude-reflect) sends short prompts to Haiku. It starts paused; /reflect-pause turns it on. remember: <rule> works either way and needs no model.
  • Skins redraw the whole transcript. It starts off; /skin noir turns it on.

How to change each: docs/CONFIGURATION.md.

Documentation

Status and limits

  • Written for Claude Code's mods feature, which is early access and may change between releases.
  • The band and panel were built by looking at the real desktop app, and are tested with Claude Code's plugin test kit on the terminal and desktop surfaces. They have been run in the desktop app's Code tab; the terminal layout is covered by tests but has had less eyes on it. Please open an issue with a screenshot if something looks wrong.
  • The file tree opens with /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.
  • Cache timings assume the one-hour prompt cache. With a five-minute cache the countdown will be wrong.

Credits and licences

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.

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