SLOPSHOPPER

git-diff

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

newpanebandguardcommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-diff
│ ┃ Changes ✕ › fix the failing auth test and add an audit log call │ ┃ 2 files +14 −3 [ View: List (t) ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ +11 −3 ◼◼◼◼◼ src/auth.ts ⎿ Read 6 lines │ ┃ +3 −0 ◼◼◼◼◼ src/auth.test.ts ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /git-diff │ ⎿ git-diff: Changes pane opened. │ │ ± 2 files +14 −3 ◼◼◼◼◼ −  feat/auth-refresh ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
± 2 files +14 −3 ◼◼◼◼◼ −  feat/auth-refresh ⟨Claude Code's own drawing⟩
Pane · Changes
2 files +14 −3 [ View: List (t) ] +11 −3 ◼◼◼◼◼ src/auth.ts +3 −0 ◼◼◼◼◼ src/auth.test.ts
README

git-diff

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 runs git commands in your project.

The band

Shown above the prompt whenever the working tree has changes:

± 3 files  +120 −45  ◼◼◼◼◼  +
  • Files changed, lines added (green) and removed (red).
  • Five squares split by the share of added lines, like GitHub's diff stat.
  • The current branch (a Nerd Font branch icon, U+E725, then the name, or the short hash on a detached HEAD) sits at the far right of the row.
  • The button after the squares shows + 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.

The pane

Open it with the + button on the band or with /git-diff.

  • List — every changed file with its full path, biggest change first.
  • Tree — folders first, then files. Each folder shows its rolled-up totals, folders with a single child folder merge into one row (src/a/b/), and a dim │ guide marks each nesting level.
  • Each row has its own +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.

What counts as a change

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.

Install

/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

Develop

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.

Limits

  • Only one hook can draw the band. This mod asks the hooks beneath it for their tree and stacks its own row on top, so it sits alongside other band mods that do the same. A mod that draws without asking replaces it.
  • Only the first 100 untracked files are counted.
  • The pane is a snapshot taken when it opens; it does not update while open.
  • A hot reload of the mod closes an open pane.
  • The terminal draws a button under the pointer as an inverted block. That comes from the engine and cannot be styled.
Source 2 files
hooks/register.tsx 347 lines
1import 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}
347
types/index.d.ts 10 lines
1export 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