A band above the Claude Code prompt: an animated mini Claude that acts out what Claude is doing, who is working (model, effort, plan) and live ctx / 5h / 7d…

Uma faixa viva acima do prompt do Claude Code: um mini Claude que encena o que o Claude está fazendo, quem está trabalhando (modelo, effort, plano) e medidores de contexto e de limite de uso que se atualizam em tempo real.

É um mod: um plugin de function hooks que roda dentro do Claude Code e desenha na interface a 30 quadros por segundo. Uma statusline comum não chega lá, porque o Claude Code redesenha a statusLine no máximo uma vez por segundo, e nenhuma animação fica fluida assim.
O mini Claude. É o bonequinho da tela de boas-vindas, desenhado em pixels de quadrante (2×2 por caractere). A cena muda conforme o que está acontecendo:

| Cena | Quando aparece | Verbos do spinner que caem nela |
|---|---|---|
| cozinhando | Bash | Sautéing, Brewing, Baking, Simmering… |
| pensando | Read | Pondering, Musing, Cogitating… |
| construindo | Edit, Write | Forging, Crafting, Tinkering… |
| passeando | Grep, Glob, busca na web | Meandering, Moseying, Zigzagging… |
| fazendo mágica | Agent, Skill, workflows | Enchanting, Clauding, Levitating… |
| dançando | escrevendo a resposta | Moonwalking, Grooving, Vibing… |
| cultivando | listas de tarefas | Sprouting, Germinating, Hatching… |
| na ventania | ferramentas de MCP | Swirling, Gusting, Thundering… |
/nome Kiko dá um nome a ele, e a legenda passa a dizer "Kiko está cozinhando".Quem está trabalhando. Modelo, effort (com os mesmos pips coloridos dos medidores), pasta, branch e custo da sessão. Em plano Pro ou Max, o custo aparece como "via API" ao lado de um selo do plano: é quanto aquela sessão custaria pagando por token, e não o que você está pagando.
Os medidores. Contexto restante, limite de 5 horas e limite semanal, cada um com o horário de reset:

▏▎▍▌▋▊▉).Painel de subagents. Um script de subagentStatusLine deixa as linhas do painel de agentes no mesmo estilo. Cada linha mostra:

Você precisa de:
No prompt do Claude Code:
/plugin install dashboard --marketplace kyso1/claude-code-dashboard
Responda y para adicionar o marketplace e escolha o escopo de usuário.
git clone https://github.com/kyso1/claude-code-dashboard ~/.claude/mods/dashboard
Em ~/.claude/settings.json:
{
"env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/dashboard" }
}
A pasta fica vigiada, então editar um arquivo recarrega o mod na hora.
{
"subagentStatusLine": { "type": "command", "command": "bash ~/.claude/mods/dashboard/extras/subagent-statusline.sh" }
}
Se instalou pelo marketplace, aponte para o extras/subagent-statusline.sh dentro da pasta do plugin. Precisa de jq. A faixa já mostra o que uma statusline costuma mostrar, então dá para remover a statusLine se você usar uma.
| O quê | Onde |
|---|---|
| Velocidade, largura e pausa do brilho | SPEED, RADIUS, PAUSE em hooks/gauges.ts |
| Largura das barras e limite do pulso | MAX_W, MIN_W, LOW em hooks/gauges.ts |
| Tempo mínimo de cada cena | HOLD_MS em hooks/clawd.ts |
| Qual ferramenta vira qual cena | TOOL_SCENES em hooks/clawd.ts |
| Quadros por segundo | FPS em hooks/register.tsx |
O mini Claude e o selo do modelo usam a cor claude do seu tema customizado, se ele definir uma. Sem isso, ficam no laranja do Claude.
hooks/
register.tsx eventos do Claude Code -> estado -> faixa
clawd.ts o mini Claude: sprite, cenas, legenda
gauges.ts medidores, rampa de cor e brilho
identity.ts modelo, effort, pasta, branch, custo e plano
paint.ts cores OKLCH e a grade de células do Raster
extras/
subagent-statusline.sh
| Hook | Para quê |
|---|---|
ui.render em AbovePrompt | desenha a faixa como um Raster (cada célula com glifo, cor de frente e de fundo) |
$.clock.every(33ms) + $.ui.blit | repinta as células 30 vezes por segundo sem refazer a interface |
ui.render em Spinner | lê o verbo e o modo do spinner, sem mudar o que ele desenha |
tool.call | qual ferramenta está rodando e com qual argumento |
session.start, session.measure | contexto, limites de 5h/7d e custo |
classic.Stop, classic.PostToolUse | o effort em uso no turno |
prompt.submit, turn.complete | começo e fim do turno |
command.run | o /nome, salvo em $.store entre sessões |
O plano vem de ~/.claude.json (oauthAccount.organizationType). O mod não lê o arquivo de credenciais.
Três armadilhas que custaram tempo:
$: a validação dos mods só aceita passar $ para funções declaradas no topo do arquivo.claude plugin validate .
claude plugin test . # 22 testes: cenas, rosto alinhado, rampa de cor, brilho, legenda…
As imagens deste README saem do próprio código do mod. docs/render.mts desenha as grades de células num canvas e monta os GIFs com ffmpeg:
PLAYWRIGHT_FROM=/caminho/de/um/projeto/com/playwright/package.json npx -y tsx docs/render.mts docs
MIT
hooks/register.tsx 207 lines1import type { EngineInterface as Dollar, Register, SessionContextUsage, SessionRateLimit } from 'claude-code'
2
3import { STAGE_W, caption, direct, sceneFor, stage, type Mood, type Scene, type Tool } from './clawd'
4import { gaugeRow, type Gauge } from './gauges'
5import { CAVEMAN, PONYTAIL, identityRow, planOf, prettyModel, type Identity } from './identity'
6import { CLAUDE, encode, fit, fromHex, safe, type Cell, type Paint } from './paint'
7
8// The band above the prompt: mini Claude on the left; on the right who is
9// working (model, effort, folder, branch, cost), what it is doing, and the
10// fuel gauges. A mod, not a statusLine command: that redraws at 1 fps at best,
11// and the animations need ~30.
12const FPS = 30
13const KEY = 'dashboard'
14const GAP: Cell[] = [{ ch: ' ', fg: CLAUDE }, { ch: ' ', fg: CLAUDE }]
15const MIN_RIGHT = 60 // narrower than this beside the stage: drop the stage
16const NAME_MAX = 16
17
18export const register: Register = on => {
19 let gauges: Gauge[] = []
20 let mood: Mood = { working: false, since: Date.now() }
21 let tool: Tool | undefined
22 let accent: Paint = CLAUDE
23 let ident: Identity = { modes: [] }
24 let band: { requestId: string; columns: number; rows: number } | null = null
25 let name: string | undefined // the mascot's, set with /nome; kept across sessions in $.store
26 const director: { scene?: Scene; since: number } = { since: 0 }
27
28 const measure = (context: SessionContextUsage, limits: SessionRateLimit[], usd?: number) => {
29 const limit = (kind: string, label: string): Gauge => {
30 const l = limits.find(r => r.kind === kind)
31 return { label, left: l ? 100 - l.percentUsed : null, resetsAt: l?.resetsAt ? Date.parse(l.resetsAt) : undefined }
32 }
33 gauges = [
34 { label: 'ctx', left: context.percent === undefined ? null : 100 - context.percent },
35 limit('five_hour', '5h'),
36 limit('seven_day', '7d'),
37 ]
38 if (usd !== undefined) ident = { ...ident, usd }
39 }
40
41 const rows = (now: number, columns: number): Cell[][] => {
42 const right = columns - STAGE_W - GAP.length
43 if (!mood.working) director.scene = undefined // the next turn opens on its own scene
44 const scene = mood.working ? direct(director, sceneFor(mood, tool), now) : undefined
45 const side = (w: number) => [identityRow(ident, accent), caption(mood, tool, now, w, accent, scene, name), gaugeRow(gauges, now, now, w)]
46 if (right < MIN_RIGHT) return side(columns).map(row => fit(row, columns))
47 const rhs = side(right)
48 return stage(mood, now, accent, scene).map((row, i) => [...row, ...GAP, ...fit(rhs[i] ?? [], right)])
49 }
50
51 on('session.start', async ($, e, next) => {
52 const usage = await $.session.usage()
53 measure(usage.context, usage.rateLimits, usage.cost?.usd)
54 accent = await themeAccent($)
55 const stored = await $.store.get('name')
56 name = typeof stored === 'string' && stored ? stored : undefined
57 await $.command.register({
58 name: 'nome',
59 description: 'Dá um nome ao mini Claude da faixa (sem nome: mostra o atual; "-": apaga)',
60 argumentHint: '<nome>',
61 })
62 const polled = await readIdentity($)
63 ident = { ...ident, ...polled, effort: ident.effort ?? polled.effort }
64
65 $.clock.every(1000 / FPS, async () => {
66 if (!band) return
67 const now = Date.now()
68 const grid = rows(now, band.columns)
69 if (grid.length !== band.rows) {
70 band = null
71 $.ui.invalidate('ui.render')
72 return
73 }
74 const { deny } = await $.ui.blit({ requestId: band.requestId, key: KEY, cells: encode(grid, band.columns) })
75 if (deny) band = null // collapsed or unmounted; the next render brings it back
76 })
77 // ponytail: who and where are polled every 5s, not hooked per way each can change
78 $.clock.every(5000, async () => {
79 accent = await themeAccent($)
80 const polled = await readIdentity($)
81 ident = { ...ident, ...polled, effort: ident.effort ?? polled.effort } // a reported effort wins
82 })
83 return next(e)
84 })
85
86 on('session.measure', ($, e, next) => {
87 measure(e.context, e.rateLimits, e.cost?.usd)
88 return next(e)
89 })
90
91 // the live effort, as the status line had it: carried by the turn's hook events
92 const effortOf = (e: { agent_id?: string; effort?: { level: string } }) => {
93 if (!e.agent_id && e.effort?.level) ident = { ...ident, effort: e.effort.level }
94 }
95 on('classic.PostToolUse', ($, e, next) => (effortOf(e), next(e)))
96 on('classic.Stop', ($, e, next) => (effortOf(e), next(e)))
97
98 // what the mascot acts out: the turn, the spinner's verb and mode, the tool
99 on('prompt.submit', ($, e, next) => {
100 mood = { working: true, since: Date.now() }
101 return next(e)
102 })
103 on('ui.render', { component: 'Spinner' }, ($, e, next) => {
104 mood = { ...mood, working: true, word: e.props.word, message: e.props.message, mode: e.props.mode }
105 return next(e)
106 })
107 on('tool.call', async ($, e, next) => {
108 if (e.agentId) return next(e)
109 const id = e.tool_use_id
110 tool = { id, name: String(e.tool), detail: detail(e as unknown as Record<string, unknown>) }
111 try {
112 return await next(e)
113 } finally {
114 if (tool?.id === id) tool = undefined
115 }
116 })
117 on('turn.complete', ($, e, next) => {
118 if (!e.agentId) mood = { working: false, since: mood.since, doneAt: Date.now(), durationMs: e.durationMs }
119 tool = undefined
120 return next(e)
121 })
122
123 on('command.run', { command: 'nome' }, async ($, e) => {
124 const asked = safe(e.args.trim()).slice(0, NAME_MAX).trim()
125 if (!asked) return { text: name ? `O mini Claude se chama ${name}.` : 'O mini Claude ainda não tem nome. Use /nome <nome>.' }
126 if (asked === '-') {
127 await $.store.delete('name')
128 name = undefined
129 return { text: 'Nome apagado.' }
130 }
131 await $.store.set('name', asked)
132 name = asked
133 return { text: `Agora o mini Claude se chama ${asked}.` }
134 })
135
136 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
137 if (e.surface !== 'terminal' || e.props.hasSurvey || !gauges.length) return next(e)
138 const { Box, Raster } = $.ui.resolve(e)
139 const columns = e.props.bodyColumns
140 const grid = rows(Date.now(), columns)
141 band = { requestId: e.requestId, columns, rows: grid.length }
142 return (
143 <Box marginTop={1}>
144 <Raster key={KEY} columns={columns} rows={grid.length} cells={encode(grid, columns)} />
145 </Box>
146 )
147 })
148}
149
150// Model, folder, branch, plan and modes: cheap reads. Effort only as settings have it;
151// the turn's own hook events report the live one.
152async function readIdentity($: Dollar): Promise<Omit<Identity, 'usd'>> {
153 const cwd = await $.session.cwd()
154 const home = (await $.env.get('HOME')) ?? ''
155 const flag = (f: string) => $.fs.read(`${home}/.claude/${f}`).then(s => s.trim() !== '', () => false)
156 const [model, branch, caveman, ponytail, plan] = await Promise.all([
157 $.session.model(),
158 $.process.run(['git', '-C', cwd, 'branch', '--show-current']).then(r => r.stdout.trim(), () => ''),
159 flag('.caveman-active'),
160 flag('.ponytail-active'),
161 $.fs.read(`${home}/.claude.json`).then(s => planOf(JSON.parse(s)), () => undefined),
162 ])
163 return {
164 model: prettyModel(model),
165 effort: await settingsEffort($, home, model),
166 folder: cwd.split('/').filter(Boolean).at(-1),
167 branch: branch || undefined,
168 plan,
169 modes: [...(caveman ? [CAVEMAN] : []), ...(ponytail ? [PONYTAIL] : [])],
170 }
171}
172
173// The first argument that says what a call is about: a command, a path, a query.
174function detail(args: Record<string, unknown>): string {
175 for (const k of ['command', 'file_path', 'pattern', 'url', 'query', 'description', 'skill', 'prompt']) {
176 const v = args[k]
177 if (typeof v !== 'string' || !v.trim()) continue
178 const first = v.trim().split('\n')[0] ?? ''
179 return k === 'file_path' ? first.split('/').slice(-2).join('/') : first
180 }
181 return ''
182}
183
184// Effort before the first turn reports it: the model's own setting, then the global one.
185async function settingsEffort($: Dollar, home: string, model: string): Promise<string | undefined> {
186 try {
187 const s = JSON.parse(await $.fs.read(`${home}/.claude/settings.json`))
188 return s?.modelSettings?.[model]?.effortLevel ?? s?.effortLevel
189 } catch {
190 return undefined
191 }
192}
193
194// The mascot and the model badge wear the theme's `claude` color when the theme
195// is a custom one (~/.claude/themes/<slug>.json) that sets it; orange otherwise.
196async function themeAccent($: Dollar): Promise<Paint> {
197 try {
198 const theme = (await $.config.list()).find(r => r.key === 'theme')?.value
199 if (typeof theme !== 'string' || !theme.startsWith('custom:')) return CLAUDE
200 const home = await $.env.get('HOME')
201 const file = JSON.parse(await $.fs.read(`${home}/.claude/themes/${theme.slice('custom:'.length)}.json`))
202 return fromHex(String(file?.overrides?.claude ?? '')) ?? CLAUDE
203 } catch {
204 return CLAUDE
205 }
206}
207hooks/clawd.ts 296 lines1// Mini Claude: the welcome screen's mascot, drawn in quadrant pixels (2x2 a
2// cell) on a 3-row stage, acting out the spinner's verb. Each of the 189 verbs
3// Claude Code samples belongs to one of eight scenes; a verb it learns later
4// falls back on the spinner's mode.
5
6import { CLAUDE, FAINT, TEXT, fit, lift, text, type Cell, type Paint } from './paint'
7
8export const STAGE_W = 22 // cells
9const PW = STAGE_W * 2 // pixels
10const PH = 6
11
12export type Scene = 'cook' | 'think' | 'build' | 'dance' | 'walk' | 'magic' | 'grow' | 'wind'
13export type Mode = 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use'
14export type Mood = { working: boolean; word?: string; message?: string | null; mode?: Mode; since: number; doneAt?: number; durationMs?: number }
15export type Tool = { id: string; name: string; detail: string }
16
17export const VERBS: Record<Scene, string> = {
18 cook: 'Baking Blanching Brewing Bunning Caramelizing Churning Concocting Cooking Crunching Drizzling Fermenting Flambéing Frosting Garnishing Infusing Julienning Kneading Leavening Marinating Onioning Percolating Proofing Sautéing Seasoning Simmering Smooshing Stewing Tempering Whisking Zesting',
19 think: 'Befuddling Bloviating Calculating Cerebrating Cogitating Computing Considering Contemplating Deciphering Deliberating Determining Elucidating Envisioning Flummoxing Hashing Ideating Imagining Inferring Mulling Musing Perusing Philosophizing Pondering Pontificating Processing Puzzling Reticulating Ruminating Thinking',
20 build: 'Accomplishing Actioning Actualizing Architecting Bootstrapping Coalescing Combobulating Composing Crafting Creating Crystallizing Doing Doodling Effecting Embellishing Finagling Forging Forming Generating Gitifying Herding Mustering Newspapering Orchestrating Polishing Puttering Recombobulating Sketching Synthesizing Tinkering Working Wrangling',
21 dance: "Beboppin' Boogieing Booping Canoodling Choreographing Frolicking Gesticulating Grooving Harmonizing Honking Improvising Jitterbugging Moonwalking Razzle-dazzling Razzmatazzing Shimmying Sock-hopping Vibing",
22 walk: 'Boondoggling Burrowing Catapulting Dilly-dallying Fiddle-faddling Flibbertigibbeting Gallivanting Galloping Hullaballooing Kerfuffling Lollygagging Meandering Moseying Noodling Perambulating Pouncing Scampering Schlepping Scurrying Shenaniganing Skedaddling Slithering Spelunking Swooping Tomfoolering Topsy-turvying Waddling Wandering Whatchamacalliting Zigzagging',
23 magic: 'Beaming Channeling Clauding Discombobulating Enchanting Hyperspacing Ionizing Levitating Manifesting Metamorphosing Nucleating Orbiting Prestidigitating Quantumizing Sublimating Transfiguring Transmogrifying Transmuting Warping',
24 grow: 'Cultivating Germinating Hatching Incubating Nesting Osmosing Photosynthesizing Pollinating Propagating Roosting Sprouting Symbioting Unfurling',
25 wind: 'Billowing Cascading Ebbing Flowing Fluttering Gusting Misting Nebulizing Precipitating Spinning Swirling Thundering Twisting Undulating Unraveling Whirlpooling Whirring Wibbling',
26}
27export const LABELS: Record<Scene, string> = {
28 cook: 'cozinhando', think: 'pensando', build: 'construindo', dance: 'dançando',
29 walk: 'passeando', magic: 'fazendo mágica', grow: 'cultivando', wind: 'na ventania',
30}
31const MODE_LABELS: Record<Mode, string> = {
32 requesting: 'esperando a API', responding: 'escrevendo a resposta', thinking: 'pensando',
33 'tool-input': 'preparando a ferramenta', 'tool-use': 'usando ferramenta',
34}
35const BY_VERB = new Map(
36 (Object.keys(VERBS) as Scene[]).flatMap(s => VERBS[s].split(' ').map(v => [v.toLowerCase(), s] as const)),
37)
38
39export function sceneOf(word: string | undefined, mode?: Mode): Scene {
40 const key = (word ?? '').replace(/(…|\.\.\.)$/, '').trim().toLowerCase()
41 const hit = BY_VERB.get(key)
42 if (hit) return hit
43 return mode === 'tool-use' || mode === 'tool-input' ? 'build' : mode === 'responding' ? 'dance' : 'think'
44}
45
46// --- pixels ------------------------------------------------------------------
47
48type Canvas = { px: (Paint | null)[]; glyphs: Map<number, Cell> }
49const QUAD = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█' // index = UL 1 | UR 2 | LL 4 | LR 8
50
51const blankCanvas = (): Canvas => ({ px: new Array<Paint | null>(PW * PH).fill(null), glyphs: new Map() })
52function dot(cv: Canvas, x: number, y: number, p: Paint | null) {
53 x = Math.round(x)
54 y = Math.round(y)
55 if (x >= 0 && x < PW && y >= 0 && y < PH) cv.px[y * PW + x] = p
56}
57function glyph(cv: Canvas, cx: number, cy: number, ch: string, fg: Paint, bg?: Paint) {
58 if (cx >= 0 && cx < STAGE_W && cy >= 0 && cy < 3) cv.glyphs.set(cy * STAGE_W + cx, { ch, fg, bg })
59}
60
61function toCells(cv: Canvas): Cell[][] {
62 return [0, 1, 2].map(cy =>
63 Array.from({ length: STAGE_W }, (_, cx): Cell => {
64 const g = cv.glyphs.get(cy * STAGE_W + cx)
65 if (g) return g
66 const quad = [cv.px[2 * cy * PW + 2 * cx], cv.px[2 * cy * PW + 2 * cx + 1], cv.px[(2 * cy + 1) * PW + 2 * cx], cv.px[(2 * cy + 1) * PW + 2 * cx + 1]]
67 let bits = 0
68 const votes = new Map<Paint, number>()
69 quad.forEach((p, i) => {
70 if (!p) return
71 bits |= 1 << i
72 votes.set(p, (votes.get(p) ?? 0) + 1)
73 })
74 const fg = [...votes].sort((a, b) => b[1] - a[1])[0]?.[0] ?? TEXT
75 return { ch: QUAD[bits] ?? ' ', fg }
76 }),
77 )
78}
79
80// --- the mascot ----------------------------------------------------------------
81
82type Eyes = 'front' | 'up' | 'shut'
83type Arm = 'out' | 'up' | 'down'
84type Pose = { eyes: Eyes; armL: Arm; armR: Arm; legs: 0 | 1 | 2 }
85
86// 18x5 pixels, as the welcome screen's ▐▛███▜▌ / ▝▜█████▛▘ / ▘▘ ▝▝
87// Drawn only on the cell grid (`cell` is a whole column, y always 0): a sprite
88// one pixel off it recombines the quadrants into a slanted blob with no eyes.
89// So motion is whole-cell steps, arms, legs, eyes and color, never half cells;
90// and the eyes only move within their own cell (front <-> up), never sideways.
91const EYES: Record<Eyes, number[][]> = { front: [[5, 1], [12, 1]], up: [[5, 0], [12, 0]], shut: [] }
92const ARMS: Record<Arm, number[][]> = { out: [[1, 2], [2, 2]], up: [[2, 1], [1, 0]], down: [[2, 3], [1, 4]] }
93const LEGS = [[4, 6, 11, 13], [3, 6, 11, 14], [4, 7, 10, 13]]
94
95function clawd(cv: Canvas, cell: number, pose: Pose, c: Paint) {
96 const x0 = cell * 2
97 for (let y = 0; y < 4; y++) for (let x = 3; x <= 14; x++) dot(cv, x0 + x, y, c)
98 for (const [x = 0, y = 0] of EYES[pose.eyes]) dot(cv, x0 + x, y, null)
99 for (const [x = 0, y = 0] of ARMS[pose.armL]) dot(cv, x0 + x, y, c)
100 for (const [x = 0, y = 0] of ARMS[pose.armR]) dot(cv, x0 + 17 - x, y, c)
101 for (const x of LEGS[pose.legs] ?? []) dot(cv, x0 + x, 4, c)
102}
103
104const beat = (t: number, ms: number) => Math.floor(t / ms)
105const blink = (t: number, eyes: Eyes): Eyes => (t % 3700 < 130 ? 'shut' : eyes)
106const hash = (n: number) => Math.imul(n ^ 0x9e3779b9, 0x85ebca6b) >>> 0
107const glow = (c: Paint, on: boolean): Paint => (on ? lift(c, 0.1, 0.8) : c)
108
109const STEEL: Paint = [0.55, 0.02, 250]
110const STEAM: Paint = [0.88, 0.01, 250]
111const FLAME: Paint = [0.7, 0.18, 40]
112const FLAME2: Paint = [0.84, 0.16, 85]
113const GOLD: Paint = [0.88, 0.15, 95]
114const WOOD: Paint = [0.52, 0.08, 60]
115const LEAF: Paint = [0.76, 0.17, 145]
116const PINK: Paint = [0.76, 0.15, 350]
117const WATER: Paint = [0.76, 0.11, 235]
118const CLOUD: Paint = [0.82, 0.01, 250]
119const BUBBLE: Paint = [0.9, 0.02, 260]
120
121const SCENES: Record<Scene, (cv: Canvas, t: number, c: Paint) => void> = {
122 cook(cv, t, c) {
123 clawd(cv, 1, { eyes: blink(t, 'front'), armL: 'down', armR: beat(t, 380) % 2 ? 'out' : 'up', legs: 0 }, c)
124 for (let x = 21; x <= 24; x++) dot(cv, x, 3, STEEL) // handle
125 dot(cv, 25, 3, STEEL)
126 dot(cv, 35, 3, STEEL)
127 for (let x = 26; x <= 34; x++) dot(cv, x, 4, STEEL)
128 for (let x = 27; x <= 33; x += 2) dot(cv, x + (beat(t, 130) % 2), 5, (beat(t, 130) + x) % 3 ? FLAME : FLAME2)
129 const hop = Math.round(Math.abs(Math.sin((t / 560) * Math.PI)) * 3)
130 dot(cv, 29, 3 - hop, GOLD)
131 dot(cv, 30, 3 - hop, GOLD)
132 for (const k of [0, 1]) {
133 const p = (t / 1100 + k / 2) % 1
134 dot(cv, 27 + k * 5 + Math.sin(p * 6.28), 2 - Math.floor(p * 3), STEAM)
135 }
136 },
137 think(cv, t, c) {
138 // eyes up, hand to the chin; bubbles rise into a cloud with the thought in it
139 clawd(cv, 1, { eyes: blink(t, 'up'), armL: 'out', armR: 'up', legs: 0 }, c)
140 const step = beat(t, 380) % 12
141 if (step >= 1) dot(cv, 21, 3, BUBBLE)
142 if (step >= 2) dot(cv, 23, 2, BUBBLE), dot(cv, 24, 2, BUBBLE)
143 if (step >= 3) {
144 for (let x = 27; x <= 34; x++) dot(cv, x, 0, BUBBLE), dot(cv, x, 2, BUBBLE)
145 for (let x = 26; x <= 35; x++) dot(cv, x, 1, BUBBLE)
146 glyph(cv, 15, 0, ['?', '…', '!', '…'][beat(t, 600) % 4] ?? '?', [0.32, 0.04, 260], BUBBLE)
147 }
148 },
149 build(cv, t, c) {
150 const down = beat(t, 300) % 2 === 1
151 clawd(cv, 1, { eyes: blink(t, 'front'), armL: 'down', armR: down ? 'out' : 'up', legs: 0 }, c)
152 for (let x = 24; x <= 31; x++) dot(cv, x, 4, WOOD), dot(cv, x, 5, [0.42, 0.07, 60])
153 if (down) {
154 for (let x = 19; x <= 22; x++) dot(cv, x, 3, WOOD)
155 for (const [x, y] of [[23, 2], [24, 2], [23, 3], [24, 3]] as const) dot(cv, x, y, STEEL)
156 if (t % 600 < 220) glyph(cv, 13, 0, '✦', GOLD), glyph(cv, 14, 1, '·', GOLD), glyph(cv, 11, 0, '·', GOLD)
157 } else {
158 dot(cv, 19, 1, WOOD)
159 for (const [x, y] of [[20, 0], [21, 0], [20, 1], [21, 1]] as const) dot(cv, x, y, STEEL)
160 }
161 },
162 dance(cv, t, c) {
163 const b = beat(t, 260)
164 const left = b % 2 === 0
165 clawd(cv, b % 4 < 2 ? 1 : 2, { eyes: blink(t, b % 8 < 6 ? 'front' : 'up'), armL: left ? 'up' : 'out', armR: left ? 'out' : 'up', legs: left ? 1 : 2 }, glow(c, b % 4 === 0))
166 for (const k of [0, 1, 2]) {
167 const p = (t / 1500 + k / 3) % 1
168 glyph(cv, 12 + k * 3 + Math.floor(p * 2), 2 - Math.floor(p * 3), k % 2 ? '♫' : '♪', [0.8, 0.14, (t / 20 + k * 120) % 360])
169 }
170 },
171 walk(cv, t, c) {
172 const span = STAGE_W - 9 // cells of road
173 const s = beat(t, 300) % (2 * span)
174 const right = s < span
175 const cell = right ? s : 2 * span - s
176 const step = s % 2
177 clawd(cv, cell, { eyes: blink(t, 'front'), armL: step ? 'out' : 'down', armR: step ? 'down' : 'out', legs: step ? 1 : 2 }, c)
178 glyph(cv, right ? cell - 1 : cell + 9, 2, step ? '·' : ' ', FAINT)
179 },
180 magic(cv, t, c) {
181 clawd(cv, 1, { eyes: blink(t, 'up'), armL: 'up', armR: 'up', legs: 0 }, glow(c, beat(t, 400) % 2 === 0))
182 const slot = beat(t, 260)
183 for (let k = 0; k < 5; k++) {
184 const h = hash(slot * 7 + k)
185 const at = 10 + ((h >>> 3) % 12)
186 glyph(cv, at, (h >>> 8) % 3, '✦✧⋆·'[(h >>> 12) % 4] ?? '·', [0.84, 0.13, [300, 200, 90][(h >>> 14) % 3] ?? 300])
187 }
188 if (beat(t, 400) % 2 === 0) glyph(cv, 0, 0, '✧', GOLD)
189 },
190 grow(cv, t, c) {
191 const cycle = (t / 650) % 9
192 clawd(cv, 1, { eyes: blink(t, 'front'), armL: 'down', armR: 'out', legs: 0 }, c)
193 for (let x = 27; x <= 32; x++) dot(cv, x, 5, WOOD)
194 for (let x = 28; x <= 31; x++) dot(cv, x, 4, WOOD)
195 const h = Math.min(4, Math.floor(cycle))
196 for (let i = 1; i <= h; i++) dot(cv, 29, 4 - i, LEAF)
197 if (h >= 2) dot(cv, 28, 2, LEAF)
198 if (h >= 3) dot(cv, 30, 1, LEAF)
199 if (cycle >= 5) dot(cv, 28, 0, PINK), dot(cv, 30, 0, PINK), dot(cv, 29, 0, GOLD)
200 else {
201 const p = (t / 420) % 1
202 dot(cv, 20 + p * 7, 3 + Math.round(p * p * 2), WATER)
203 }
204 },
205 wind(cv, t, c) {
206 const flap = beat(t, 300) % 2
207 clawd(cv, beat(t, 900) % 2 ? 2 : 1, { eyes: blink(t, 'front'), armL: flap ? 'up' : 'out', armR: flap ? 'out' : 'up', legs: 0 }, c)
208 for (let x = 28; x <= 35; x++) dot(cv, x, 0, CLOUD)
209 for (let x = 26; x <= 37; x++) dot(cv, x, 1, CLOUD)
210 for (let k = 0; k < 4; k++) dot(cv, 27 + k * 3, 2 + Math.floor(((t / 260 + k * 0.37) % 1) * 4), WATER)
211 glyph(cv, 21 - (beat(t, 110) % 10), 2, '~', FAINT)
212 },
213}
214
215function idle(cv: Canvas, t: number, c: Paint, asleep: boolean) {
216 clawd(cv, 1, { eyes: asleep ? 'shut' : blink(t, beat(t, 5200) % 4 === 3 ? 'up' : 'front'), armL: 'out', armR: 'out', legs: 0 }, c)
217 if (asleep) {
218 const p = (t / 1600) % 1
219 glyph(cv, 10 + Math.floor(p * 2), 1 - Math.floor(p * 2), p < 0.5 ? 'z' : 'Z', FAINT)
220 }
221}
222
223function party(cv: Canvas, t: number, c: Paint) {
224 const b = beat(t, 200)
225 clawd(cv, 1, { eyes: 'front', armL: b % 2 ? 'up' : 'out', armR: b % 2 ? 'out' : 'up', legs: (b % 2) + 1 as 1 | 2 }, glow(c, b % 2 === 0))
226 for (const [cx, cy, d] of [[0, 0, 0], [10, 0, 1], [11, 2, 2], [0, 2, 3]] as const)
227 if ((beat(t, 150) + d) % 2) glyph(cv, cx, cy, '✦', GOLD)
228}
229
230// What it acts out: the tool in use when there is one, the turn's verb between
231// tools. The verb is sampled once a turn, so a long turn would play one scene
232// throughout; the tools keep it changing.
233const TOOL_SCENES: [RegExp, Scene][] = [
234 [/^(Bash|PowerShell|Monitor)$/, 'cook'],
235 [/^(Read|NotebookRead)$/, 'think'],
236 [/^(Grep|Glob|LS|WebFetch|WebSearch|ToolSearch)$/, 'walk'],
237 [/^(Edit|MultiEdit|Write|NotebookEdit)$/, 'build'],
238 [/^(Agent|Task|Workflow|Skill|SendMessage)$/, 'magic'],
239 [/^(TodoWrite|Task(Create|Update)|Artifact)/, 'grow'],
240 [/^mcp__/, 'wind'],
241]
242export function sceneFor(m: Mood, tool?: Tool): Scene {
243 for (const [re, scene] of tool ? TOOL_SCENES : []) if (re.test(tool?.name ?? '')) return scene
244 return sceneOf(m.word, m.mode)
245}
246
247// A scene holds HOLD_MS before the next takes over, so a burst of quick tools
248// does not flicker.
249export const HOLD_MS = 1500
250export function direct(d: { scene?: Scene; since: number }, want: Scene, now: number): Scene {
251 if (d.scene === undefined || (want !== d.scene && now - d.since >= HOLD_MS)) {
252 d.scene = want
253 d.since = now
254 }
255 return d.scene
256}
257
258// --- what the band draws -------------------------------------------------------
259
260export const DONE_MS = 2500
261const SLEEP_MS = 90_000
262type Phase = 'work' | 'done' | 'idle' | 'sleep'
263
264export function phaseOf(m: Mood, now: number): Phase {
265 if (m.working) return 'work'
266 if (m.doneAt !== undefined && now - m.doneAt < DONE_MS) return 'done'
267 return now - (m.doneAt ?? m.since) > SLEEP_MS ? 'sleep' : 'idle'
268}
269
270export function stage(m: Mood, t: number, accent: Paint = CLAUDE, scene?: Scene): Cell[][] {
271 const cv = blankCanvas()
272 const phase = phaseOf(m, t)
273 if (phase === 'work') SCENES[scene ?? sceneOf(m.word, m.mode)](cv, t, accent)
274 else if (phase === 'done') party(cv, t, accent)
275 else idle(cv, t, accent, phase === 'sleep')
276 return toCells(cv)
277}
278
279const STAR = '·✢✳✶✻✽✻✶✳✢'
280const secs = (ms: number) => (ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`)
281
282export function caption(m: Mood, tool: Tool | undefined, t: number, width: number, accent: Paint = CLAUDE, scene?: Scene, name?: string): Cell[] {
283 const phase = phaseOf(m, t)
284 const who = name ? `${name} ` : ''
285 if (phase === 'done') return fit(text(name ? `✓ ${name} terminou em ${secs(m.durationMs ?? 0)}` : `✓ feito em ${secs(m.durationMs ?? 0)}`, [0.8, 0.15, 150]), width)
286 if (phase === 'sleep') return fit(text(`z ${who}${name ? 'está ' : ''}dormindo`, FAINT), width)
287 if (phase === 'idle') return fit(text(`✻ ${who}${name ? 'está ' : ''}pronto`, FAINT), width)
288
289 const star = STAR[beat(t, 120) % STAR.length] ?? '✻'
290 const said = (m.message ?? m.word ?? 'Working').replace(/(…|\.\.\.)$/, '')
291 const label = LABELS[scene ?? sceneOf(m.word, m.mode)]
292 const mode = m.mode && MODE_LABELS[m.mode] !== label ? ` ${MODE_LABELS[m.mode]}` : '' // not "pensando pensando"
293 const doing = tool ? [...text(` › ${tool.name} `, TEXT), ...text(tool.detail, FAINT)] : text(mode, FAINT)
294 return fit([...text(`${star} ${said}…`, accent), ...text(` ${name ? `${name} está ${label}` : label}`, FAINT), ...doing], width)
295}
296hooks/gauges.ts 102 lines1// The gauge row: ctx, 5h and 7d fuel left. Each gauge is a rounded badge, a
2// rounded capsule bar (1/8-cell precision over a dark track of its own hue)
3// and its percentage, on one continuous OKLCH ramp: green at 100% -> red at 0%,
4// the same stops as the effort pips in ~/.claude/statusline.sh. A glint sweeps
5// the row, foreground and background; under LOW% a gauge breathes.
6
7import { CAP_L, CAP_R, FAINT, TEXT, type Cell, type Paint } from './paint'
8
9// tune: glint speed (columns/s), radius (columns), pause between sweeps (ms)
10export const SPEED = 80
11export const RADIUS = 2.5
12export const PAUSE = 1800
13const MAX_W = 16 // bar cells, shrunk down to MIN_W in a narrow band
14const MIN_W = 6
15const GAP = ' '
16const LOW = 15 // % left under which a gauge breathes
17const BREATH_MS = 1400
18
19export type Gauge = { label: string; left: number | null; resetsAt?: number }
20type Base = { ch: string; fg: Paint; bg?: Paint; breathe?: boolean }
21
22const WEEKDAYS = ['dom', 'seg', 'ter', 'qua', 'qui', 'sex', 'sáb']
23const clamp = (v: number) => Math.min(100, Math.max(0, v))
24
25// fuel left -> its place on the ramp; i = 0 green .. 4 red
26export function ramp(left: number): Paint {
27 const i = 4 * (1 - clamp(left) / 100)
28 return [0.82 - i * 0.035, 0.15 + i * 0.01, 150 - i * 32]
29}
30
31function when(resetsAt: number | undefined, now: number): string {
32 if (resetsAt === undefined) return '--'
33 const d = new Date(resetsAt)
34 const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
35 return d.toDateString() === new Date(now).toDateString() ? hm : `${WEEKDAYS[d.getDay()]} ${hm}`
36}
37
38function layout(gauges: Gauge[], now: number, columns: number): Base[] {
39 const tail = (g: Gauge) => (g.label === 'ctx' ? '' : ` ↻ ${when(g.resetsAt, now)}`)
40 const fixed =
41 gauges.reduce((n, g) => n + g.label.length + 2 + 1 + 2 + 5 + tail(g).length, 0) + GAP.length * (gauges.length - 1)
42 const w = Math.max(MIN_W, Math.min(MAX_W, Math.floor((columns - fixed) / gauges.length)))
43
44 const out: Base[] = []
45 const put = (s: string, fg: Paint, bg?: Paint, breathe?: boolean) => {
46 for (const ch of s) out.push({ ch, fg, bg, breathe })
47 }
48
49 gauges.forEach((g, n) => {
50 if (n) put(GAP, TEXT)
51 const known = g.left !== null
52 const left = clamp(g.left ?? 0)
53 const [L, C, hue] = ramp(left)
54 const tint = known ? C : 0
55 const low = known && left < LOW
56
57 const badge: Paint = [0.33, tint * 0.4, hue]
58 put(CAP_L, badge, undefined, low)
59 put(g.label.toUpperCase(), [0.9, 0.06 * Math.sign(tint), hue], badge, low)
60 put(CAP_R, badge, undefined, low)
61 put(' ', TEXT)
62
63 const track: Paint = [0.26, tint * 0.25, hue]
64 const exact = (left * w) / 100
65 let full = Math.floor(exact)
66 let eighth = Math.round((exact - full) * 8)
67 if (eighth === 8) (full += 1), (eighth = 0)
68 const fill = (i: number): Paint => [L - 0.07 * (1 - i / Math.max(1, w - 1)), C, hue] // darker base, bright head
69 put(CAP_L, known && full > 0 ? fill(0) : track)
70 for (let i = 0; i < w; i++) {
71 if (!known) put('╌', FAINT, track)
72 else if (i < full) put('█', fill(i), track)
73 else if (i === full && eighth) put(' ▏▎▍▌▋▊▉'[eighth] ?? ' ', fill(i), track)
74 else put(' ', TEXT, track)
75 }
76 put(CAP_R, known && full >= w ? fill(w - 1) : track)
77
78 put((known ? `${Math.round(left)}%` : '--').padStart(5), known ? [L, C, hue] : FAINT, undefined, low)
79 const t = tail(g)
80 if (t) put(t.slice(0, 2), FAINT), put(t.slice(2), TEXT)
81 })
82 return out
83}
84
85// The row at time t (ms): the glint is a soft bump running left to right, then
86// resting for PAUSE; it lifts lightness and washes chroma, background too, so
87// it crosses the tracks as a streak.
88export function gaugeRow(gauges: Gauge[], now: number, t: number, columns = Infinity): Cell[] {
89 const cells = layout(gauges, now, columns)
90 const period = ((cells.length + 6 * RADIUS) / SPEED) * 1000 + PAUSE
91 const pos = ((t % period) / 1000) * SPEED - 3 * RADIUS
92 const breath = 0.5 + 0.5 * Math.sin((2 * Math.PI * t) / BREATH_MS)
93 const lit = ([L, C, h]: Paint, k: number, b: number): Paint => [L + 0.16 * k + b, C * (1 - 0.5 * k), h]
94
95 return cells.map((c, x) => {
96 const d = x - pos
97 const k = Math.exp((-d * d) / (2 * RADIUS * RADIUS))
98 const b = c.breathe ? 0.09 * breath : 0
99 return { ch: c.ch, fg: lit(c.fg, k, b), bg: c.bg && lit(c.bg, k, b) }
100 })
101}
102hooks/identity.ts 58 lines1// The identity row, moved here from ~/.claude/statusline.sh: model, effort,
2// folder, branch, cost and the active modes, in the band's own look (a rounded
3// badge in the theme's accent, effort pips on the gauges' ramp).
4
5import { ramp } from './gauges'
6import { CAP_L, CAP_R, FAINT, TEXT, text, type Cell, type Paint } from './paint'
7
8export type Identity = { model?: string; effort?: string; folder?: string; branch?: string; usd?: number; plan?: string; modes: string[] }
9
10const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max']
11const GAP = ' '
12const PLAN: Paint = [0.42, 0.09, 85] // a gold badge
13const icon = (code: number) => String.fromCharCode(code) // Nerd Font glyphs, as the old status line drew them
14export const BOLT = icon(0xf0e7)
15const FOLDER = icon(0xf07b)
16const BRANCH = icon(0xe0a0)
17export const CAVEMAN = icon(0xf6d5)
18export const PONYTAIL = icon(0xf1b0)
19
20// The Pro or Max plan, from ~/.claude.json's oauthAccount (no secrets there):
21// on one, the session's cost is what the API would have charged, not a bill.
22export function planOf(config: { oauthAccount?: { organizationType?: string; organizationRateLimitTier?: string } }): string | undefined {
23 const type = config.oauthAccount?.organizationType
24 const tier = /_(\d+x)$/.exec(config.oauthAccount?.organizationRateLimitTier ?? '')?.[1]
25 if (type === 'claude_max') return tier ? `Max ${tier}` : 'Max'
26 if (type === 'claude_pro') return 'Pro'
27 return undefined
28}
29
30// "claude-opus-5-5" -> "Opus 5.5"; an alias ("opus") just capitalized
31export function prettyModel(id: string): string {
32 const m = /claude-([a-z]+)-(\d+)(?:-(\d+))?/.exec(id)
33 const name = m?.[1] ?? id
34 const title = name.charAt(0).toUpperCase() + name.slice(1)
35 return m ? `${title} ${m[2]}${m[3] ? `.${m[3]}` : ''}` : title
36}
37
38export function identityRow(id: Identity, accent: Paint): Cell[] {
39 const [, C, hue] = accent
40 const badge: Paint = [0.33, C * 0.45, hue]
41 const out: Cell[] = []
42 const gap = () => out.length && out.push(...text(GAP, TEXT))
43
44 if (id.model) out.push({ ch: CAP_L, fg: badge }, ...text(`${BOLT} ${id.model}`, [0.9, C * 0.5, hue], badge), { ch: CAP_R, fg: badge })
45 if (id.effort) {
46 gap()
47 const n = EFFORTS.indexOf(id.effort)
48 if (n >= 0) for (let i = 0; i < 5; i++) out.push({ ch: i <= n ? '▰' : '▱', fg: i <= n ? ramp(100 - 25 * i) : FAINT })
49 out.push(...text(n >= 0 ? ` ${id.effort}` : id.effort, n >= 0 ? ramp(100 - 25 * n) : FAINT))
50 }
51 if (id.folder) gap(), out.push(...text(`${FOLDER} `, [0.74, 0.11, 300]), ...text(id.folder, TEXT))
52 if (id.branch) gap(), out.push(...text(`${BRANCH} `, [0.8, 0.11, 165]), ...text(id.branch, TEXT))
53 if (id.usd !== undefined) gap(), out.push(...text(`$${id.usd.toFixed(2)}${id.plan ? ' via API' : ''}`, FAINT))
54 if (id.plan) out.push(...text(' ', TEXT), { ch: CAP_L, fg: PLAN }, ...text(id.plan, [0.92, 0.08, 85], PLAN), { ch: CAP_R, fg: PLAN })
55 if (id.modes.length) out.push(...text(` ${id.modes.join(' ')}`, FAINT))
56 return out
57}
58hooks/paint.ts 82 lines1// Colors in OKLCH and the Raster cell grid they are painted into.
2
3export type Paint = readonly [L: number, C: number, hue: number]
4export type Cell = { ch: string; fg: Paint; bg?: Paint }
5
6export const TEXT: Paint = [0.7, 0.03, 275]
7export const FAINT: Paint = [0.47, 0.02, 275]
8export const CLAUDE = fromHex('#d77757') ?? [0.67, 0.13, 40]
9const DEFAULT = 0x01000000 // the terminal's own color
10
11const enc = (x: number) =>
12 x <= 0 ? 0 : x >= 1 ? 255 : Math.round(255 * (x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055))
13
14export function rgb([L, C, deg]: Paint): number {
15 const h = (deg * Math.PI) / 180
16 const a = C * Math.cos(h)
17 const b = C * Math.sin(h)
18 const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3
19 const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3
20 const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3
21 return (
22 (enc(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s) << 16) |
23 (enc(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s) << 8) |
24 enc(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)
25 )
26}
27
28// '#rrggbb' / '#rgb' -> OKLCH; undefined for anything else (ansi names, rgb())
29export function fromHex(hex: string): Paint | undefined {
30 const m = /^#([0-9a-f]{6}|[0-9a-f]{3})$/i.exec(hex.trim())
31 if (!m?.[1]) return undefined
32 const s = m[1].length === 3 ? m[1].replace(/./g, c => c + c) : m[1]
33 const [r, g, b] = [0, 2, 4].map(i => {
34 const c = parseInt(s.slice(i, i + 2), 16) / 255
35 return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
36 }) as [number, number, number]
37 const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b)
38 const mm = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b)
39 const ss = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b)
40 const A = 1.9779984951 * l - 2.428592205 * mm + 0.4505937099 * ss
41 const B = 0.0259040371 * l + 0.7827717662 * mm - 0.808675766 * ss
42 return [0.2104542553 * l + 0.793617785 * mm - 0.0040720468 * ss, Math.hypot(A, B), (Math.atan2(B, A) * 180) / Math.PI]
43}
44
45export const lift = ([L, C, h]: Paint, dL: number, cMul = 1): Paint => [L + dL, C * cMul, h]
46
47export const CAP_L = String.fromCharCode(0xe0b6) // Nerd Font half circles: rounded ends
48export const CAP_R = String.fromCharCode(0xe0b4)
49
50// Raster takes one printable width-1 BMP character a cell: keep ASCII, Latin
51// accents and the glyphs drawn here; anything else (emoji, CJK) becomes '·'.
52const DRAWN = /[\u2026\u203a\u2190-\u21ff\u22c6\u2500-\u25ff\u266a\u266b\u2713\u2722-\u273d\ue000-\uf8ff]/ // last range: Nerd Font icons
53export const safe = (s: string) =>
54 [...s].map(ch => (/[\x20-\x7e\u00a0-\u024f]/.test(ch) || DRAWN.test(ch) ? ch : '\u00b7')).join('')
55
56export const text = (s: string, fg: Paint, bg?: Paint): Cell[] => [...safe(s)].map(ch => ({ ch, fg, bg }))
57
58export function fit(cells: Cell[], width: number): Cell[] {
59 if (cells.length <= width) return cells
60 return [...cells.slice(0, Math.max(0, width - 1)), { ch: '…', fg: cells[width - 1]?.fg ?? FAINT }]
61}
62
63export function encode(rows: Cell[][], columns: number): string {
64 const words = new Uint32Array(rows.length * columns * 3)
65 rows.forEach((row, y) => {
66 for (let x = 0; x < columns; x++) {
67 const c = row[x]
68 const i = (y * columns + x) * 3
69 words[i] = c?.ch.codePointAt(0) ?? 0x20
70 words[i + 1] = c ? rgb(c.fg) : DEFAULT
71 words[i + 2] = c?.bg ? rgb(c.bg) : DEFAULT
72 }
73 })
74 return new Uint8Array(words.buffer).toBase64()
75}
76
77declare global {
78 interface Uint8Array {
79 toBase64(): string // ES2026: in the engine's runtime, not yet in lib es2023
80 }
81}
82