SLOPSHOPPER

replay-theater

/replay opens a pane that steps through the session's file edits (Edit, Write, MultiEdit) one at a time, showing each as a diff with Prev / Next.

newpaneguardcommand
v0.1.3no licenseupdated 2026-10-08oleksandrkodua/qa-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 │ ┃ [ ◀ Prev ] step 3/3 [ Next ▶ ] │ ┃ Write · /work/app/src/cache.ts (+0 −0) ⏺ Read(src/auth.ts) │ ┃ 1 (no textual change) ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /replay │ ⎿ replay-theater: Replay Theater — step 3/3: Write /work/app/src/c │ ⎿ replay-theater: │ ⎿ replay-theater: @@ -1 +1 @@ │ ⎿ replay-theater: (no textual change) │ ⎿ replay-theater: │ ⎿ replay-theater: 1. Edit /work/app/src/auth.ts │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Replay Theater
[ ◀ Prev ] step 3/3 [ Next ▶ ] Write · /work/app/src/cache.ts (+0 −0) 1 (no textual change)
README

qa-mods

Fourteen Claude Code mods, packaged as one plugin marketplace (qa-mods). Made for a manual QA workflow: guard rails around risky commands, evidence for what was tested, and a status band above the prompt.

Install

Run in your own Terminal (a sandboxed Claude session cannot write ~/.claude).

claude plugin marketplace add oleksandrkodua/qa-mods
claude plugin install hud@qa-mods

Replace hud with any plugin from the table.

To install everything from a local clone, or to set up a fresh machine, see INSTALL.md and install-pack.sh.

Plugins

PluginWhat it does
sandbox-guardDenies writes to protected Claude config paths and hands back a Terminal command
remote-gateAsks before git push and wrangler --remote, showing folder, branch, remote and the commits that will go out
blast-radiusShows what a risky Bash command will touch before it runs
retry-analyzerDetects repeated identical failing tool calls and tells Claude to change strategy
secret-redactorMasks tokens and keys in tool output
evidence-saverSaves screenshots from tool results to a folder you pick
replay-theater/replay: step through the session's file edits one by one
verification-guardChecks claims like "tests pass" against tool calls that actually ran
handoffOffers /handoff as the context fills up; fills the prompt box with a HANDOFF.md prompt
notifyA toast and a macOS notification when a long command or turn finishes
quick-actionsCompact and Clear buttons, each asks to confirm first
hudContext fill and rate-limit windows above the prompt, a кеш 59:48 chip (m:ss) while the prompt cache is warm, five buttons, and /hud with the figures as text
next-steps-ukFork of next-steps (MIT, Thariq Shihipar): suggested next prompts are always in Ukrainian. Install instead of next-steps
plan-progressFork of plan-progress 0.7.6 (zycck, MIT): live progress bars above the prompt. Install instead of plan-progress@zycck-mods

Tests

claude plugin test hud                                              # from the repo root
cd hud && node --test tests/format.test.mjs tests/handoff-prompt.test.mjs

A bare node --test also picks up register.test.ts, which only runs under claude plugin test.

License

No license file for the original mods yet. The two forks keep their upstream MIT notices: next-steps-uk/NOTICE.md and plan-progress/NOTICE.md (plus plan-progress/LICENSE.upstream).

Source 2 files
hooks/register.tsx 109 lines
1import type { Register } from 'claude-code'
2
3import { stats, unifiedDiff } from './diff'
4
5const PANE = 'replay-theater'
6const MAX_STEPS = 200
7
8interface Step {
9  n: number
10  tool: string
11  path: string
12  diff: string
13}
14
15/**
16 * Replay Theater: records each successful Edit/Write as a diff step and lets
17 * `/replay` page through them with Prev / Next.
18 */
19export const register: Register = on => {
20  const steps: Step[] = []
21  let cursor = 0
22
23  on('session.start', async ($, e, next) => {
24    // a build that already ships /replay refuses the name: keep going without it
25    await $.command
26      .register({
27        name: 'replay',
28        description: 'Step through this session’s file edits, one diff at a time',
29      })
30      .catch(() => undefined)
31
32    return next(e)
33  })
34
35  on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
36    const path = String(e.file_path ?? '')
37    const before = await $.fs.read(path).then(
38      t => (typeof t === 'string' ? t : ''),
39      () => '',
40    )
41
42    const ran = await next(e)
43
44    if (ran.deny === undefined && ran.isError !== true) {
45      const after = await $.fs.read(path).then(
46        t => (typeof t === 'string' ? t : ''),
47        () => '',
48      )
49
50      steps.push({ n: steps.length + 1, tool: e.tool, path, diff: unifiedDiff(before, after) })
51
52      if (steps.length > MAX_STEPS) steps.shift()
53
54      cursor = steps.length - 1
55      $.ui.invalidate('ui.render')
56    }
57
58    return ran
59  })
60
61  on('command.run', { command: 'replay' }, async ($, e) => {
62    if (steps.length === 0) return { text: 'Replay Theater: no file edits recorded in this session yet.' }
63
64    // `/replay`, `/replay 2`, `/replay next`, `/replay prev`: text works on every surface.
65    const arg = String(e.args ?? '').trim()
66
67    if (arg === 'next') cursor = Math.min(steps.length - 1, cursor + 1)
68    else if (arg === 'prev') cursor = Math.max(0, cursor - 1)
69    else if (/^\d+$/.test(arg)) cursor = Math.min(steps.length, Math.max(1, Number(arg))) - 1
70    else cursor = steps.length - 1
71
72    const s = steps[cursor]!
73    const { added, removed } = stats(s.diff)
74    const index = steps.map((x, i) => `${i === cursor ? '▶' : ' '} ${i + 1}. ${x.tool} ${x.path}`).join('\n')
75
76    await $.ui.open({ id: PANE, title: 'Replay Theater' }).catch(() => undefined)
77
78    return {
79      text: `Replay Theater — step ${cursor + 1}/${steps.length}: ${s.tool} ${s.path} (+${added} −${removed})\n\n${s.diff}\n\n${index}\n\n/replay next · /replay prev · /replay <n>`,
80    }
81  })
82
83  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
84    const { Box, Text, Button, Code } = $.ui.resolve(e)
85    const s = steps[cursor]
86
87    if (!s) return <Text dimColor>No edits recorded.</Text>
88
89    const { added, removed } = stats(s.diff)
90
91    const go = (d: number) => () => {
92      cursor = Math.min(steps.length - 1, Math.max(0, cursor + d))
93      $.ui.invalidate('ui.render')
94    }
95
96    return (
97      <Box flexDirection="column">
98        <Box flexDirection="row">
99          <Button label="◀ Prev" hotkey="p" onPress={go(-1)} />
100          <Text>{` step ${cursor + 1}/${steps.length} `}</Text>
101          <Button label="Next ▶" hotkey="n" onPress={go(1)} />
102        </Box>
103        <Text bold>{`${s.tool} · ${s.path} (+${added} −${removed})`}</Text>
104        <Code source={s.diff} format="diff" path={s.path} />
105      </Box>
106    )
107  })
108}
109
hooks/diff.ts 88 lines
1const MAX_LINES = 1500
2
3type Op = { t: ' ' | '+' | '-'; s: string }
4
5const lines = (s: string) => (s === '' ? [] : s.split('\n'))
6
7/** Unified diff with real `@@ -a,b +c,d @@` headers (LCS-based), for Code format="diff". */
8export function unifiedDiff(before: string, after: string, context = 3): string {
9  const a = lines(before)
10  const b = lines(after)
11
12  if (a.length > MAX_LINES || b.length > MAX_LINES) {
13    return `@@ -1 +1 @@\n-${a.length} lines\n+${b.length} lines (too large for a line diff)`
14  }
15
16  const n = a.length
17  const m = b.length
18  const dp: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1))
19
20  for (let i = n - 1; i >= 0; i--)
21    for (let j = m - 1; j >= 0; j--)
22      dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)
23
24  const ops: Op[] = []
25  let i = 0
26  let j = 0
27
28  while (i < n && j < m) {
29    if (a[i] === b[j]) ops.push({ t: ' ', s: a[i++]! }), j++
30    else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) ops.push({ t: '-', s: a[i++]! })
31    else ops.push({ t: '+', s: b[j++]! })
32  }
33
34  while (i < n) ops.push({ t: '-', s: a[i++]! })
35  while (j < m) ops.push({ t: '+', s: b[j++]! })
36
37  if (!ops.some(o => o.t !== ' ')) return '@@ -1 +1 @@\n (no textual change)'
38
39  const keep = new Array<boolean>(ops.length).fill(false)
40
41  ops.forEach((o, k) => {
42    if (o.t !== ' ')
43      for (let x = Math.max(0, k - context); x <= Math.min(ops.length - 1, k + context); x++) keep[x] = true
44  })
45
46  // old/new line number of each op's start
47  const oldAt: number[] = []
48  const newAt: number[] = []
49  let o = 1
50  let nn = 1
51
52  ops.forEach((op, k) => {
53    oldAt[k] = o
54    newAt[k] = nn
55    if (op.t !== '+') o++
56    if (op.t !== '-') nn++
57  })
58
59  const out: string[] = []
60  let k = 0
61
62  while (k < ops.length) {
63    if (!keep[k]) {
64      k++
65      continue
66    }
67
68    let end = k
69
70    while (end < ops.length && keep[end]) end++
71
72    const hunk = ops.slice(k, end)
73    const oc = hunk.filter(x => x.t !== '+').length
74    const nc = hunk.filter(x => x.t !== '-').length
75
76    out.push(`@@ -${oc === 0 ? oldAt[k]! - 1 : oldAt[k]},${oc} +${nc === 0 ? newAt[k]! - 1 : newAt[k]},${nc} @@`)
77    hunk.forEach(x => out.push(x.t + x.s))
78    k = end
79  }
80
81  return out.join('\n')
82}
83
84export const stats = (d: string) => ({
85  added: d.split('\n').filter(l => l.startsWith('+')).length,
86  removed: d.split('\n').filter(l => l.startsWith('-')).length,
87})
88