SLOPSHOPPER

context-map

A bar above the prompt showing how the context window fills up by category, in Vesuvius colors

newband
v0.1.0no licenseupdated 2026-10-10pompeitech/affreschi/plugins/context-map
A shopper browsing a rack in a slop shop
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
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 changedTerminal and desktop
context-mapA 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 legendTerminal and desktop

The palette

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.

Tests

claude plugin validate ./plugins/<mod-name>
claude plugin test ./plugins/<mod-name>
Source 3 files
hooks/register.tsx 137 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ContextMap, Segment } from '../types'
5import { SEQUENCE, VESUVIUS } from './palette'
6
7const map = atom({ plugin: 'context-map', key: 'map' } as const, null)
8const isExpanded = atom({ plugin: 'context-map', key: 'isExpanded' } as const, false)
9
10export function formatTokens(n: number): string {
11  return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
12}
13
14// How many cells of the bar each segment gets: any segment with tokens gets at least one,
15// and the total never exceeds the width.
16export function allocateCells(segments: readonly Segment[], max: number, width: number): number[] {
17  const cells = segments.map(s => {
18    const t = s.kind === 'deferred' || max <= 0 ? 0 : s.tokens
19
20    return t > 0 ? Math.max(1, Math.round((t / max) * width)) : 0
21  })
22  let excess = cells.reduce((a, b) => a + b, 0) - width
23
24  // On overflow, take from the free space first, then from the largest shares.
25  segments.forEach((s, i) => {
26    const c = cells[i] ?? 0
27    if (excess > 0 && s.kind === 'free') {
28      const cut = Math.min(excess, c)
29      cells[i] = c - cut
30      excess -= cut
31    }
32  })
33  while (excess > 0) {
34    const largest = Math.max(...cells)
35    if (largest <= 1) break
36    const i = cells.indexOf(largest)
37    cells[i] = largest - 1
38    excess -= 1
39  }
40
41  return cells
42}
43
44export function colorOf(segments: readonly Segment[], i: number): string {
45  const s = segments[i]
46  if (s === undefined || s.kind !== 'used') return VESUVIUS.ash
47  const usedBefore = segments.slice(0, i).filter(x => x.kind === 'used').length
48
49  return SEQUENCE[usedBefore % SEQUENCE.length] ?? VESUVIUS.lava
50}
51
52export function alertColor(percent: number): string {
53  return percent >= 85 ? VESUVIUS.lava : percent >= 60 ? VESUVIUS.magma : VESUVIUS.tuff
54}
55
56async function refresh($: EngineInterface): Promise<void> {
57  const usage = await $.session.usage({ breakdown: 'summary' })
58  const b = usage.context.breakdown
59  if (b === undefined) return
60
61  const next: ContextMap = {
62    percent: b.percentage,
63    total: b.totalTokens,
64    max: b.rawMaxTokens,
65    // What is used first, then the compaction reserve, then the free space.
66    segments: [
67      ...b.categories.filter(c => c.kind === 'used'),
68      ...b.categories.filter(c => c.kind === 'buffer'),
69      ...b.categories.filter(c => c.kind === 'free'),
70    ].map(c => ({ name: c.name, tokens: c.tokens, kind: c.kind })),
71  }
72  await update($, map, () => next)
73}
74
75export const register: Register = on => {
76  on('session.start', async ($, e, next) => {
77    await refresh($).catch(() => undefined)
78
79    return next(e)
80  })
81
82  on('turn.complete', async ($, e, next) => {
83    const result = await next(e)
84    await refresh($).catch(() => undefined)
85
86    return result
87  })
88
89  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
90    const m = await read($, map)
91    if (e.props.hasSurvey || m === null || m.max <= 0) return next(e)
92
93    const { Box, Text, Button } = $.ui.resolve(e)
94    const isOpen = await read($, isExpanded)
95    const width = Math.max(10, Math.min(60, e.props.bodyColumns - 30))
96    const cells = allocateCells(m.segments, m.max, width)
97    const used = m.segments.filter(s => s.kind === 'used')
98
99    return (
100      <Box flexDirection="column">
101        <Box flexDirection="row" columnGap={1}>
102          <Text color={VESUVIUS.lava}>▲</Text>
103          <Text>
104            {m.segments.map((s, i) => (
105              <Text color={colorOf(m.segments, i)} dimColor={s.kind === 'free'}>
106                {(s.kind === 'used' ? '█' : s.kind === 'buffer' ? '▒' : '░').repeat(cells[i] ?? 0)}
107              </Text>
108            ))}
109          </Text>
110          <Text color={alertColor(m.percent)} bold={m.percent >= 85}>
111            {m.percent}%
112          </Text>
113          <Text color={VESUVIUS.ash}>
114            {formatTokens(m.total)}/{formatTokens(m.max)}
115          </Text>
116          <Button
117            key="details"
118            plain
119            label={isOpen ? 'close' : 'details'}
120            onPress={() => update($, isExpanded, v => !v)}
121          />
122        </Box>
123        {isOpen &&
124          used.map(s => (
125            <Text>
126              {'  '}
127              <Text color={colorOf(m.segments, m.segments.indexOf(s))}>■</Text> {s.name}{' '}
128              <Text color={VESUVIUS.ash}>
129                {formatTokens(s.tokens)} · {Math.round((s.tokens / m.max) * 100)}%
130              </Text>
131            </Text>
132          ))}
133      </Box>
134    )
135  })
136}
137
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 15 lines
1export type Segment = { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' | 'deferred' }
2
3export type ContextMap = {
4  percent: number
5  total: number
6  max: number
7  segments: Segment[]
8}
9
10declare module 'claude-code' {
11  interface PluginState {
12    'context-map': { map: ContextMap | null; isExpanded: boolean }
13  }
14}
15