Pulcinella lives above the prompt: he cheers green tests, frets over failures, naps when you're away and his hat grows with the tokens you spend

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 492 lines1import type { EngineInterface, Register, RenderElement } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { DetailItem, Face, Mood } from '../types'
5import {
6 alertColor,
7 allocateCells,
8 colorOf,
9 fillOf,
10 formatTokens,
11 headroomColor,
12 headroomOf,
13 percentOf,
14 pushTotal,
15 toContextMap,
16 turnsLeft
17} from './context'
18import { cellsOf, labelOf, levelOf, linesOf, quoteFor, spriteOf, svgOf } from './mascot'
19import { VESUVIUS } from './palette'
20
21const PANE = 'context-map'
22// CSS pixels per sprite pixel where Pulcinella is drawn as an image.
23const SVG_PIXEL = 6
24// The animation's beat; away NAP_MS, he falls asleep; a cheer or a gasp fades after CALM_MS.
25const TICK_MS = 500
26const NAP_MS = 10 * 60 * 1000
27const CALM_MS = 30 * 1000
28// The context is read again at most this often while a turn runs.
29const REFRESH_MS = 1500
30// An edit or write of this many lines leaves him stunned.
31const BIG_CHANGE_LINES = 300
32// Categories the band names as chips; the rest fold into "+N".
33const TOP_CHIPS = 4
34const TOKENS_KEY = 'tokens'
35const TEST_COMMAND = /\b(test|tests|vitest|jest|pytest|mocha|playwright|cargo test|go test)\b/
36
37const CALM_FACE: Face = { mood: 'idle', quote: 'Pronto, comandate!' }
38const NO_TOTALS: number[] = []
39
40const face = atom({ plugin: 'pulcinella', key: 'face' } as const, CALM_FACE)
41const tokens = atom({ plugin: 'pulcinella', key: 'tokens' } as const, 0)
42const tick = atom({ plugin: 'pulcinella', key: 'tick' } as const, 0)
43const map = atom({ plugin: 'pulcinella', key: 'map' } as const, null)
44const isLegendHidden = atom({ plugin: 'pulcinella', key: 'isLegendHidden' } as const, false)
45const totals = atom({ plugin: 'pulcinella', key: 'totals' } as const, NO_TOTALS)
46
47// The module's own clock, in beats since it loaded, and when things last happened on it:
48// a reload starts them over.
49let beats = 0
50let lastActivityAt = 0
51let faceSetAt = 0
52let lastRefreshAt = -Infinity
53
54function now(): number {
55 return beats * TICK_MS
56}
57
58// A failure the mod lives through (a read it retries on the next beat, a band beneath that did
59// not draw) goes to the debug log, never silently away.
60function logFailure($: EngineInterface, what: string, error: unknown): void {
61 const reason = error instanceof Error ? error.message : String(error)
62 $.ui.log(`pulcinella: ${what} failed: ${reason}`, { to: 'debug' })
63}
64
65async function setFace($: EngineInterface, mood: Mood, what = ''): Promise<void> {
66 const next: Face = { mood, quote: quoteFor(mood, what) }
67 faceSetAt = now()
68 await update($, face, () => next)
69}
70
71// Any sign of life wakes him and keeps him awake.
72async function touch($: EngineInterface): Promise<void> {
73 lastActivityAt = now()
74
75 if ((await read($, face)).mood === 'sleeping') {
76 await setFace($, 'idle')
77 }
78}
79
80// One beat: he naps when you are away, calms down after a cheer, and moves to the next frame.
81// The frame is written only when it changes, so an idle Pulcinella redraws twice per blink.
82async function beat($: EngineInterface): Promise<void> {
83 beats += 1
84 const { mood } = await read($, face)
85
86 if (mood !== 'sleeping' && now() - lastActivityAt >= NAP_MS) {
87 return setFace($, 'sleeping')
88 }
89
90 if ((mood === 'happy' || mood === 'stunned') && now() - faceSetAt >= CALM_MS) {
91 return setFace($, 'idle')
92 }
93
94 const frame =
95 mood === 'working'
96 ? beats
97 : mood === 'idle'
98 ? beats % 12 === 11
99 ? 11
100 : 0
101 : Math.floor(beats / 2)
102
103 if ((await read($, tick)) !== frame) {
104 await update($, tick, () => frame)
105 }
106}
107
108async function refresh($: EngineInterface, { isTurnEnd = false } = {}): Promise<void> {
109 lastRefreshAt = now()
110 const usage = await $.session.usage({ breakdown: 'summary' })
111 const b = usage.context.breakdown
112
113 if (b === undefined) {
114 return
115 }
116
117 const next = toContextMap(b)
118 await update($, map, () => next)
119
120 if (isTurnEnd) {
121 await update($, totals, h => pushTotal(h, next.total))
122 }
123}
124
125// The band beneath, when a plugin below drew one: an empty Box or nothing means none.
126function hasContent(tree: RenderElement | null | undefined): tree is RenderElement {
127 if (tree === null || tree === undefined) {
128 return false
129 }
130
131 const children = 'children' in tree ? tree.children : undefined
132
133 return !(
134 tree.type === 'Box' &&
135 (children === undefined || (Array.isArray(children) && children.length === 0))
136 )
137}
138
139export const register: Register = on => {
140 on('session.start', async ($, e, next) => {
141 await $.command.register({
142 name: 'context-map',
143 description: 'Open the full context window breakdown',
144 immediate: true
145 })
146 const spent = Number((await $.store.get(TOKENS_KEY)) ?? 0)
147 await update($, tokens, () => (Number.isFinite(spent) ? spent : 0))
148 await setFace($, 'idle')
149 lastActivityAt = now()
150 await refresh($).catch(error => logFailure($, 'reading the context', error))
151 $.clock.every(TICK_MS, () => {
152 beat($).catch(error => logFailure($, 'the animation beat', error))
153 })
154
155 return next(e)
156 })
157
158 on('command.run', { command: 'context-map' }, async $ => {
159 await refresh($).catch(error => logFailure($, 'reading the context', error))
160 await $.ui.open({ id: PANE, title: 'Context map', focus: true, closeOnEscape: true })
161
162 return {}
163 })
164
165 on('prompt.submit', async ($, e, next) => {
166 await touch($)
167
168 return next(e)
169 })
170
171 // Every tool call: he works while it runs, then frets, cheers or gasps at what came back.
172 on('tool.call', async ($, e, next) => {
173 await touch($)
174 const before = (await read($, face)).mood
175
176 // A fret or a cheer stays until the next one, rather than flickering back to work.
177 if (before !== 'worried' && before !== 'happy') {
178 await setFace($, 'working')
179 }
180
181 const result = await next(e)
182
183 if (result.deny !== undefined) {
184 return result
185 }
186
187 const command = e.tool === 'Bash' ? e.command : undefined
188 const written = e.tool === 'Write' ? e.content : e.tool === 'Edit' ? e.new_string : ''
189
190 if (result.isError === true) {
191 await setFace($, 'worried', labelOf(String(e.tool), command))
192 } else if (command !== undefined && TEST_COMMAND.test(command)) {
193 await setFace($, 'happy')
194 } else {
195 const lines = linesOf(written)
196
197 if (lines >= BIG_CHANGE_LINES) {
198 await setFace($, 'stunned', `${lines} righe`)
199 }
200 }
201
202 return result
203 })
204
205 // Every model request: its tokens grow his hat, and the main loop's refresh the context live.
206 on('turn.step', async function* ($, e, next) {
207 const result = yield* next(e)
208 const usage = result.usage
209
210 if (usage !== null) {
211 const spent =
212 usage.input_tokens +
213 usage.output_tokens +
214 usage.cache_read_input_tokens +
215 usage.cache_creation_input_tokens
216 await update($, tokens, t => t + spent)
217 }
218
219 if (e.agentId === undefined && now() - lastRefreshAt >= REFRESH_MS) {
220 await refresh($).catch(error => logFailure($, 'reading the context', error))
221 }
222
223 return result
224 })
225
226 on('turn.complete', async ($, e, next) => {
227 const result = await next(e)
228 lastActivityAt = now()
229
230 if ((await read($, face)).mood === 'working') {
231 await setFace($, 'idle')
232 }
233
234 await refresh($, { isTurnEnd: true }).catch(error =>
235 logFailure($, 'reading the context', error)
236 )
237 await $.store.set(TOKENS_KEY, await read($, tokens))
238
239 return result
240 })
241
242 // The band: Pulcinella, then the context beside him; a band a plugin beneath draws goes under.
243 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
244 if (e.props.hasSurvey) {
245 return next(e)
246 }
247
248 const below = await next(e).catch(error => {
249 logFailure($, 'drawing the band beneath', error)
250
251 return null
252 })
253 const { Box, Text, Button } = $.ui.resolve(e)
254 const { mood, quote } = await read($, face)
255 const level = levelOf(await read($, tokens))
256 const m = await read($, map)
257 const elements = $.ui.resolve(e)
258 const Svg = e.surface !== 'terminal' && 'Svg' in elements ? elements.Svg : undefined
259 const pixels = spriteOf(
260 mood,
261 await read($, tick),
262 level,
263 Svg === undefined ? 'compact' : 'full'
264 )
265
266 // A graphical surface draws him as an image, pixel for pixel. The terminal draws two pixels
267 // to a character, the glyph's color above and its background below, monospaced so the
268 // columns line up.
269 const pulcinella =
270 Svg === undefined ? (
271 <Box flexDirection="column" flexShrink={0} alignSelf="center">
272 {cellsOf(pixels).map(row => (
273 <Text mono>
274 {row.map(cell =>
275 cell.color === undefined ? (
276 cell.text
277 ) : (
278 <Text color={cell.color} backgroundColor={cell.background}>
279 {cell.text}
280 </Text>
281 )
282 )}
283 </Text>
284 ))}
285 </Box>
286 ) : (
287 <Box flexShrink={0} alignSelf="center">
288 <Svg source={svgOf(pixels, SVG_PIXEL)} alt={`Pulcinella, ${mood}`} />
289 </Box>
290 )
291 const says = (
292 <Text color={VESUVIUS.tuff} italic wrap="truncate-end">
293 “{quote}”
294 </Text>
295 )
296
297 let side: RenderElement
298
299 if (m === null || m.max <= 0) {
300 side = (
301 <Box flexDirection="column" flexGrow={1} justifyContent="center">
302 {says}
303 <Text color={VESUVIUS.ash}>livello {level}</Text>
304 </Box>
305 )
306 } else {
307 const isHidden = await read($, isLegendHidden)
308 const headroom = headroomOf(m)
309 const turns = turnsLeft(await read($, totals), headroom)
310 // Cells only weigh the segments: the bar is boxes that grow by their share of the row.
311 const cells = allocateCells(m.segments, m.max, 1000)
312 const used = m.segments.flatMap((s, i) =>
313 s.kind === 'used' && s.tokens > 0 ? [{ s, i }] : []
314 )
315 const chips = [...used].sort((a, b) => b.s.tokens - a.s.tokens).slice(0, TOP_CHIPS)
316 const folded = used.length - chips.length
317
318 side = (
319 <Box
320 flexDirection="column"
321 flexGrow={1}
322 borderStyle="round"
323 borderColor={VESUVIUS.ash}
324 paddingX={1}
325 >
326 <Box flexDirection="row" justifyContent="space-between">
327 <Text>
328 <Text color={VESUVIUS.lava}>◆</Text> <Text bold>context</Text>{' '}
329 <Text color={VESUVIUS.ash}>
330 · {formatTokens(m.max)} · {m.percent}% · livello {level}
331 </Text>
332 </Text>
333 <Box flexDirection="row" columnGap={1}>
334 <Button
335 key="legend"
336 plain
337 label={isHidden ? 'legend' : 'hide'}
338 onPress={() => update($, isLegendHidden, v => !v)}
339 />
340 <Button
341 key="details"
342 plain
343 label="details"
344 onPress={() =>
345 $.ui.open({ id: PANE, title: 'Context map', focus: true, closeOnEscape: true })
346 }
347 />
348 </Box>
349 </Box>
350 <Box flexDirection="row" alignItems="flex-end" columnGap={4}>
351 <Box flexDirection="column">
352 <Text color={VESUVIUS.ash}>used</Text>
353 <Text bold>{formatTokens(m.total)}</Text>
354 </Box>
355 <Box flexDirection="column">
356 <Text color={VESUVIUS.ash}>
357 {m.autoCompactThreshold !== null ? 'before compaction' : 'free'}
358 </Text>
359 <Text color={headroomColor(m)} bold>
360 {formatTokens(headroom)}
361 </Text>
362 </Box>
363 <Box flexGrow={1} />
364 {turns !== null && (
365 <Text color={VESUVIUS.ash}>≈ {turns} turns at the current pace</Text>
366 )}
367 </Box>
368 {/* One row whatever the surface: each segment is a filled box that grows by its share,
369 so nothing can wrap where a graphical surface's cells are not bodyColumns'. */}
370 <Box flexDirection="row" height={1}>
371 {m.segments.map((s, i) =>
372 (cells[i] ?? 0) > 0 ? (
373 <Box
374 key={s.name}
375 width={0}
376 flexGrow={cells[i]}
377 height={1}
378 backgroundColor={fillOf(m.segments, i)}
379 >
380 <Text> </Text>
381 </Box>
382 ) : null
383 )}
384 </Box>
385 {!isHidden && (
386 <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
387 {chips.map(({ s, i }) => (
388 <Text key={s.name}>
389 <Text color={colorOf(m.segments, i)}>■</Text>{' '}
390 <Text color={VESUVIUS.ash}>{s.name.toLowerCase()}</Text>{' '}
391 <Text bold>{formatTokens(s.tokens)}</Text>
392 </Text>
393 ))}
394 {folded > 0 && <Text color={VESUVIUS.ash}>+{folded} more</Text>}
395 </Box>
396 )}
397 {says}
398 </Box>
399 )
400 }
401
402 const band = (
403 <Box flexDirection="row" columnGap={2}>
404 {pulcinella}
405 {side}
406 </Box>
407 )
408
409 return hasContent(below) ? (
410 <Box flexDirection="column">
411 {band}
412 {below}
413 </Box>
414 ) : (
415 band
416 )
417 })
418
419 // The pane: every category with its share, then what fills the biggest ones.
420 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
421 const { Box, Text } = $.ui.resolve(e)
422 const m = await read($, map)
423
424 if (m === null || m.max <= 0) {
425 return <Text color={VESUVIUS.ash}>No context data yet: it shows after the first answer.</Text>
426 }
427
428 const nameWidth = Math.max(...m.segments.map(s => s.name.length), 12)
429
430 const section = (title: string, items: DetailItem[]) =>
431 items.length > 0 && (
432 <Box flexDirection="column">
433 <Text color={VESUVIUS.tuff} bold>
434 {title}
435 </Text>
436 {items.map(item => (
437 <Text wrap="truncate-end">
438 {' '}
439 {item.name} <Text color={VESUVIUS.ash}>{item.note}</Text>{' '}
440 <Text color={VESUVIUS.magma}>{formatTokens(item.tokens)}</Text>
441 </Text>
442 ))}
443 </Box>
444 )
445
446 return (
447 <Box flexDirection="column" rowGap={1}>
448 <Text>
449 <Text color={alertColor(m.percent)} bold>
450 {m.percent}%
451 </Text>{' '}
452 of {formatTokens(m.max)} · <Text color={VESUVIUS.ash}>{m.model}</Text>
453 {m.autoCompactThreshold !== null ? (
454 <Text color={VESUVIUS.ash}> · compacts at {formatTokens(m.autoCompactThreshold)}</Text>
455 ) : (
456 ''
457 )}
458 </Text>
459 <Box flexDirection="column">
460 {m.segments.map((s, i) => {
461 const share = m.max > 0 ? (s.tokens / m.max) * 100 : 0
462
463 return (
464 <Box flexDirection="row" columnGap={1}>
465 {/* Monospaced, so the names and numbers line up in columns on every surface. */}
466 <Text mono>
467 <Text color={fillOf(m.segments, i)}>■</Text> {s.name.padEnd(nameWidth)}{' '}
468 <Text color={VESUVIUS.ash}>{formatTokens(s.tokens).padStart(6)}</Text>{' '}
469 <Text color={VESUVIUS.tuff}>{percentOf(s.tokens, m.max).padStart(4)}</Text>
470 </Text>
471 <Box flexDirection="row" flexGrow={1} height={1}>
472 <Box
473 width={`${Math.max(s.tokens > 0 ? 1 : 0, Math.round(share))}%`}
474 height={1}
475 backgroundColor={fillOf(m.segments, i)}
476 >
477 <Text> </Text>
478 </Box>
479 </Box>
480 </Box>
481 )
482 })}
483 </Box>
484 {section('Memory files', m.memoryFiles)}
485 {section('MCP servers', m.mcpServers)}
486 {section('Custom agents', m.agents)}
487 {section('Skills', m.skills)}
488 </Box>
489 )
490 })
491}
492hooks/context.ts 207 lines1import type { SessionContextBreakdown } from 'claude-code'
2
3import type { ContextMap, DetailItem, Segment } from '../types'
4import { SEQUENCE, VESUVIUS } from './palette'
5
6// The context window's numbers: what the band and the details pane draw, from /context's breakdown.
7
8const TOP_ITEMS = 8
9// Turns the pace estimate averages over.
10const PACE_TURNS = 5
11const FREE_FILL = '#3A3836'
12const BUFFER_FILL = '#5C5955'
13
14export function formatTokens(n: number): string {
15 if (n >= 1_000_000) {
16 return `${(n / 1_000_000).toFixed(1)}M`
17 }
18
19 if (n >= 10_000) {
20 return `${Math.round(n / 1000)}k`
21 }
22
23 if (n >= 1000) {
24 return `${(n / 1000).toFixed(1)}k`
25 }
26
27 return String(n)
28}
29
30export function percentOf(tokens: number, max: number): string {
31 if (max <= 0) {
32 return '0%'
33 }
34
35 const p = (tokens / max) * 100
36
37 return p > 0 && p < 1 ? '<1%' : `${Math.round(p)}%`
38}
39
40// How many cells of the bar each segment gets: any segment with tokens gets at least one,
41// and the total never exceeds the width.
42export function allocateCells(segments: readonly Segment[], max: number, width: number): number[] {
43 const cells = segments.map(s => {
44 const t = s.kind === 'deferred' || max <= 0 ? 0 : s.tokens
45
46 return t > 0 ? Math.max(1, Math.round((t / max) * width)) : 0
47 })
48 let excess = cells.reduce((a, b) => a + b, 0) - width
49
50 // On overflow, take from the free space first, then from the largest shares.
51 segments.forEach((s, i) => {
52 const c = cells[i] ?? 0
53
54 if (excess > 0 && s.kind === 'free') {
55 const cut = Math.min(excess, c)
56 cells[i] = c - cut
57 excess -= cut
58 }
59 })
60
61 while (excess > 0) {
62 const largest = Math.max(...cells)
63
64 if (largest <= 1) {
65 break
66 }
67
68 const i = cells.indexOf(largest)
69 cells[i] = largest - 1
70 excess -= 1
71 }
72
73 return cells
74}
75
76export function colorOf(segments: readonly Segment[], i: number): string {
77 const s = segments[i]
78
79 if (s === undefined || s.kind !== 'used') {
80 return VESUVIUS.ash
81 }
82
83 const usedBefore = segments.slice(0, i).filter(x => x.kind === 'used').length
84
85 return SEQUENCE[usedBefore % SEQUENCE.length] ?? VESUVIUS.lava
86}
87
88// The bar's fill: the category's color for what is used, greys for the reserve and the free space.
89export function fillOf(segments: readonly Segment[], i: number): string {
90 const kind = segments[i]?.kind
91
92 if (kind === 'free') {
93 return FREE_FILL
94 }
95
96 if (kind === 'buffer') {
97 return BUFFER_FILL
98 }
99
100 return colorOf(segments, i)
101}
102
103export function glyphOf(kind: Segment['kind']): string {
104 return kind === 'used' ? '█' : kind === 'buffer' ? '▒' : '░'
105}
106
107export function alertColor(percent: number): string {
108 return percent >= 85 ? VESUVIUS.lava : percent >= 60 ? VESUVIUS.magma : VESUVIUS.tuff
109}
110
111// Tokens left before the context compacts, or before it is full when it never does.
112export function headroomOf(m: ContextMap): number {
113 return Math.max(0, (m.autoCompactThreshold ?? m.max) - m.total)
114}
115
116export function headroomColor(m: ContextMap): string {
117 const limit = m.autoCompactThreshold ?? m.max
118 const used = limit > 0 ? (m.total / limit) * 100 : 100
119
120 return used >= 85 ? VESUVIUS.lava : used >= 60 ? VESUVIUS.magma : VESUVIUS.vineyard
121}
122
123// Adds a turn's total to the recent ones; a total that shrank (a compaction, a clear) starts over.
124export function pushTotal(history: readonly number[], total: number): number[] {
125 const last = history[history.length - 1]
126
127 if (last !== undefined && total < last) {
128 return [total]
129 }
130
131 return [...history, total].slice(-(PACE_TURNS + 1))
132}
133
134// How many more turns fit in the headroom at the recent average growth per turn, or null
135// before there are two turns to compare or while the context is not growing.
136export function turnsLeft(history: readonly number[], headroom: number): number | null {
137 if (history.length < 2) {
138 return null
139 }
140
141 const first = history[0] ?? 0
142 const last = history[history.length - 1] ?? 0
143 const pace = (last - first) / (history.length - 1)
144
145 return pace > 0 ? Math.floor(headroom / pace) : null
146}
147
148function top(items: DetailItem[]): DetailItem[] {
149 return [...items].sort((a, b) => b.tokens - a.tokens).slice(0, TOP_ITEMS)
150}
151
152// Shortens a path to its last two parts: ~/.claude/CLAUDE.md → .claude/CLAUDE.md
153export function shortPath(path: string): string {
154 const parts = path.split('/').filter(Boolean)
155
156 return parts.length <= 2 ? path : parts.slice(-2).join('/')
157}
158
159// Turns /context's breakdown into what the band and the pane draw.
160export function toContextMap(b: SessionContextBreakdown): ContextMap {
161 const servers = new Map<string, { tokens: number; tools: number; loaded: number }>()
162
163 for (const tool of b.mcpTools) {
164 const s = servers.get(tool.serverName) ?? { tokens: 0, tools: 0, loaded: 0 }
165 s.tokens += tool.tokens
166 s.tools += 1
167
168 if (tool.isLoaded) {
169 s.loaded += 1
170 }
171
172 servers.set(tool.serverName, s)
173 }
174
175 return {
176 percent: b.percentage,
177 total: b.totalTokens,
178 max: b.rawMaxTokens,
179 model: b.model,
180 autoCompactThreshold: b.isAutoCompactEnabled ? (b.autoCompactThreshold ?? null) : null,
181 // What is used first, then the compaction reserve, then the free space.
182 segments: [
183 ...b.categories.filter(c => c.kind === 'used'),
184 ...b.categories.filter(c => c.kind === 'buffer'),
185 ...b.categories.filter(c => c.kind === 'free')
186 ].map(c => ({ name: c.name, tokens: c.tokens, kind: c.kind })),
187 memoryFiles: top(
188 b.memoryFiles.map(f => ({ name: shortPath(f.path), note: f.type, tokens: f.tokens }))
189 ),
190 mcpServers: top(
191 [...servers].map(([name, s]) => ({
192 name,
193 note: `${s.loaded}/${s.tools} tools loaded`,
194 tokens: s.tokens
195 }))
196 ),
197 agents: top(b.agents.map(a => ({ name: a.agentType, note: a.source, tokens: a.tokens }))),
198 skills: top(
199 (b.skills?.skillFrontmatter ?? []).map(s => ({
200 name: s.name,
201 note: s.pluginName ?? s.source,
202 tokens: s.tokens
203 }))
204 )
205 }
206}
207hooks/mascot.ts 305 lines1import type { Mood } from '../types'
2
3// Pulcinella himself: his hat, his face in each mood and frame, and what he says.
4
5// Tokens spent with him (cache reads included) at which his hat grows a level.
6export const LEVELS = [0, 1_000_000, 10_000_000, 50_000_000, 200_000_000] as const
7
8// What he says in each mood; `{what}` is the command or tool the moment is about.
9export const QUOTES: Record<Mood, readonly string[]> = {
10 idle: ['Pronto, comandate!', 'Che facimmo mo?', 'Stongo cca, guagliò.'],
11 working: ['Sto faticanno…', "Nu mumento, sto 'mpastanno.", 'Lassame lavorà!'],
12 happy: ['Tutto verde, guagliò!', 'Che bellezza, passa tutto!', "Jamme, n'ata vittoria!"],
13 worried: [
14 'Mannaggia… {what} è caduto.',
15 'Uè, {what} nun va.',
16 "Nun te preoccupà, {what} s'aggiusta."
17 ],
18 stunned: ['Mamma mia, {what}!', 'Chesto è nu terremoto: {what}!'],
19 sleeping: ['Zzz… sveglieme quanno serve.', 'Pennichella…']
20}
21
22// Pulcinella in pixel art, fourteen pixels wide. One letter per pixel: `.` is see-through,
23// and each other letter a color of the costume.
24export const PIXEL_COLOR: Record<string, string> = {
25 W: '#F4F1EA',
26 w: '#C9C4BA',
27 K: '#151413',
28 S: '#E9B48C',
29 P: '#F2A3A0',
30 R: '#D9573B',
31 Y: '#F2C14E',
32 B: '#6B4A33',
33 C: '#2E86AB'
34}
35
36export const SPRITE_WIDTH = 14
37
38// At rest: the floppy hat leaning right, the black half mask, pink cheeks, the white blouse.
39const IDLE: readonly string[] = [
40 '..........WW..',
41 '........WWWW..',
42 '......WWWWW...',
43 '....WWWWWW....',
44 '...WWWWWWW....',
45 '..wWWWWWWWWw..',
46 '.KKKKKKKKKKKK.',
47 '.KKWWKKKKWWKK.',
48 '.KKWKKKKKWKKK.',
49 '..SSSSKKSSSS..',
50 '..SPSSKKSSPS..',
51 '...SSSRRSSS...',
52 '....SSSSSS....',
53 '...WwWWWWwW...',
54 '..WWWWKKWWWW..',
55 '..WW.WWWW.WW..'
56]
57
58// A frame as the rows it changes, by index, over the resting one.
59type Edits = Readonly<Record<number, string>>
60
61const SWAYED_HAT: Edits = {
62 0: '............WW',
63 1: '..........WWWW',
64 2: '........WWWWW.',
65 3: '......WWWWWW..',
66 4: '....WWWWWWW...'
67}
68
69// Two frames per mood, swapped on the animation's beat.
70const FRAMES: Record<Mood, readonly [Edits, Edits]> = {
71 idle: [{}, {}],
72 working: [
73 { 9: '..SSSSKKSSSS.Y', 10: '..SPSSKKSSPSB.' },
74 { ...SWAYED_HAT, 11: '...SSSRRSSS..Y', 12: '....SSSSSS..B.' }
75 ],
76 happy: [
77 { 7: '.KKKWKKKKKWKK.', 8: '.KKWKWKKKWKWK.', 11: '...SRRRRRRS...', 12: '....SRRRRS....' },
78 {
79 0: '.Y........WW..',
80 1: '.Y......WWWW..',
81 7: '.KKKWKKKKKWKK.',
82 8: '.KKWKWKKKWKWK.',
83 11: '...SRRRRRRS...',
84 12: '....SRRRRS....'
85 }
86 ],
87 worried: [
88 { 7: '.KKKWWKKWWKKK.', 8: '.KKKWKKKKWKKK.', 11: '...SSSSSSSS...', 12: '....SRRRRS....' },
89 {
90 7: '.KKKWWKKWWKKKC',
91 8: '.KKKWKKKKWKKK.',
92 11: '...SSSSSSSS...',
93 12: '....SRRRRS....'
94 }
95 ],
96 stunned: [
97 { 7: '.KWWWKKKWWWKK.', 8: '.KWKWKKKWKWKK.', 11: '...SSRRRSSS...', 12: '....SRRRSS....' },
98 {
99 0: '.Y........WW..',
100 1: '.Y......WWWW..',
101 2: '......WWWWW...',
102 3: '.Y..WWWWWW....',
103 7: '.KWWWKKKWWWKK.',
104 8: '.KWKWKKKWKWKK.',
105 11: '...SSRRRSSS...',
106 12: '....SRRRSS....'
107 }
108 ],
109 sleeping: [
110 { 0: '..........WW.Y', 7: '.KKKKKKKKKKKK.', 8: '.KKWWKKKKWWKK.', 11: '...SSSRSSSS...' },
111 {
112 0: '..........WWYY',
113 1: '........WWWW.Y',
114 7: '.KKKKKKKKKKKK.',
115 8: '.KKWWKKKKWWKK.',
116 11: '...SSSRSSSS...'
117 }
118 ]
119}
120
121// Closed eyes, for a blink now and then while he rests.
122const BLINK: Edits = { 7: '.KKKKKKKKKKKK.', 8: '.KKWWKKKKWWKK.' }
123
124// The tip the hat grows by, a pixel per level past the first, curling up and to the right.
125const TIP: readonly string[] = [
126 '.............W',
127 '............W.',
128 '............W.',
129 '...........W..'
130]
131
132// Pulcinella small, ten pixels wide and ten tall, for the terminal, where every two pixel rows
133// take a whole line: the same costume in five lines.
134const COMPACT: readonly string[] = [
135 '.......WW.',
136 '.....WWWW.',
137 '...WWWWW..',
138 '.wWWWWWWw.',
139 'KKKKKKKKKK',
140 'KWKKKKKWKK',
141 '.SSSKKSSS.',
142 '.SPSRRSPS.',
143 '..WWWWWW..',
144 '.WWWKKWWW.'
145]
146
147const COMPACT_FRAMES: Record<Mood, readonly [Edits, Edits]> = {
148 idle: [{}, {}],
149 working: [
150 { 6: '.SSSKKSSSY' },
151 { 0: '........WW', 1: '......WWWW', 2: '....WWWWW.', 7: '.SPSRRSPSY' }
152 ],
153 happy: [
154 { 5: 'KKWKKKWKKK', 7: '.SRRRRRRS.' },
155 { 0: 'Y......WW.', 5: 'KKWKKKWKKK', 7: '.SRRRRRRS.' }
156 ],
157 worried: [{ 7: '.SSSRRSSS.' }, { 5: 'KWKKKKKWKC', 7: '.SSSRRSSS.' }],
158 stunned: [
159 { 5: 'KWWKKKWWKK', 7: '.SPRRRRPS.' },
160 { 0: 'Y......WW.', 1: 'Y....WWWW.', 5: 'KWWKKKWWKK', 7: '.SPRRRRPS.' }
161 ],
162 sleeping: [
163 { 0: '.......WWY', 5: 'KKKKKKKKKK', 7: '.SSSRSSSS.' },
164 { 0: '.......WWY', 1: '.....WWWWY', 5: 'KKKKKKKKKK', 7: '.SSSRSSSS.' }
165 ]
166}
167
168const COMPACT_BLINK: Edits = { 5: 'KKKKKKKKKK' }
169
170// Its hat grows by two pixels at most, so he never stands taller than the band beside him.
171const COMPACT_TIP: readonly string[] = ['.........W', '........W.']
172
173export type Size = 'full' | 'compact'
174
175// His pixels in a mood at a beat of the animation, his hat a pixel taller for every level past
176// the first, and an even number of rows, so they pair up into character cells. `compact` is the
177// small one the terminal draws.
178export function spriteOf(mood: Mood, tick: number, level: number, size: Size = 'full'): string[] {
179 const isCompact = size === 'compact'
180 const frame = (isCompact ? COMPACT_FRAMES : FRAMES)[mood][tick % 2 === 0 ? 0 : 1]
181 const blink = mood === 'idle' && tick % 12 === 11 ? (isCompact ? COMPACT_BLINK : BLINK) : {}
182 const body = (isCompact ? COMPACT : IDLE).map((row, i) => blink[i] ?? frame[i] ?? row)
183 const tip = isCompact ? COMPACT_TIP : TIP
184 const grown = Math.min(tip.length, Math.max(0, level - 1))
185 const rows = [...tip.slice(tip.length - grown), ...body]
186 const width = body[0]?.length ?? SPRITE_WIDTH
187
188 return rows.length % 2 === 0 ? rows : ['.'.repeat(width), ...rows]
189}
190
191// The card behind him on a graphical surface, so the black mask stands out on a dark theme.
192const CARD = '#3A3633'
193
194// His pixels as an SVG, `px` CSS pixels each, on a rounded card: what a graphical surface draws,
195// where text rows leave gaps between them. Each row's runs of one color are one rectangle.
196export function svgOf(pixels: readonly string[], px: number): string {
197 const pad = px
198 const width = Math.max(0, ...pixels.map(row => row.length)) * px + pad * 2
199 const height = pixels.length * px + pad * 2
200 const rects: string[] = []
201
202 pixels.forEach((row, y) => {
203 let x = 0
204
205 while (x < row.length) {
206 const letter = row[x] ?? '.'
207 let end = x + 1
208
209 while (end < row.length && row[end] === letter) {
210 end += 1
211 }
212
213 const color = PIXEL_COLOR[letter]
214
215 if (color !== undefined) {
216 rects.push(
217 `<rect x="${pad + x * px}" y="${pad + y * px}" width="${(end - x) * px}" height="${px}" fill="${color}"/>`
218 )
219 }
220
221 x = end
222 }
223 })
224
225 return (
226 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
227 `viewBox="0 0 ${width} ${height}" shape-rendering="crispEdges">` +
228 `<rect width="${width}" height="${height}" rx="${px * 2}" fill="${CARD}"/>${rects.join('')}</svg>`
229 )
230}
231
232// One run of a character row: its text, the color of its glyphs, and the color behind them.
233export type Cell = { text: string; color?: string; background?: string }
234
235// Two pixel rows as one row of characters: each cell's upper pixel is the glyph's color and its
236// lower pixel the background, so a terminal cell carries two pixels. Neighbouring cells of the
237// same colors join into one run.
238function cellRow(top: string, bottom: string): Cell[] {
239 const cells: Cell[] = [...top].map((t, i) => {
240 const up = PIXEL_COLOR[t]
241 const down = PIXEL_COLOR[bottom[i] ?? '.']
242
243 if (up === undefined && down === undefined) {
244 return { text: ' ' }
245 }
246
247 if (up === undefined) {
248 return { text: '▄', color: down }
249 }
250
251 if (down === undefined || down === up) {
252 return { text: down === up ? '█' : '▀', color: up }
253 }
254
255 return { text: '▀', color: up, background: down }
256 })
257
258 const runs: Cell[] = []
259
260 for (const cell of cells) {
261 const last = runs.at(-1)
262
263 if (last !== undefined && last.color === cell.color && last.background === cell.background) {
264 last.text += cell.text
265 } else {
266 runs.push({ ...cell })
267 }
268 }
269
270 return runs
271}
272
273// Pixel rows as character rows, two pixel rows to each.
274export function cellsOf(pixels: readonly string[]): Cell[][] {
275 return Array.from({ length: Math.ceil(pixels.length / 2) }, (_, i) =>
276 cellRow(pixels[i * 2] ?? '', pixels[i * 2 + 1] ?? '.'.repeat(pixels[i * 2]?.length ?? 0))
277 )
278}
279
280export function levelOf(spent: number): number {
281 return LEVELS.filter(t => spent >= t).length
282}
283
284export function quoteFor(mood: Mood, what = '', pick = Math.random()): string {
285 const lines = QUOTES[mood]
286 const line = lines[Math.floor(pick * lines.length) % lines.length] ?? lines[0] ?? ''
287
288 return line.replace('{what}', what)
289}
290
291// The command a Bash call ran, cut to a few words, or the tool's name when it ran none.
292export function labelOf(tool: string, command: string | undefined): string {
293 if (command === undefined) {
294 return tool
295 }
296
297 const short = command.trim().split(/\s+/).slice(0, 3).join(' ')
298
299 return short.length > 28 ? `${short.slice(0, 27)}…` : short
300}
301
302export function linesOf(text: string): number {
303 return text === '' ? 0 : text.split('\n').length
304}
305hooks/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 43 lines1export type Mood = 'idle' | 'working' | 'happy' | 'worried' | 'stunned' | 'sleeping'
2
3// What Pulcinella shows now: his face and the line he says beside it.
4export type Face = { mood: Mood; quote: string }
5
6export type Segment = {
7 name: string
8 tokens: number
9 kind: 'used' | 'free' | 'buffer' | 'deferred'
10}
11
12// One line of a detail section: a memory file, an MCP server, an agent type, a skill.
13export type DetailItem = { name: string; note: string; tokens: number }
14
15export type ContextMap = {
16 percent: number
17 total: number
18 max: number
19 model: string
20 autoCompactThreshold: number | null
21 segments: Segment[]
22 memoryFiles: DetailItem[]
23 mcpServers: DetailItem[]
24 agents: DetailItem[]
25 skills: DetailItem[]
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 pulcinella: {
31 face: Face
32 // Every token spent with him, across sessions (kept in $.store, mirrored here to draw).
33 tokens: number
34 // The animation's frame counter.
35 tick: number
36 map: ContextMap | null
37 isLegendHidden: boolean
38 // The context's size at the end of each recent turn, oldest first, for the pace estimate.
39 totals: number[]
40 }
41 }
42}
43