SLOPSHOPPER

diff-preview

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

newpanecommandprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · diff-preview
│ ┃ Diff ✕ › fix the failing auth test and add an audit log call │ ┃ src/auth.ts | 14 +++++++++--- │ ┃ src/auth.test.ts | 3 +++ ⏺ Read(src/auth.ts) │ ┃ 2 files changed, 14 insertions(+), 3 ⎿ Read 6 lines │ ┃ deletions(-) ⏺ Update(src/auth.ts) │ ┃ 1 untracked file ⎿ 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 │ │ › /diff-preview │ ⎿ diff-preview: Diff pane opened (1 untracked file). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Diff
src/auth.ts | 14 +++++++++--- src/auth.test.ts | 3 +++ 2 files changed, 14 insertions(+), 3 deletions(-) 1 untracked file
README

diff-preview

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.

What it does

  • Runs 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).
  • Shows the stat lines (as many as fit the pane), then the untracked-file count in bold (3 untracked files).
  • Shows No changes to tracked files. when the diff is empty, not a git repository when git fails, git unavailable when it cannot run.

Commands

CommandEffect
/diff-previewRefresh and open the pane
/diff-preview refreshSame, reported as "refreshed"
/diff-preview close (or off)Close the pane

Install

claude --plugin-dir /path/to/ModsTools/mods/diff-preview

Limits

  • Refreshes at the end of a turn (and on the command), not after a manual edit or /compact.
  • git diff --stat covers unstaged changes to tracked files only; staged changes are not shown.
  • Untracked files are counted, not listed.

Develop

claude plugin validate mods/diff-preview
claude plugin test mods/diff-preview   # 7 tests
Source 3 files
hooks/register.tsx 73 lines
1import { 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}
73
hooks/diff.ts 17 lines
1import 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'}`
17
types/index.d.ts 8 lines
1export 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