SLOPSHOPPER

dashboard

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…

newbandspinnerguardcommandprompt
v0.3.0MITupdated 2026-10-08kyso1/claude-code-dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · dashboard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /nome ⎿ dashboard: O mini Claude ainda não tem nome. Use /nome <nome>. ✦ ▐▛███▜▙▘  Opus 5.5 app $0.42 ▝▜█████▌ ✓ feito em 42s ▝ ▘ ▝ ▘ ✦ CTX ███ 51% 5H ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✦ ▐▛███▜▙▘  Opus 5.5 app $0.42 ▝▜█████▌ ✓ feito em 42s ▝ ▘ ▝ ▘ ✦ CTX ███ 51% 5H
README

claude-code-dashboard

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.

A faixa durante um turno: o mini Claude pensa, cozinha enquanto o Bash roda, constrói enquanto edita um arquivo e comemora no fim

É 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 que tem na faixa

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:

As oito cenas do mini Claude

CenaQuando apareceVerbos do spinner que caem nela
cozinhandoBashSautéing, Brewing, Baking, Simmering…
pensandoReadPondering, Musing, Cogitating…
construindoEdit, WriteForging, Crafting, Tinkering…
passeandoGrep, Glob, busca na webMeandering, Moseying, Zigzagging…
fazendo mágicaAgent, Skill, workflowsEnchanting, Clauding, Levitating…
dançandoescrevendo a respostaMoonwalking, Grooving, Vibing…
cultivandolistas de tarefasSprouting, Germinating, Hatching…
na ventaniaferramentas de MCPSwirling, Gusting, Thundering…
  • Os 189 verbos que o Claude Code sorteia para o spinner estão distribuídos nessas oito cenas.
  • Durante a ferramenta, a cena acompanha a ferramenta em uso. Entre uma e outra, volta para a do verbo do turno. Cada cena fica pelo menos 1,5 s na tela para não piscar.
  • Fora de trabalho, ele pisca quando está à toa, dorme depois de 90 s parado e comemora quando o turno termina.
  • /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:

Medidores de 96%, 58% e 9%, com o brilho passando

  • Cor: contínua, do verde (cheio) ao vermelho (vazio), calculada em OKLCH, então não há degraus nem faixas escuras no meio do caminho.
  • Precisão: as barras preenchem com 1/8 de célula (▏▎▍▌▋▊▉).
  • Brilho: atravessa os três medidores a cada 3 segundos.
  • Alerta: abaixo de 15%, o medidor pulsa.

Painel de subagents. Um script de subagentStatusLine deixa as linhas do painel de agentes no mesmo estilo. Cada linha mostra:

  • status animado;
  • minigráfico de quantos tokens o agente gastou a cada atualização;
  • contexto usado;
  • tempo rodando;
  • modelo e effort.

Linhas do painel de subagents

Instalação

Você precisa de:

  • um Claude Code com suporte a mods (plugins of function hooks); testado na 2.1.295, e a API ainda é de acesso antecipado;
  • um terminal com truecolor e uma Nerd Font, para os ícones e as pontas arredondadas.

Pelo marketplace

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.

Pelo clone

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.

Linhas de subagents (opcional)

{
  "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.

Personalizar

O quêOnde
Velocidade, largura e pausa do brilhoSPEED, RADIUS, PAUSE em hooks/gauges.ts
Largura das barras e limite do pulsoMAX_W, MIN_W, LOW em hooks/gauges.ts
Tempo mínimo de cada cenaHOLD_MS em hooks/clawd.ts
Qual ferramenta vira qual cenaTOOL_SCENES em hooks/clawd.ts
Quadros por segundoFPS 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.

Como funciona

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
HookPara quê
ui.render em AbovePromptdesenha a faixa como um Raster (cada célula com glifo, cor de frente e de fundo)
$.clock.every(33ms) + $.ui.blitrepinta as células 30 vezes por segundo sem refazer a interface
ui.render em Spinnerlê o verbo e o modo do spinner, sem mudar o que ele desenha
tool.callqual ferramenta está rodando e com qual argumento
session.start, session.measurecontexto, limites de 5h/7d e custo
classic.Stop, classic.PostToolUseo effort em uso no turno
prompt.submit, turn.completecomeço e fim do turno
command.runo /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:

  • Sprite fora da grade: um sprite em pixels de quadrante só pode andar de célula inteira em célula inteira. Um pixel fora da grade e os quadrantes se recombinam numa mancha torta.
  • Verbo do turno: o verbo do spinner é sorteado uma vez por turno. Por isso a cena segue a ferramenta, senão um turno longo teria uma animação só.
  • Onde passar $: a validação dos mods só aceita passar $ para funções declaradas no topo do arquivo.

Desenvolvimento

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

Licença

MIT

Source 5 files
hooks/register.tsx 207 lines
1import 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}
207
hooks/clawd.ts 296 lines
1// 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}
296
hooks/gauges.ts 102 lines
1// 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}
102
hooks/identity.ts 58 lines
1// 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}
58
hooks/paint.ts 82 lines
1// 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