Shows what is staged for the next commit (git diff --cached, stat and diff) in a pane, with a Refresh button. Read-only.

Install: /plugin install staged-diff --marketplace samuelbfernandes/moddeck
Shows what is staged for your next commit, in a pane beside the chat, before you commit.
/staged-diff runs git diff --cached --stat and then git diff --cached (2 lines of context, no external diff tool, no text conversion, no colour) and shows both in a pane, with a Refresh button (hotkey r)./staged-diff <path> limits both to that file or folder (passed to git after --).git and nothing else, only when you run the command or press Refresh. It hooks no tool call, so it never slows down or blocks Claude.1 file changed, 2 insertions(+)) is also what the model sees./staged-diff the whole index /staged-diff src/model.R one file or folder
A path that starts with - or holds control characters is refused, so it can never be read as a git option.
Close the pane, or /plugin disable staged-diff. Nothing runs until you call it.
{ plugin: 'staged-diff', key: 'report' }: the last reading (path, stat, diff, isEmpty, isCut, error, at), or null before the first one. Nothing is kept in $.store.
claude plugin test), on the terminal and desktop element tables; not yet checked by hand in a live session.git diff --cached in a shell for the rest.Adapted from staged-diff in awesome-claude-code-mods (MIT, © 2026 Yash Thakker and contributors). Rewritten for moddeck: TypeScript in the house style, the reading kept in $.state instead of module variables, the path checked and passed after -- to both git calls, "Nothing is staged." and git errors shown plainly, output cut at a line end with a note, and tests for each case. The upstream licence is in LICENSE.
hooks/register.tsx 121 lines1// staged-diff: `/staged-diff [path]` shows what would go into the next commit (git diff --cached, the stat and the
2// diff) in a pane, with a Refresh button. Read-only: it starts `git` (and nothing else) only when asked.
3// FAIL OPEN: it hooks no tool call and refuses nothing; a git error is shown in the pane, never thrown.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { StagedReviewReport } from '../types'
8import { LIMIT, STAT_LIMIT, bound, checkPath, clean, gitArgs, summaryOf } from './lib'
9
10type Dollar = EngineInterface
11
12const COMMAND = 'staged-diff'
13const PANE = 'staged-diff'
14const TITLE = 'Staged'
15const TIMEOUT_MS = 15000
16
17const report = atom({ plugin: 'staged-diff', key: 'report' } as const, null)
18
19/** Runs one git command; any failure comes back as text, never as a throw. */
20async function git($: Dollar, argv: string[]): Promise<{ out: string; err: string; isTruncated: boolean }> {
21 try {
22 const ran = await $.process.run(argv, { timeoutMs: TIMEOUT_MS })
23 if (ran.exitCode !== 0) return { out: '', err: clean(ran.stderr || `git exited with ${ran.exitCode}`).trim(), isTruncated: false }
24
25 return { out: clean(ran.stdout), err: '', isTruncated: ran.isStdoutTruncated === true }
26 } catch (error) {
27 return { out: '', err: `could not start git (${error instanceof Error ? error.message : 'unknown error'})`, isTruncated: false }
28 }
29}
30
31/** Reads the index through git and keeps a bounded copy in `$.state`. */
32async function load($: Dollar, path: string): Promise<StagedReviewReport> {
33 const argv = gitArgs(path)
34 const at = await $.clock.now()
35 const stat = await git($, argv.stat)
36 let next: StagedReviewReport
37 if (stat.err !== '') {
38 next = { path, stat: '', diff: '', isEmpty: false, isCut: false, error: bound(stat.err, 600).text, at }
39 } else if (stat.out.trim() === '') {
40 next = { path, stat: '', diff: '', isEmpty: true, isCut: false, error: '', at }
41 } else {
42 const diff = await git($, argv.diff)
43 const shownStat = bound(stat.out.trimEnd(), STAT_LIMIT)
44 const shownDiff = bound(diff.err !== '' ? `git diff failed: ${diff.err}` : diff.out.trimEnd(), LIMIT - shownStat.text.length)
45 const isCut = shownStat.isCut || shownDiff.isCut || stat.isTruncated || diff.isTruncated
46 next = { path, stat: shownStat.text, diff: shownDiff.text, isEmpty: false, isCut, error: '', at }
47 }
48 await update($, report, () => next)
49
50 return next
51}
52
53function headline(r: StagedReviewReport): string {
54 const where = r.path === '' ? '' : ` (${r.path})`
55 if (r.error !== '') return `staged-diff · git could not be read${where}\n${r.error.split('\n')[0]}`
56 if (r.isEmpty) return `staged-diff · Nothing is staged${where}.`
57
58 return `staged-diff${where} · ${summaryOf(r.stat)}`
59}
60
61async function run($: Dollar, args: string): Promise<string> {
62 const checked = checkPath(args)
63 if ('error' in checked) return `staged-diff · ${checked.error}\nUse /staged-diff or /staged-diff <path>.`
64 const r = await load($, checked.path)
65 const opened = await $.ui.open({ id: PANE, title: TITLE, columns: 90 })
66 const surfaces = await $.session.surfaces().catch(() => ['terminal'])
67 if (!opened.isPlaced || surfaces.length === 0) {
68 // Nothing will draw the pane now: say the short version here.
69 return [headline(r), r.stat].filter(Boolean).join('\n')
70 }
71
72 return headline(r)
73}
74
75export const register: Register = on => {
76 on('session.start', async ($, e, next) => {
77 try {
78 await $.command.register({ name: COMMAND, description: 'Show the staged diff (what the next commit would hold) in a pane', argumentHint: '[path]' })
79 } catch {
80 // fail-open: the session goes on without the command
81 }
82
83 return next(e)
84 })
85
86 on('command.run', { command: COMMAND }, async ($, e) => ({ text: await run($, e.args) })).catch(() => ({
87 text: 'staged-diff · something went wrong, nothing was changed',
88 }))
89
90 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
91 const { Box, Button, Code, Text } = $.ui.resolve(e)
92 const r = await read($, report)
93 const refresh = (
94 <Button key="refresh" label="Refresh" hotkey="r" onPress={async () => void (await load($, r?.path ?? ''))} />
95 )
96 if (r === null) {
97 return (
98 <Box flexDirection="column">
99 <Text dimColor>Nothing read yet.</Text>
100 {refresh}
101 </Box>
102 )
103 }
104 const where = r.path === '' ? 'the whole index' : r.path
105
106 return (
107 <Box flexDirection="column">
108 <Box>
109 <Text bold>Staged · {where} </Text>
110 {refresh}
111 </Box>
112 {r.error !== '' && <Text color="yellow">git could not be read: {r.error}</Text>}
113 {r.isEmpty && <Text>Nothing is staged.</Text>}
114 {r.stat !== '' && <Text>{r.stat}</Text>}
115 {r.diff !== '' && <Code source={r.diff} language="diff" />}
116 {r.isCut && <Text dimColor>Cut to {LIMIT} characters. Run git diff --cached yourself for the rest.</Text>}
117 </Box>
118 )
119 })
120}
121hooks/lib.ts 49 lines1// staged-diff: pure helpers (no engine access), so the tests can call them directly.
2
3/** The most characters the pane shows of git's output, stat and diff together. */
4export const LIMIT = 9000
5/** The share of LIMIT the --stat summary may take; the diff gets the rest. */
6export const STAT_LIMIT = 2000
7
8/** Removes control characters a pane cannot draw (keeps newline and tab). */
9export function clean(text: string): string {
10 return text.replace(/\r\n/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
11}
12
13/** Cuts text to `limit` characters at a line end where it can. */
14export function bound(text: string, limit: number): { text: string; isCut: boolean } {
15 if (text.length <= limit) return { text, isCut: false }
16 const head = text.slice(0, limit)
17 const lineEnd = head.lastIndexOf('\n')
18
19 return { text: lineEnd > limit / 2 ? head.slice(0, lineEnd) : head, isCut: true }
20}
21
22/** The path the person typed, or why it is refused. A path is optional ('' means the whole index). */
23export function checkPath(raw: string): { path: string } | { error: string } {
24 const path = raw.trim()
25 if (path === '') return { path: '' }
26 if (path.startsWith('-')) return { error: 'a path may not start with “-” (it would be read as an option)' }
27 if (/[\u0000-\u001f\u007f]/.test(path)) return { error: 'a path may not hold control characters' }
28 if (path.length > 1000) return { error: 'that path is too long' }
29
30 return { path }
31}
32
33/** The argument vectors for `git diff --cached`, the stat first. `--` keeps a path from being read as an option. */
34export function gitArgs(path: string): { stat: string[]; diff: string[] } {
35 const tail = path === '' ? [] : ['--', path]
36
37 return {
38 stat: ['git', '--no-pager', 'diff', '--cached', '--no-color', '--stat', ...tail],
39 diff: ['git', '--no-pager', 'diff', '--cached', '--no-color', '--no-ext-diff', '--no-textconv', '--unified=2', ...tail],
40 }
41}
42
43/** The last line of `git diff --stat` (" 2 files changed, 5 insertions(+)"), or ''. */
44export function summaryOf(stat: string): string {
45 const lines = stat.trim().split('\n')
46
47 return (lines[lines.length - 1] ?? '').trim()
48}
49types/index.d.ts 27 lines1/** What the last `/staged-diff` (or Refresh) read from git, already bounded for drawing. */
2export type StagedReviewReport = {
3 /** The path the person named, '' for the whole index. */
4 path: string
5 /** `git diff --cached --stat` output, bounded. */
6 stat: string
7 /** `git diff --cached` output, bounded. */
8 diff: string
9 /** True when nothing is staged (for this path). */
10 isEmpty: boolean
11 /** True when the stat or the diff was cut to fit the pane. */
12 isCut: boolean
13 /** Why git could not be read ('' when it could): not a repository, git missing, a timeout. */
14 error: string
15 /** When it was read, ms since the epoch. */
16 at: number
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'staged-diff': {
22 /** The last reading, or null before the first `/staged-diff`. */
23 report: StagedReviewReport | null
24 }
25 }
26}
27