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

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

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.
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.
I mod sono una funzione di Claude Code, in early access.
| Dove | Cosa si vede |
|---|---|
| Claude Desktop, tab Code | la bubble animata |
| Terminale | una barra di testo con lo stesso consiglio |
| Estensione VS Code | per ora niente pannello: /bubble risponde in testo (#99045) |
| Chat e Cowork di Claude Desktop | niente: i mod non ci arrivano |
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.
| File | Cosa contiene |
|---|---|
hooks/register.tsx | gli eventi: misura del contesto, /compact, il comando, il disegno del pannello |
hooks/bubble.ts | la bubble in SVG con animazione SMIL, la scala, i testi |
types/index.d.ts | il contratto dello stato di sessione |
tests/bubble.test.ts | i test, su desktop, mobile e terminale |
claude plugin validate .
claude plugin test .
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.
Il codice è MIT (LICENSE). Il personaggio della bubble è parte del brand AI, MAX: si può usare dentro questo mod, non come marchio altrove.
hooks/register.tsx 112 lines1import { 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}
112hooks/bubble.ts 134 lines1import 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}
134types/index.d.ts 22 lines1/**
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