SLOPSHOPPER

git-changes

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

newpaneguardcommandprocesstimer
★ 1v0.1.2MITupdated 2026-10-11matr1xp/local-mods/git-changes
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-changes
│ ┃ Changes ✕ › fix the failing auth test and add an audit log call │ ┃ on feat/auth-refresh │ ┃ 1 file changed +11 -3 ⏺ Read(src/auth.ts) │ ┃ ──────────────────────────────────────────── ⎿ Read 6 lines │ ┃ ──────────── ⏺ Update(src/auth.ts) │ ┃ M src/auth.ts 3 0 src/auth.test.ts + ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ updated 08:53:20 [ Refresh ] ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /changes │ ⎿ git-changes: Changes pane opened: 1 file changed, +11 -3. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Changes
on feat/auth-refresh 1 file changed +11 -3 ──────────────────────────────────────────────────────── M src/auth.ts 3 0 src/auth.test.ts +11 -3 ██████ updated 08:53:20 [ Refresh ]
README

local-mods

A personal Claude Code plugin marketplace of terminal UI mods: a status band above the prompt and two side panes.

PluginWhat it addsCommand
prompt-statusA two-line band above the prompt: model and context window, current directory, git branch, context fill and rate-limit usage—
token-usageA pane with context fill, session token totals, cost and rate limits/token-usage
git-changesA pane listing the files changed in the working tree, with colored git diff stats/changes

Install

/plugin marketplace add matr1xp/local-mods
/plugin install prompt-status@local-mods
/plugin install token-usage@local-mods
/plugin install git-changes@local-mods

Plugins

prompt-status

Renders above the prompt in every session:

The prompt-status band: model and context window, directory and git branch on the first line; context fill and five-hour usage bars with the reset time on the second

  • The model name comes from the latest main-agent request, falling back to the model setting.
  • Usage shows the five-hour rate limit when it is reported, otherwise the fullest limit.
  • The git branch and dirty marker (*) refresh after each turn and once a minute. A detached HEAD shows its short commit hash.
  • The band hides itself while a survey is showing.

token-usage

Opens a pane at session start. Run /token-usage to reopen it. The pane shows:

The token-usage pane: a context fill bar at 11% of a 1M window, session input, output and cache totals with the request count and cost, and the model and token counts of the last request

  • Context: fill bar, percentage and tokens used out of the window.
  • Session: input, output, cache read and cache write tokens, the request count with subagent requests counted separately, and cost.
  • Last request: model and token counts for the most recent request.
  • Rate limits: the 5h, 7d and spend limits with reset times. Each bar turns yellow at 60% and red at 85%.

Running /clear resets the session totals.

git-changes

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:

The git-changes pane on branch master: 3 files changed, +3 -4; a modified README.md marked as just changed with its diff counts and stat bar, and two untracked PNG files shown as bin

  • A status letter for each file (M, A, D, R, ??, …), +added / -deleted counts and a git --stat-style bar. Binary files show bin.
  • A ● marker on files whose stats changed since the previous scan.
  • The pane rescans after Edit, Write, NotebookEdit and Bash tool calls, and every 4 seconds while it is on screen. Press r to refresh by hand.
  • Git runs with GIT_OPTIONAL_LOCKS=0, so polling never holds index.lock while you run your own git commands.
  • Only the first 40 untracked files get line counts. The rest show as new.

Layout

.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

Development

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.

License

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.

Source 2 files
hooks/register.tsx 354 lines
1import { 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}
354
types/index.d.ts 32 lines
1/**
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