SLOPSHOPPER

bubble-contesto

La bubble AI, MAX che si riempie con il contesto della sessione

newpanecommand
v0.1.0MITupdated 2026-10-06maxturazzini/bubble-contesto
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bubble-contesto
│ ┃ bubble-contesto ✕ › fix the failing auth test and add an audit log call │ ┃ (███████████████░░░░░░░░░░░░░░░) 49% │ ┃ Contesto 97k su 200k token ⏺ Read(src/auth.ts) │ ┃ C'è spazio. ⎿ 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 │ │ › /bubble │ ⎿ bubble-contesto: Bubble spenta. /bubble la riaccende. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · bubble-contesto
(███████████████░░░░░░░░░░░░░░░) 49% Contesto 97k su 200k token C'è spazio.
README

bubble-contesto

Un mod di Claude Code: un pannello con la bubble AI, MAX che si riempie con il contesto della sessione.

La bubble a cinque livelli di contesto

Il contesto è la cosa più importante che non si vede: più la sessione lo accumula, più l'agente fatica a tenere a mente l'inizio. Questo mod lo rende visibile. Una bubble senza contesto è solo contorno; a ogni risposta il liquido sale, sopra il 70% sciaborda, al 90% la bubble guarda in alto e il pannello propone /compact. Dopo la compattazione si svuota.

Quando è piena

Il limite non è scritto nel mod: è la finestra di contesto del modello che state usando, e il mod la legge dal motore a ogni risposta. Se cambiate modello a metà sessione (/model), la scala cambia con lui: un modello da 200k token riempie la bubble a 200k, uno da 128k a 128k.

Vale anche per le finestre da 1 milione di token: la bubble è piena a un milione. Il tetto è la costante FULL_AT in hooks/bubble.ts.

Le immagini qui sopra mostrano un modello da 200k token.

Dove funziona

I mod sono una funzione di Claude Code, in early access.

DoveCosa si vede
Claude Desktop, tab Codela bubble animata
Terminaleuna barra di testo con lo stesso consiglio
Estensione VS Codeper ora niente pannello: /bubble risponde in testo (#99045)
Chat e Cowork di Claude Desktopniente: i mod non ci arrivano

Installazione

Come plugin, da questo repository:

claude plugin marketplace add maxturazzini/bubble-contesto
claude plugin install bubble-contesto@bubble-contesto

Oppure da una copia locale, per una sessione sola:

git clone https://github.com/maxturazzini/bubble-contesto
claude --plugin-dir ./bubble-contesto

Per averla in ogni sessione, compreso il tab Code di Claude Desktop, la cartella va in CLAUDE_CODE_PLUGIN_DIRS (per Desktop su macOS: launchctl setenv, poi riavvio dell'app).

In Desktop il mod si carica al primo messaggio della sessione. /bubble accende e spegne il pannello, e se lo spegni se lo ricorda anche nelle sessioni dopo.

Com'è fatto

FileCosa contiene
hooks/register.tsxgli eventi: misura del contesto, /compact, il comando, il disegno del pannello
hooks/bubble.tsla bubble in SVG con animazione SMIL, la scala, i testi
types/index.d.tsil contratto dello stato di sessione
tests/bubble.test.tsi test, su desktop, mobile e terminale
claude plugin validate .
claude plugin test .

English

A Claude Code mod that draws a speech-bubble mascot filling up with the session's context (scaled to the context window of the model in use, read live from the engine, up to 1M tokens), and suggests /compact when it's nearly full. It draws in the Claude Desktop Code tab (animated SVG) and in the terminal (text bar). The VS Code extension doesn't draw mod UI yet. UI strings are in Italian.

Licenza

Il codice è MIT (LICENSE). Il personaggio della bubble è parte del brand AI, MAX: si può usare dentro questo mod, non come marchio altrove.

Source 3 files
hooks/register.tsx 112 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage } from 'claude-code'
3
4import type { Fill } from '../types'
5import { EMPTY, bar, bubbleSvg, describeFill } from './bubble'
6
7const PANE = 'bubble-contesto'
8// in $.store, quindi vale fra una sessione e l'altra: true se la persona ha spento la bubble
9const HIDDEN = 'hidden'
10const fill = atom({ plugin: 'bubble-contesto', key: 'fill' } as const, EMPTY)
11
12const measured = (f: Fill, context: SessionContextUsage, now: number): Fill => {
13  const tokens =
14    context.tokens ?? (context.percent === undefined ? null : Math.round((context.percent / 100) * context.window))
15  if (tokens === f.tokens) return f
16  return { tokens, window: context.window, previous: f.tokens, changedAt: now, isCompacted: false }
17}
18
19const open = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Contesto' })
20
21export const register: Register = on => {
22  on('session.start', async ($, e, next) => {
23    await $.command.register({
24      name: 'bubble',
25      description: 'Accende e spegne la bubble che si riempie con il contesto della sessione',
26    })
27    const usage = await $.session.usage()
28    const now = await $.clock.now()
29    await update($, fill, f => measured(f, usage.context, now))
30    if ((await $.store.get(HIDDEN)) !== true) void open($)
31
32    return next(e)
33  })
34
35  on('command.run', { command: 'bubble' }, async $ => {
36    // dove nessuna superficie disegna (l'estensione VS Code, oggi) la bubble diventa testo
37    if ((await $.session.surfaces()).length === 0) {
38      const f = await read($, fill)
39      return { text: [bar(f, 20), ...describeFill(f)].join('\n') }
40    }
41
42    if ((await $.ui.panes()).some(pane => pane.id === PANE)) {
43      await $.ui.close({ id: PANE })
44      await $.store.set(HIDDEN, true)
45      return { text: 'Bubble spenta. /bubble la riaccende.' }
46    }
47
48    await $.store.set(HIDDEN, false)
49    const opened = await open($)
50    return { text: opened.isPlaced ? 'Bubble accesa.' : `Bubble accesa, in attesa: ${opened.reason}` }
51  })
52
53  // chiusa con la ✕ o con ctrl+x x: resta spenta anche nelle sessioni dopo
54  on('ui.close', { id: PANE }, async ($, e, next) => {
55    const result = await next(e)
56    if (e.origin.kind === 'person') await $.store.set(HIDDEN, true)
57
58    return result
59  })
60
61  on('session.measure', async ($, e, next) => {
62    if (e.changed.includes('context')) {
63      const now = await $.clock.now()
64      await update($, fill, f => measured(f, e.context, now))
65    }
66
67    return next(e)
68  })
69
70  // dopo /compact il motore non ha una misura fino alla risposta dopo: la bubble si svuota
71  on('session.compact', async ($, e, next) => {
72    const result = await next(e)
73    const now = await $.clock.now()
74    await update($, fill, f => ({ tokens: null, window: f.window, previous: f.tokens, changedAt: now, isCompacted: true }))
75
76    return result
77  })
78
79  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
80    const f = await read($, fill)
81    const [headline, hint] = describeFill(f)
82
83    if (e.surface === 'terminal') {
84      const { Box, Text } = $.ui.resolve(e)
85      const width = Math.max(10, Math.min(30, e.props.bodyColumns - 8))
86
87      return (
88        <Box flexDirection="column">
89          <Text key="bar">{bar(f, width)}</Text>
90          <Text key="headline">{headline}</Text>
91          <Text key="hint" dimColor>
92            {hint}
93          </Text>
94        </Box>
95      )
96    }
97
98    const { Box, Svg, Text } = $.ui.resolve(e)
99    const now = await $.clock.now()
100
101    return (
102      <Box flexDirection="column">
103        <Svg key="bubble" source={bubbleSvg(f, now)} alt={headline ?? 'Bubble del contesto'} width={240} isInteractive />
104        <Text key="headline">{headline}</Text>
105        <Text key="hint" dimColor>
106          {hint}
107        </Text>
108      </Box>
109    )
110  })
111}
112
hooks/bubble.ts 134 lines
1import type { Fill } from '../types'
2
3export const EMPTY: Fill = { tokens: null, window: null, previous: null, changedAt: null, isCompacted: false }
4
5// La bubble è piena quando è piena la finestra del modello, fino a un tetto di un milione
6// di token: anche le finestre da un milione si misurano intere.
7export const FULL_AT = 1_000_000
8
9// quanto resta la salita animata dopo un cambio di livello: un ridisegno successivo non la ripete
10const RISE_MS = 2_000
11
12// la bubble v2 del brand AI, MAX, in posa relax
13const ELLIPSE =
14  'M 80 134 C 80 80.97, 142.68 38, 220 38 C 297.32 38, 360 80.97, 360 134 C 360 187.03, 297.32 230, 220 230 C 142.68 230, 80 187.03, 80 134 Z'
15const TAIL = 'M 146 199 L 192 210 L 130 252 Z'
16const SHOE =
17  'M -13 -4 C -13 -13, 0 -15, 9 -11 C 20 -7, 28 -2, 28 5 C 28 10, 24 12, 18 12 L -8 12 C -12 12, -14 9, -14 5 Z'
18const CY = 134
19const RY = 96
20const BELOW = 300
21
22const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))
23
24export const scaleOf = (window: number | null): number => Math.min(window ?? FULL_AT, FULL_AT)
25
26export const percentOf = (tokens: number | null, window: number | null): number | null =>
27  tokens === null ? null : Math.round((tokens / scaleOf(window)) * 100)
28
29// la quota del liquido per una percentuale: conta l'area dell'ellisse, non l'altezza
30export const levelY = (percent: number): number => {
31  const target = clamp(percent, 0, 100) / 100
32  let lo = -1
33  let hi = 1
34  for (let i = 0; i < 40; i++) {
35    const u = (lo + hi) / 2
36    const area = 0.5 + (u * Math.sqrt(1 - u * u) + Math.asin(u)) / Math.PI
37    if (area < target) lo = u
38    else hi = u
39  }
40  return CY - ((lo + hi) / 2) * RY
41}
42
43// superficie del liquido attorno a y = 0; periodica in 2π, così il loop si chiude
44const wave = (amp: number, phase: number): string => {
45  let d = ''
46  for (let x = 60; x <= 380; x += 10) {
47    const y = amp * Math.sin(x * 0.045 + phase) + 0.5 * amp * Math.sin(x * 0.11 - 2 * phase)
48    d += `${d ? ' L' : 'M'} ${x} ${y.toFixed(2)}`
49  }
50  return `${d} L 380 ${BELOW} L 60 ${BELOW} Z`
51}
52
53const k = (n: number) =>
54  n >= 1_000_000 ? `${Number((n / 1_000_000).toFixed(1))}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
55
56export const describeFill = (f: Fill): string[] => {
57  if (f.tokens === null) {
58    return f.isCompacted
59      ? ['Contesto compattato', 'Si riparte da un riassunto della sessione.']
60      : ['Contesto vuoto', 'Si riempie man mano che la sessione accumula contesto.']
61  }
62  const p = percentOf(f.tokens, f.window) ?? 0
63  const hint =
64    p >= 100
65      ? 'Traboccata. Adesso /compact, oppure ricomincia da un riassunto.'
66      : p >= 90
67        ? 'Piena. Adesso /compact, oppure ricomincia da un riassunto.'
68        : p >= 75
69          ? 'Quasi piena. Se cambi argomento, meglio una sessione nuova.'
70          : p >= 50
71            ? "Si riempie. Più è piena, più l'agente fatica a tenere a mente l'inizio."
72            : "C'è spazio."
73  return [`Contesto ${k(f.tokens)} su ${k(scaleOf(f.window))} token`, hint]
74}
75
76export const bar = (f: Fill, width: number): string => {
77  const p = percentOf(f.tokens, f.window)
78  if (p === null) return `(${' '.repeat(width)})`
79  const n = Math.round((clamp(p, 0, 100) / 100) * width)
80  return `(${'█'.repeat(n)}${'░'.repeat(width - n)}) ${p}%`
81}
82
83export const bubbleSvg = (f: Fill, now: number): string => {
84  const p = percentOf(f.tokens, f.window)
85  const shown = p ?? 0
86  const amp = shown >= 90 ? 9 : shown >= 70 ? 6 : 3.5
87  const dur = shown >= 90 ? 1.6 : shown >= 70 ? 2.6 : 4
88  const frames = Array.from({ length: 9 }, (_, i) => wave(amp, (i / 8) * 2 * Math.PI))
89  const to = p === null ? BELOW : levelY(p)
90  const before = percentOf(f.previous, f.window)
91  const from = before === null ? BELOW : levelY(before)
92  const isRising = f.changedAt !== null && now - f.changedAt < RISE_MS && from !== to
93  const rise = isRising
94    ? `<animateTransform attributeName="transform" type="translate" from="0 ${from.toFixed(1)}" to="0 ${to.toFixed(1)}" dur="1.4s" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines="0.25 0.9 0.3 1"/>`
95    : ''
96  const liquid =
97    p === null && !isRising
98      ? ''
99      : `<g clip-path="url(#bc-clip)"><g transform="translate(0 ${to.toFixed(1)})">${rise}<path fill="url(#bc-grad)" d="${frames[0]}"><animate attributeName="d" dur="${dur}s" repeatCount="indefinite" values="${frames.join(';')}"/></path></g></g>`
100  // sopra il 90% la bubble guarda in alto, verso il pelo del liquido
101  const eyesY = shown >= 90 ? 114 : 124
102
103  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 0 460 330" width="460" height="330">
104<defs>
105<linearGradient id="bc-grad" gradientUnits="userSpaceOnUse" x1="80" y1="252" x2="360" y2="38"><stop offset="0" stop-color="#FF91C9"/><stop offset="1" stop-color="#35C2FF"/></linearGradient>
106<radialGradient id="bc-shadow" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="#000" stop-opacity="0.22"/><stop offset="100%" stop-color="#000" stop-opacity="0"/></radialGradient>
107<clipPath id="bc-clip"><path d="${ELLIPSE}"/><path d="${TAIL}"/></clipPath>
108<path id="bc-shoe" d="${SHOE}"/>
109</defs>
110<rect x="-10" y="0" width="460" height="330" rx="18" fill="#FAF7F2"/>
111<ellipse cx="220" cy="312" rx="110" ry="11" fill="url(#bc-shadow)"/>
112<g stroke="#1a1a1a" fill="none" stroke-linecap="round">
113<path d="M 190 222 C 190 250, 186 270, 186 298" stroke-width="20"/>
114<path d="M 250 222 C 250 250, 254 270, 254 298" stroke-width="20"/>
115<path d="M 112 176 C 84 190, 68 208, 64 236" stroke-width="18"/>
116<path d="M 328 176 C 356 190, 372 208, 376 236" stroke-width="18"/>
117</g>
118<g fill="#1a1a1a">
119<use href="#bc-shoe" transform="translate(186,298) scale(-1,1)"/>
120<use href="#bc-shoe" transform="translate(254,298)"/>
121<circle cx="64" cy="247" r="15"/>
122<circle cx="376" cy="247" r="15"/>
123</g>
124<g stroke="#111" stroke-width="9" stroke-linejoin="round" fill="none"><path d="${ELLIPSE}"/><path d="${TAIL}"/></g>
125<g fill="#FAF7F2"><path d="${ELLIPSE}"/><path d="${TAIL}"/></g>
126${liquid}
127<g transform="translate(220 ${eyesY})"><g>
128<animateTransform attributeName="transform" type="scale" values="1 1;1 1;1 0.08;1 1" keyTimes="0;0.93;0.965;1" dur="4.7s" repeatCount="indefinite"/>
129<ellipse cx="-40" cy="0" rx="14.7" ry="32.4" fill="#fff" stroke="#111" stroke-width="3"/>
130<ellipse cx="40" cy="0" rx="14.7" ry="32.4" fill="#fff" stroke="#111" stroke-width="3"/>
131</g></g>
132</svg>`
133}
134
types/index.d.ts 22 lines
1/**
2 * Quanto è piena la bubble: i token del contesto all'ultima misura del motore,
3 * più quelli di prima, da cui parte il liquido quando il livello cambia.
4 */
5export type Fill = {
6  /** token del contesto; null prima della prima risposta e subito dopo /compact */
7  tokens: number | null
8  /** la finestra del modello, in token */
9  window: number | null
10  /** i token della misura precedente */
11  previous: number | null
12  /** quando il livello è cambiato: la salita si anima solo nei secondi subito dopo */
13  changedAt: number | null
14  isCompacted: boolean
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'bubble-contesto': { fill: Fill }
20  }
21}
22