A pane showing git diff --stat and the untracked file count, refreshed after each turn

A pane showing git diff --stat and the number of untracked files for the session folder, so you can see what changed without leaving Claude Code.
git diff --stat and git status --porcelain in the session folder when the pane is opened or refreshed, and again after each main-conversation turn (subagent turns are ignored).3 untracked files).No changes to tracked files. when the diff is empty, not a git repository when git fails, git unavailable when it cannot run.| Command | Effect |
|---|---|
/diff-preview | Refresh and open the pane |
/diff-preview refresh | Same, reported as "refreshed" |
/diff-preview close (or off) | Close the pane |
claude --plugin-dir /path/to/ModsTools/mods/diff-preview
/compact.git diff --stat covers unstaged changes to tracked files only; staged changes are not shown.claude plugin validate mods/diff-preview
claude plugin test mods/diff-preview # 7 testshooks/register.tsx 73 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DiffSnapshot } from '../types'
5import { NOT_A_REPO, failed, snapshot, untrackedLabel } from './diff'
6
7const PANE = 'diff-preview'
8const TITLE = 'Diff'
9const empty: DiffSnapshot = { error: null, stat: [], untracked: 0 }
10const current = atom({ plugin: 'diff-preview', key: 'snapshot' } as const, empty)
11
12async function refresh($: EngineInterface) {
13 const cwd = await $.session.cwd()
14 let next: DiffSnapshot
15 try {
16 const stat = await $.process.run(['git', 'diff', '--stat'], { cwd, timeoutMs: 10_000 })
17 const porcelain = await $.process.run(['git', 'status', '--porcelain'], { cwd, timeoutMs: 10_000 })
18 next = stat.exitCode !== 0 || porcelain.exitCode !== 0 ? failed(NOT_A_REPO) : snapshot(stat.stdout, porcelain.stdout)
19 } catch {
20 next = failed('git unavailable')
21 }
22 await update($, current, () => next)
23}
24
25export const register: Register = on => {
26 on('session.start', async ($, e, next) => {
27 await $.command.register({
28 name: 'diff-preview',
29 description: 'Show git diff --stat in a pane (open | close | refresh)',
30 })
31
32 return next(e)
33 })
34
35 on('command.run', { command: 'diff-preview' }, async ($, e) => {
36 const arg = e.args.trim()
37 if (arg === 'close' || arg === 'off') {
38 await $.ui.close({ id: PANE })
39 return { text: 'Diff pane closed.' }
40 }
41 await refresh($)
42 await $.ui.open({ id: PANE, title: TITLE })
43 const snap: DiffSnapshot = await read($, current)
44
45 return { text: snap.error ?? `Diff pane ${arg === 'refresh' ? 'refreshed' : 'opened'} (${untrackedLabel(snap.untracked)}).` }
46 })
47
48 on('turn.complete', async ($, e, next) => {
49 const done = await next(e)
50 if (e.agentId === undefined) await refresh($)
51
52 return done
53 })
54
55 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
56 const { Box, Text } = $.ui.resolve(e)
57 const snap: DiffSnapshot = await read($, current)
58 const room = Math.max(1, (e.viewport?.rows ?? 24) - 4)
59
60 if (snap.error !== null) return <Text dimColor>{snap.error}</Text>
61
62 return (
63 <Box flexDirection="column">
64 {snap.stat.length === 0 && <Text dimColor>No changes to tracked files.</Text>}
65 {snap.stat.slice(0, room).map(line => (
66 <Text>{line.trim()}</Text>
67 ))}
68 <Text bold>{untrackedLabel(snap.untracked)}</Text>
69 </Box>
70 )
71 })
72}
73hooks/diff.ts 17 lines1import type { DiffSnapshot } from '../types'
2
3export const NOT_A_REPO = 'not a git repository'
4
5const lines = (text: string) => text.split('\n').filter(line => line.trim() !== '')
6
7// `git diff --stat` output plus the `??` lines of `git status --porcelain`.
8export const snapshot = (stat: string, porcelain: string): DiffSnapshot => ({
9 error: null,
10 stat: lines(stat),
11 untracked: lines(porcelain).filter(line => line.startsWith('??')).length,
12})
13
14export const failed = (error: string): DiffSnapshot => ({ error, stat: [], untracked: 0 })
15
16export const untrackedLabel = (count: number) => `${count} untracked file${count === 1 ? '' : 's'}`
17types/index.d.ts 8 lines1export type DiffSnapshot = { error: string | null; stat: string[]; untracked: number }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'diff-preview': { snapshot: DiffSnapshot }
6 }
7}
8