SLOPSHOPPER

pulcinella

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

newpanebandguardcommandprompt
v0.3.2no licenseupdated 2026-10-25pompeitech/affreschi/plugins/pulcinella
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pulcinella
│ ┃ Context map ✕ › fix the failing auth test and add an audit log call │ ┃ No context data yet: it shows after the │ ┃ first answer. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ 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 │ │ › /context-map │ │ ▄▄██ “Uè, bun test nun va.” ▄▄█████▄ livello 1 █▀█████▀██ ███▀▀███ ▄██▀▀██▄ ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▄▄██ “Uè, bun test nun va.” ▄▄█████▄ livello 1 █▀█████▀██ ███▀▀███ ▄██▀▀██▄ ⟨Claude Code's own drawing⟩
Pane · Context map
No context data yet: it shows after the first answer.
README

affreschi

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.

Layout

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/

Adding a mod

  1. Create the folder plugins/<mod-name>/ with its files.
  2. Add it to the plugins array in .claude-plugin/marketplace.json:
   { "name": "<mod-name>", "source": "./plugins/<mod-name>", "description": "..." }
  1. Check it: claude plugin validate ./plugins/<mod-name>

Development

Load a mod for the current session only (it reloads when you save):

claude --plugin-dir ./plugins/<mod-name>

Installing

/plugin marketplace add pompeitech/affreschi
/plugin install <mod-name>@affreschi

The mods

ModWhat it doesWhere it shows
vesuvius-themeA 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 finishesTerminal and desktop
pulcinellaThe 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 sessionsTerminal 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 sessionTerminal and desktop
spaccanapoliA 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 bandTerminal 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
lazzaroneWhen 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 themTerminal and desktop
chiaveCounts, 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 aloneTerminal and desktop

The palette

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.

Tests

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.

Source 5 files
hooks/register.tsx 492 lines
1import 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}
492
hooks/context.ts 207 lines
1import 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}
207
hooks/mascot.ts 305 lines
1import 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}
305
hooks/palette.ts 22 lines
1// 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
22
types/index.d.ts 43 lines
1export 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