A live branch pane and status entry: ahead/behind, dirty files, migrations added on the branch.

Personal Claude Code mods, published as one local-style marketplace named heitor-mods.
Requires a Claude Code build with mod (function hooks) support.
1. Add the marketplace. The repository is private, so gh auth login (or any git credential that can read it) must work first.
claude plugin marketplace add fhva29/claude-code-mods
Or clone it and add the folder; Claude Code then reads the mods straight from it, so an edit plus /reload-plugins is enough, with no reinstall:
git clone https://github.com/fhva29/claude-code-mods ~/.claude/mods
claude plugin marketplace add ~/.claude/mods
2. Install the mods you want (user scope):
claude plugin install next-lite@heitor-mods
claude plugin install tidy-tools@heitor-mods
# ...or all of them:
for m in blast-radius commit-gate model-stack next-lite pixel-quest prompt-git \
replay-theater tidy-tools usage-weather brief-mode; do
claude plugin install "$m@heitor-mods"
done
3. Reload inside Claude Code with /reload-plugins.
Options (userConfig) are set in /plugin → pick the mod → configure, then /reload-plugins. They are stored in ~/.claude/settings.json under pluginConfigs.
| Mod | What it does | Command | |
|---|---|---|---|
next-lite | Suggests up to three next prompts above the input (press 1/2/3 to fill the prompt box, 0 to dismiss; the top one is also Tab ghost text). Sends a ~1–2k token digest of the turn to a small model instead of forking the whole session, so its cost does not grow with the conversation. Replacement for next-steps. Options: mode (auto/manual), model, minAnswerChars, suggestSkills. | /next | |
model-stack | Routes the model and effort of the main chat turn and of each subagent with TypeSafe's Jev, and shows the roster, decisions and agent graph in a pane. Needs TYPESAFE_API_KEY in the environment. | /stack | |
tidy-tools | Draws each tool call as one compact line, or folds a whole turn into one "N steps this turn" line. | /lines | |
blast-radius | Holds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would change, with Proceed and Cancel buttons. | ||
commit-gate | Holds git commit in a pane listing the staged files, flags files not edited this session and blocks AI attribution trailers. | ||
replay-theater | Steps through the file edits Claude made in the last turn, one diff at a time. | /replay | |
usage-weather | A forecast band above the prompt: context fill, sparkline, rate limits, model and effort. | ||
prompt-git | Appends the git branch and working-tree counts to the hint line under the prompt. | ||
pixel-quest | Replaces the spinner with a pixel RPG scene for each tool, with XP, levels and achievements. No model calls. | /toons | |
brief-mode | Appends a short-answer style section to the system prompt: answer or next action first, numbered steps, lists capped at five, no preamble, recap or closers. Edit hooks/rules.ts to tune it. | ||
midnight-neon | Draws assistant replies with a dark neon palette: filled h1 banner, colored h2–h6, colored bullets, quote bars, highlighted bold, italic, inline code and links. Fences and tables keep the engine's renderer. Not installed by default; /neon flips it per session, `claude plugin enable\ | disable` flips it for good. | /neon |
_archive holds mods that are kept but not in use (branch-hud); they are not listed in the marketplace.
Each mod is a folder with .claude-plugin/plugin.json, hooks/hooks.json and a hooks module (register.ts or register.tsx). Check one with:
claude plugin validate <mod>
claude plugin test <mod>
claude plugin test does not type-check; run tsc against the SDK declarations Claude Code writes to <mod>/.claude-plugin/types/ as well.
next-lite builds on next-steps (MIT, Thariq Shihipar): it reuses the text sanitizer and the reply parsing, and replaces the session fork with a small digest.
hooks/register.tsx 243 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BranchHudChange, BranchHudMigration, BranchHudSnapshot } from '../types'
5import { parseChanges, parseCommits, parseMigrations } from './parse'
6
7const PANE = 'branch-hud'
8const REFRESH_MS = 60_000
9const BASES = ['main', 'origin/main', 'master', 'origin/master']
10
11const snapshot = atom({ plugin: 'branch-hud', key: 'snapshot' } as const, null)
12
13async function git($: EngineInterface, args: string[]): Promise<{ ok: boolean; out: string }> {
14 const { exitCode, stdout } = await $.process.run(['git', ...args])
15 return { ok: exitCode === 0, out: stdout.trim() }
16}
17
18async function takeSnapshot($: EngineInterface): Promise<BranchHudSnapshot | null> {
19 const branch = await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])
20 if (!branch.ok) return null
21
22 let base = ''
23 for (const candidate of BASES) {
24 if ((await git($, ['rev-parse', '--verify', '--quiet', candidate])).ok) {
25 base = candidate
26 break
27 }
28 }
29
30 let ahead = 0
31 let behind = 0
32 let committed: BranchHudMigration[] = []
33 if (base !== '' && base !== branch.out) {
34 const counts = await git($, ['rev-list', '--left-right', '--count', `${base}...HEAD`])
35 const [left = '0', right = '0'] = counts.out.split(/\s+/)
36 behind = Number(left)
37 ahead = Number(right)
38 const added = await git($, ['diff', '--name-only', '--diff-filter=A', `${base}...HEAD`])
39 committed = parseMigrations(added.out, true)
40 }
41
42 const status = await git($, ['status', '--porcelain', '--untracked-files=all'])
43 const changes = parseChanges(status.out)
44 const pending = parseMigrations(
45 changes.filter(change => change.status === '??' || change.status === 'A').map(c => c.path).join('\n'),
46 false,
47 )
48 const log = await git($, ['log', '-6', '--format=%h%x09%s'])
49
50 return {
51 branch: branch.out,
52 base,
53 ahead,
54 behind,
55 changes,
56 migrations: [...committed, ...pending],
57 commits: parseCommits(log.out),
58 }
59}
60
61function statusLine(snap: BranchHudSnapshot): string {
62 const parts = [`⎇ ${snap.branch}`]
63 if (snap.base !== '') parts.push(`↑${snap.ahead} ↓${snap.behind}`)
64 if (snap.changes.length > 0) parts.push(`●${snap.changes.length}`)
65 if (snap.migrations.length > 0) parts.push(`⛁${snap.migrations.length}`)
66 return parts.join(' ')
67}
68
69function migrationKey(m: BranchHudMigration): string {
70 return `${m.app}/${m.name}`
71}
72
73async function refresh($: EngineInterface): Promise<void> {
74 const next = await takeSnapshot($)
75 if (next === null) return
76 const previous = await read($, snapshot)
77 await update($, snapshot, () => next)
78 $.ui.status(statusLine(next))
79
80 if (previous !== null) {
81 const known = new Set(previous.migrations.map(migrationKey))
82 const fresh = next.migrations.filter(m => !known.has(migrationKey(m)))
83 if (fresh.length > 0) {
84 $.ui.toast(`⛁ New migration: ${fresh.map(migrationKey).join(', ')}`)
85 }
86 }
87}
88
89function changeColor(change: BranchHudChange): string {
90 if (change.status === '??') return 'subtle'
91 if (change.status.includes('D')) return 'diffRemoved'
92 if (change.status.includes('A')) return 'diffAdded'
93 return 'warning'
94}
95
96// Splits `width` cells between behind (left) and ahead (right), each side at least one
97// cell when it is non-zero.
98export function divergence(behind: number, ahead: number, width: number): [number, number] {
99 const total = behind + ahead
100 if (total === 0) return [0, 0]
101 let left = Math.round((behind / total) * width)
102 if (behind > 0) left = Math.max(1, left)
103 if (ahead > 0) left = Math.min(width - 1, left)
104 return [left, width - left]
105}
106
107function groupByApp(migrations: readonly BranchHudMigration[]): Map<string, BranchHudMigration[]> {
108 const groups = new Map<string, BranchHudMigration[]>()
109 for (const m of migrations) {
110 groups.set(m.app, [...(groups.get(m.app) ?? []), m])
111 }
112 return groups
113}
114
115export const register: Register = on => {
116 on('session.start', async ($, e, next) => {
117 const result = await next(e)
118 await $.command.register({ name: 'branch', description: 'Open the branch HUD pane' })
119 await refresh($)
120 $.clock.every(REFRESH_MS, () => void refresh($))
121 void $.ui.open({ id: PANE, title: 'Branch' })
122 return result
123 })
124
125 on('command.run', { command: 'branch' }, async $ => {
126 await refresh($)
127 await $.ui.open({ id: PANE, title: 'Branch' })
128 return { text: 'Branch HUD opened.' }
129 })
130
131 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
132 const result = await next(e)
133 if (/\bgit\b|makemigrations/.test(e.command)) {
134 await refresh($)
135 }
136 return result
137 })
138
139 on('turn.complete', async ($, e, next) => {
140 const result = await next(e)
141 if (e.agentId === undefined) {
142 await refresh($)
143 }
144 return result
145 })
146
147 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
148 const { Box, Text } = $.ui.resolve(e)
149 const snap = await read($, snapshot)
150 if (snap === null) {
151 return <Text dimColor>Not a git repository.</Text>
152 }
153
154 const columns = e.props.bodyColumns
155 const [left, right] = divergence(snap.behind, snap.ahead, Math.max(10, Math.min(40, columns - 20)))
156 const rows = Math.max(4, (e.viewport?.rows ?? 30) - 18)
157 const shownChanges = snap.changes.slice(0, Math.ceil(rows / 2))
158 const apps = groupByApp(snap.migrations)
159
160 return (
161 <Box flexDirection="column" paddingX={1}>
162 <Box>
163 <Text color="claude" bold>
164 ⎇ {snap.branch}
165 </Text>
166 {snap.base !== '' && <Text dimColor> vs {snap.base}</Text>}
167 </Box>
168
169 {snap.base !== '' && (
170 <Box marginTop={1} flexDirection="column">
171 <Box>
172 <Text color="diffRemoved" bold>
173 ↓{snap.behind}{' '}
174 </Text>
175 <Text color="diffRemoved">{'▮'.repeat(left)}</Text>
176 <Text dimColor>│</Text>
177 <Text color="diffAdded">{'▮'.repeat(right)}</Text>
178 <Text color="diffAdded" bold>
179 {' '}
180 ↑{snap.ahead}
181 </Text>
182 </Box>
183 <Text dimColor>
184 {snap.behind > 0 ? `${snap.behind} behind ${snap.base}` : `up to date with ${snap.base}`}
185 {snap.ahead > 0 ? ` · ${snap.ahead} ahead` : ''}
186 </Text>
187 </Box>
188 )}
189
190 <Box marginTop={1} flexDirection="column">
191 <Text bold>
192 {snap.changes.length === 0 ? '✓ Clean tree' : `● ${snap.changes.length} uncommitted`}
193 </Text>
194 {shownChanges.map(change => (
195 <Box>
196 <Text color={changeColor(change)} bold>
197 {change.status.padEnd(3)}
198 </Text>
199 <Text wrap="truncate-start">{change.path}</Text>
200 </Box>
201 ))}
202 {snap.changes.length > shownChanges.length && (
203 <Text dimColor> … {snap.changes.length - shownChanges.length} more</Text>
204 )}
205 </Box>
206
207 {snap.migrations.length > 0 && (
208 <Box marginTop={1} flexDirection="column">
209 <Text color="warning" bold>
210 ⛁ {snap.migrations.length} migration{snap.migrations.length === 1 ? '' : 's'} on this branch
211 </Text>
212 {[...apps.entries()].map(([app, list]) => (
213 <Box flexDirection="column">
214 <Text color="suggestion"> {app}</Text>
215 {list.map(m => (
216 <Box>
217 <Text color={m.isCommitted ? 'success' : 'warning'}>{m.isCommitted ? ' ✓ ' : ' ○ '}</Text>
218 <Text dimColor={m.isCommitted} wrap="truncate-end">
219 {m.name}
220 </Text>
221 </Box>
222 ))}
223 </Box>
224 ))}
225 </Box>
226 )}
227
228 <Box marginTop={1} flexDirection="column">
229 <Text bold>Recent commits</Text>
230 {snap.commits.map(commit => (
231 <Box>
232 <Text color="claude">{commit.sha} </Text>
233 <Text dimColor wrap="truncate-end">
234 {commit.subject}
235 </Text>
236 </Box>
237 ))}
238 </Box>
239 </Box>
240 )
241 })
242}
243hooks/parse.ts 29 lines1import type { BranchHudChange, BranchHudCommit, BranchHudMigration } from '../types'
2
3const MIGRATION = /^(?:.*\/)?([^/]+)\/migrations\/(\d{4}_[^/]+)\.py$/
4
5export function parseChanges(porcelain: string): BranchHudChange[] {
6 return porcelain
7 .split('\n')
8 .filter(line => line.length > 3)
9 .map(line => ({ status: line.slice(0, 2).trim() || '?', path: line.slice(3) }))
10}
11
12export function parseMigrations(paths: string, isCommitted: boolean): BranchHudMigration[] {
13 return paths
14 .split('\n')
15 .map(path => MIGRATION.exec(path.trim()))
16 .filter(match => match !== null)
17 .map(match => ({ app: match[1] ?? '', name: match[2] ?? '', isCommitted }))
18}
19
20export function parseCommits(log: string): BranchHudCommit[] {
21 return log
22 .split('\n')
23 .filter(line => line.includes('\t'))
24 .map(line => {
25 const [sha = '', ...subject] = line.split('\t')
26 return { sha, subject: subject.join('\t') }
27 })
28}
29types/index.d.ts 22 lines1export type BranchHudChange = { status: string; path: string }
2
3export type BranchHudMigration = { app: string; name: string; isCommitted: boolean }
4
5export type BranchHudCommit = { sha: string; subject: string }
6
7export type BranchHudSnapshot = {
8 branch: string
9 base: string
10 ahead: number
11 behind: number
12 changes: BranchHudChange[]
13 migrations: BranchHudMigration[]
14 commits: BranchHudCommit[]
15}
16
17declare module 'claude-code' {
18 interface PluginState {
19 'branch-hud': { snapshot: BranchHudSnapshot | null }
20 }
21}
22