SLOPSHOPPER

change-ledger

Lists the files this session edited in a pane (/changes), with line counts and who edited them, beside the git working tree

newpaneguardcommandprocess
★ 5v0.1.4MITupdated 2026-10-07arasovic/claude-code-mods/change-ledger
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · change-ledger
│ ┃ Changes ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ 3 files +14 −1 M src/auth.ts ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ╭─ Edits ⎿ Added 2 lines, removed 1 line │ ┃ ──────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ─╮ ⎿ 3 pass, 1 fail │ ┃ │ cache.ts new ■■■■■■■■ │ ┃ +7 −0 │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ src/ main · 1× │ ┃ · 0s ago │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ │ ┃ │ › /changes │ ┃ │ audit.ts new ■■■■■■·· ⎿ change-ledger: Changes pane opened. │ ┃ +5 −0 │ │ ┃ │ src/ main · 1× │ ┃ · 0s ago │ │ ┃ │ │ ┃ │ │ ┃ │ auth.ts ■■■····· │ ┃ +2 −1 │ │ ┃ │ src/ main · 1× │ ┃ · 0s ago │ │ ┃ ╰─────────────────────────────────────────── │ ┃ ──────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Changes
3 files +14 −1 M src/auth.ts ╭─ Edits ─────────────────────────────────────────────╮ │ cache.ts new ■■■■■■■■ +7 −0 │ │ src/ main · 1× · 0s ago │ │ │ │ audit.ts new ■■■■■■·· +5 −0 │ │ src/ main · 1× · 0s ago │ │ │ │ auth.ts ■■■····· +2 −1 │ │ src/ main · 1× · 0s ago │ ╰─────────────────────────────────────────────────────╯ ╭─ Working tree ────────────────────────── 1 changed ─╮ │ ○ ?? src/auth.test.ts +3 −0 │ ╰─────────────────────────────────────────────────────╯
README

change-ledger

A pane that lists the files the session edited, next to the git working tree.

<img src="../docs/change-ledger.png" alt="The change-ledger pane listing edited files with line counts, above the git working tree" width="640">

What it shows

  • Glance row: files changed, lines added and removed, and the branch with commits ahead and behind.
  • Edits: each file Claude edited or wrote, newest first, with a bar of lines added and removed, a new tag for created files, its folder, which agents touched it, how many edits, and how long ago.
  • Working tree: git status for the repo, with files Claude edited marked ● and other changes ○, so edits made outside the session stand out.

Line counts for edits come from the edit itself; the working tree counts come from git diff --numstat HEAD, refreshed after each turn.

Install

claude plugin marketplace add arasovic/claude-code-mods
claude plugin install change-ledger@claude-code-mods

Restart Claude Code and type /changes. Panes open as tabs, so it sits beside other mod panes.

Notes

  • Only the Edit and Write tools are tracked. Files changed by shell commands show in the working tree, not in Edits.
  • The working tree needs the session to start inside a git repository.

Develop

claude plugin validate .
claude plugin test .
../typecheck.sh change-ledger
Source 2 files
hooks/register.tsx 298 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LedgerFile, LedgerGit, LedgerGitFile } from '../types'
5
6const PANE = 'changes'
7
8const files = atom({ plugin: 'change-ledger', key: 'files' } as const, [] as LedgerFile[])
9const git = atom({ plugin: 'change-ledger', key: 'git' } as const, null as LedgerGit | null)
10
11// Subagent ids never change type, so one lookup per id is enough.
12const agentTypes = new Map<string, string>()
13
14// A final newline ends the last line; it does not start another.
15export const lines = (text: string) => (text ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0)
16
17// A path's folder: relative inside the session folder, the last two folders when far outside it.
18export const folder = (path: string, cwd: string) => {
19  const outside = !path.startsWith(`${cwd}/`)
20  const parts = (outside ? path : path.slice(cwd.length + 1)).split('/').slice(0, -1)
21  if (!parts.length) return './'
22  return outside && parts.filter(Boolean).length > 2 ? `…/${parts.slice(-2).join('/')}/` : `${parts.join('/')}/`
23}
24
25// An edit's added and removed lines, not counting the lines old and new share at either end.
26export const lineDelta = (before: string, after: string) => {
27  const a = before.split('\n')
28  const b = after.split('\n')
29  let head = 0
30  while (head < a.length && head < b.length && a[head] === b[head]) head++
31  let tail = 0
32  while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
33  return { added: b.length - head - tail, removed: a.length - head - tail }
34}
35
36export const parseStatus = (text: string) => {
37  const [head = '', ...rest] = text.split('\n')
38  const branch = head.replace(/^## /, '').split('...')[0]?.replace(/^No commits yet on /, '') ?? ''
39  const ahead = Number(/ahead (\d+)/.exec(head)?.[1] ?? 0)
40  const behind = Number(/behind (\d+)/.exec(head)?.[1] ?? 0)
41  const entries = rest.filter(Boolean).map(l => ({ status: l.slice(0, 2).trim() || '?', path: l.slice(3).split(' -> ').at(-1) ?? '' }))
42  return { branch, ahead, behind, entries }
43}
44
45export const parseNumstat = (text: string) => {
46  const out = new Map<string, { added: number; removed: number }>()
47  for (const l of text.split('\n')) {
48    const [a, r, ...p] = l.split('\t')
49    // Binary files report "-" for both counts.
50    if (p.length && a !== '-') out.set(p.join('\t').split(' => ').at(-1) ?? '', { added: Number(a), removed: Number(r) })
51  }
52  return out
53}
54
55// A diffstat bar like git's: adds then removes, scaled to the largest change listed.
56export const statBar = (added: number, removed: number, max: number, w: number) => {
57  const total = added + removed
58  const cellsUsed = max > 0 ? Math.max(total > 0 ? 1 : 0, Math.round((total / max) * w)) : 0
59  const plus = total ? Math.round((added / total) * cellsUsed) : 0
60  return { plus, minus: cellsUsed - plus, rest: w - cellsUsed }
61}
62
63export const ago = (ms: number) => {
64  const s = Math.max(0, Math.round(ms / 1000))
65  if (s < 60) return `${s}s ago`
66  if (s < 3600) return `${Math.floor(s / 60)}m ago`
67  return `${Math.floor(s / 3600)}h ago`
68}
69
70const agentLabel = async ($: EngineInterface, id: string | undefined) => {
71  if (!id) return 'main'
72  // The engine's own forks (compaction, memory) carry ids no list names, so they are never cached.
73  const type = agentTypes.get(id) ?? (await $.agent.list()).find(a => a.id === id)?.type
74  if (type) agentTypes.set(id, type)
75  return type ?? 'fork'
76}
77
78const record = async ($: EngineInterface, path: string, delta: { added: number; removed: number }, agentId: string | undefined, created: boolean) => {
79  const at = await $.clock.now()
80  const agent = await agentLabel($, agentId)
81  await update($, files, list => {
82    const prev = list.find(f => f.path === path)
83    const next: LedgerFile = prev
84      ? { ...prev, added: prev.added + delta.added, removed: prev.removed + delta.removed, edits: prev.edits + 1, at, agents: prev.agents.includes(agent) ? prev.agents : [...prev.agents, agent] }
85      : { path, ...delta, edits: 1, at, agents: [agent], created }
86    return [next, ...list.filter(f => f.path !== path)]
87  })
88}
89
90const refreshGit = async ($: EngineInterface) => {
91  const repo = await $.session.repo().catch(() => null)
92  if (!repo) return update($, git, () => null)
93  const status = await $.process.run(['git', 'status', '--porcelain=v1', '--branch'], { cwd: repo.root })
94  if (status.exitCode !== 0) return
95  // A repository without a first commit has no HEAD to diff against; its files are all new.
96  const numstat = await $.process.run(['git', 'diff', '--numstat', 'HEAD'], { cwd: repo.root })
97  const counts = numstat.exitCode === 0 ? parseNumstat(numstat.stdout) : new Map<string, { added: number; removed: number }>()
98  const s = parseStatus(status.stdout)
99  const changed: LedgerGitFile[] = s.entries.map(f => ({ ...f, ...counts.get(f.path) }))
100  await update($, git, () => ({ root: repo.root, branch: s.branch, ahead: s.ahead, behind: s.behind, files: changed }))
101}
102
103// Rows are runs of styled text. The terminal draws them on a grid of cells, so frames pad and cut them exactly.
104// Other surfaces draw text in a proportional font, where spaces line nothing up: there a row is a flex line.
105// `fill` stretches, `w` is a column that many characters wide (`right` aligns it), and a Bar draws `parts`
106// as real bars in place of its `cells`, `w` wide or stretching.
107type Seg = { t: string; c?: string; d?: boolean; b?: boolean; fill?: boolean; w?: number; right?: boolean }
108type Bar = { cells: Seg[]; parts: { n: number; c?: string }[]; w?: number }
109type Row = (Seg | Bar)[]
110
111// The empty part of a bar where the surface draws real bars.
112const TRACK = 'userMessageBackgroundHover'
113
114const segs = (row: Row): Seg[] => row.flatMap(s => ('t' in s ? [s] : s.cells))
115
116const width = (row: Row) => segs(row).reduce((n, s) => n + s.t.length, 0)
117
118const fit = (row: Row, w: number): Seg[] => {
119  const out: Seg[] = []
120  let n = 0
121  for (const s of segs(row)) {
122    const room = w - n
123    if (room <= 0) break
124    if (s.t.length <= room) {
125      out.push(s)
126      n += s.t.length
127    } else {
128      out.push({ ...s, t: `${s.t.slice(0, room - 1)}…` })
129      n = w
130    }
131  }
132  if (n < w) out.push({ t: ' '.repeat(w - n) })
133  return out
134}
135
136const spread = (left: Row, right: Row, w: number): Row => [...left, { t: ' '.repeat(Math.max(1, w - width(left) - width(right))), fill: true }, ...right]
137
138export const register: Register = on => {
139  on('session.start', async ($, e, next) => {
140    await $.command.register({ name: 'changes', description: 'Show the files this session changed, beside the git working tree' })
141    return next(e)
142  })
143
144  on('command.run', { command: 'changes' }, async $ => {
145    await refreshGit($)
146    const opened = await $.ui.open({ id: PANE, title: 'Changes' })
147    return { text: opened.isPlaced ? 'Changes pane opened.' : `Changes pane is waiting: ${opened.reason}` }
148  })
149
150  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
151    const result = await next(e)
152    if (result.deny === undefined && !result.isError) await record($, e.file_path, lineDelta(e.old_string, e.new_string), e.agentId, false)
153    return result
154  })
155
156  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
157    // The file as it was, so an overwrite counts its old lines as removed.
158    const before = await $.fs.read(e.file_path).then(
159      r => (typeof r === 'string' ? r : null),
160      () => null,
161    )
162    const result = await next(e)
163    if (result.deny === undefined && !result.isError)
164      await record($, e.file_path, before === null ? { added: lines(e.content), removed: 0 } : lineDelta(before, e.content), e.agentId, before === null)
165    return result
166  })
167
168  on('turn.complete', async ($, e, next) => {
169    if (e.agentId === undefined) await refreshGit($).catch(() => undefined)
170    return next(e)
171  })
172
173  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
174    const { Box, Text } = $.ui.resolve(e)
175    const list = await read($, files)
176    const tree = await read($, git)
177    const now = await $.clock.now()
178    const cwd = await $.session.cwd()
179    const w = Math.max(28, e.props.bodyColumns - 1)
180    const inner = w - 6
181    const isGrid = e.surface === 'terminal'
182
183    const draw = (row: Row, width: number) =>
184      fit(row, width).map((s, j) => <Text key={String(j)} color={s.c} dimColor={s.d} bold={s.b}>{s.t}</Text>)
185    // A row as a flex line, for surfaces that draw text in a proportional font.
186    const items = (row: Row) =>
187      row.map((s, j) => {
188        if ('cells' in s) {
189          const total = Math.max(1, s.parts.reduce((n, p) => n + p.n, 0))
190          return (
191            <Box height={0.5} {...(s.w ? { width: s.w, flexShrink: 0 } : { flexGrow: 1 })}>
192              {s.parts.map(p => <Box flexGrow={(p.n / total) * 1000} backgroundColor={p.c ?? TRACK} />)}
193            </Box>
194          )
195        }
196        if (s.fill) return <Box flexGrow={1} />
197        const text = <Text key={String(j)} color={s.c} dimColor={s.d} bold={s.b} wrap="truncate-end">{!s.w ? s.t : s.right ? s.t.trim() : s.t.trimEnd()}</Text>
198        return s.w ? <Box width={s.w} flexShrink={0} justifyContent={s.right ? 'flex-end' : 'flex-start'}>{text}</Box> : text
199      })
200    const line = (row: Row) => (row.length ? <Box alignItems="center">{items(row)}</Box> : <Box height={0.5} />)
201    const frame = (title: string, tone: string, rows: Row[], right = '') => {
202      if (!isGrid)
203        return (
204          <Box flexDirection="column" marginTop={2} borderStyle="round" borderColor={tone} borderDimColor>
205            <Box marginBottom={1}>
206              <Text color={tone} bold>{title}</Text>
207              <Box flexGrow={1} />
208              {right ? <Text dimColor>{right}</Text> : null}
209            </Box>
210            {rows.map(line)}
211          </Box>
212        )
213      const rule = Math.max(0, w - 6 - title.length - (right ? right.length + 2 : 0))
214      return (
215        <Box key={title} flexDirection="column" marginTop={1}>
216          <Text>
217            <Text color={tone} dimColor>╭─ </Text>
218            <Text color={tone} bold>{title}</Text>
219            <Text color={tone} dimColor> {'─'.repeat(rule)}</Text>
220            {right ? <Text dimColor> {right} </Text> : null}
221            <Text color={tone} dimColor>─╮</Text>
222          </Text>
223          {rows.map((row, i) => (
224            <Text key={String(i)}>
225              <Text color={tone} dimColor>│  </Text>
226              {draw(row, inner)}
227              <Text color={tone} dimColor>  │</Text>
228            </Text>
229          ))}
230          <Text color={tone} dimColor>╰{'─'.repeat(w - 2)}╯</Text>
231        </Box>
232      )
233    }
234
235    const name = (path: string) => path.split('/').pop() ?? path
236    const counts = (added: number, removed: number): Row => [
237      { t: `+${added}`.padStart(5), c: 'success', w: 5, right: true },
238      { t: ` −${removed}`.padEnd(6), c: 'error', w: 6 },
239    ]
240
241    const added = list.reduce((n, f) => n + f.added, 0)
242    const removed = list.reduce((n, f) => n + f.removed, 0)
243    const glance: Row = list.length
244      ? [{ t: `${list.length} ${list.length === 1 ? 'file' : 'files'}  `, b: true }, { t: `+${added}`, c: 'success' }, { t: ` −${removed}`, c: 'error' }]
245      : [{ t: 'No edits yet', d: true }]
246    if (tree) glance.push({ t: `   ${tree.branch}`, c: 'suggestion' }, ...(tree.ahead ? [{ t: ` ↑${tree.ahead}`, d: true }] : []), ...(tree.behind ? [{ t: ` ↓${tree.behind}`, c: 'warning' }] : []))
247
248    // The git frame lists every changed path: the ones this session edited get a filled dot, the rest (shell, you) an empty one.
249    const touched = new Set(list.map(f => f.path))
250    const GIT_MAX = 8
251    const gitRows: Row[] = tree
252      ? tree.files.length
253        ? tree.files.slice(0, GIT_MAX).map(f => {
254            const mine = touched.has(`${tree.root}/${f.path}`)
255            const tone = f.status === '??' || f.status === 'A' ? 'success' : f.status === 'D' ? 'error' : 'warning'
256            const right: Row = f.added === undefined ? [{ t: f.status === '??' ? 'new' : '', d: true }] : counts(f.added, f.removed ?? 0)
257            return spread([{ t: mine ? '● ' : '○ ', c: mine ? 'suggestion' : undefined, d: !mine }, { t: f.status.padEnd(3), c: tone, w: 3 }, { t: f.path, d: !mine }], right, inner)
258          })
259        : [[{ t: 'Working tree clean.', d: true }]]
260      : []
261    if (tree && tree.files.length > GIT_MAX) gitRows.push([{ t: `+${tree.files.length - GIT_MAX} more`, d: true }])
262
263    // Edits get the rows left after the top padding, the glance row and the git frame; each file takes two rows plus a blank between files.
264    const fixed = 2 + 3 + (tree ? gitRows.length + 3 : 0)
265    const room = Math.max(5, e.props.scroll.bodyRows - fixed)
266    const shown = list.slice(0, Math.max(1, Math.floor((room + 1) / 3)))
267    const max = Math.max(1, ...shown.map(f => f.added + f.removed))
268    const editRows: Row[] = shown.length
269      ? shown.flatMap((f, i) => {
270          const bar = statBar(f.added, f.removed, max, 8)
271          const top = spread(
272            [{ t: name(f.path), b: true }, ...(f.created ? [{ t: ' new', c: 'success' }] : [])],
273            [
274              {
275                cells: [{ t: '■'.repeat(bar.plus), c: 'success' }, { t: '■'.repeat(bar.minus), c: 'error' }, { t: '·'.repeat(bar.rest), d: true }],
276                parts: [{ n: bar.plus, c: 'success' }, { n: bar.minus, c: 'error' }, { n: bar.rest }],
277                w: 8,
278              },
279              ...counts(f.added, f.removed),
280            ],
281            inner,
282          )
283          const sub = spread([{ t: `  ${folder(f.path, cwd)}`, d: true }], [{ t: `${f.agents.join(', ')} · ${f.edits}× · ${ago(now - f.at)}`, d: true }], inner)
284          return i ? [[], top, sub] : [top, sub]
285        })
286      : [[{ t: 'Files Claude edits or writes show here.', d: true }]]
287    const more = list.length - shown.length
288
289    return (
290      <Box flexDirection="column" paddingTop={1}>
291        {isGrid ? <Text>{draw(glance, w - 3)}</Text> : line(glance)}
292        {frame('Edits', 'suggestion', editRows, more > 0 ? `+${more} more` : '')}
293        {tree ? frame('Working tree', tree.files.some(f => !touched.has(`${tree.root}/${f.path}`)) ? 'warning' : 'success', gitRows, `${tree.files.length} changed`) : null}
294      </Box>
295    )
296  })
297}
298
types/index.d.ts 13 lines
1export type LedgerFile = { path: string; added: number; removed: number; edits: number; at: number; agents: string[]; created: boolean }
2export type LedgerGitFile = { path: string; status: string; added?: number; removed?: number }
3export type LedgerGit = { root: string; branch: string; ahead: number; behind: number; files: LedgerGitFile[] }
4
5declare module 'claude-code' {
6  interface PluginState {
7    'change-ledger': {
8      files: LedgerFile[]
9      git: LedgerGit | null
10    }
11  }
12}
13