Spiel die Dateiänderungen des letzten Turns Schritt für Schritt ab, auch Änderungen durch Shell-Befehle (in Git-Repos).

Nach jedem Turn siehst du alle Dateiänderungen von Claude noch mal, Schritt für Schritt, als kleinen Diff in Rot und Grün.
Während ein Turn läuft, merkt sich der Mod jede Änderung durch Edit, Write und MultiEdit. In einem Git-Repo erkennt er zusätzlich Dateien, die ein Shell-Befehl geändert hat (sed -i, mv, ein Codemod). Die stehen als Schritt mit dem Label "Shell" drin.
Wenn der Turn fertig ist, erscheint über dem Prompt:
▶ Replay: 3 Änderungen (r drücken) [ Abspielen ]
Im Fenster siehst du pro Schritt:
+3 -1Write · neue Datei oder MultiEdit · Bearbeitung 1 von 3Der Mod schaut nur zu. Er blockiert, ändert oder verzögert keine Änderung.
/plugin marketplace add Verselo-Orbit/claude-mods
/plugin install replay-theater-plus@verselo-mods
Öffnen, wenn der Turn fertig ist:
/replay eingeben, oderctrl+x und dann Tab (Fokus auf die Zeile über dem Prompt), dann rIm Fenster:
| Taste | Was passiert |
|---|---|
n | Weiter ▶ |
p | ◀ Zurück |
c oder Escape | Schließen |
Klicks auf die Buttons gehen auch. Das Replay bleibt, bis ein neuer Turn wieder Dateien ändert.
git diff (nicht gestagte Änderungen). Was ein Befehl direkt mit git add staged, taucht ohne Diff auf.old_string gegen new_string, nicht die ganze Datei. Zeilennummern gibt es nicht.Getestet auf macOS mit Claude Code 2.1.295. Windows: nicht getestet.
Basiert auf Replay Theater von Anthropic (Apache-2.0). Änderungen von Verselo Systems SL, siehe NOTICE.md.
hooks/replay-theater.mjs 303 lines1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3// Modified by Verselo Systems SL, 2026: German UI, readability, fixes (see NOTICE.md)
4//
5// Replay Theater: records the file edits Claude makes in a turn, then lets you
6// step through them in a pane, one diff at a time. Inside a git repo it also
7// picks up files changed by shell commands (sed -i, mv, codemods).
8//
9// Claude Code function hooks. Load with --plugin-dir.
10
11import { MAX_DIFF_LINES, diffLines, countChanges, parseUnifiedDiff, parseStatusZ, splitGitDiff } from "./diff.mjs";
12
13const PANE_ID = "replay-theater";
14const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
15const MAX_SHELL_FILES = 20;
16const GIT_TIMEOUT_MS = 5000;
17const MAX_STRIP_CELLS = 15;
18
19// Module state. `pending` fills during a turn; `replay` is the last finished
20// turn's steps, the ones the pane shows. `snap` is the git snapshot taken when
21// the main-loop turn started (null outside a git repo).
22const state = { pending: [], replay: [], index: 0, isOpen: false, inBand: false, turns: 0, snap: null };
23
24function relPath(cwd, path) {
25 if (!path) return "(unbekannte Datei)";
26 if (cwd && path.startsWith(cwd + "/")) return path.slice(cwd.length + 1);
27 return path;
28}
29
30function plural(n) {
31 return `${n} Änderung${n === 1 ? "" : "en"}`;
32}
33
34async function sessionCwd($) {
35 try { return (await $.session.cwd()) || ""; } catch { return ""; }
36}
37
38// Turns one tool call into one or more steps. `abs` is kept so the shell
39// detection can skip files an edit tool already recorded.
40async function stepsFor($, e) {
41 const cwd = await sessionCwd($);
42 const file = relPath(cwd, e.file_path);
43 const abs = e.file_path;
44 if (e.tool === "Edit") {
45 return [{ tool: "Edit", file, abs, diff: diffLines(e.old_string, e.new_string), note: e.replace_all ? "alles ersetzt" : "" }];
46 }
47 if (e.tool === "MultiEdit" && Array.isArray(e.edits)) {
48 return e.edits.map((ed, k) => ({
49 tool: "MultiEdit", file, abs, diff: diffLines(ed.old_string, ed.new_string), note: `Bearbeitung ${k + 1} von ${e.edits.length}`,
50 }));
51 }
52 if (e.tool === "Write") {
53 let before = "";
54 let isNew = true;
55 try {
56 if (e.file_path && (await $.fs.exists(e.file_path))) {
57 before = await $.fs.read(e.file_path);
58 isNew = false;
59 }
60 } catch { /* unreadable: show it as a new file */ }
61 return [{ tool: "Write", file, abs, diff: diffLines(before, e.content), note: isNew ? "neue Datei" : "überschrieben" }];
62 }
63 return [];
64}
65
66// --- Shell changes (git) ---------------------------------------------------
67
68async function git($, cwd, args) {
69 return $.process.run(["git", ...args], { cwd, timeoutMs: GIT_TIMEOUT_MS });
70}
71
72// The repo's state: its top folder, each changed path's status code and its
73// `git diff` chunk. null outside a git repo, or when git fails.
74async function gitSnapshot($) {
75 try {
76 const cwd = await sessionCwd($);
77 if (!cwd) return null;
78 const top = await git($, cwd, ["rev-parse", "--show-toplevel"]);
79 if (top.exitCode !== 0) return null;
80 const root = top.stdout.trim();
81 const [status, diff] = await Promise.all([
82 git($, root, ["status", "--porcelain", "-z", "--untracked-files=all"]),
83 git($, root, ["-c", "core.quotePath=false", "diff", "--no-color", "--no-ext-diff"]),
84 ]);
85 if (status.exitCode !== 0 || diff.exitCode !== 0) return null;
86 return { cwd, root, status: parseStatusZ(status.stdout), chunks: splitGitDiff(diff.stdout) };
87 } catch {
88 return null;
89 }
90}
91
92// One "Shell" step per file whose status or diff changed between the two
93// snapshots and that no edit tool recorded this turn. At most 20 files.
94async function shellSteps($, before, after, recorded) {
95 if (!before || !after || before.root !== after.root) return [];
96 const key = (snap, p) => `${snap.status.get(p) ?? ""}\0${snap.chunks.get(p) ?? ""}`;
97 const paths = [...new Set([...before.status.keys(), ...after.status.keys()])]
98 .filter((p) => key(before, p) !== key(after, p))
99 .filter((p) => !recorded.has(`${after.root}/${p}`))
100 .sort()
101 .slice(0, MAX_SHELL_FILES);
102 const steps = [];
103 for (const p of paths) {
104 const abs = `${after.root}/${p}`;
105 const code = after.status.get(p);
106 let text = after.chunks.get(p) ?? "";
107 let note = "";
108 if (code === "??") {
109 // Untracked: plain `git diff` shows nothing, so diff against nothing.
110 note = "neue Datei";
111 try {
112 text = (await git($, after.root, ["diff", "--no-color", "--no-ext-diff", "--no-index", "--", "/dev/null", p])).stdout;
113 } catch { text = ""; }
114 } else if (code === undefined) {
115 note = "zurückgesetzt";
116 } else if (code.includes("D")) {
117 note = "gelöscht";
118 }
119 steps.push({ tool: "Shell", file: relPath(after.cwd, abs), abs, diff: parseUnifiedDiff(text), note });
120 }
121 return steps;
122}
123
124// --- UI --------------------------------------------------------------------
125
126function hintText() {
127 return `▶ Replay: ${plural(state.replay.length)} (r drücken)`;
128}
129
130async function openReplay($) {
131 if (!state.replay.length) return false;
132 state.index = 0;
133 state.isOpen = true;
134 // The pane only takes the keyboard while nothing else holds it. Hide the
135 // band first (it held the keys if its button was pressed), then open.
136 $.ui.invalidate("ui.render");
137 await $.clock.sleep(200);
138 const rows = Math.min(MAX_DIFF_LINES + 9, 23);
139 const placed = await $.ui.open({ id: PANE_ID, title: "Replay Theater", focus: true, closeOnEscape: true, rows, columns: 64 });
140 // No room for a pane (a narrow terminal): draw the replay in the band.
141 state.inBand = placed?.isPlaced === false;
142 $.ui.invalidate("ui.render");
143 return true;
144}
145
146// Which step numbers the strip shows: all of them when they fit, else a
147// window around the current one.
148function stripWindow(k, total) {
149 if (total <= MAX_STRIP_CELLS) return { from: 0, to: total };
150 const from = Math.max(0, Math.min(k - Math.floor(MAX_STRIP_CELLS / 2), total - MAX_STRIP_CELLS));
151 return { from, to: from + MAX_STRIP_CELLS };
152}
153
154// The replay view: one step at a time. Drawn in the Pane, or in the band
155// above the prompt when the surface can't place a pane.
156function replayView($, e, inBand) {
157 const { Box, Text, Button } = $.ui.resolve(e);
158 const maxDiff = inBand ? Math.max(3, Math.min(MAX_DIFF_LINES, (e.maxRows || 20) - 8)) : MAX_DIFF_LINES;
159 const total = state.replay.length;
160 if (!total) return Text({ dimColor: true, children: "Keine Änderungen zum Abspielen." });
161 const k = Math.max(0, Math.min(state.index, total - 1));
162 const step = state.replay[k];
163 const width = Math.max(40, (e.bodyColumns || 100) - 4);
164 const { add, del } = countChanges(step.diff);
165 const shown = step.diff.slice(0, maxDiff);
166
167 const diffRows = shown.map((l, n) => {
168 const color = l.op === "+" ? "green" : l.op === "-" ? "red" : undefined;
169 const line = `${l.op === "~" ? " " : l.op} ${l.t}`.slice(0, width);
170 return Text({ key: `d${n}`, color, dimColor: l.op === " " || l.op === "~", wrap: "truncate-end", children: line });
171 });
172 if (step.diff.length > maxDiff) diffRows.push(Text({ key: "more", dimColor: true, children: ` … ${step.diff.length - maxDiff} weitere Zeilen` }));
173 if (!shown.length) diffRows.push(Text({ key: "empty", dimColor: true, children: " (keine Zeilenänderungen)" }));
174
175 // The step strip: one cell per step, the current one highlighted. Many
176 // steps scroll in a window with "…" on the cut sides.
177 const { from, to } = stripWindow(k, total);
178 const strip = [];
179 if (from > 0) strip.push(Text({ key: "s-left", dimColor: true, children: " … " }));
180 for (let n = from; n < to; n++) {
181 strip.push(Text({ key: `s${n}`, inverse: n === k, bold: n === k, color: n === k ? "cyan" : undefined, dimColor: n !== k, children: ` ${n + 1} ` }));
182 }
183 if (to < total) strip.push(Text({ key: "s-right", dimColor: true, children: " … " }));
184
185 const go = (to) => { state.index = Math.max(0, Math.min(to, total - 1)); $.ui.invalidate("ui.render"); };
186 const close = () => {
187 state.isOpen = false;
188 if (!state.inBand) $.ui.close({ id: PANE_ID });
189 state.inBand = false;
190 $.ui.invalidate("ui.render");
191 };
192
193 return Box({
194 flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1,
195 children: [
196 Box({ flexDirection: "row", justifyContent: "space-between", children: [
197 Text({ bold: true, color: "magenta", children: "▶ Replay Theater" }),
198 Text({ key: "counter", bold: true, children: `Schritt ${k + 1} von ${total}` }),
199 ] }),
200 Box({ flexDirection: "row", children: strip }),
201 // The file is the headline: bold, with its +/- counts right beside it.
202 Box({ flexDirection: "row", gap: 2, marginTop: 1, children: [
203 Text({ key: "file", bold: true, color: "cyan", wrap: "truncate-start", children: step.file }),
204 Text({ key: "add", bold: true, color: "green", children: `+${add}` }),
205 Text({ key: "del", bold: true, color: "red", children: `-${del}` }),
206 ] }),
207 Text({ key: "tool", dimColor: true, children: `${step.tool}${step.note ? " · " + step.note : ""}` }),
208 Box({ flexDirection: "column", marginTop: 1, children: diffRows }),
209 Box({ flexDirection: "row", gap: 2, marginTop: 1, children: [
210 Button({ key: "prev", label: "◀ Zurück", hotkey: "p", onPress: () => go(k - 1) }),
211 Button({ key: "next", label: "Weiter ▶", hotkey: "n", autoFocus: true, onPress: () => go(k + 1) }),
212 Button({ key: "close", label: "Schließen", hotkey: "c", onPress: close }),
213 ] }),
214 ],
215 });
216}
217
218export function register(on, options) {
219 // Slash command: /replay opens the pane.
220 on("session.start", async ($, e, next) => {
221 const r = await next(e);
222 try {
223 await $.command.register({ name: "replay", description: "Replay Theater: die Änderungen des letzten Turns Schritt für Schritt ansehen" });
224 } catch { /* the command is a convenience; the band still works */ }
225 return r;
226 });
227
228 on("command.run", { command: "replay" }, async ($, e) => {
229 const opened = await openReplay($);
230 return { text: opened ? `Replay Theater: ${plural(state.replay.length)}` : "Replay Theater: keine Änderungen im letzten Turn." };
231 });
232
233 // Record each edit, then let it run. Never blocks the call.
234 on("tool.call", async ($, e, next) => {
235 if (EDIT_TOOLS.has(e.tool)) {
236 try {
237 const steps = await stepsFor($, e);
238 state.pending.push(...steps);
239 } catch { /* recording must never stop the edit */ }
240 }
241 return next(e);
242 });
243
244 // A main-loop turn starts: clear the pending steps and snapshot git, so
245 // turn.complete can tell which files shell commands changed.
246 on("turn.start", async ($, e, next) => {
247 if (!e.agentId) {
248 state.pending = [];
249 state.snap = await gitSnapshot($);
250 }
251 return next(e);
252 });
253
254 // At the end of a main-loop turn, the pending edits (plus shell changes)
255 // become the replay.
256 on("turn.complete", async ($, e, next) => {
257 const r = await next(e);
258 if (e.agentId) return r;
259 try {
260 const before = state.snap;
261 state.snap = null;
262 if (before) {
263 const recorded = new Set(state.pending.map((s) => s.abs).filter(Boolean));
264 state.pending.push(...(await shellSteps($, before, await gitSnapshot($), recorded)));
265 }
266 } catch { /* shell detection is best effort */ }
267 if (!state.pending.length) return r;
268 state.replay = state.pending;
269 state.pending = [];
270 state.index = 0;
271 state.turns++;
272 $.ui.invalidate("ui.render");
273 return r;
274 });
275
276 // The band above the prompt: the hint and a button that opens the pane.
277 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
278 if (state.isOpen && state.inBand) return replayView($, e, true);
279 if (!state.replay.length || state.isOpen) return next(e);
280 const { Box, Text, Button } = $.ui.resolve(e);
281 return Box({
282 flexDirection: "row", gap: 2, paddingX: 1,
283 children: [
284 Text({ key: "hint", color: "magenta", bold: true, children: hintText() }),
285 Button({ key: "open-replay", label: "Abspielen", hotkey: "r", onPress: () => openReplay($) }),
286 ],
287 });
288 });
289
290 // The pane.
291 on("ui.render", { component: "Pane" }, ($, e, next) => {
292 if (e.requestId !== PANE_ID) return next(e);
293 return replayView($, e, false);
294 });
295
296 // The person closed the pane (Escape): keep our flag in step.
297 on("ui.close", ($, e, next) => {
298 if (e.id === PANE_ID || e.requestId === PANE_ID) state.isOpen = false;
299 $.ui.invalidate("ui.render");
300 return next(e);
301 });
302}
303hooks/diff.mjs 123 lines1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3// Modified by Verselo Systems SL, 2026: German UI, readability, fixes (see NOTICE.md)
4//
5// Pure diff helpers: the line diff for edit tools, and the parsers for git's
6// output that the shell-change detection uses. No `$` in here, so the tests
7// can call them directly.
8
9export const MAX_DIFF_LINES = 12;
10export const MAX_LCS_LINES = 400;
11
12export function splitLines(text) {
13 if (text === undefined || text === null || text === "") return [];
14 const lines = String(text).split("\n");
15 if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
16 return lines;
17}
18
19// A line diff. Small inputs get an LCS diff, so unchanged lines show as
20// context; large ones fall back to all-removed then all-added.
21export function diffLines(oldText, newText) {
22 const a = splitLines(oldText);
23 const b = splitLines(newText);
24 if (a.length > MAX_LCS_LINES || b.length > MAX_LCS_LINES) {
25 return [...a.map((t) => ({ op: "-", t })), ...b.map((t) => ({ op: "+", t }))];
26 }
27 const n = a.length, m = b.length;
28 const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
29 for (let i = n - 1; i >= 0; i--)
30 for (let j = m - 1; j >= 0; j--)
31 dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
32 const out = [];
33 let i = 0, j = 0;
34 while (i < n && j < m) {
35 if (a[i] === b[j]) { out.push({ op: " ", t: a[i] }); i++; j++; }
36 else if (dp[i + 1][j] >= dp[i][j + 1]) out.push({ op: "-", t: a[i++] });
37 else out.push({ op: "+", t: b[j++] });
38 }
39 while (i < n) out.push({ op: "-", t: a[i++] });
40 while (j < m) out.push({ op: "+", t: b[j++] });
41 return trimContext(out);
42}
43
44// Keep one line of context around each change, so a long file's Write shows
45// the changed lines, not the whole file. A skipped run becomes one "⋯" row.
46export function trimContext(lines) {
47 if (!lines.some((l) => l.op !== " " && l.op !== "~")) return lines.slice(0, MAX_DIFF_LINES);
48 const changed = (l) => l && l.op !== " " && l.op !== "~";
49 const keep = lines.map((l, k) => changed(l) || (l.op === " " && (changed(lines[k - 1]) || changed(lines[k + 1]))));
50 const out = [];
51 let skipped = false;
52 lines.forEach((l, k) => {
53 if (keep[k]) { if (skipped && out.length) out.push({ op: "~", t: "⋯" }); out.push(l); skipped = false; }
54 else skipped = true;
55 });
56 return out;
57}
58
59export function countChanges(diff) {
60 let add = 0, del = 0;
61 for (const l of diff) { if (l.op === "+") add++; else if (l.op === "-") del++; }
62 return { add, del };
63}
64
65// A unified diff (one file's `git diff` output) as {op,t} rows. Header lines
66// (diff --git, index, ---/+++, mode lines, @@) are dropped; a gap between two
67// hunks becomes one "⋯" row. Then the same context trim as the edit tools.
68export function parseUnifiedDiff(text) {
69 const out = [];
70 let inHunk = false;
71 for (const line of String(text ?? "").split("\n")) {
72 if (line.startsWith("@@")) {
73 if (out.length) out.push({ op: "~", t: "⋯" });
74 inHunk = true;
75 continue;
76 }
77 if (!inHunk) continue;
78 const c = line[0];
79 if (c === "+" || c === "-" || c === " ") out.push({ op: c, t: line.slice(1) });
80 else if (line === "") continue; // trailing newline of the output
81 else if (c === "\\") continue; // "\ No newline at end of file"
82 else inHunk = false; // the next file's "diff --git" header
83 }
84 return trimContext(out);
85}
86
87// `git status --porcelain -z --untracked-files=all` as Map(path -> "XY").
88// A rename or copy entry carries its source as one more NUL field; skip it.
89export function parseStatusZ(text) {
90 const map = new Map();
91 const parts = String(text ?? "").split("\0");
92 for (let k = 0; k < parts.length; k++) {
93 const p = parts[k];
94 if (p.length < 4) continue;
95 const xy = p.slice(0, 2);
96 map.set(p.slice(3), xy);
97 if (xy[0] === "R" || xy[0] === "C") k++;
98 }
99 return map;
100}
101
102// A whole `git diff` output split into Map(path -> that file's chunk). The
103// path comes from "diff --git a/P b/P"; quoted or renamed headers are skipped
104// (ponytail: such files are never told apart, so they never show as Shell).
105export function splitGitDiff(text) {
106 const map = new Map();
107 let path = null, buf = [];
108 const flush = () => { if (path !== null) map.set(path, buf.join("\n")); };
109 for (const line of String(text ?? "").split("\n")) {
110 if (line.startsWith("diff --git ")) {
111 flush();
112 buf = [];
113 const rest = line.slice("diff --git ".length);
114 const len = (rest.length - 5) / 2; // "a/P b/P"
115 const p = Number.isInteger(len) && len > 0 ? rest.slice(2, 2 + len) : null;
116 path = p !== null && rest === `a/${p} b/${p}` ? p : null;
117 }
118 buf.push(line);
119 }
120 flush();
121 return map;
122}
123