SLOPSHOPPER

vhs

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…

newpaneguardcommandtimer
v0.1.0MITupdated 2026-10-04saksham10arora-dotcom/claude-vhs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vhs
│ ┃ vhs ✕ › fix the failing auth test and add an audit log call │ ┃ The tape is empty: no edits yet this │ ┃ session. ⏺ Read(src/auth.ts) │ ⎿ 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 │ │ › /vhs │ ⎿ vhs: The tape is empty: no edits yet this session. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · vhs
The tape is empty: no edits yet this session.
README

vhs

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

vhs replaying an edit in a real Claude Code session

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.

Keys and commands

  • h / l step, p play or pause, f next file, c close
  • r 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 close

What it touches

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

Install

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

Develop

claude plugin validate .
claude plugin test .

More mods

Part of a set: teach-me · vhs · frugal · lofi. More community mods: awesome-claude-mods.

MIT. Made by Saksham Arora (@nerfsaksham).

Source 1 files
hooks/register.ts 271 lines
1import 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