A side pane listing the files changed in the working tree, with colored git diff stats

A personal Claude Code plugin marketplace of terminal UI mods: a status band above the prompt and two side panes.
| Plugin | What it adds | Command |
|---|---|---|
prompt-status | A two-line band above the prompt: model and context window, current directory, git branch, context fill and rate-limit usage | — |
token-usage | A pane with context fill, session token totals, cost and rate limits | /token-usage |
git-changes | A pane listing the files changed in the working tree, with colored git diff stats | /changes |
/plugin marketplace add matr1xp/local-mods
/plugin install prompt-status@local-mods
/plugin install token-usage@local-mods
/plugin install git-changes@local-mods
Renders above the prompt in every session:

model setting.*) refresh after each turn and once a minute. A detached HEAD shows its short commit hash.Opens a pane at session start. Run /token-usage to reopen it. The pane shows:

Running /clear resets the session totals.
Opens a pane in interactive sessions. Run /changes to reopen it and print a summary. The pane lists every file that differs from HEAD, whether staged, unstaged or untracked:

M, A, D, R, ??, …), +added / -deleted counts and a git --stat-style bar. Binary files show bin.● marker on files whose stats changed since the previous scan.Edit, Write, NotebookEdit and Bash tool calls, and every 4 seconds while it is on screen. Press r to refresh by hand.GIT_OPTIONAL_LOCKS=0, so polling never holds index.lock while you run your own git commands.new..claude-plugin/marketplace.json marketplace manifest listing the plugins
<plugin>/
.claude-plugin/plugin.json name, version, description
hooks/hooks.json points at the hooks module
hooks/register.tsx the mod: event hooks and UI rendering
types/index.d.ts shapes of the state each mod stores
tests/*.test.ts(x) tests using claude-code/testing
Run a plugin's tests, then validate the marketplace:
claude plugin test git-changes
claude plugin validate .
After changing a plugin, bump version in its .claude-plugin/plugin.json so other machines pick up the update with claude plugin update <plugin>@local-mods.
MIT. The software is provided as is, without warranty of any kind, and the author is not liable for any damage arising from its use.
hooks/register.tsx 354 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ChangedFile, Snapshot } from '../types'
5
6const PANE = 'git-changes'
7const TITLE = 'Changes'
8const POLL_MS = 4000
9// Untracked files are counted one `git diff --no-index` each; past this many they show as "new".
10const MAX_UNTRACKED_COUNTED = 40
11const MAX_ROWS = 500
12// git's well-known empty tree: the diff base before the first commit.
13const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
14// Polling must never take index.lock out from under the person's own git commands.
15const GIT_ENV = { GIT_OPTIONAL_LOCKS: '0' }
16
17const STATUS_COLOR: Record<string, string> = {
18 M: 'warning',
19 A: 'success',
20 D: 'error',
21 R: 'suggestion',
22 C: 'suggestion',
23 T: 'warning',
24 U: 'error',
25 '?': 'subtle',
26}
27
28const snapshot = atom({ plugin: 'git-changes', key: 'snapshot' } as const, null)
29
30type Stat = { added: number | null; deleted: number | null; isBinary: boolean }
31
32const git = ($: EngineInterface, args: string[], cwd?: string) =>
33 $.process.run(['git', ...args], { cwd, env: GIT_ENV, timeoutMs: 10_000 })
34
35// `--numstat -z`: "added\tdeleted\tpath\0", or "added\tdeleted\t\0old\0new\0" for a
36// rename; a binary file counts "-".
37export function parseNumstat(out: string): Map<string, Stat> {
38 const tokens = out.split('\0')
39 const stats = new Map<string, Stat>()
40 for (let i = 0; i < tokens.length; i++) {
41 const head = tokens[i]
42 if (head === undefined || head === '') continue
43 const firstTab = head.indexOf('\t')
44 const secondTab = head.indexOf('\t', firstTab + 1)
45 const added = head.slice(0, firstTab)
46 const deleted = head.slice(firstTab + 1, secondTab)
47 let path = head.slice(secondTab + 1)
48 if (path === '') {
49 i += 2
50 path = tokens[i] ?? ''
51 }
52 const isBinary = added === '-'
53 stats.set(path, {
54 added: isBinary ? null : Number(added),
55 deleted: isBinary ? null : Number(deleted),
56 isBinary,
57 })
58 }
59 return stats
60}
61
62// `--name-status -z`: "M\0path\0", or "R100\0old\0new\0" for a rename or copy.
63export function parseNameStatus(out: string): Map<string, string> {
64 const tokens = out.split('\0')
65 const statuses = new Map<string, string>()
66 for (let i = 0; i < tokens.length; i++) {
67 const code = tokens[i]
68 if (code === undefined || code === '') continue
69 const letter = code.charAt(0)
70 if (letter === 'R' || letter === 'C') i++
71 i++
72 statuses.set(tokens[i] ?? '', letter)
73 }
74 return statuses
75}
76
77async function countUntracked($: EngineInterface, root: string, path: string): Promise<Stat> {
78 // Exits 1 when the file differs from /dev/null, which it always does.
79 const { stdout } = await git($, ['diff', '--no-index', '--numstat', '-z', '--', '/dev/null', path], root)
80 const [stat] = parseNumstat(stdout).values()
81 return stat ?? { added: null, deleted: null, isBinary: false }
82}
83
84async function scan($: EngineInterface): Promise<Omit<Snapshot, 'fresh' | 'changedAt'>> {
85 const top = await git($, ['rev-parse', '--show-toplevel'])
86 if (top.exitCode !== 0) {
87 return { branch: '', files: [], error: 'Not a git repository' }
88 }
89 const root = top.stdout.trim()
90
91 const [symbolic, head] = await Promise.all([
92 git($, ['symbolic-ref', '--short', '-q', 'HEAD'], root),
93 git($, ['rev-parse', '--short', '-q', '--verify', 'HEAD'], root),
94 ])
95 const branch = symbolic.exitCode === 0 ? symbolic.stdout.trim() : `detached ${head.stdout.trim()}`
96 const base = head.exitCode === 0 ? 'HEAD' : EMPTY_TREE
97
98 const [numstat, nameStatus, others] = await Promise.all([
99 git($, ['diff', '-M', '--numstat', '-z', base], root),
100 git($, ['diff', '-M', '--name-status', '-z', base], root),
101 git($, ['ls-files', '--others', '--exclude-standard', '-z'], root),
102 ])
103 if (numstat.exitCode !== 0) {
104 return { branch, files: [], error: numstat.stderr.trim() || 'git diff failed' }
105 }
106
107 const stats = parseNumstat(numstat.stdout)
108 const statuses = parseNameStatus(nameStatus.stdout)
109 const files: ChangedFile[] = [...stats]
110 .map(([path, stat]) => ({ path, status: statuses.get(path) ?? 'M', ...stat }))
111 .sort((a, b) => a.path.localeCompare(b.path))
112
113 const untracked = others.stdout.split('\0').filter(Boolean).sort()
114 const counted = await Promise.all(
115 untracked
116 .slice(0, MAX_UNTRACKED_COUNTED)
117 .map(path => countUntracked($, root, path).catch(() => null)),
118 )
119 untracked.forEach((path, i) => {
120 const stat = counted[i] ?? { added: null, deleted: null, isBinary: false }
121 files.push({ path, status: '?', ...stat })
122 })
123
124 return { branch, files, error: null }
125}
126
127const signature = (file: ChangedFile) => `${file.status}|${file.added}|${file.deleted}`
128
129async function scanAndStore($: EngineInterface) {
130 let next: Omit<Snapshot, 'fresh' | 'changedAt'>
131 try {
132 next = await scan($)
133 } catch (error) {
134 next = { branch: '', files: [], error: error instanceof Error ? error.message : String(error) }
135 }
136 const prev = await read($, snapshot)
137 const prevByPath = new Map(prev?.files.map(file => [file.path, signature(file)]))
138 const isSame =
139 prev !== null &&
140 prev.branch === next.branch &&
141 prev.error === next.error &&
142 prev.files.length === next.files.length &&
143 next.files.every(file => prevByPath.get(file.path) === signature(file))
144 if (isSame) return
145
146 // The first scan highlights nothing; later ones mark what moved since the last.
147 const fresh =
148 prev === null ? [] : next.files.filter(file => prevByPath.get(file.path) !== signature(file)).map(file => file.path)
149 const changedAt = await $.clock.now()
150 await update($, snapshot, () => ({ ...next, fresh, changedAt }))
151}
152
153// One scan at a time; a call that lands mid-scan queues one more pass after it.
154let running: Promise<void> | undefined
155let isQueued = false
156
157function refresh($: EngineInterface): Promise<void> {
158 if (running !== undefined) {
159 isQueued = true
160 return running
161 }
162 running = (async () => {
163 try {
164 do {
165 isQueued = false
166 await scanAndStore($)
167 } while (isQueued)
168 } finally {
169 running = undefined
170 }
171 })()
172 return running
173}
174
175async function isPaneUp($: EngineInterface) {
176 const panes = await $.ui.panes()
177 return panes.some(pane => pane.id === PANE && pane.isPlaced)
178}
179
180const clock = (ms: number) => {
181 const at = new Date(ms)
182 return [at.getHours(), at.getMinutes(), at.getSeconds()].map(n => String(n).padStart(2, '0')).join(':')
183}
184
185const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
186
187export const register: Register = on => {
188 on('session.start', async ($, e, next) => {
189 await $.command.register({
190 name: 'changes',
191 description: 'Show changed files with git diff stats in a side pane',
192 })
193 if (e.isInteractive) {
194 await refresh($)
195 void $.ui.open({ id: PANE, title: TITLE })
196 $.clock.every(POLL_MS, () => {
197 void isPaneUp($)
198 .then(isUp => (isUp ? refresh($) : undefined))
199 .catch(() => undefined)
200 })
201 }
202
203 return next(e)
204 })
205
206 on('command.run', { command: 'changes' }, async ($, e) => {
207 await refresh($)
208 await $.ui.open({ id: PANE, title: TITLE })
209 const snap = await read($, snapshot)
210 const tip = e.presentation.isFullscreen
211 ? ''
212 : ' It docks to the right in the fullscreen layout; on the main screen it sits above the prompt.'
213 if (snap === null || snap.error !== null) {
214 return { text: `Changes pane opened${snap?.error ? ` (${snap.error})` : ''}.${tip}` }
215 }
216 const added = snap.files.reduce((sum, file) => sum + (file.added ?? 0), 0)
217 const deleted = snap.files.reduce((sum, file) => sum + (file.deleted ?? 0), 0)
218
219 return {
220 text: `Changes pane opened: ${plural(snap.files.length, 'file')} changed, +${added} -${deleted}.${tip}`,
221 }
222 })
223
224 // Rescan right after a tool that may have touched files, so the pane keeps up with each edit.
225 on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit', 'Bash'] }, async ($, e, next) => {
226 const ran = await next(e)
227 if (await isPaneUp($)) await refresh($)
228
229 return ran
230 }).catch(($, e, next) => next(e))
231
232 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
233 const { Box, Text, Button } = $.ui.resolve(e)
234 const snap = await read($, snapshot)
235 const width = e.props.bodyColumns
236
237 if (snap === null) {
238 return <Text dimColor>Scanning…</Text>
239 }
240 if (snap.error !== null) {
241 return (
242 <Box flexDirection="column">
243 <Text color="error">{snap.error}</Text>
244 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refresh($)} />
245 </Box>
246 )
247 }
248
249 const fresh = new Set(snap.fresh)
250 const totalAdded = snap.files.reduce((sum, file) => sum + (file.added ?? 0), 0)
251 const totalDeleted = snap.files.reduce((sum, file) => sum + (file.deleted ?? 0), 0)
252
253 // Columns: marker, status, path, +added, -deleted, then a git --stat style bar.
254 const addedText = (file: ChangedFile) => (file.isBinary ? 'bin' : file.added === null ? 'new' : `+${file.added}`)
255 const deletedText = (file: ChangedFile) => (file.deleted === null ? '' : `-${file.deleted}`)
256 const addedWidth = Math.max(0, ...snap.files.map(file => addedText(file).length))
257 const deletedWidth = Math.max(0, ...snap.files.map(file => deletedText(file).length))
258 const barWidth = width >= 60 ? 12 : width >= 44 ? 6 : 0
259 const pathWidth = Math.max(8, width - 4 - (1 + addedWidth + 1 + deletedWidth) - (barWidth > 0 ? 1 + barWidth : 0))
260 const largest = Math.max(1, ...snap.files.map(file => (file.added ?? 0) + (file.deleted ?? 0)))
261 const scale = largest <= barWidth ? 1 : barWidth / largest
262
263 const bar = (file: ChangedFile) => {
264 const added = file.added ?? 0
265 const deleted = file.deleted ?? 0
266 let plus = added > 0 ? Math.max(1, Math.round(added * scale)) : 0
267 let minus = deleted > 0 ? Math.max(1, Math.round(deleted * scale)) : 0
268 while (plus + minus > barWidth) {
269 if (plus >= minus) plus--
270 else minus--
271 }
272 return (
273 <Text>
274 {' '}
275 <Text color="success">{'█'.repeat(plus)}</Text>
276 <Text color="error">{'█'.repeat(minus)}</Text>
277 {' '.repeat(barWidth - plus - minus)}
278 </Text>
279 )
280 }
281
282 const row = (file: ChangedFile) => {
283 const isFresh = fresh.has(file.path)
284 const slash = file.path.lastIndexOf('/')
285 const dir = file.path.slice(0, slash + 1)
286 const name = file.path.slice(slash + 1)
287 return (
288 <Box flexDirection="row">
289 <Text color="claude">{isFresh ? '●' : ' '}</Text>
290 <Text color={STATUS_COLOR[file.status] ?? 'text'} bold>
291 {(file.status === '?' ? '??' : file.status).padEnd(2)}{' '}
292 </Text>
293 <Box width={pathWidth}>
294 <Text wrap="truncate-start" bold={isFresh}>
295 <Text dimColor>{dir}</Text>
296 {name}
297 </Text>
298 </Box>
299 <Text> </Text>
300 <Text color={file.added === null ? 'subtle' : 'success'} bold={isFresh}>
301 {addedText(file).padStart(addedWidth)}
302 </Text>
303 <Text> </Text>
304 <Text color="error" bold={isFresh}>
305 {deletedText(file).padStart(deletedWidth)}
306 </Text>
307 {barWidth > 0 && bar(file)}
308 </Box>
309 )
310 }
311
312 const shown = snap.files.slice(0, MAX_ROWS)
313
314 return (
315 <Box flexDirection="column">
316 <Box flexDirection="row">
317 <Text dimColor>on </Text>
318 <Text color="suggestion" bold wrap="truncate-end">
319 {snap.branch}
320 </Text>
321 </Box>
322 {snap.files.length === 0 ? (
323 <Text color="success">Working tree clean</Text>
324 ) : (
325 <Text>
326 <Text bold>{plural(snap.files.length, 'file')} changed</Text>
327 {' '}
328 <Text color="success" bold>
329 +{totalAdded}
330 </Text>
331 {' '}
332 <Text color="error" bold>
333 -{totalDeleted}
334 </Text>
335 </Text>
336 )}
337 <Text dimColor>{'─'.repeat(Math.max(1, width))}</Text>
338 {shown.map(row)}
339 {snap.files.length > shown.length && (
340 <Text dimColor>…and {snap.files.length - shown.length} more</Text>
341 )}
342 <Text> </Text>
343 <Box flexDirection="row">
344 <Text dimColor>
345 {fresh.size > 0 && <Text color="claude">● </Text>}
346 {fresh.size > 0 ? 'just changed · ' : ''}updated {clock(snap.changedAt)}{' '}
347 </Text>
348 <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refresh($)} />
349 </Box>
350 </Box>
351 )
352 })
353}
354types/index.d.ts 32 lines1/**
2 * One changed file, as `git diff --numstat` / `--name-status` against HEAD
3 * report it (staged and unstaged together), or an untracked file.
4 */
5export type ChangedFile = {
6 /** Repo-relative path (the new path for a rename). */
7 path: string
8 /** git's status letter (M, A, D, R, C, T, U), or `?` for untracked. */
9 status: string
10 /** Lines added; null when binary or not counted. */
11 added: number | null
12 /** Lines deleted; null when binary or not counted. */
13 deleted: number | null
14 isBinary: boolean
15}
16
17export type Snapshot = {
18 branch: string
19 files: ChangedFile[]
20 /** Paths whose stats changed in the latest scan that found a difference. */
21 fresh: string[]
22 /** When the list last changed, ms since the epoch. */
23 changedAt: number
24 error: string | null
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 'git-changes': { snapshot: Snapshot | null }
30 }
31}
32