/replay steps through every file edit Claude made in the last turn (or the session), one diff at a time.

Install (Claude Code 2.1.287 or later): /plugin install edit-replay --marketplace samuelbfernandes/moddeck
Step through the file edits Claude made, one diff at a time.
● N edits in M files with a Replay button. It goes away when you send the next prompt./replay opens a pane on the last turn that recorded edits; /replay all covers everything held for this session. The reply is one line, Replay · 4 edits in the last turn; a second dim line appears only for a problem. The pane shows Replay · i of N, the path, the tool, and the diff (removed red, added green). Buttons: Prev (p), Next (n), Close (c). With the pane focused, Tab and the arrows move between buttons./clear. Nothing is written to disk.turnHint, default on): when a turn changed 3 or more files, one toast says 3 files changed · /replay to review. At most once per 10 minutes, never for a smaller turn, and only when a screen is attached (not in a plain -p run). Turn it off in /config (turnHint = false).Turn it off: /plugin disable edit-replay.
Shell edits are not replayed.r hotkey works while the band holds the keyboard (click it or ctrl+x tab). A bare r typed at an empty prompt is not routed to a band button by the engine, so run /replay at the prompt./replay in the real prompt box, pane placement and the diff colours were not seen.hooks/register.tsx 249 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Edit } from '../types'
5import { MAX_EDITS, MAX_PRIOR_BYTES, editFromCall, fileCount, looksLikeShellWrite, stepIndex } from './edits'
6import type { Prior } from './edits'
7import { unifiedDiff } from './diff'
8
9const PANE = 'edit-replay'
10
11const edits = atom({ plugin: 'edit-replay', key: 'edits' } as const, [])
12const turn = atom({ plugin: 'edit-replay', key: 'turn' } as const, 0)
13const shellTurns = atom({ plugin: 'edit-replay', key: 'shellTurns' } as const, [])
14const ready = atom({ plugin: 'edit-replay', key: 'ready' } as const, null)
15const view = atom({ plugin: 'edit-replay', key: 'view' } as const, null)
16const hintAt = atom({ plugin: 'edit-replay', key: 'hintAt' } as const, 0)
17
18/** A turn that changed at least this many files earns the one-line hint. */
19const HINT_MIN_FILES = 3
20/** The hint shows at most once in this long. */
21const HINT_GAP_MS = 10 * 60_000
22
23type Scope = 'turn' | 'all'
24
25/** What a Write is about to overwrite, read BEFORE the write. Never throws:
26 * a file we cannot read just means the diff has no "before" side. */
27async function readPrior($: EngineInterface, path: unknown): Promise<Prior> {
28 if (typeof path !== 'string' || path === '') return { created: false }
29 try {
30 if (!(await $.fs.exists(path))) return { created: true, text: '' }
31 const stat = await $.fs.stat(path)
32 if (stat.kind !== 'file') return { created: true, text: '' }
33 if (stat.size > MAX_PRIOR_BYTES) return { created: false }
34 return { created: false, text: await $.fs.read(path) }
35 } catch {
36 return { created: false }
37 }
38}
39
40async function record($: EngineInterface, tool: string, input: Record<string, unknown>, prior?: Prior) {
41 const entry = editFromCall(tool, input, prior)
42 if (!entry) return
43 const current = await read($, turn)
44 await update($, edits, list =>
45 [...list, { ...entry, id: (list[list.length - 1]?.id ?? 0) + 1, turn: current }].slice(-MAX_EDITS),
46 )
47}
48
49/** The edits a view covers: one turn's, or all that are still held. */
50function inView(all: readonly Edit[], v: { scope: Scope; turn: number }): Edit[] {
51 return v.scope === 'all' ? [...all] : all.filter(x => x.turn === v.turn)
52}
53
54/** Points the view at the last turn that recorded edits (or the whole
55 * session) and opens the pane. Returns what the command says: a headline, then at most one detail line. */
56async function openReplay($: EngineInterface, scope: Scope): Promise<string> {
57 const all = await read($, edits)
58 const lastTurn = all.reduce((t, x) => Math.max(t, x.turn), -1)
59 const shown = inView(all, { scope, turn: lastTurn })
60 if (shown.length === 0) {
61 return "Replay · nothing to show yet\nOnly Claude's file edits are recorded."
62 }
63 await update($, view, () => ({ scope, turn: lastTurn, i: 0 }))
64 const opened = await $.ui.open({ id: PANE, title: 'Replay', focus: true, closeOnEscape: true })
65 if (!opened.isPlaced) return 'Replay · no room for the pane\nTry a wider terminal.'
66 return `Replay · ${shown.length} edit${shown.length === 1 ? '' : 's'} in ${scope === 'all' ? 'this session' : 'the last turn'}`
67}
68
69/** One short toast after a turn that changed several files, rate-limited, and only where a person can see it. */
70async function hintAfterTurn($: EngineInterface, files: number): Promise<void> {
71 if (files < HINT_MIN_FILES) return
72 if ((await $.session.surfaces()).length === 0) return // a plain `-p` run: nobody is looking
73 const now = await $.clock.now()
74 const last = await read($, hintAt)
75 if (last !== 0 && now - last < HINT_GAP_MS) return
76 await update($, hintAt, () => now)
77 $.ui.toast(`${files} files changed · /replay to review`)
78}
79
80/** What the hooks below this one drew in the band. Only one tree can fill the band, so a mod that draws returns its row with this under it (never instead of it), and the other mods' rows stay visible. */
81async function drawnBelow<E>(next: (e: E) => unknown, e: E): Promise<object | undefined> {
82 try {
83 const tree = await next(e)
84 return tree !== null && typeof tree === 'object' ? tree : undefined
85 } catch {
86 return undefined
87 }
88}
89
90export const register: Register = (on, options) => {
91 on('session.start', async ($, e, next) => {
92 await $.command.register({
93 name: 'replay',
94 description: 'Step through the file edits from the last turn',
95 argumentHint: '[all]',
96 })
97
98 return next(e)
99 })
100
101 // A /clear ends the conversation the edits belong to.
102 on('session.end', async ($, e, next) => {
103 await update($, edits, () => [])
104 await update($, shellTurns, () => [])
105 await update($, ready, () => null)
106 await update($, view, () => null)
107
108 return next(e)
109 }).catch(($, e, next) => next(e)) // observational: never get in the way of ending
110
111 on('command.run', { command: 'replay' }, async ($, e) => {
112 const arg = e.args.trim().toLowerCase()
113 if (arg !== '' && arg !== 'all') return { text: 'Replay · /replay for the last turn, /replay all for the session' }
114
115 return { text: await openReplay($, arg === 'all' ? 'all' : 'turn') }
116 }).catch(() => ({ text: 'Replay · could not open' }))
117
118 // Recording. Observational and fail-open: the call is never altered, denied
119 // or delayed by a failure here; an error in this hook just means the edit is
120 // not recorded. The previous content of a Write is read before `next`, since
121 // afterwards it is gone.
122 // (MultiEdit is matched too, for builds that have it; this one does not.)
123 on('tool.call', { tool: /^(Edit|Write|MultiEdit|NotebookEdit)$/ }, async ($, e, next) => {
124 const input = e as unknown as Record<string, unknown>
125 const tool = String(e.tool)
126 const prior = tool === 'Write' ? await readPrior($, input.file_path) : undefined
127 const ran = await next(e)
128 if (ran.deny === undefined && ran.isError !== true) {
129 await record($, tool, input, prior).catch(() => {})
130 }
131
132 return ran
133 }).catch(($, e, next) => next(e))
134
135 // Only remembers that a shell command looked like it wrote files, for the
136 // pane's footer note. The command itself is never touched.
137 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
138 const ran = await next(e)
139 const command = (e as unknown as Record<string, unknown>).command
140 if (ran.deny === undefined && typeof command === 'string' && looksLikeShellWrite(command)) {
141 const current = await read($, turn)
142 await update($, shellTurns, list => (list.includes(current) ? list : [...list, current].slice(-50))).catch(() => {})
143 }
144
145 return ran
146 }).catch(($, e, next) => next(e))
147
148 on('turn.start', async ($, e, next) => {
149 await update($, turn, n => n + 1)
150 await update($, ready, () => null)
151
152 return next(e)
153 }).catch(($, e, next) => next(e))
154
155 on('turn.complete', async ($, e, next) => {
156 if (e.agentId === undefined) {
157 const current = await read($, turn)
158 const made = (await read($, edits)).filter(x => x.turn === current)
159 await update($, ready, () => (made.length > 0 ? { turn: current, edits: made.length, files: fileCount(made) } : null))
160 // Fail-open and observational: a hint that cannot be shown costs nothing and never touches the turn.
161 if (options.turnHint !== false) await hintAfterTurn($, fileCount(made)).catch(() => {})
162 }
163
164 return next(e)
165 }).catch(($, e, next) => next(e))
166
167 // The band goes away as soon as the next prompt is sent. The prompt itself passes on untouched, and any failure
168 // here passes it on too (fail-open).
169 on('prompt.submit', async ($, e, next) => {
170 await update($, ready, () => null).catch(() => {})
171 return next(e)
172 }).catch(($, e, next) => next(e))
173
174 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
175 const info = await read($, ready)
176 if (e.props.hasSurvey || info === null) return next(e)
177
178 const { Box, Button, Text } = $.ui.resolve(e)
179 const s = (n: number) => (n === 1 ? '' : 's')
180 // A Button's hotkey works while the band holds the keyboard (ctrl+x tab
181 // or a click); a bare letter typed at the prompt is not routed to it.
182 const row = (
183 <Box key="replay-row" gap={1}>
184 <Text>{`● ${info.edits} edit${s(info.edits)} in ${info.files} file${s(info.files)}`}</Text>
185 <Button
186 key="open"
187 label="Replay"
188 hotkey="r"
189 onPress={async () => {
190 $.ui.toast(await openReplay($, 'turn'))
191 }}
192 />
193 </Box>
194 )
195 const below = await drawnBelow(next, e)
196 return below === undefined ? row : <Box key="replay-band" flexDirection="column">{row}{below as never}</Box>
197 })
198
199 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
200 const v = await read($, view)
201 if (v === null) return next(e)
202 const list = inView(await read($, edits), v)
203 const shell = await read($, shellTurns)
204 const { Box, Button, Code, Text } = $.ui.resolve(e)
205 const close = (
206 <Button key="close" label="Close" hotkey="c" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
207 )
208 if (list.length === 0) {
209 return (
210 <Box flexDirection="column">
211 <Text bold>Replay</Text>
212 <Text dimColor>Nothing left to show</Text>
213 {close}
214 </Box>
215 )
216 }
217
218 const i = stepIndex(v.i, 0, list.length)
219 const edit = list[i]!
220 const diff = unifiedDiff(edit.changes)
221 const hasShell = v.scope === 'all' ? shell.length > 0 : shell.includes(v.turn)
222 const go = (delta: number) => update($, view, cur => (cur ? { ...cur, i: stepIndex(cur.i, delta, list.length) } : cur))
223 const meta = [edit.tool, edit.created ? 'new file' : null, `+${diff.added} −${diff.removed}`, v.scope === 'all' ? 'session' : 'last turn']
224 return (
225 <Box flexDirection="column">
226 <Text bold>{`Replay · ${i + 1} of ${list.length}`}</Text>
227 <Text>{edit.path}</Text>
228 <Text dimColor>{meta.filter(Boolean).join(' · ')}</Text>
229 {edit.note !== undefined && <Text dimColor>{edit.note}</Text>}
230 {edit.truncated === true && <Text dimColor>Long edit shortened</Text>}
231 {diff.text === '' ? (
232 <Text dimColor>No text change</Text>
233 ) : (
234 <Code source={diff.text} format="diff" path={edit.path} />
235 )}
236 <Box gap={1}>
237 <Button key="prev" label="Prev" hotkey="p" dimColor={i === 0} onPress={() => go(-1)} />
238 <Button key="next" label="Next" hotkey="n" variant="primary" dimColor={i === list.length - 1} onPress={() => go(1)} />
239 {close}
240 </Box>
241 <Text dimColor>Line numbers are relative to the change</Text>
242 {hasShell && (
243 <Text dimColor>Shell edits are not replayed</Text>
244 )}
245 </Box>
246 )
247 })
248}
249hooks/edits.ts 96 lines1// Pure helpers: turning a tool call's input into a stored edit, stepping,
2// and the shell-command heuristic. No engine calls here.
3import type { Change, Edit } from '../types'
4
5export const MAX_EDITS = 200
6/** Characters kept per stored edit (old and new text together). */
7export const MAX_CHARS = 20_000
8/** Largest file whose previous content a Write reads before overwriting it. */
9export const MAX_PRIOR_BYTES = 200_000
10
11/** What was found at a Write's path before the write. */
12export type Prior = { created: boolean; text?: string }
13
14export type NewEdit = Omit<Edit, 'id' | 'turn'>
15
16const str = (v: unknown): string => (typeof v === 'string' ? v : '')
17
18/** Cuts `changes` to a character budget; says whether anything was lost. */
19export function capChanges(changes: Change[], budget = MAX_CHARS): { changes: Change[]; truncated: boolean } {
20 const kept: Change[] = []
21 let left = budget
22 let truncated = false
23 for (const c of changes) {
24 if (left <= 0) {
25 truncated = true
26 break
27 }
28 const old = c.old.slice(0, left)
29 left -= old.length
30 const next = c.new.slice(0, left)
31 left -= next.length
32 truncated ||= old.length < c.old.length || next.length < c.new.length
33 kept.push({ old, new: next })
34 }
35 return { changes: kept, truncated }
36}
37
38/** Builds the stored form of an Edit, Write, MultiEdit or NotebookEdit call;
39 * null for any other tool or for an input without a path. `prior` is what a
40 * Write found at its path before overwriting it. */
41export function editFromCall(tool: string, input: Record<string, unknown>, prior?: Prior): NewEdit | null {
42 const base = (path: string, changes: Change[], extra: Partial<NewEdit> = {}): NewEdit | null => {
43 if (path === '') return null
44 const capped = capChanges(changes)
45 return { path, tool, changes: capped.changes, ...(capped.truncated ? { truncated: true } : {}), ...extra }
46 }
47 switch (tool) {
48 case 'Edit':
49 return base(str(input.file_path), [{ old: str(input.old_string), new: str(input.new_string) }], {
50 ...(input.replace_all === true ? { note: 'Replaced every match · shown once' } : {}),
51 })
52 case 'MultiEdit': {
53 const edits = Array.isArray(input.edits) ? (input.edits as Record<string, unknown>[]) : []
54 return base(
55 str(input.file_path),
56 edits.map(x => ({ old: str(x?.old_string), new: str(x?.new_string) })),
57 )
58 }
59 case 'Write': {
60 const text = prior?.text
61 return base(str(input.file_path), [{ old: text ?? '', new: str(input.content) }], {
62 ...(prior?.created ? { created: true } : {}),
63 ...(!prior?.created && text === undefined
64 ? { note: 'Previous content not captured' }
65 : {}),
66 })
67 }
68 case 'NotebookEdit':
69 return base(str(input.notebook_path), [{ old: '', new: str(input.new_source) }], {
70 note: `Notebook cell (${str(input.edit_mode) || 'replace'}) · new source only`,
71 })
72 default:
73 return null
74 }
75}
76
77/** The index after moving `delta` steps in a list of `length`, kept in bounds. */
78export function stepIndex(i: number, delta: number, length: number): number {
79 return Math.min(Math.max(i + delta, 0), Math.max(length - 1, 0))
80}
81
82/** Distinct files among edits. */
83export function fileCount(edits: readonly Edit[]): number {
84 return new Set(edits.map(e => e.path)).size
85}
86
87// Rough, on purpose: sed -i, mv/cp/rm/tee/patch/truncate, git commands that
88// rewrite the working tree, and output redirection (not 2>&1 or >/dev/null).
89const SHELL_WRITE =
90 /\bsed\s+(-[a-zA-Z]*i|--in-place)|\b(mv|cp|rm|tee|patch|truncate|rsync)\s|\bgit\s+(checkout|restore|reset|apply|merge|rebase|pull|stash|mv|rm|clean|cherry-pick|revert)\b|(^|[^0-9&>])>>?\s*(?!\/dev\/null|&)[\w./~$"']/
91
92/** True when a Bash command looks like it modifies files. */
93export function looksLikeShellWrite(command: string): boolean {
94 return SHELL_WRITE.test(command)
95}
96hooks/diff.ts 130 lines1// Pure line diff, no engine calls: easy to test and safe to run in any hook.
2import type { Change } from '../types'
3
4export type Op = { t: ' ' | '-' | '+'; s: string }
5
6// Largest middle section (old lines x new lines) we run the LCS table on.
7// Past it the middle is shown as one block removed, one block added.
8const MAX_CELLS = 250_000
9
10/** Lines of a text, without the empty "line" after a final newline. The Code
11 * element refuses control characters other than tab and newline, so CRs and
12 * the like are dropped here. */
13export function splitLines(text: string): string[] {
14 if (text === '') return []
15 const clean = text.replace(/\r\n?/g, '\n').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
16 const lines = clean.split('\n')
17 if (lines[lines.length - 1] === '') lines.pop()
18 return lines
19}
20
21/** Line-level edit script from `a` to `b`: common prefix and suffix are
22 * peeled off, the middle is solved by LCS. On a tie removals come first. */
23export function lineDiff(a: string[], b: string[]): Op[] {
24 let head = 0
25 while (head < a.length && head < b.length && a[head] === b[head]) head++
26 let tail = 0
27 while (
28 tail < a.length - head &&
29 tail < b.length - head &&
30 a[a.length - 1 - tail] === b[b.length - 1 - tail]
31 ) {
32 tail++
33 }
34 const midA = a.slice(head, a.length - tail)
35 const midB = b.slice(head, b.length - tail)
36 const ops: Op[] = a.slice(0, head).map(s => ({ t: ' ', s }))
37 ops.push(...diffMiddle(midA, midB))
38 ops.push(...a.slice(a.length - tail).map(s => ({ t: ' ' as const, s })))
39 return ops
40}
41
42function diffMiddle(a: string[], b: string[]): Op[] {
43 const removed = (s: string): Op => ({ t: '-', s })
44 const added = (s: string): Op => ({ t: '+', s })
45 if (a.length === 0) return b.map(added)
46 if (b.length === 0) return a.map(removed)
47 if (a.length * b.length > MAX_CELLS) return [...a.map(removed), ...b.map(added)]
48
49 // lcs[i][j] = length of the LCS of a[i..] and b[j..]
50 const lcs: number[][] = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0))
51 for (let i = a.length - 1; i >= 0; i--) {
52 for (let j = b.length - 1; j >= 0; j--) {
53 lcs[i]![j] = a[i] === b[j] ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!)
54 }
55 }
56 const ops: Op[] = []
57 let i = 0
58 let j = 0
59 while (i < a.length && j < b.length) {
60 if (a[i] === b[j]) {
61 ops.push({ t: ' ', s: a[i]! })
62 i++
63 j++
64 } else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) {
65 ops.push(removed(a[i]!))
66 i++
67 } else {
68 ops.push(added(b[j]!))
69 j++
70 }
71 }
72 while (i < a.length) ops.push(removed(a[i++]!))
73 while (j < b.length) ops.push(added(b[j++]!))
74 return ops
75}
76
77type Range = [from: number, to: number]
78
79/** Index ranges of `ops` to show: each change with `context` lines around it,
80 * neighbouring ranges merged when their context would touch. */
81function hunkRanges(ops: Op[], context: number): Range[] {
82 const changed = ops.flatMap((o, i) => (o.t === ' ' ? [] : [i]))
83 const ranges: Range[] = []
84 let start = changed[0]
85 let end = changed[0]
86 for (const idx of changed.slice(1)) {
87 if (idx - end! - 1 <= 2 * context) {
88 end = idx
89 } else {
90 ranges.push([start!, end!])
91 start = end = idx
92 }
93 }
94 if (start !== undefined) ranges.push([start, end!])
95 return ranges.map(([s, e]) => [Math.max(0, s - context), Math.min(ops.length - 1, e + context)])
96}
97
98export type DiffResult = { text: string; added: number; removed: number }
99
100/** Unified-diff hunks (no ---/+++ header) for a list of old->new changes.
101 * Line numbers are relative to the changed snippets, not to the file: the
102 * tool inputs do not say where in the file a snippet sat. Changes after the
103 * first continue counting from where the previous one ended, so the hunks
104 * stay in order. */
105export function unifiedDiff(changes: readonly Change[], context = 3): DiffResult {
106 const out: string[] = []
107 let added = 0
108 let removed = 0
109 let oldBase = 0
110 let newBase = 0
111 for (const change of changes) {
112 const ops = lineDiff(splitLines(change.old), splitLines(change.new))
113 for (const [from, to] of hunkRanges(ops, context)) {
114 const before = ops.slice(0, from)
115 const body = ops.slice(from, to + 1)
116 const oldCount = body.filter(o => o.t !== '+').length
117 const newCount = body.filter(o => o.t !== '-').length
118 const oldStart = oldBase + before.filter(o => o.t !== '+').length + (oldCount > 0 ? 1 : 0)
119 const newStart = newBase + before.filter(o => o.t !== '-').length + (newCount > 0 ? 1 : 0)
120 out.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`)
121 out.push(...body.map(o => o.t + o.s))
122 }
123 added += ops.filter(o => o.t === '+').length
124 removed += ops.filter(o => o.t === '-').length
125 oldBase += ops.filter(o => o.t !== '+').length
126 newBase += ops.filter(o => o.t !== '-').length
127 }
128 return { text: out.join('\n'), added, removed }
129}
130types/index.d.ts 39 lines1export type Change = { old: string; new: string }
2
3export type Edit = {
4 id: number
5 /** The turn counter value when the edit ran (see `turn` in the state). */
6 turn: number
7 path: string
8 /** Edit, Write, MultiEdit or NotebookEdit. */
9 tool: string
10 changes: Change[]
11 /** Write only: the file did not exist before. */
12 created?: boolean
13 /** One line shown under the header (what could not be captured, etc.). */
14 note?: string
15 /** Contents were cut to keep memory small. */
16 truncated?: boolean
17}
18
19export type Ready = { turn: number; edits: number; files: number }
20
21/** What the pane shows: the edits of one turn, or of the whole session. */
22export type View = { scope: 'turn' | 'all'; turn: number; i: number }
23
24declare module 'claude-code' {
25 interface PluginState {
26 'edit-replay': {
27 edits: Edit[]
28 /** Counts turn.start events; edits carry the value current when they ran. */
29 turn: number
30 /** Turns that ran a Bash command which looks like it modifies files. */
31 shellTurns: number[]
32 ready: Ready | null
33 view: View | null
34 /** When the turn hint last showed, ms; 0 = not yet this session. */
35 hintAt: number
36 }
37 }
38}
39