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
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 +16 −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 ■■■■■■■■ │ ┃ +8 −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. │ ┃ +6 −0 │ │ ┃ │ src/ main · 1× │ ┃ · 0s ago │ │ ┃ │ │ ┃ │ │ ┃ │ auth.ts ■■■····· │ ┃ +2 −1 │ │ ┃ │ src/ main · 1× │ ┃ · 0s ago │ │ ┃ ╰─────────────────────────────────────────── │ ┃ ──────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Changes
3 files +16 −1 M src/auth.ts ╭─ Edits ─────────────────────────────────────────────╮ │ cache.ts new ■■■■■■■■ +8 −0 │ │ src/ main · 1× · 0s ago │ │ │ │ audit.ts new ■■■■■■·· +6 −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 253 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
14const lines = (text: string) => (text ? text.split('\n').length : 0)
15
16// An edit's added and removed lines, not counting the lines old and new share at either end.
17export const lineDelta = (before: string, after: string) => {
18  const a = before.split('\n')
19  const b = after.split('\n')
20  let head = 0
21  while (head < a.length && head < b.length && a[head] === b[head]) head++
22  let tail = 0
23  while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++
24  return { added: b.length - head - tail, removed: a.length - head - tail }
25}
26
27export const parseStatus = (text: string) => {
28  const [head = '', ...rest] = text.split('\n')
29  const branch = head.replace(/^## /, '').split('...')[0]?.replace(/^No commits yet on /, '') ?? ''
30  const ahead = Number(/ahead (\d+)/.exec(head)?.[1] ?? 0)
31  const behind = Number(/behind (\d+)/.exec(head)?.[1] ?? 0)
32  const entries = rest.filter(Boolean).map(l => ({ status: l.slice(0, 2).trim() || '?', path: l.slice(3).split(' -> ').at(-1) ?? '' }))
33  return { branch, ahead, behind, entries }
34}
35
36export const parseNumstat = (text: string) => {
37  const out = new Map<string, { added: number; removed: number }>()
38  for (const l of text.split('\n')) {
39    const [a, r, ...p] = l.split('\t')
40    // Binary files report "-" for both counts.
41    if (p.length && a !== '-') out.set(p.join('\t').split(' => ').at(-1) ?? '', { added: Number(a), removed: Number(r) })
42  }
43  return out
44}
45
46// A diffstat bar like git's: adds then removes, scaled to the largest change listed.
47export const statBar = (added: number, removed: number, max: number, w: number) => {
48  const total = added + removed
49  const cellsUsed = max > 0 ? Math.max(total > 0 ? 1 : 0, Math.round((total / max) * w)) : 0
50  const plus = total ? Math.round((added / total) * cellsUsed) : 0
51  return { plus, minus: cellsUsed - plus, rest: w - cellsUsed }
52}
53
54export const ago = (ms: number) => {
55  const s = Math.max(0, Math.round(ms / 1000))
56  if (s < 60) return `${s}s ago`
57  if (s < 3600) return `${Math.floor(s / 60)}m ago`
58  return `${Math.floor(s / 3600)}h ago`
59}
60
61const agentLabel = async ($: EngineInterface, id: string | undefined) => {
62  if (!id) return 'main'
63  // The engine's own forks (compaction, memory) carry ids no list names, so they are never cached.
64  const type = agentTypes.get(id) ?? (await $.agent.list()).find(a => a.id === id)?.type
65  if (type) agentTypes.set(id, type)
66  return type ?? 'fork'
67}
68
69const record = async ($: EngineInterface, path: string, delta: { added: number; removed: number }, agentId: string | undefined, created: boolean) => {
70  const at = await $.clock.now()
71  const agent = await agentLabel($, agentId)
72  await update($, files, list => {
73    const prev = list.find(f => f.path === path)
74    const next: LedgerFile = prev
75      ? { ...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] }
76      : { path, ...delta, edits: 1, at, agents: [agent], created }
77    return [next, ...list.filter(f => f.path !== path)]
78  })
79}
80
81const refreshGit = async ($: EngineInterface) => {
82  const repo = await $.session.repo().catch(() => null)
83  if (!repo) return update($, git, () => null)
84  const status = await $.process.run(['git', 'status', '--porcelain=v1', '--branch'], { cwd: repo.root })
85  if (status.exitCode !== 0) return
86  // A repository without a first commit has no HEAD to diff against; its files are all new.
87  const numstat = await $.process.run(['git', 'diff', '--numstat', 'HEAD'], { cwd: repo.root })
88  const counts = numstat.exitCode === 0 ? parseNumstat(numstat.stdout) : new Map<string, { added: number; removed: number }>()
89  const s = parseStatus(status.stdout)
90  const changed: LedgerGitFile[] = s.entries.map(f => ({ ...f, ...counts.get(f.path) }))
91  await update($, git, () => ({ root: repo.root, branch: s.branch, ahead: s.ahead, behind: s.behind, files: changed }))
92}
93
94// Rows are runs of styled text, so their width is known before drawing: frames pad and cut them exactly.
95type Seg = { t: string; c?: string; d?: boolean; b?: boolean }
96type Row = Seg[]
97
98const width = (row: Row) => row.reduce((n, s) => n + s.t.length, 0)
99
100const fit = (row: Row, w: number): Row => {
101  const out: Row = []
102  let n = 0
103  for (const s of row) {
104    const room = w - n
105    if (room <= 0) break
106    if (s.t.length <= room) {
107      out.push(s)
108      n += s.t.length
109    } else {
110      out.push({ ...s, t: `${s.t.slice(0, room - 1)}…` })
111      n = w
112    }
113  }
114  if (n < w) out.push({ t: ' '.repeat(w - n) })
115  return out
116}
117
118const spread = (left: Row, right: Row, w: number): Row => [...left, { t: ' '.repeat(Math.max(1, w - width(left) - width(right))) }, ...right]
119
120export const register: Register = on => {
121  on('session.start', async ($, e, next) => {
122    await $.command.register({ name: 'changes', description: 'Show the files this session changed, beside the git working tree' })
123    return next(e)
124  })
125
126  on('command.run', { command: 'changes' }, async $ => {
127    await refreshGit($)
128    const opened = await $.ui.open({ id: PANE, title: 'Changes' })
129    return { text: opened.isPlaced ? 'Changes pane opened.' : `Changes pane is waiting: ${opened.reason}` }
130  })
131
132  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
133    const result = await next(e)
134    if (result.deny === undefined && !result.isError) await record($, e.file_path, lineDelta(e.old_string, e.new_string), e.agentId, false)
135    return result
136  })
137
138  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
139    // The file as it was, so an overwrite counts its old lines as removed.
140    const before = await $.fs.read(e.file_path).then(
141      r => (typeof r === 'string' ? r : null),
142      () => null,
143    )
144    const result = await next(e)
145    if (result.deny === undefined && !result.isError)
146      await record($, e.file_path, before === null ? { added: lines(e.content), removed: 0 } : lineDelta(before, e.content), e.agentId, before === null)
147    return result
148  })
149
150  on('turn.complete', async ($, e, next) => {
151    if (e.agentId === undefined) await refreshGit($).catch(() => undefined)
152    return next(e)
153  })
154
155  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
156    const { Box, Text } = $.ui.resolve(e)
157    const list = await read($, files)
158    const tree = await read($, git)
159    const now = await $.clock.now()
160    const cwd = await $.session.cwd()
161    const w = Math.max(28, e.props.bodyColumns - 1)
162    const inner = w - 6
163
164    const draw = (row: Row, width: number) =>
165      fit(row, width).map((s, j) => <Text key={String(j)} color={s.c} dimColor={s.d} bold={s.b}>{s.t}</Text>)
166    const frame = (title: string, tone: string, rows: Row[], right = '') => {
167      const rule = Math.max(0, w - 6 - title.length - (right ? right.length + 2 : 0))
168      return (
169        <Box key={title} flexDirection="column" marginTop={1}>
170          <Text>
171            <Text color={tone} dimColor>╭─ </Text>
172            <Text color={tone} bold>{title}</Text>
173            <Text color={tone} dimColor> {'─'.repeat(rule)}</Text>
174            {right ? <Text dimColor> {right} </Text> : null}
175            <Text color={tone} dimColor>─╮</Text>
176          </Text>
177          {rows.map((row, i) => (
178            <Text key={String(i)}>
179              <Text color={tone} dimColor>│  </Text>
180              {draw(row, inner)}
181              <Text color={tone} dimColor>  │</Text>
182            </Text>
183          ))}
184          <Text color={tone} dimColor>╰{'─'.repeat(w - 2)}╯</Text>
185        </Box>
186      )
187    }
188
189    const name = (path: string) => path.split('/').pop() ?? path
190    const dir = (path: string) => {
191      const inside = path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
192      const parts = inside.split('/').slice(0, -1)
193      if (!parts.length) return './'
194      return parts.length > 2 && !path.startsWith(`${cwd}/`) ? `…/${parts.slice(-2).join('/')}/` : `${parts.join('/')}/`
195    }
196    const counts = (added: number, removed: number): Row => [
197      { t: `+${added}`.padStart(5), c: 'success' },
198      { t: ` −${removed}`.padEnd(6), c: 'error' },
199    ]
200
201    const added = list.reduce((n, f) => n + f.added, 0)
202    const removed = list.reduce((n, f) => n + f.removed, 0)
203    const glance: Row = list.length
204      ? [{ t: `${list.length} ${list.length === 1 ? 'file' : 'files'}  `, b: true }, { t: `+${added}`, c: 'success' }, { t: ` −${removed}`, c: 'error' }]
205      : [{ t: 'No edits yet', d: true }]
206    if (tree) glance.push({ t: `   ${tree.branch}`, c: 'suggestion' }, ...(tree.ahead ? [{ t: ` ↑${tree.ahead}`, d: true }] : []), ...(tree.behind ? [{ t: ` ↓${tree.behind}`, c: 'warning' }] : []))
207
208    // The git frame lists every changed path: the ones this session edited get a filled dot, the rest (shell, you) an empty one.
209    const touched = new Set(list.map(f => f.path))
210    const GIT_MAX = 8
211    const gitRows: Row[] = tree
212      ? tree.files.length
213        ? tree.files.slice(0, GIT_MAX).map(f => {
214            const mine = touched.has(`${tree.root}/${f.path}`)
215            const tone = f.status === '??' || f.status === 'A' ? 'success' : f.status === 'D' ? 'error' : 'warning'
216            const right: Row = f.added === undefined ? [{ t: f.status === '??' ? 'new' : '', d: true }] : counts(f.added, f.removed ?? 0)
217            return spread([{ t: mine ? '● ' : '○ ', c: mine ? 'suggestion' : undefined, d: !mine }, { t: f.status.padEnd(3), c: tone }, { t: f.path, d: !mine }], right, inner)
218          })
219        : [[{ t: 'Working tree clean.', d: true }]]
220      : []
221    if (tree && tree.files.length > GIT_MAX) gitRows.push([{ t: `+${tree.files.length - GIT_MAX} more`, d: true }])
222
223    // 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.
224    const fixed = 2 + 3 + (tree ? gitRows.length + 3 : 0)
225    const room = Math.max(5, e.props.scroll.bodyRows - fixed)
226    const shown = list.slice(0, Math.max(1, Math.floor((room + 1) / 3)))
227    const max = Math.max(1, ...shown.map(f => f.added + f.removed))
228    const editRows: Row[] = shown.length
229      ? shown.flatMap((f, i) => {
230          const bar = statBar(f.added, f.removed, max, 8)
231          const top = spread(
232            [{ t: name(f.path), b: true }, ...(f.created ? [{ t: ' new', c: 'success' }] : [])],
233            [{ t: '■'.repeat(bar.plus), c: 'success' }, { t: '■'.repeat(bar.minus), c: 'error' }, { t: '·'.repeat(bar.rest), d: true }, ...counts(f.added, f.removed)],
234            inner,
235          )
236          const sub = spread([{ t: `  ${dir(f.path)}`, d: true }], [{ t: `${f.agents.join(', ')} · ${f.edits}× · ${ago(now - f.at)}`, d: true }], inner)
237          return i ? [[], top, sub] : [top, sub]
238        })
239      : [[{ t: 'Files Claude edits or writes show here.', d: true }]]
240    const more = list.length - shown.length
241
242    return (
243      <Box flexDirection="column" paddingTop={1}>
244        <Text>
245          {draw(glance, w - 3)}
246        </Text>
247        {frame('Edits', 'suggestion', editRows, more > 0 ? `+${more} more` : '')}
248        {tree ? frame('Working tree', tree.files.some(f => !touched.has(`${tree.root}/${f.path}`)) ? 'warning' : 'success', gitRows, `${tree.files.length} changed`) : null}
249      </Box>
250    )
251  })
252}
253
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