A tape of every edit Claude makes. /vhs opens a replay pane: watch each change type itself in, step with h and l, auto-play with p, and press r twice to rewind…

A tape of every edit Claude makes. A Claude Code mod.

A real, unedited session: after Claude edits api.js twice, /vhs opens the tape.
Every Edit and Write is snapshotted before and after, so each file has its versions in order. /vhs opens a pane that replays them: the diff is computed line by line (unchanged lines stay context, as in git) and the new lines type themselves in.
Like git history for one session, without committing every keystroke.
h / l step, p play or pause, f next file, c closer twice rewinds the file to the step on screen (one press only arms it; the rewind is taped too)/vhs, /vhs list, /vhs <file>, /vhs closeThe files Claude edits: read before and after each edit, and written only when you press r twice. The tape lives in memory for the session.
Claude Code 2.1.287 or newer runs mods out of the box. On 2.1.273 to 2.1.286, start it with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 first.
claude plugin marketplace add saksham10arora-dotcom/claude-vhs
claude plugin install vhs@nerfsaksham-vhs
Every option has a default, so the installer's "userConfig options not yet set" note is safe to ignore.
Or try it without installing:
git clone https://github.com/saksham10arora-dotcom/claude-vhs && claude --plugin-dir claude-vhs
claude plugin validate .
claude plugin test .
Part of a set: teach-me · vhs · frugal · lofi. More community mods: awesome-claude-mods.
MIT. Made by Saksham Arora (@nerfsaksham).
hooks/register.ts 271 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// vhs: a tape of every edit Claude makes this session.
4// Each Edit/Write/MultiEdit/NotebookEdit is snapshotted before and after, so
5// every file has its versions in order. /vhs opens a pane that replays them:
6// each step shows the change in context with the new lines typing themselves
7// in. h/l step, p plays, f switches file, r twice rewinds the file.
8
9const PANE = 'vhs'
10const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
11const MAX_FILE = 400_000 // characters; bigger files are not taped
12const MAX_TAPE = 6_000_000 // characters across the whole tape
13const CONTEXT = 3 // unchanged lines shown around a change
14const TYPE_MS = 1400 // how long a step's new lines take to type in
15const HOLD_MS = 900 // pause on a finished step while playing
16const TICK_MS = 50
17
18const RED = '#ef5a5a'
19const GREEN = '#3ecf8e'
20const AMBER = '#f8b45a'
21const MUTED = '#7d828c'
22const CREAM = '#e9e4dc'
23
24type Version = { at: number; tool: string; text: string }
25type Op = { kind: ' ' | '-' | '+'; text: string }
26type Hunk = { start: number; ops: Op[]; addedCount: number; removedCount: number }
27
28// The tape and the player. Module state: a hot reload starts the tape over.
29const tape = new Map<string, Version[]>()
30let tapeChars = 0
31const player = { file: 0, step: 1, isPlaying: false, typedFrom: 0, armedRestore: false, notice: '' }
32let ticker: { cancel: () => void } | null = null
33
34const files = () => [...tape.keys()]
35const base = (p: string) => p.split('/').pop() || p
36const clock = (ms: number) => new Date(ms).toTimeString().slice(0, 8)
37
38// The change between two versions as a line diff, the way git shows it: the
39// common head and tail are trimmed, the middle is diffed by longest common
40// subsequence (so a line that did not change is shown as context, not as
41// removed and added again), with CONTEXT unchanged lines around it.
42function hunk(a: string, b: string): Hunk {
43 const x = a.split('\n')
44 const y = b.split('\n')
45 let pre = 0
46 while (pre < x.length && pre < y.length && x[pre] === y[pre]) pre++
47 let suf = 0
48 while (suf < x.length - pre && suf < y.length - pre && x[x.length - 1 - suf] === y[y.length - 1 - suf]) suf++
49 const xs = x.slice(pre, x.length - suf)
50 const ys = y.slice(pre, y.length - suf)
51 const mid: Op[] = []
52 if (xs.length * ys.length <= 250_000) {
53 const L = Array.from({ length: xs.length + 1 }, () => new Array<number>(ys.length + 1).fill(0))
54 for (let i = xs.length - 1; i >= 0; i--)
55 for (let j = ys.length - 1; j >= 0; j--) L[i][j] = xs[i] === ys[j] ? L[i + 1][j + 1] + 1 : Math.max(L[i + 1][j], L[i][j + 1])
56 let i = 0
57 let j = 0
58 while (i < xs.length || j < ys.length) {
59 if (i < xs.length && j < ys.length && xs[i] === ys[j]) mid.push({ kind: ' ', text: xs[i++] }), j++
60 else if (j < ys.length && (i >= xs.length || L[i][j + 1] > L[i + 1][j])) mid.push({ kind: '+', text: ys[j++] }) // ties go to '-', so removals come first, as in git
61 else mid.push({ kind: '-', text: xs[i++] })
62 }
63 } else {
64 // too big to diff line by line: show it as one replaced block
65 for (const t of xs) mid.push({ kind: '-', text: t })
66 for (const t of ys) mid.push({ kind: '+', text: t })
67 }
68 const head = x.slice(Math.max(0, pre - CONTEXT), pre).map(text => ({ kind: ' ' as const, text }))
69 const tail = y.slice(y.length - suf, Math.min(y.length, y.length - suf + CONTEXT)).map(text => ({ kind: ' ' as const, text }))
70 return {
71 start: Math.max(0, pre - CONTEXT) + 1,
72 ops: [...head, ...mid, ...tail],
73 addedCount: mid.filter(o => o.kind === '+').length,
74 removedCount: mid.filter(o => o.kind === '-').length,
75 }
76}
77
78async function snapshot($: EngineInterface, path: string): Promise<string | null> {
79 try {
80 if (!(await $.fs.exists(path))) return ''
81 const t = String(await $.fs.read(path))
82 return t.length > MAX_FILE ? null : t
83 } catch {
84 return null
85 }
86}
87
88function add(path: string, v: Version) {
89 const list = tape.get(path) ?? []
90 list.push(v)
91 tape.set(path, list)
92 tapeChars += v.text.length
93 // Over budget: drop the oldest middle versions, never a file's first or last.
94 for (const [p, l] of tape) {
95 while (tapeChars > MAX_TAPE && l.length > 2) {
96 const [gone] = l.splice(1, 1)
97 tapeChars -= gone.text.length
98 }
99 if (tapeChars <= MAX_TAPE) break
100 void p
101 }
102}
103
104function stopTicker() {
105 ticker?.cancel()
106 ticker = null
107}
108
109export const register: Register = on => {
110 on('session.start', async ($, e, next) => {
111 const r = await next(e)
112 await $.command.register({
113 name: 'vhs',
114 description: "vhs: replay every edit Claude made this session (/vhs, /vhs list, /vhs <file>)",
115 argumentHint: '[list | <file> | close]',
116 immediate: true,
117 })
118 return r
119 })
120
121 on('tool.call', async ($, e, next) => {
122 if (!EDIT_TOOLS.has(e.tool)) return next(e)
123 const input = e as unknown as { file_path?: unknown; notebook_path?: unknown }
124 const path = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : ''
125 const before = path ? await snapshot($, path) : null
126 const ran = await next(e)
127 if (!path || before === null || ran.deny !== undefined || ran.isError === true) return ran
128 const after = await snapshot($, path)
129 if (after === null || after === before) return ran
130 const now = await $.clock.now()
131 if (!tape.has(path)) add(path, { at: now, tool: before === '' ? 'new file' : 'original', text: before })
132 add(path, { at: now, tool: e.tool, text: after })
133 return ran
134 })
135
136 on('command.run', { command: 'vhs' }, async ($, e) => {
137 const arg = String(e.args ?? '').trim()
138 const names = files()
139 if (arg === 'close') {
140 stopTicker()
141 await $.ui.close({ id: PANE })
142 return { text: 'Tape ejected.' }
143 }
144 if (names.length === 0) return { text: 'The tape is empty: no edits yet this session.' }
145 if (arg === 'list') {
146 return { text: names.map((p, i) => `${i + 1}. ${p} (${(tape.get(p)?.length ?? 1) - 1} edits)`).join('\n') }
147 }
148 let idx = names.length - 1 // the file touched most recently
149 if (arg) {
150 const n = Number(arg)
151 const found = n >= 1 && n <= names.length ? n - 1 : names.findIndex(p => p.toLowerCase().includes(arg.toLowerCase()))
152 if (found < 0) return { text: `No taped file matches "${arg}". Try /vhs list.` }
153 idx = found
154 }
155 Object.assign(player, { file: idx, step: 1, isPlaying: true, typedFrom: await $.clock.now(), armedRestore: false, notice: '' })
156 const opened = await $.ui.open({ id: PANE, title: 'vhs', focus: true, rows: 22 })
157 if (!opened.isPlaced) return { text: `The replay pane could not open (${opened.reason}). /vhs list shows the tape.` }
158 return { text: `Playing ${base(names[idx])}. h and l step, p plays or pauses, f next file, r twice rewinds, Esc returns to the prompt.` }
159 })
160
161 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
162 const { Box, Text, Button } = $.ui.resolve(e)
163 const names = files()
164 const path = names[player.file]
165 const versions = path ? tape.get(path) ?? [] : []
166 if (!path || versions.length < 2) {
167 stopTicker()
168 return Text({ color: MUTED, children: 'The tape is empty: no edits yet this session.' })
169 }
170 player.step = Math.min(Math.max(1, player.step), versions.length - 1)
171 const cur = versions[player.step]
172 const prev = versions[player.step - 1]
173 const h = hunk(prev.text, cur.text)
174 const now = await $.clock.now()
175 const typed = Math.min(1, (now - player.typedFrom) / TYPE_MS)
176 const width = Math.max(30, (e.props.bodyColumns ?? 80) - 4)
177 const room = Math.max(4, (e.viewport?.rows ?? 22) - 7)
178
179 // Keep the clock running while a step types in or the tape plays.
180 if ((typed < 1 || player.isPlaying) && !ticker) {
181 ticker = $.clock.every(TICK_MS, () => {
182 void (async () => {
183 const t = await $.clock.now()
184 const list = tape.get(files()[player.file] ?? '') ?? []
185 if (player.isPlaying && t - player.typedFrom > TYPE_MS + HOLD_MS) {
186 if (player.step < list.length - 1) {
187 player.step += 1
188 player.typedFrom = t
189 } else {
190 player.isPlaying = false
191 }
192 }
193 if (!player.isPlaying && t - player.typedFrom > TYPE_MS) stopTicker()
194 $.ui.invalidate('ui.render')
195 })()
196 })
197 }
198
199 const go = async (step: number, isPlaying = false) => {
200 Object.assign(player, { step, isPlaying, typedFrom: await $.clock.now(), armedRestore: false, notice: '' })
201 $.ui.invalidate('ui.render')
202 }
203
204 // The diff, with the added lines revealed by `typed`, in order.
205 const total = h.ops.reduce((n, o) => n + (o.kind === '+' ? o.text.length + 1 : 0), 0)
206 let budget = Math.round(total * typed)
207 const lines: ReturnType<typeof Text>[] = []
208 let n = h.start
209 const row = (mark: string, text: string, color: string, num: number | null) =>
210 Text({ wrap: 'truncate', children: [Text({ color: MUTED, children: `${num === null ? ' ' : String(num).padStart(4)} ` }), Text({ color, children: `${mark} ${text}`.slice(0, width) })] })
211 for (const o of h.ops) {
212 if (o.kind === '-') {
213 lines.push(row('-', o.text, RED, null))
214 } else if (o.kind === '+') {
215 if (budget <= 0) break
216 const shown = o.text.slice(0, budget)
217 budget -= o.text.length + 1
218 lines.push(row('+', shown + (budget < 0 ? '█' : ''), GREEN, n++))
219 } else {
220 lines.push(row(' ', o.text, MUTED, n++))
221 }
222 }
223 const clipped = lines.length > room ? [...lines.slice(0, room - 1), Text({ color: MUTED, children: ` ... ${lines.length - room + 1} more lines` })] : lines
224
225 const rec = Math.floor(now / 500) % 2 === 0 ? '●' : ' '
226 const header = Text({
227 wrap: 'truncate',
228 children: [
229 Text({ color: RED, bold: true, children: `${rec} ${player.isPlaying ? 'PLAY' : 'PAUSE'} ` }),
230 Text({ color: CREAM, bold: true, children: `${base(path)} ` }),
231 Text({ color: MUTED, children: `step ${player.step}/${versions.length - 1} · ${clock(cur.at)} · ${cur.tool} · ` }),
232 Text({ color: GREEN, children: `+${h.addedCount} ` }),
233 Text({ color: RED, children: `-${h.removedCount}` }),
234 Text({ color: MUTED, children: names.length > 1 ? ` · file ${player.file + 1}/${names.length}` : '' }),
235 ],
236 })
237
238 const restore = async () => {
239 if (!player.armedRestore) {
240 player.armedRestore = true
241 player.notice = `Press r again to rewind ${base(path)} to step ${player.step} (${clock(cur.at)}).`
242 $.ui.invalidate('ui.render')
243 return
244 }
245 await $.fs.write(path, cur.text)
246 const t = await $.clock.now()
247 add(path, { at: t, tool: `rewind to step ${player.step}`, text: cur.text })
248 Object.assign(player, { armedRestore: false, notice: `Rewound ${base(path)} to step ${player.step}. That rewind is on the tape too.` })
249 $.ui.invalidate('ui.render')
250 }
251
252 const controls = Box({
253 flexDirection: 'row',
254 gap: 1,
255 children: [
256 Button({ key: 'prev', label: 'h ◀', hotkey: 'h', onPress: () => go(Math.max(1, player.step - 1)) }),
257 Button({ key: 'play', label: player.isPlaying ? 'p ❚❚' : 'p ▶', hotkey: 'p', variant: 'primary', onPress: () => (player.isPlaying ? go(player.step, false) : go(player.step >= versions.length - 1 ? 1 : player.step, true)) }),
258 Button({ key: 'next', label: 'l ▶', hotkey: 'l', onPress: () => go(Math.min(versions.length - 1, player.step + 1)) }),
259 Button({ key: 'file', label: 'f file', hotkey: 'f', onPress: async () => { player.file = (player.file + 1) % files().length; await go(1, true) } }),
260 Button({ key: 'restore', label: player.armedRestore ? 'r confirm' : 'r rewind', hotkey: 'r', onPress: restore }),
261 Button({ key: 'close', label: 'c close', hotkey: 'c', role: 'dismiss', onPress: async () => { stopTicker(); await $.ui.close({ id: PANE }) } }),
262 ],
263 })
264
265 return Box({
266 flexDirection: 'column',
267 children: [header, Text({ color: MUTED, children: '─'.repeat(Math.min(width, 60)) }), ...clipped, ...(player.notice ? [Text({ color: AMBER, children: player.notice })] : []), controls],
268 })
269 })
270}
271