Spinner, righe delle tool call e fine turno nei colori del design system Vesuvius

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 |
touched-files | /touched-files opens a pane listing the files read (R) and edited (M, with +/-) this turn; press a file to see its diff. At the end of a turn a toast says how many files changed | Terminal and desktop |
context-map | A band above the prompt with the context window split by category, a percentage that turns from tuff to magma to lava past 60% and 85%, and details for the legend | Terminal and desktop |
The colors live in each mod's hooks/palette.ts (a plugin cannot import another plugin's files, so all three keep 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>hooks/register.tsx 100 lines1import type { Register, Timer } from 'claude-code'
2
3import { VESUVIUS } from './palette'
4
5// The words for the spinner and the end-of-turn line, in place of Claude Code's.
6const WORDS: [string, ...string[]] = ['Bubbling', 'Erupting', 'Smoking', 'Rumbling', 'Venting', 'Flowing', 'Heating the magma']
7const PAST_WORDS: [string, ...string[]] = ['Erupted', 'Bubbled', 'Smoked', 'Rumbled', 'Flowed', 'Vented']
8
9// The crater's glow: one frame per tick, alternating colors.
10const FRAMES = ['·', '∘', '○', '◎', '●', '◎', '○', '∘']
11const TICK_MS = 140
12
13export function formatDuration(ms: number): string {
14 const seconds = Math.max(0, Math.round(ms / 1000))
15 const minutes = Math.floor(seconds / 60)
16
17 return minutes > 0 ? `${minutes}m ${seconds % 60}s` : `${seconds}s`
18}
19
20export function pick<T>(list: readonly [T, ...T[]], seed: number): T {
21 return list[Math.abs(Math.floor(seed)) % list.length] ?? list[0]
22}
23
24export const register: Register = on => {
25 // Module values: they reset on every reload, which is fine here.
26 let frame = 0
27 let word = WORDS[0]
28 let timer: Timer | undefined
29
30 on('turn.start', async ($, e, next) => {
31 word = pick(WORDS, await $.clock.now())
32 timer?.cancel()
33 timer = $.clock.every(TICK_MS, () => {
34 frame = (frame + 1) % FRAMES.length
35 $.ui.invalidate('ui.render')
36 })
37
38 return next(e)
39 })
40
41 on('turn.complete', async ($, e, next) => {
42 timer?.cancel()
43 timer = undefined
44
45 return next(e)
46 })
47
48 // The spinner: redrawn in the terminal; on the desktop Claude Code's own stays,
49 // since there it says what the step is doing ("Creating notes.md").
50 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
51 if (e.surface !== 'terminal') return next(e)
52
53 const { Box, Text } = $.ui.resolve(e)
54 const text = e.props.message ?? word
55 const color = frame % 2 === 0 ? VESUVIUS.lava : VESUVIUS.magma
56
57 return (
58 <Box flexDirection="row" columnGap={1}>
59 <Text color={color} bold>
60 {FRAMES[frame]}
61 </Text>
62 <Text color={VESUVIUS.magma}>{text}…</Text>
63 </Box>
64 )
65 })
66
67 // Every tool call row: Claude Code's row, with a stripe of color on its left.
68 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
69 const { Box, Text } = $.ui.resolve(e)
70 const theirs = await next(e)
71 const color = e.props.isErrored
72 ? VESUVIUS.lava
73 : e.props.isRunning
74 ? VESUVIUS.magma
75 : VESUVIUS.ash
76
77 return (
78 <Box flexDirection="row" columnGap={1}>
79 <Text color={color}>▍</Text>
80 {theirs}
81 </Box>
82 )
83 })
84
85 // The line that closes a turn ("Baked for 3s") becomes an eruption.
86 on('ui.render', { component: 'TurnDuration' }, async ($, e) => {
87 const { Box, Text } = $.ui.resolve(e)
88 const past = pick(PAST_WORDS, e.props.durationMs / 1000)
89
90 return (
91 <Box flexDirection="row" columnGap={1}>
92 <Text color={VESUVIUS.lava}>▲</Text>
93 <Text color={VESUVIUS.tuff}>
94 {past} for {formatDuration(e.props.durationMs)}
95 </Text>
96 </Box>
97 )
98 })
99}
100hooks/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
22