Shows uncommitted git changes above the prompt: files changed, lines added (green) and removed (red), with a GitHub-style bar.

A Claude Code mod that keeps your uncommitted git changes in view.
A band above the prompt shows how much has changed, and a pane lists every changed file, as a flat list or a folder tree.
Mods run code inside Claude Code on your machine. Read the source before you install one — this one is a single file,
hooks/register.tsx, and only runsgitcommands in your project.
Shown above the prompt whenever the working tree has changes:
± 3 files +120 −45 ◼◼◼◼◼ +
U+E725, then the name, or the short hash on a detached HEAD) sits at the far right of the row.+ while the pane is closed and − while it is open. Press it to open or close the pane.With no changes the band shows only the branch, at the right. It is hidden outside a git repository and while a survey is up.
Open it with the + button on the band or with /git-diff.
src/a/b/), and a dim │ guide marks each nesting level.+N −N and five-square bar.Press t to switch views (the button reads View: List (t) or View: Tree (t)). Esc hands the keyboard back and closes the pane. The pane opens sized to fit its content, up to 30 rows. Your choice of view is kept for the session.
t works while the pane holds the keyboard, which it does when you open it. If you have clicked away, click the pane or press ctrl+x then tab to give it back.
git diff HEAD --numstat for tracked files, plus untracked files that are not gitignored, counted line by line without touching your index. Binary files are listed with +0 −0.
It refreshes at session start, after each Edit or Write, and at the end of every turn. Changes made some other way (your editor, a shell command) show up at the end of the next turn, or when you reopen the pane.
/plugin marketplace add cjmellor/mella-marketplace
/plugin install git-diff@mella-marketplace
/reload-plugins
To try it from a checkout without installing:
claude --plugin-dir plugins/git-diff
claude plugin validate plugins/git-diff
claude plugin test plugins/git-diff
validate checks the manifest and the hooks module, but it does not check what the band draws: an invalid render tree is dropped by the engine and the band simply does not appear. If it goes missing after an edit, run Claude Code with --debug and look for a ui.render (AbovePrompt): a hook returned a tree that does not validate line.
hooks/register.tsx 347 lines1import type { EngineInterface, Register, RenderChildren } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { DiffStat, FileStat, ViewMode } from '../types'
5
6type Entry = { label: string; depth: number; isDir: boolean; added: number; removed: number }
7type Node = { name: string; children: Map<string, Node>; file?: FileStat }
8
9const stat = atom({ plugin: 'git-diff', key: 'stat' } as const, null)
10const view = atom({ plugin: 'git-diff', key: 'view' } as const, 'list' as ViewMode)
11const isOpen = atom({ plugin: 'git-diff', key: 'isOpen' } as const, false)
12const branch = atom({ plugin: 'git-diff', key: 'branch' } as const, null)
13
14const RIGHT_GUTTER = 4
15
16const BAR_CELLS = 5
17const PANE = 'changes'
18const UNTRACKED_LIMIT = 100
19const MAX_PANE_ROWS = 30
20
21export const parseNumstat = (output: string): DiffStat => {
22 const rows: FileStat[] = output
23 .split('\n')
24 .filter(Boolean)
25 .map(line => {
26 const [added, removed, ...path] = line.split('\t')
27
28 return { path: path.join('\t'), added: Number(added) || 0, removed: Number(removed) || 0 }
29 })
30 .sort((a, b) => b.added + b.removed - (a.added + a.removed))
31
32 return {
33 files: rows.length,
34 added: rows.reduce((sum, row) => sum + row.added, 0),
35 removed: rows.reduce((sum, row) => sum + row.removed, 0),
36 rows,
37 }
38}
39
40export const barCells = (
41 { added, removed }: { added: number; removed: number },
42 cells = BAR_CELLS,
43): { green: number; red: number; grey: number } => {
44 const total = added + removed
45 const green = total === 0 ? 0 : Math.round((added / total) * cells)
46 const red = total === 0 ? 0 : cells - green
47
48 return { green, red, grey: cells - green - red }
49}
50
51const totals = (node: Node): { added: number; removed: number } => {
52 if (node.file) {
53 return { added: node.file.added, removed: node.file.removed }
54 }
55
56 return [...node.children.values()].reduce(
57 (sum, child) => {
58 const part = totals(child)
59
60 return { added: sum.added + part.added, removed: sum.removed + part.removed }
61 },
62 { added: 0, removed: 0 },
63 )
64}
65
66const flatten = (node: Node, depth: number): Entry[] =>
67 [...node.children.values()]
68 .sort((a, b) => Number(Boolean(a.file)) - Number(Boolean(b.file)) || a.name.localeCompare(b.name))
69 .flatMap(child => {
70 let merged = child
71 let label = child.name
72
73 while (!merged.file && merged.children.size === 1) {
74 const only = [...merged.children.values()][0]
75
76 if (only.file) {
77 break
78 }
79
80 merged = only
81 label = `${label}/${only.name}`
82 }
83
84 const entry: Entry = {
85 label: merged.file ? label : `${label}/`,
86 depth,
87 isDir: !merged.file,
88 ...totals(merged),
89 }
90
91 return [entry, ...flatten(merged, depth + 1)]
92 })
93
94export const entries = (rows: FileStat[], mode: ViewMode): Entry[] => {
95 if (mode === 'list') {
96 return rows.map(row => ({ label: row.path, depth: 0, isDir: false, added: row.added, removed: row.removed }))
97 }
98
99 const root: Node = { name: '', children: new Map() }
100
101 for (const row of rows) {
102 const parts = row.path.split('/')
103 let node = root
104
105 parts.forEach((part, index) => {
106 const next = node.children.get(part) ?? { name: part, children: new Map<string, Node>() }
107 node.children.set(part, next)
108 node = next
109
110 if (index === parts.length - 1) {
111 node.file = row
112 }
113 })
114 }
115
116 return flatten(root, 0)
117}
118
119async function untrackedNumstat($: EngineInterface): Promise<string> {
120 const { stdout } = await $.process.run(['git', 'ls-files', '--others', '--exclude-standard'])
121 const files = stdout.split('\n').filter(Boolean).slice(0, UNTRACKED_LIMIT)
122
123 const lines = await Promise.all(
124 files.map(async file => {
125 const { stdout: counted } = await $.process.run(['git', 'diff', '--no-index', '--numstat', '--', '/dev/null', file])
126 const [added] = counted.split('\t')
127
128 return `${Number(added) || 0}\t0\t${file}`
129 }),
130 )
131
132 return lines.join('\n')
133}
134
135export const barSquares = (
136 counts: { added: number; removed: number },
137 cells = BAR_CELLS,
138): ('green' | 'red' | 'grey')[] => {
139 const { green, red, grey } = barCells(counts, cells)
140
141 return [...Array(green).fill('green'), ...Array(red).fill('red'), ...Array(grey).fill('grey')]
142}
143
144async function currentBranch($: EngineInterface): Promise<string | null> {
145 const named = await $.process.run(['git', 'branch', '--show-current'])
146 const name = named.stdout.trim()
147
148 if (name) {
149 return name
150 }
151
152 const detached = await $.process.run(['git', 'rev-parse', '--short', 'HEAD'])
153
154 return detached.exitCode === 0 ? detached.stdout.trim() : null
155}
156
157async function refresh($: EngineInterface) {
158 const name = await currentBranch($)
159 await update($, branch, () => name)
160 const tracked = await $.process.run(['git', 'diff', 'HEAD', '--numstat'])
161 const untracked = tracked.exitCode === 0 ? await untrackedNumstat($) : ''
162 await update($, stat, () => (tracked.exitCode === 0 ? parseNumstat(`${tracked.stdout}\n${untracked}`) : null))
163}
164
165async function openPane($: EngineInterface) {
166 await refresh($)
167 const current = await read($, stat)
168 const lines = current === null ? 1 : entries(current.rows, 'tree').length
169 await $.ui.open({ id: PANE, title: 'Changes', focus: true, closeOnEscape: true, rows: Math.min(lines + 2, MAX_PANE_ROWS) })
170 await update($, isOpen, () => true)
171}
172
173async function togglePane($: EngineInterface) {
174 if (await read($, isOpen)) {
175 await $.ui.close({ id: PANE })
176 await update($, isOpen, () => false)
177
178 return
179 }
180
181 await openPane($)
182}
183
184const RESEED_MS = 500
185
186async function seed($: EngineInterface) {
187 await update($, isOpen, () => false)
188 await refresh($)
189}
190
191export const register: Register = on => {
192 on('session.start', async ($, e, next) => {
193 await $.command.register({ name: 'git-diff', description: 'Show uncommitted changes per file in a pane' })
194 await seed($)
195
196 return next(e)
197 })
198
199 // /clear resets this plugin's state after session.end and fires no session.start, so setup reruns from a timer.
200 on('session.end', async ($, e, next) => {
201 if (e.reason === 'clear') {
202 $.clock.after(RESEED_MS, () => {
203 void seed($)
204 })
205 }
206
207 return next(e)
208 })
209
210 on('command.run', { command: 'git-diff' }, async $ => {
211 await openPane($)
212
213 return { text: 'Changes pane opened.' }
214 })
215
216 on('ui.close', async ($, e, next) => {
217 const closed = await next(e)
218
219 if (e.id === PANE) {
220 await update($, isOpen, () => false)
221 }
222
223 return closed
224 })
225
226 on('turn.complete', async ($, e, next) => {
227 await refresh($)
228
229 return next(e)
230 })
231
232 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
233 const ran = await next(e)
234 await refresh($)
235
236 return ran
237 })
238
239 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
240 const ran = await next(e)
241 await refresh($)
242
243 return ran
244 })
245
246 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
247 const other = await next(e)
248 const current = await read($, stat)
249
250 if (e.props.hasSurvey) {
251 return other
252 }
253
254 const { Box, Button, Text } = $.ui.resolve(e)
255 const name = await read($, branch)
256 const stack = <T extends RenderChildren>(band: T) => (other ? <Box flexDirection="column">{band}{other}</Box> : band)
257
258 if (current === null || current.files === 0) {
259 return name ? stack(
260 <Box width={e.props.bodyColumns - RIGHT_GUTTER} justifyContent="flex-end">
261 <Text dimColor>{` ${name}`}</Text>
262 </Box>,
263 ) : other
264 }
265
266 const squares = barSquares(current)
267 const paneIsOpen = await read($, isOpen)
268
269 const band = (
270 <Box width={e.props.bodyColumns - RIGHT_GUTTER} justifyContent="space-between">
271 <Box>
272 <Text dimColor>{`± ${current.files} ${current.files === 1 ? 'file' : 'files'} `}</Text>
273 <Text color="green" bold>+{current.added}</Text>
274 <Text> </Text>
275 <Text color="red" bold>−{current.removed}</Text>
276 <Text> </Text>
277 {squares.map(color => (
278 <Text color={color === 'grey' ? undefined : color} dimColor={color === 'grey'}>◼</Text>
279 ))}
280 <Text> </Text>
281 <Button
282 key="toggle"
283 label={paneIsOpen ? '−' : '+'}
284 plain
285 dimColor
286 onPress={() => togglePane($)}
287 />
288 </Box>
289 {name && <Text dimColor>{` ${name}`}</Text>}
290 </Box>
291 )
292
293 return stack(band)
294 })
295
296 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
297 const { Box, Button, Text } = $.ui.resolve(e)
298 const current = await read($, stat)
299 const mode = await read($, view)
300
301 if (current === null || current.files === 0) {
302 return <Text dimColor>No uncommitted changes.</Text>
303 }
304
305 const shown = entries(current.rows, mode)
306 const room = Math.max(1, (e.viewport?.rows ?? 24) - 6)
307 const addedWidth = Math.max(...shown.map(entry => `+${entry.added}`.length))
308 const removedWidth = Math.max(...shown.map(entry => `−${entry.removed}`.length))
309
310 return (
311 <Box flexDirection="column">
312 <Box>
313 <Text bold>{current.files} {current.files === 1 ? 'file' : 'files'} </Text>
314 <Text color="green" bold>+{current.added}</Text>
315 <Text> </Text>
316 <Text color="red" bold>−{current.removed}</Text>
317 <Text> </Text>
318 <Button
319 key="view"
320 label={mode === 'list' ? 'View: List (t)' : 'View: Tree (t)'}
321 hotkey="t"
322 onPress={() => update($, view, current => (current === 'list' ? 'tree' : 'list'))}
323 />
324 </Box>
325 <Text> </Text>
326 {shown.slice(0, room).map(entry => {
327 const squares = barSquares(entry)
328
329 return (
330 <Box>
331 <Text color="green">{`+${entry.added}`.padStart(addedWidth)} </Text>
332 <Text color="red">{`−${entry.removed}`.padStart(removedWidth)} </Text>
333 {squares.map(color => (
334 <Text color={color === 'grey' ? undefined : color} dimColor={color === 'grey'}>◼</Text>
335 ))}
336 <Text> </Text>
337 <Text color="gray" dimColor>{'│ '.repeat(entry.depth)}</Text>
338 <Text bold={entry.isDir}>{entry.label}</Text>
339 </Box>
340 )
341 })}
342 {shown.length > room && <Text dimColor>…and {shown.length - room} more</Text>}
343 </Box>
344 )
345 })
346}
347types/index.d.ts 10 lines1export type FileStat = { path: string; added: number; removed: number }
2export type DiffStat = { files: number; added: number; removed: number; rows: FileStat[] }
3export type ViewMode = 'list' | 'tree'
4
5declare module 'claude-code' {
6 interface PluginState {
7 'git-diff': { stat: DiffStat | null; view: ViewMode; isOpen: boolean; branch: string | null }
8 }
9}
10