SLOPSHOPPER

replay-theater

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

newpanebandguardcommand
★ 1v0.1.0WTFPLupdated 2026-10-11savaf/dotfiles/claude-mods/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 │ ⏺ Read(src/auth.ts) │ ┃ │ 1 2 3 │ ⎿ Read 6 lines │ ┃ │ src/auth.ts │ ⏺ Update(src/auth.ts) │ ┃ │ Edit +2 -1 │ ⎿ Added 2 lines, removed 1 line │ ┃ │ │ ⏺ Bash(bun test) │ ┃ │ - if (!claims) throw new Error('invalid… │ ⎿ 3 pass, 1 fail │ ┃ │ + if (!claims || claims.exp < Date.now(… │ │ ┃ │ + 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 ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▶ Replay: 3 edits (press r) [ Replay ]
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 … │ │ + await audit('refresh', claims.sub) │ │ │ │ [ ◀ Prev ] [ Next ▶ ] [ Close ] │ ╰──────────────────────────────────────────────────────────╯
README

Dotfiles

Cross-platform dotfiles for Ubuntu/WSL, Fedora/Bazzite, Arch/Omarchy and macOS, organized as GNU Stow packages.

Contents

Repository structure

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.

Requirements

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

Quick start (recommended)

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.

Manual usage with Stow

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

Package lists

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.

Notes

  • WSL .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.
  • Prompt: Starship, configured in starship/.config/starship.toml.
  • lazygit: launch with lzg. See docs/lazygit.md.

Setup guides

Step-by-step guides for a fresh machine live in docs/:

GuideWhat it covers
shell-and-dotfiles.mdzsh modules, prompt, and applying the dotfiles with Stow
ubuntu-wsl.mdUbuntu & WSL2 setup from scratch (incl. .wslconfig, fonts)
bazzite.mdFedora & Bazzite (immutable) setup — dnf / rpm-ostree
omarchy.mdArch & Omarchy (Hyprland) setup — pacman, default shell (zsh), bash alternative
macos-setup.mdmacOS system preferences, apps, Homebrew, iTerm2
git-and-ssh.mdGit defaults and GitHub SSH key setup
nodejs.mdNode.js via nvm and global modules
vscode.mdVS Code settings and extensions
nvim.mdNeovim (LazyVim) setup, requirements and basics
tmux.mdtmux prefix, panes/windows and copy mode
lazygit.mdlazygit keybindings and workflows
hardware.mdMy machines, for reference
storage.mdGames / LIBRARY disks: fstab, mountpoints, Kavita
Source 1 files
hooks/replay-theater.mjs 250 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
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