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

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">
new tag for created files, its folder, which agents touched it, how many edits, and how long ago.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.
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.
claude plugin validate .
claude plugin test .
../typecheck.sh change-ledgerhooks/register.tsx 253 lines1import { 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}
253types/index.d.ts 13 lines1export 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