SLOPSHOPPER

edit-replay

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

newpanebandguardcommandtoast
★ 1v0.3.2MITupdated 2026-10-14samuelbfernandes/moddeck/mods/edit-replay
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · edit-replay
│ ┃ Replay ✕ › fix the failing auth test a╭─────────────────────────────────────╮ │ ┃ Replay · 1 of 3 │ edit-replay │ │ ┃ /work/app/src/auth.ts ⏺ Read(src/auth.ts) │ 3 files changed · /replay to review │ │ ┃ Edit · +2 −1 · last turn ⎿ Read 6 lines ╰─────────────────────────────────────╯ │ ┃ 1 -if (!claims) throw new Error('invalid ⏺ Update(src/auth.ts) │ ┃ 1 +if (!claims || claims.exp < Date.now() ⎿ Added 2 lines, removed 1 line │ ┃ 2 + await audit('refresh', claims.sub) ⏺ Bash(bun test) │ ┃ [ Prev ] [ Next ] [ Close ] ⎿ 3 pass, 1 fail │ ┃ Line numbers are relative to the change │ ┃ Shell edits are not replayed ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /replay │ ⎿ edit-replay: Replay · 3 edits in the last turn │ │ ● 3 edits in 3 files [ Replay ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● 3 edits in 3 files [ Replay ] ⟨Claude Code's own drawing⟩
Pane · Replay
Replay · 1 of 3 /work/app/src/auth.ts Edit · +2 −1 · last turn 1 -if (!claims) throw new Error('invalid token') 1 +if (!claims || claims.exp < Date.now() / 1000) throw n 2 + await audit('refresh', claims.sub) [ Prev ] [ Next ] [ Close ] Line numbers are relative to the change Shell edits are not replayed
README

edit-replay

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.

  • Records Claude's own Edit, Write, MultiEdit (where the build has it) and NotebookEdit calls, after they succeed. The call, its input and its result are never altered; any failure here is ignored (fail-open).
  • When a turn made at least one edit, a band above the prompt says ● 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.
  • The last 200 edits are kept in memory (each cut to about 20,000 characters) and cleared on /clear. Nothing is written to disk.
  • Hint (setting 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.

Limits

  • Only Claude's file-edit tools are recorded. Shell edits (sed, mv, git, redirects) are not. When the turn also ran a Bash command that looks like it modifies files (a rough regex), the pane footer says Shell edits are not replayed.
  • Line numbers in the diff count from the changed snippet, not from the file: the tool inputs do not say where it sat.
  • A Write shows a real before/after only if the file was at most 200 KB and readable just before the write; otherwise the pane says the previous content was not captured. A new file is labelled "new file".
  • NotebookEdit shows the new cell source only. Edits made by subagents are recorded with the turn that spawned them.
  • The band's 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.
  • N counts distinct files, not edits. The hint's 10-minute gap is per session and forgotten on restart.
  • Not verified in a live session: only validate, type-check and the engine's test kit were run. The toast's look, /replay in the real prompt box, pane placement and the diff colours were not seen.
Source 4 files
hooks/register.tsx 249 lines
1import { 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}
249
hooks/edits.ts 96 lines
1// 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}
96
hooks/diff.ts 130 lines
1// 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}
130
types/index.d.ts 39 lines
1export 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