/replay: step through the file edits Claude made in the last turn, one diff at a time

Cross-platform dotfiles for Ubuntu/WSL, Fedora/Bazzite, Arch/Omarchy and macOS, organized as GNU Stow packages.
Most top-level folders are a Stow package: running stow <package> symlinks its contents into $HOME, preserving the internal directory layout. packages/, scripts/, docs/ and system/ are the exceptions — plain utility/reference dirs, not linked by Stow.
dotfiles/
├── bash/ # .bashrc + .inputrc + .config/bash/*.sh modules (alternative shell)
├── starship/ # .config/starship.toml (prompt shared by bash and zsh)
├── zsh/ # .zshrc (slim loader) + .config/zsh/*.zsh modules
├── git/ # .gitconfig + .config/git/ignore
├── nvim/ # .config/nvim/ (LazyVim distribution)
├── tmux/ # .config/tmux/tmux.conf
├── lazygit/ # .config/lazygit/config.yml (terminal UI for git)
├── shell/ # .profile
├── claude/ # .claude/ (Claude Code settings, agents, global CLAUDE.md; shared across profiles)
├── clef/ # systemd --user units + clef-server for the local decision engine (Linux; docs/decision-engine.md)
├── vscode/ # settings.json (symlinked per-OS by a script)
├── wsl/ # .wslconfig (copied to the Windows profile on WSL)
├── packages/ # package lists (brew/apt/dnf/node/vscode extensions)
├── scripts/ # bootstrap + install/sync helpers
│ ├── lib/ # shared utilities (log, os_detect, sudo keep-alive…)
│ └── modules/ # one installable/optional step per file, sourced by bootstrap.sh
├── system/ # non-$HOME system files (/etc/…), installed by a script, not Stow
├── docs/ # setup guides (macOS, shell, git/ssh, lazygit, …)
└── README.md
The zsh config is modular: zsh/.zshrc is a slim loader that sources focused files from ~/.config/zsh/ (exports, path, plugins, completion, history, keybindings, aliases, functions, integrations). See docs/shell-and-dotfiles.md.
git and stow (the bootstrap installs stow for you if missing):
sudo apt install git stow # Ubuntu/WSL
sudo dnf install git stow # Fedora (Bazzite ships both in the base image)
sudo pacman -S git stow # Arch/Omarchy
brew install git stow # macOS
Clone the repo and run the single bootstrap entry point. It detects the OS, installs base packages, stows the config packages, syncs VS Code, installs global node packages, and applies OS-specific extras (macOS defaults / WSL .wslconfig).
git clone git@github.com:savaf/dotfiles.git ~/dotfiles
cd ~/dotfiles
./scripts/bootstrap.sh
The bootstrap backs up any conflicting real files to ~/.dotfiles-backup/<timestamp>/ before linking.
To link (or unlink) individual packages yourself:
cd ~/dotfiles
stow bash zsh git starship nvim tmux shell lazygit claude # link everything
stow nvim # link just one package
stow -D nvim # unlink (remove symlinks)
stow -R zsh # restow (refresh) after changes
Reproducible package manifests live in packages/:
xargs brew install < packages/brew-cli.txt # macOS CLI
xargs brew install --cask < packages/brew-casks.txt # macOS apps
sudo xargs -a packages/apt-cli.txt apt install -y # Ubuntu/WSL CLI
grep -v '^#' packages/dnf-cli.txt | xargs sudo dnf install -y # Fedora CLI (Bazzite: rpm-ostree install)
grep -v '^#' packages/pacman-cli.txt | xargs sudo pacman -S --needed # Arch/Omarchy CLI
VS Code settings (vscode/settings.json) are symlinked to the OS-specific location by scripts/sync-vscode-settings.sh, and extensions are installed from packages/vs-extensions.txt. See docs/vscode.md.
.wslconfig is read by Windows from your Windows user profile, not the Linux $HOME. The bootstrap copies it to C:\Users\<you>\.wslconfig; apply changes with wsl --shutdown.starship/.config/starship.toml.lzg. See docs/lazygit.md.Step-by-step guides for a fresh machine live in docs/:
| Guide | What it covers |
|---|---|
| shell-and-dotfiles.md | zsh modules, prompt, and applying the dotfiles with Stow |
| ubuntu-wsl.md | Ubuntu & WSL2 setup from scratch (incl. .wslconfig, fonts) |
| bazzite.md | Fedora & Bazzite (immutable) setup — dnf / rpm-ostree |
| omarchy.md | Arch & Omarchy (Hyprland) setup — pacman, default shell (zsh), bash alternative |
| macos-setup.md | macOS system preferences, apps, Homebrew, iTerm2 |
| git-and-ssh.md | Git defaults and GitHub SSH key setup |
| nodejs.md | Node.js via nvm and global modules |
| vscode.md | VS Code settings and extensions |
| nvim.md | Neovim (LazyVim) setup, requirements and basics |
| tmux.md | tmux prefix, panes/windows and copy mode |
| lazygit.md | lazygit keybindings and workflows |
| hardware.md | My machines, for reference |
| storage.md | Games / LIBRARY disks: fstab, mountpoints, Kavita |
hooks/replay-theater.mjs 250 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
9const PANE_ID = "replay-theater";
10const MAX_DIFF_LINES = 12;
11const MAX_LCS_LINES = 400;
12const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
13
14// Module state. `pending` fills during a turn; `replay` is the last finished
15// turn's steps, the ones the pane shows.
16const state = { pending: [], replay: [], index: 0, isOpen: false, inBand: false, turns: 0 };
17
18function relPath(cwd, path) {
19 if (!path) return "(unknown file)";
20 if (cwd && path.startsWith(cwd + "/")) return path.slice(cwd.length + 1);
21 return path;
22}
23
24function splitLines(text) {
25 if (text === undefined || text === null || text === "") return [];
26 const lines = String(text).split("\n");
27 if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
28 return lines;
29}
30
31// A line diff. Small inputs get an LCS diff, so unchanged lines show as
32// context; large ones fall back to all-removed then all-added.
33function diffLines(oldText, newText) {
34 const a = splitLines(oldText);
35 const b = splitLines(newText);
36 if (a.length > MAX_LCS_LINES || b.length > MAX_LCS_LINES) {
37 return [...a.map((t) => ({ op: "-", t })), ...b.map((t) => ({ op: "+", t }))];
38 }
39 const n = a.length, m = b.length;
40 const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
41 for (let i = n - 1; i >= 0; i--)
42 for (let j = m - 1; j >= 0; j--)
43 dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
44 const out = [];
45 let i = 0, j = 0;
46 while (i < n && j < m) {
47 if (a[i] === b[j]) { out.push({ op: " ", t: a[i] }); i++; j++; }
48 else if (dp[i + 1][j] >= dp[i][j + 1]) out.push({ op: "-", t: a[i++] });
49 else out.push({ op: "+", t: b[j++] });
50 }
51 while (i < n) out.push({ op: "-", t: a[i++] });
52 while (j < m) out.push({ op: "+", t: b[j++] });
53 return trimContext(out);
54}
55
56// Keep one line of context around each change, so a long file's Write shows
57// the changed lines, not the whole file.
58function trimContext(lines) {
59 if (!lines.some((l) => l.op !== " ")) return lines.slice(0, MAX_DIFF_LINES);
60 const keep = lines.map((l, k) =>
61 l.op !== " " || lines[k - 1]?.op && lines[k - 1].op !== " " || lines[k + 1]?.op && lines[k + 1].op !== " ");
62 const out = [];
63 let skipped = false;
64 lines.forEach((l, k) => {
65 if (keep[k]) { if (skipped && out.length) out.push({ op: "~", t: "⋯" }); out.push(l); skipped = false; }
66 else skipped = true;
67 });
68 return out;
69}
70
71function countChanges(diff) {
72 let add = 0, del = 0;
73 for (const l of diff) { if (l.op === "+") add++; else if (l.op === "-") del++; }
74 return { add, del };
75}
76
77// Turns one tool call into one or more steps.
78async function stepsFor($, e) {
79 let cwd = "";
80 try { cwd = await $.session.cwd(); } catch { /* keep the full path */ }
81 const file = relPath(cwd, e.file_path);
82 if (e.tool === "Edit") {
83 return [{ tool: "Edit", file, diff: diffLines(e.old_string, e.new_string), note: e.replace_all ? "replace all" : "" }];
84 }
85 if (e.tool === "MultiEdit" && Array.isArray(e.edits)) {
86 return e.edits.map((ed, k) => ({
87 tool: "MultiEdit", file, diff: diffLines(ed.old_string, ed.new_string), note: `edit ${k + 1} of ${e.edits.length}`,
88 }));
89 }
90 if (e.tool === "Write") {
91 let before = "";
92 let isNew = true;
93 try {
94 if (e.file_path && (await $.fs.exists(e.file_path))) {
95 before = await $.fs.read(e.file_path);
96 isNew = false;
97 }
98 } catch { /* unreadable: show it as a new file */ }
99 return [{ tool: "Write", file, diff: diffLines(before, e.content), note: isNew ? "new file" : "rewrite" }];
100 }
101 return [];
102}
103
104function hintText() {
105 const n = state.replay.length;
106 return `▶ Replay: ${n} edit${n === 1 ? "" : "s"} (press r)`;
107}
108
109async function openReplay($) {
110 if (!state.replay.length) return false;
111 state.index = 0;
112 state.isOpen = true;
113 // The pane only takes the keyboard while nothing else holds it. Hide the
114 // band first (it held the keys if its button was pressed), then open.
115 $.ui.invalidate("ui.render");
116 await $.clock.sleep(200);
117 const rows = Math.min(MAX_DIFF_LINES + 8, 22);
118 const placed = await $.ui.open({ id: PANE_ID, title: "Replay Theater", focus: true, closeOnEscape: true, rows, columns: 58 });
119 // No room for a pane (a narrow terminal): draw the replay in the band.
120 state.inBand = placed?.isPlaced === false;
121 $.ui.invalidate("ui.render");
122 return true;
123}
124
125// The replay view: one step at a time. Drawn in the Pane, or in the band
126// above the prompt when the surface can't place a pane.
127function replayView($, e, inBand) {
128 const { Box, Text, Button } = $.ui.resolve(e);
129 const maxDiff = inBand ? Math.max(3, Math.min(MAX_DIFF_LINES, (e.maxRows || 20) - 7)) : MAX_DIFF_LINES;
130 const total = state.replay.length;
131 if (!total) return Text({ dimColor: true, children: "No edits to replay." });
132 const k = Math.max(0, Math.min(state.index, total - 1));
133 const step = state.replay[k];
134 const width = Math.max(40, (e.bodyColumns || 100) - 4);
135 const { add, del } = countChanges(step.diff);
136 const shown = step.diff.slice(0, maxDiff);
137
138 const diffRows = shown.map((l, n) => {
139 const color = l.op === "+" ? "green" : l.op === "-" ? "red" : undefined;
140 const line = `${l.op === "~" ? " " : l.op} ${l.t}`.slice(0, width);
141 return Text({ key: `d${n}`, color, dimColor: l.op === " " || l.op === "~", wrap: "truncate-end", children: line });
142 });
143 if (step.diff.length > maxDiff) diffRows.push(Text({ key: "more", dimColor: true, children: ` … ${step.diff.length - maxDiff} more lines` }));
144 if (!shown.length) diffRows.push(Text({ key: "empty", dimColor: true, children: " (no line changes)" }));
145
146 // The step list: one cell per step, the current one highlighted.
147 const strip = state.replay.map((s, n) =>
148 Text({ key: `s${n}`, inverse: n === k, color: n === k ? "cyan" : undefined, dimColor: n !== k, children: ` ${n + 1} ` }));
149
150 const go = (to) => { state.index = Math.max(0, Math.min(to, total - 1)); $.ui.invalidate("ui.render"); };
151 const close = () => {
152 state.isOpen = false;
153 if (!state.inBand) $.ui.close({ id: PANE_ID });
154 state.inBand = false;
155 $.ui.invalidate("ui.render");
156 };
157
158 return Box({
159 flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1,
160 children: [
161 Box({ flexDirection: "row", justifyContent: "space-between", children: [
162 Text({ bold: true, color: "magenta", children: "▶ Replay Theater" }),
163 Text({ bold: true, children: `step ${k + 1} of ${total}` }),
164 ] }),
165 Box({ flexDirection: "row", children: strip }),
166 Text({ bold: true, color: "cyan", wrap: "truncate-start", children: step.file }),
167 Box({ flexDirection: "row", gap: 2, children: [
168 Text({ dimColor: true, children: `${step.tool}${step.note ? " · " + step.note : ""}` }),
169 Text({ color: "green", children: `+${add}` }),
170 Text({ color: "red", children: `-${del}` }),
171 ] }),
172 Box({ flexDirection: "column", marginTop: 1, children: diffRows }),
173 Box({ flexDirection: "row", gap: 2, marginTop: 1, children: [
174 Button({ key: "prev", label: "◀ Prev", hotkey: "p", onPress: () => go(k - 1) }),
175 Button({ key: "next", label: "Next ▶", hotkey: "n", autoFocus: true, onPress: () => go(k + 1) }),
176 Button({ key: "close", label: "Close", hotkey: "c", onPress: close }),
177 ] }),
178 ],
179 });
180}
181
182export function register(on, options) {
183 // Slash command: /replay opens the pane.
184 on("session.start", async ($, e, next) => {
185 const r = await next(e);
186 await $.command.register({ name: "replay", description: "Replay Theater: step through the last turn's file edits" });
187 return r;
188 });
189
190 on("command.run", { command: "replay" }, async ($, e) => {
191 const opened = await openReplay($);
192 return { text: opened ? `Replay Theater: ${state.replay.length} edits` : "Replay Theater: no edits in the last turn." };
193 });
194
195 // Record each edit, then let it run. Never blocks the call.
196 on("tool.call", async ($, e, next) => {
197 if (EDIT_TOOLS.has(e.tool)) {
198 try {
199 const steps = await stepsFor($, e);
200 state.pending.push(...steps);
201 } catch { /* recording must never stop the edit */ }
202 }
203 return next(e);
204 });
205
206 on("turn.start", ($, e, next) => {
207 if (!e.agentId) state.pending = [];
208 return next(e);
209 });
210
211 // At the end of a main-loop turn, the pending edits become the replay.
212 on("turn.complete", async ($, e, next) => {
213 const r = await next(e);
214 if (e.agentId || !state.pending.length) return r;
215 state.replay = state.pending;
216 state.pending = [];
217 state.index = 0;
218 state.turns++;
219 $.ui.invalidate("ui.render");
220 return r;
221 });
222
223 // The band above the prompt: the hint and a button that opens the pane.
224 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
225 if (state.isOpen && state.inBand) return replayView($, e, true);
226 if (!state.replay.length || state.isOpen) return next(e);
227 const { Box, Text, Button } = $.ui.resolve(e);
228 return Box({
229 flexDirection: "row", gap: 2, paddingX: 1,
230 children: [
231 Text({ color: "magenta", bold: true, children: hintText() }),
232 Button({ key: "open-replay", label: "Replay", hotkey: "r", onPress: () => openReplay($) }),
233 ],
234 });
235 });
236
237 // The pane.
238 on("ui.render", { component: "Pane" }, ($, e, next) => {
239 if (e.requestId !== PANE_ID) return next(e);
240 return replayView($, e, false);
241 });
242
243 // The person closed the pane (Escape): keep our flag in step.
244 on("ui.close", ($, e, next) => {
245 if (e.id === PANE_ID || e.requestId === PANE_ID) state.isOpen = false;
246 $.ui.invalidate("ui.render");
247 return next(e);
248 });
249}
250