A git row above the prompt: branch and base, ahead and behind, files changed with lines added and removed, the branch's PR, and a warning when you work on main

Claude Code interface mods, collected in a private pompeitech marketplace. Just as frescoes decorated the walls of Pompeii's houses, these mods decorate Claude Code's interface.
affreschi/
├── .claude-plugin/marketplace.json # the list of mods
└── plugins/
└── <mod-name>/ # one folder (one plugin) per mod
├── .claude-plugin/plugin.json
├── hooks/
│ ├── hooks.json
│ ├── palette.ts # Vesuvius colors
│ └── register.tsx
├── types/index.d.ts # the mod's $.state contract, when it has one
└── tests/
plugins/<mod-name>/ with its files.plugins array in .claude-plugin/marketplace.json: { "name": "<mod-name>", "source": "./plugins/<mod-name>", "description": "..." }
claude plugin validate ./plugins/<mod-name>Load a mod for the current session only (it reloads when you save):
claude --plugin-dir ./plugins/<mod-name>
/plugin marketplace add pompeitech/affreschi
/plugin install <mod-name>@affreschi
| Mod | What it does | Where it shows |
|---|---|---|
vesuvius-theme | A spinner with a glowing crater and volcanic words, a stripe of color beside every tool call (magma while it runs, lava when it fails, ash once done), and the end-of-turn line as an eruption (▲ Erupted for 1m 4s) | Terminal; on the desktop, only the tool call rows |
agent-usage | /agent-usage opens a live pane (it polls every 2s while an agent runs) where every agent plays a character cast from its type or name: Pulcinella for the main conversation, the artist (beret and brush) for frontend and UI work, the nerd (glasses) for planners and architects, the explorer (head lamp), the reviewer (monocle), the guard (helmet) for security, the tester (lab mask) and the handyman (flat cap) for the rest; their eyes follow the status. In scena holds the main conversation, the agents at work and the ones that did something this turn; at the next prompt the finished ones move to the archivio, one row per role with count, tokens and failures (▸ opens it, svuota archivio clears it and keeps its tokens in the totals). Press a name for model, input/output/cache tokens, requests, tool calls and run time. Teammates and agents started before the mod loaded are listed too. The status line shows the subagents running, and a toast says what each one consumed when it finishes | Terminal and desktop |
pulcinella | The band above the prompt: Pulcinella in true pixel art: an SVG of 14×16 pixels in the desktop app, a compact 10×10 drawn two pixels to a character with half blocks (five lines) in the terminal (floppy hat, black half mask, pink cheeks, white blouse), animated two frames per mood (the hat sways and a hammer swings while a tool runs, he blinks at rest, a little z floats while he naps), beside the context window, read again after every model request: the tokens used, the headroom before compaction (vineyard green, then magma and lava past 60% and 85% of the threshold), an estimate of the turns left at the recent pace, the window split by category in one bar, and the largest categories as chips (hide/legend toggles them). details or /context-map opens a pane with every category's tokens and share, the auto-compact threshold, and what fills the window: memory files, MCP servers, custom agents, skills He frets when a command fails, cheers green tests, gasps at changes of 300+ lines, naps after ten quiet minutes, and his line in Neapolitan sits under the bar. His hat grows a pixel per level with the tokens you spend (1M, 10M, 50M, 200M), kept across sessions | Terminal and desktop |
maschere | /maschera opens a picker (or /maschera arlecchino puts one on at once) and Claude answers in that mask's voice: Pulcinella warm and ironic with a Neapolitan word now and then, Arlecchino as terse as it gets, Balanzone thorough with the reasons and trade-offs. Only the tone changes: the mask's section goes last in the system prompt and says every other instruction wins. The mask worn shows in the status line and is remembered for the next session | Terminal and desktop |
spaccanapoli | A row above the prompt with the git branch and its base, commits ahead and behind, files changed with lines added and removed (and a bar of the two), and the branch's PR from gh (open, draft, mergeable, conflicts). Changes made right on the base branch turn it into a red warning. Read again every 10s, after every tool that can change files and at the end of a turn; outside a repository it stays away. Next to pulcinella each keeps the other's band | Terminal and desktop |
golfo | /golfo opens Il Golfo: the last seven days as bars of cost and tokens, today and the priciest day picked out, and the week's cost by project, recorded across sessions (90 days kept). With a weeklyBudget set in the plugin's options, a bar shows how much of it is spent, and a toast warns at 80% and 100% | Terminal and desktop |
lazzarone | When Claude has been waiting on you for minutes (3 by default, in the plugin's options): a permission, a question, a plan to approve, or a turn of two minutes or more just finished, a system notification says what it waits for. One call per wait; answering or writing a prompt clears them | Terminal and desktop |
chiave | Counts, per project, the calls you approve by hand, as the rule that would allow them (Bash(npm run lint:*), WebFetch(domain:docs.github.com), an MCP tool), with a line under the permission dialog that keeps the tally. Past threshold approvals (3 by default) a toast says a rule is ready, and /chiave offers it: Consenti nel progetto writes it into .claude/settings.json, Solo per me into .claude/settings.local.json, Ignora never offers it again, togli takes an added rule back. Risky commands (rm, sudo, git push, curl, mv…) and chained or piped ones are never offered; a settings file that is not plain JSON is left alone | Terminal and desktop |
The colors live in each mod's hooks/palette.ts (a plugin cannot import another plugin's files, so each keeps a copy). They are placeholders, to be replaced with the Vesuvius design system's.
claude plugin validate ./plugins/<mod-name>
claude plugin test ./plugins/<mod-name>
npx -y @biomejs/biome@2.2.4 check plugins
Code follows the officina standards: Biome in StandardJS style (biome.json), braced blocks set off by blank lines, no as casts beyond as const (values the engine hands over as unknown are narrowed by checks), and failures logged to the debug log with $.ui.log(..., { to: 'debug' }), never swallowed.
hooks/register.tsx 215 lines1import type { EngineInterface, Register, RenderElement } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { PullRequest, Repo } from '../types'
5import { isOnBase, parseBase, parseNumstat, parsePr, parseStatus } from './git'
6import { VESUVIUS } from './palette'
7
8// Git is read again at most this often, on a timer and after every tool that can change files.
9const REFRESH_MS = 3000
10const POLL_MS = 10_000
11// gh asks GitHub, so the PR is asked for less often.
12const PR_EVERY_MS = 60_000
13const BADGE_TEXT = '#1A1A1A'
14const FILE_TOOLS = new Set(['Bash', 'Edit', 'Write', 'NotebookEdit'])
15
16const NO_REPO: Repo | null = null
17const repo = atom({ plugin: 'spaccanapoli', key: 'repo' } as const, NO_REPO)
18
19// The module's own: where the session runs and when git and gh were last asked.
20let cwd = ''
21let lastReadAt = Number.NEGATIVE_INFINITY
22let lastPrAt = Number.NEGATIVE_INFINITY
23let pr: PullRequest | null = null
24
25function logFailure($: EngineInterface, what: string, error: unknown): void {
26 const reason = error instanceof Error ? error.message : String(error)
27 $.ui.log(`spaccanapoli: ${what} failed: ${reason}`, { to: 'debug' })
28}
29
30// One command's output, or null when it could not run or ended in error.
31async function output($: EngineInterface, argv: readonly string[]): Promise<string | null> {
32 const ran = await $.process.run(argv, { cwd }).catch(error => {
33 logFailure($, argv.slice(0, 2).join(' '), error)
34
35 return null
36 })
37
38 return ran !== null && ran.exitCode === 0 ? ran.stdout : null
39}
40
41// Reads the repository; outside one, the row goes away. Writes only what changed.
42async function refresh($: EngineInterface, { force = false } = {}): Promise<void> {
43 const now = await $.clock.now()
44
45 if (!force && now - lastReadAt < REFRESH_MS) {
46 return
47 }
48
49 lastReadAt = now
50 const status = await output($, ['git', 'status', '--porcelain=v1', '--branch'])
51 let next: Repo | null = null
52
53 if (status !== null) {
54 const { branch, ahead, behind, files } = parseStatus(status)
55 const { added, removed } = parseNumstat(
56 (await output($, ['git', 'diff', '--numstat', 'HEAD'])) ?? ''
57 )
58 const base = parseBase(
59 (await output($, ['git', 'rev-parse', '--abbrev-ref', 'origin/HEAD'])) ?? ''
60 )
61
62 if (force || now - lastPrAt >= PR_EVERY_MS) {
63 lastPrAt = now
64 const view = await output($, ['gh', 'pr', 'view', '--json', 'number,state,isDraft,mergeable'])
65 pr = view === null ? null : parsePr(view)
66 }
67
68 next = { branch, base, ahead, behind, files, added, removed, pr }
69 }
70
71 if (JSON.stringify(next) !== JSON.stringify(await read($, repo))) {
72 await update($, repo, () => next)
73 }
74}
75
76// What the PR part of the row says, and in which color.
77function prLabel(p: PullRequest): { text: string; color: string } {
78 if (p.state === 'MERGED') {
79 return { text: `PR #${p.number} merged`, color: VESUVIUS.vineyard }
80 }
81
82 if (p.state === 'CLOSED') {
83 return { text: `PR #${p.number} closed`, color: VESUVIUS.ash }
84 }
85
86 if (p.mergeable === 'CONFLICTING') {
87 return { text: `PR #${p.number} · conflicts`, color: VESUVIUS.lava }
88 }
89
90 return {
91 text: `PR #${p.number} ${p.isDraft ? 'draft' : 'open'}${p.mergeable === 'MERGEABLE' ? ' · mergeable' : ''}`,
92 color: VESUVIUS.gulf
93 }
94}
95
96// The band beneath, when a plugin below drew one: an empty Box or nothing means none.
97function hasContent(tree: RenderElement | null | undefined): tree is RenderElement {
98 if (tree === null || tree === undefined) {
99 return false
100 }
101
102 const children = 'children' in tree ? tree.children : undefined
103
104 return !(
105 tree.type === 'Box' &&
106 (children === undefined || (Array.isArray(children) && children.length === 0))
107 )
108}
109
110export const register: Register = on => {
111 on('session.start', async ($, e, next) => {
112 cwd = e.cwd
113 await refresh($, { force: true })
114 $.clock.every(POLL_MS, () => {
115 refresh($).catch(error => logFailure($, 'reading git', error))
116 })
117
118 return next(e)
119 })
120
121 // A tool that can change files: the row follows right after it.
122 on('tool.call', async ($, e, next) => {
123 const result = await next(e)
124
125 if (FILE_TOOLS.has(String(e.tool))) {
126 await refresh($).catch(error => logFailure($, 'reading git', error))
127 }
128
129 return result
130 })
131
132 on('turn.complete', async ($, e, next) => {
133 const result = await next(e)
134 await refresh($, { force: true }).catch(error => logFailure($, 'reading git', error))
135
136 return result
137 })
138
139 // The row, over whatever band a plugin beneath draws.
140 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
141 if (e.props.hasSurvey) {
142 return next(e)
143 }
144
145 const below = await next(e).catch(error => {
146 logFailure($, 'drawing the band beneath', error)
147
148 return null
149 })
150 const r = await read($, repo)
151 const { Box, Text } = $.ui.resolve(e)
152
153 if (r === null) {
154 return hasContent(below) ? below : <Box />
155 }
156
157 const onBase = isOnBase(r.branch, r.base)
158 const isRisky = onBase && r.files > 0
159 const branchColor = isRisky ? VESUVIUS.lava : VESUVIUS.vineyard
160 const pull = r.pr === null ? null : prLabel(r.pr)
161 const changed = r.added + r.removed
162
163 const row = (
164 <Box flexDirection="column" paddingX={1}>
165 <Box flexDirection="row" justifyContent="space-between" columnGap={2}>
166 <Text wrap="truncate-end">
167 <Text color={branchColor}>⎇</Text>{' '}
168 <Text bold color={isRisky ? VESUVIUS.lava : undefined}>
169 {r.branch}
170 </Text>
171 {r.base !== null && !onBase && <Text color={VESUVIUS.ash}> → {r.base}</Text>}{' '}
172 <Text color={r.ahead > 0 ? VESUVIUS.vineyard : VESUVIUS.ash}>↑{r.ahead}</Text>{' '}
173 <Text color={r.behind > 0 ? VESUVIUS.magma : VESUVIUS.ash}>↓{r.behind}</Text>
174 </Text>
175 <Box flexDirection="row" columnGap={2}>
176 <Text>
177 <Text color={VESUVIUS.ash}>
178 {r.files} {r.files === 1 ? 'file' : 'files'}
179 </Text>{' '}
180 <Text color={VESUVIUS.vineyard}>+{r.added}</Text>{' '}
181 <Text color={VESUVIUS.lava}>−{r.removed}</Text>
182 </Text>
183 {isRisky ? (
184 <Text backgroundColor={VESUVIUS.lava} color={BADGE_TEXT} bold>
185 {` ATTENTO: SEI SU ${r.branch.toUpperCase()} `}
186 </Text>
187 ) : (
188 pull !== null && <Text color={pull.color}>{pull.text}</Text>
189 )}
190 </Box>
191 </Box>
192 {changed > 0 && (
193 <Box flexDirection="row" height={1}>
194 <Box width={0} flexGrow={r.added} height={1} backgroundColor={VESUVIUS.vineyard}>
195 <Text> </Text>
196 </Box>
197 <Box width={0} flexGrow={r.removed} height={1} backgroundColor={VESUVIUS.lava}>
198 <Text> </Text>
199 </Box>
200 </Box>
201 )}
202 </Box>
203 )
204
205 return hasContent(below) ? (
206 <Box flexDirection="column">
207 {row}
208 {below}
209 </Box>
210 ) : (
211 row
212 )
213 })
214}
215hooks/git.ts 84 lines1import type { PullRequest } from '../types'
2
3// What git and gh print, read into the row's numbers. Each reader takes the command's output as
4// text and checks it, so an unexpected line is skipped rather than trusted.
5
6export type Status = { branch: string; ahead: number; behind: number; files: number }
7
8// `git status --porcelain=v1 --branch`: the `##` header names the branch and how far it is from
9// its upstream; every other line is a changed or untracked file.
10export function parseStatus(output: string): Status {
11 const lines = output.split('\n').filter(line => line !== '')
12 const header = lines.find(line => line.startsWith('## ')) ?? ''
13 const files = lines.filter(line => !line.startsWith('## ')).length
14 const name = header.slice(3)
15 const unborn = /^No commits yet on (.+)$/.exec(name)
16 const branch = unborn?.[1] ?? name.split('...')[0]?.split(' ')[0] ?? ''
17 const ahead = Number(/ahead (\d+)/.exec(name)?.[1] ?? 0)
18 const behind = Number(/behind (\d+)/.exec(name)?.[1] ?? 0)
19
20 return { branch: branch === 'HEAD' ? 'detached' : branch, ahead, behind, files }
21}
22
23// `git diff --numstat HEAD`: lines added and removed in every tracked file; a binary file
24// (`-` in both columns) counts as neither.
25export function parseNumstat(output: string): { added: number; removed: number } {
26 let added = 0
27 let removed = 0
28
29 for (const line of output.split('\n')) {
30 const [plus, minus] = line.split('\t')
31 const a = Number(plus)
32 const r = Number(minus)
33
34 if (Number.isInteger(a) && Number.isInteger(r)) {
35 added += a
36 removed += r
37 }
38 }
39
40 return { added, removed }
41}
42
43// `git rev-parse --abbrev-ref origin/HEAD`, as `origin/main`: the branch after the remote's name.
44export function parseBase(output: string): string | null {
45 const ref = output.trim()
46 const slash = ref.indexOf('/')
47
48 return slash > 0 && slash < ref.length - 1 ? ref.slice(slash + 1) : null
49}
50
51const STATES: readonly PullRequest['state'][] = ['OPEN', 'CLOSED', 'MERGED']
52const MERGEABLE: readonly PullRequest['mergeable'][] = ['MERGEABLE', 'CONFLICTING', 'UNKNOWN']
53
54// `gh pr view --json number,state,isDraft,mergeable`, checked field by field.
55export function parsePr(output: string): PullRequest | null {
56 let data: unknown
57
58 try {
59 data = JSON.parse(output)
60 } catch {
61 return null
62 }
63
64 if (typeof data !== 'object' || data === null) {
65 return null
66 }
67
68 const number = 'number' in data ? data.number : undefined
69 const state = STATES.find(s => 'state' in data && data.state === s)
70 const isDraft = 'isDraft' in data ? data.isDraft : undefined
71 const mergeable = MERGEABLE.find(m => 'mergeable' in data && data.mergeable === m) ?? 'UNKNOWN'
72
73 if (typeof number !== 'number' || state === undefined || typeof isDraft !== 'boolean') {
74 return null
75 }
76
77 return { number, state, isDraft, mergeable }
78}
79
80// Working right on the base branch (or on main or master when the base is unknown).
81export function isOnBase(branch: string, base: string | null): boolean {
82 return base === null ? branch === 'main' || branch === 'master' : branch === base
83}
84hooks/palette.ts 22 lines1// Vesuvius palette (placeholder): replace the hex values with the design system's.
2// Every mod in affreschi keeps its own copy, because a plugin cannot import another plugin's files.
3export const VESUVIUS = {
4 lava: '#E4572E',
5 magma: '#F28C28',
6 sulfur: '#F2C14E',
7 tuff: '#C9B79C',
8 ash: '#8A8D91',
9 gulf: '#2E86AB',
10 vineyard: '#7A9E4E'
11} as const
12
13// The order categories take their colors in, warmest first.
14export const SEQUENCE = [
15 VESUVIUS.lava,
16 VESUVIUS.magma,
17 VESUVIUS.sulfur,
18 VESUVIUS.tuff,
19 VESUVIUS.gulf,
20 VESUVIUS.vineyard
21] as const
22types/index.d.ts 28 lines1// The PR of the current branch, as `gh pr view` reports it.
2export type PullRequest = {
3 number: number
4 state: 'OPEN' | 'CLOSED' | 'MERGED'
5 isDraft: boolean
6 mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
7}
8
9// The repository as the row draws it.
10export type Repo = {
11 branch: string
12 // The branch the default remote branch points at (main, master…), when known.
13 base: string | null
14 ahead: number
15 behind: number
16 files: number
17 added: number
18 removed: number
19 pr: PullRequest | null
20}
21
22declare module 'claude-code' {
23 interface PluginState {
24 // null outside a git repository, or before the first read.
25 spaccanapoli: { repo: Repo | null }
26 }
27}
28