/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.

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.
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.
| Plugin | What it does |
|---|---|
sandbox-guard | Denies writes to protected Claude config paths and hands back a Terminal command |
remote-gate | Asks before git push and wrangler --remote, showing folder, branch, remote and the commits that will go out |
blast-radius | Shows what a risky Bash command will touch before it runs |
retry-analyzer | Detects repeated identical failing tool calls and tells Claude to change strategy |
secret-redactor | Masks tokens and keys in tool output |
evidence-saver | Saves screenshots from tool results to a folder you pick |
replay-theater | /replay: step through the session's file edits one by one |
verification-guard | Checks claims like "tests pass" against tool calls that actually ran |
handoff | Offers /handoff as the context fills up; fills the prompt box with a HANDOFF.md prompt |
notify | A toast and a macOS notification when a long command or turn finishes |
quick-actions | Compact and Clear buttons, each asks to confirm first |
hud | Context 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-uk | Fork of next-steps (MIT, Thariq Shihipar): suggested next prompts are always in Ukrainian. Install instead of next-steps |
plan-progress | Fork of plan-progress 0.7.6 (zycck, MIT): live progress bars above the prompt. Install instead of plan-progress@zycck-mods |
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.
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).
hooks/register.tsx 109 lines1import 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}
109hooks/diff.ts 88 lines1const 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