SLOPSHOPPER

braves-panel

Usage dashboard pane: session time, branch, tokens, cost, context and tools

paneguardcommandpromptprocesstimer
★ 1v0.1.0MITupdated 2026-10-16Carlos-Vera/braves-skills/mods/braves-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · braves-panel
│ ┃ braves-panel ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./panel.tsx │ ┃ ⏺ 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 │ ┃ │ ┃ › /braves-panel │ ┃ ⎿ braves-panel: Panel de Braves cerrado. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · braves-panel
▣ client module ./panel.tsx
README

<img src="media/Braves_Skills_black.svg" alt="braves-skills" width="360">

<a href="https://github.com/Carlos-Vera/braves-skills/releases"><img src="https://img.shields.io/github/v/release/Carlos-Vera/braves-skills" alt="Release"></a> <a href="LICENSE"><img src="https://img.shields.io/github/license/Carlos-Vera/braves-skills" alt="License"></a> <a href="https://github.com/Carlos-Vera/braves-skills/graphs/contributors"><img src="https://img.shields.io/github/contributors/Carlos-Vera/braves-skills" alt="Contributors"></a> <a href="CONTRIBUTING.md"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen" alt="PRs welcome"></a>

Español | English

braves-skills

La caja de herramientas todo-en-uno que uso en mi trabajo con Claude Code, posee 20 skills que cubren el ciclo de vida completo de un proyecto (13 skills de ciclo de vida + 7 skills de soporte), porque me volvia loco recordar 40 skills sueltas y sin saber si chocaban o no, braves skills resuelve esto.

Claude también le pasa trabajo a Gemini y te lo devuelve hecho. Necesitas las dos suscripciones.

Mi ciclo de trabajo con Claude Code

Puedes ejecutarlo así:

/braves-start → /fable-plan → /braves-opinion → [construye tu proyecto] →
/braves-security → /braves-audit → /braves-fix → /braves-ship → /braves-save
SkillQué hace
/braves-setupTe permite configurar tu entorno de trabajo para que Claude pueda trabajar contigo profesionalmente. Configura: identidad git, firma de commits (coautoría de IA OFF por defecto), política de PR/merge, Gemini Notebook opcional, adopción de tus propias skills/MCPs/plugins y chequeo de uso de lo instalado.
/braves-helpMuestra esta caja de ayuda para saber que skill usar para cada tarea.
/braves-startArranque de proyecto: te ayuda a crear: PRD, TRD, UI/UX, Flujo, Backend y Plan antes de tocar código.
/fable-planTe hace las preguntas que un arquitecto senior hace antes de construir → un plan por fases con verificación.
/braves-opinionDécimo Hombre: crítica constructiva sin adulación. Veredicto SHIP / SHIP WITH CHANGES / RETHINK / KILL.
/braves-geminiLe pasas una tarea a Gemini y la resuelve aparte, sin gastar el contexto de tu sesión. Ves en la barra qué está haciendo mientras lo hace. El frontend y la UI van aquí por defecto. Necesitas suscripción de Gemini, además de la de Claude.
/braves-securityEl Candado: realiza una auditoría de infraestructura (secretos, proxy de API, RLS, pooling, cache, rate limits, pruebas de carga con k6/Artillery) + código (OWASP).
/braves-auditAuditoría Global (seguridad + sobre-ingeniería + rendimiento). Escribe un braves-audit-DATE.md ejecutable en la raíz del repo.
/braves-fixArregla bugs con evidencia obligatoria; ejecuta el runbook braves-audit-DATE.md si existe uno.
/braves-shipCierre profesional: chequeos previos, commit con tu firma, PR/merge según tu configuración, checklist de release.
/braves-saveCierre de sesión: memorias + entrada de log al notebook AI Brain (Gemini Notebook). Se ofrece solo al pasar el umbral de contexto, y si quedan tareas le deja el traspaso preparado a la siguiente sesión que abras en ese proyecto, sin que copies nada. Cada bitácora lleva el tag del proyecto delante (PRJ-…) y va al cuaderno de ese proyecto, nunca al de otro.
/braves-notebookConecta la API completa de Gemini Notebook (fuentes, podcasts, reportes, quizzes, descargas) a Claude para usar como memoria y fuente de información, va de la mano con /braves-save: el save, al correr, crea/alimenta tu notebook AI Brain donde guarda la bitácora de tu sesión; conviene guardar antes de llegar al 40% de contexto de la sesión.
/braves-updateTe avisa al arrancar la sesión si hay versión nueva de Braves-skills (chequeo 1 vez al día) y la verifica/actualiza cuando tú digas, mostrando qué trae de nuevo.

Skills de soporte

SkillQué hace
/desarrolloPlanifica una feature y constrúyela mediante agentes delegados.
codebase-memoryConsultas estructurales de código mediante el grafo de codebase-memory-mcp.
delegate-by-defaultModo orquestador: despacha subagentes en vez de trabajar en línea, ideal para ahorro de tokens. Cada subagente arranca ya con las reglas de la casa inyectadas (artefactos en inglés, solución mínima, nada de nombres de clientes, verificar antes de decir "listo", firma de commit).
cavemanRespuestas ultracomprimidas: quita la paja, conserva cada detalle técnico. Gasta muchos menos tokens — tú y los subagentes que despachas.
humanizarVoz de marca para copy en español, puedes personalizarla con tu estilo de copy.
n8n-workflow-builderConstruye/depura workflows de n8n con validación de nodos y chequeo de CVE (vulnerabilidades).
wordpress-spanishTraducción es_ES para plugins de WordPress, ideal si creas plugins para WordPress.

Instalación

Clona (o copia) dentro del directorio de skills de Claude Code:

git clone https://github.com/Carlos-Vera/braves-skills ~/.claude/skills/braves-skills

Se auto-carga en la siguiente sesión como braves-skills@skills-dir (o ejecuta /reload-plugins para cargarla de inmediato). En la primera sesión, un hook detecta que aún no hay configuración y ofrece ejecutar /braves-setup.

Opcional: un panel lateral con el uso de la sesión y una mascota animada (bravesline sigue siendo la barra). /braves-setup lo instala si lo quieres, o a mano:

claude plugin marketplace add ~/.claude/skills/braves-skills
claude plugin install braves-panel@braves-skills

¿Dudas? Pregunta en Discussions — el foro es en español. Los bugs, a Issues.

Configuración

/braves-setup es un flujo de configuración y onboarding único (re-ejecutable en cualquier momento para cambiar valores luego). Pregunta una cosa a la vez:

  1. Idioma en el que Claude debe hablarte.
  2. Identidad git para los commits.
  3. Si Claude hace los commits por ti (always / ask / never).
  4. Firma de commit (pie de texto libre).
  5. Coautoría de IA en los commits — OFF por defecto.
  6. Política de PR y merge (¿crear PRs?, estrategia de merge, quién mergea, push directo a main — no por defecto) y política de releases (convención de versionado — patch por cambio, semver o la tuya propia; los releases nunca se publican sin preguntar, con recomendaciones en momentos clave).
  7. Integración opcional con Gemini Notebook (logs de sesión enviados a un notebook "AI Brain" mediante el CLI no oficial notebooklm-py, login de Google asistido por navegador).
  8. Voz de copy: por defecto humanizar escribe con la voz de BravesLab; si quieres la tuya, el setup te hace las preguntas necesarias (marca, tono, tuteo/usted, público, palabras vetadas, ejemplos) y guarda tu estilo en ~/.claude/braves-voice.md.
  9. Avisos sonoros: un tono cuando Claude necesita permiso y otro cuando termina la tarea, para que no tengas que vigilar la terminal. En Mac los eliges entre los ~120 tonos del sistema (sonidos de aviso, tonos de llamada y AlertTones) y el setup te los reproduce anunciando cada uno por número y nombre con voz en tu idioma; si prefieres no elegir, usa Iluminación para los permisos y Estelar para tarea terminada. En Windows suenan los sonidos estándar del sistema.
  10. Checkpoint de contexto: cuando la sesión pasa del porcentaje que elijas (40% por defecto) Claude te ofrece ejecutar /braves-save en el primer punto de corte limpio — no a mitad de una edición —, y si quedan tareas prepara el traspaso para que no tengas que copiar nada: la siguiente sesión que abras en ese proyecto lo recibe sola y sigue justo donde ibas. Abres y ya sabe dónde quedaron las cosas — no tienes que ponerlo al día. El traspaso queda en un CONTEXTO.md en la raíz del repo, para que lo veas y viaje con el proyecto si lo mueves de sitio; braves-save lo añade al .gitignore la primera vez, porque lleva dentro el estado de tu trabajo. El bloque igual se te muestra en pantalla, por si retomas desde otra máquina o desde fuera del proyecto. Nunca guarda sin tu sí, y vuelve a avisar cada 15 puntos por encima del umbral.
  11. MCPs opcionales, con configuración guiada: Perplexity (búsqueda web con IA), Firecrawl (rastreo/scraping de sitios), Chrome DevTools (debugging frontend), Playwright (automatización y pruebas de navegador), Codebase memory (grafo de conocimiento del código), n8n (construcción de workflows), Context7 (docs actualizadas de librerías).
  12. Adopción de tus propias skills, MCPs y plugins en la caja: las skills se copian al plugin, los MCPs extra entran al set curado, y los plugins se registran como parte de tu kit estándar para máquinas nuevas.
  13. Chequeo de uso (en re-ejecuciones): audita cada MCP, skill y plugin contra los transcripts de sesión y te dice los días sin uso — siempre con número: si algo nunca se usó, muestra cuándo se instaló y cuántos días de historial cubre el análisis. Antes de retirar algo te dice con qué choca y qué cubre el hueco; nunca desinstala sin tu sí explícito.

La configuración vive en ~/.claude/braves-skills.json:

{
  "version": 1,
  "language": "es",
  "github_user": "your-github-user",
  "git_name": "Your Name",
  "git_email": "you@example.com",
  "commits_by_claude": "ask",
  "coauthor_ai": false,
  "commit_signature": "Author: Your Name <you@example.com>",
  "pr": {
    "create": true,
    "merge_strategy": "squash",
    "who_merges": "user",
    "direct_push_main": false
  },
  "notebooklm": { "enabled": false },
  "projects": {
    "/ruta/absoluta/al/repo": { "tag": "PRJ", "name": "Nombre del proyecto", "notebook": "id-del-cuaderno" }
  },
  "voice": { "custom": false },
  "sounds": { "permission": "Illuminate", "done": "Stargaze" },
  "context_checkpoint": { "enabled": true, "threshold": 40 },
  "releases": { "versioning": "semver", "always_ask": true, "recommend_at_key_moments": true },
  "mcps": [],
  "plugins": [],
  "adopted_skills": []
}

Todas las skills te hablan en el language configurado (fallback: español por defecto si language no está definido).

Contribuir

Consulta CONTRIBUTING.md para saber cómo colaborar en el proyecto.

Contribuidores

master está protegida: se trabaja en rama nueva → PR → el mantenedor verifica y mergea (detalle en CONTRIBUTING.md). Cada PR mergeado te agrega a esta grilla y suma para tus badges de GitHub (Pull Shark y compañía) — los logros son de quien contribuye, no solo del mantenedor:

<a href="https://github.com/Carlos-Vera/braves-skills/graphs/contributors"> <img src="https://contrib.rocks/image?repo=Carlos-Vera/braves-skills" alt="Contribuidores" /> </a>

Créditos

  • Las skills de auditoría heredan la filosofía y el formato de ponytail (MIT, Dietrich Gebert), del cual este proyecto es un fork conceptual.
  • caveman es una adaptación del brazo de comparación benchmarks/arms/caveman-SKILL.md de ponytail (MIT, Dietrich Gebert), redistribuido con cambios: fuera los niveles wenyan, disparadores en español, un límite de idioma explícito y la salvaguarda de seguridad reforzada.
  • braves-save y braves-notebook son ports de BrainClaude (Carlos Vera).

Licencia

MIT — ver LICENSE.


Autor: Carlos Vera &lt;carlos@braveslab.com&gt;<br> <em>“Todo lo puedo en Cristo que me fortalece.” fil 4:13</em>

Source 7 files
hooks/register.tsx 252 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Account, Gemini, Lang, News, Turn } from '../types'
5
6import { LABELS, NEWS_TTL, accountOf, commitTitle, fmtDuration, langOf, parseGemini, planOf, tildePath, untilReset } from './format'
7import type { View } from './format'
8import { STAGE_ROWS } from './mascot'
9
10const PANE = 'braves-panel'
11const TITLE = 'Braves'
12// Wide enough for the context row (label, 12-cell bar, % and tokens) on one line.
13const WIDTH = 48
14const GEMINI_SCRIPT = '$HOME/.claude/skills/braves-skills/scripts/gemini-dispatch.sh'
15
16const turns = atom({ plugin: 'braves-panel', key: 'turns' } as const, [] as Turn[])
17const tools = atom({ plugin: 'braves-panel', key: 'tools' } as const, {} as Record<string, number>)
18const branch = atom({ plugin: 'braves-panel', key: 'branch' } as const, '')
19const now = atom({ plugin: 'braves-panel', key: 'now' } as const, 0)
20const lang = atom({ plugin: 'braves-panel', key: 'lang' } as const, 'es' as Lang)
21const account = atom({ plugin: 'braves-panel', key: 'account' } as const, null as Account | null)
22const isWorking = atom({ plugin: 'braves-panel', key: 'isWorking' } as const, false)
23const isOpen = atom({ plugin: 'braves-panel', key: 'isOpen' } as const, false)
24// Rows the pane shows at once; only ui.scroll tells, 0 until it does.
25const news = atom({ plugin: 'braves-panel', key: 'news' } as const, [] as News[])
26// A sprint: SPRINT or more agents running at once, or that many open tasks while Claude works.
27// The mascot jogs instead of typing. `sprint` itself is the demo's switch (/braves-panel sprint).
28const sprint = atom({ plugin: 'braves-panel', key: 'sprint' } as const, false)
29const crowd = atom({ plugin: 'braves-panel', key: 'crowd' } as const, 0)
30const tasks = atom({ plugin: 'braves-panel', key: 'tasks' } as const, 0)
31const SPRINT = 3
32const gemini = atom({ plugin: 'braves-panel', key: 'gemini' } as const, null as Gemini | null)
33// What Zeus should do next; the panel's Client plays it when `n` moves.
34const cue = atom({ plugin: 'braves-panel', key: 'cue' } as const, { name: 'hello', n: 0 })
35
36async function refreshLang($: EngineInterface) {
37  const r = await $.process.run(['sh', '-c', 'cat "$HOME/.claude/braves-skills.json" 2>/dev/null'], { timeoutMs: 2000 })
38  await update($, lang, () => langOf(r.stdout))
39}
40
41async function refreshAccount($: EngineInterface) {
42  const [home, auth] = await Promise.all([
43    $.process.run(['sh', '-c', 'printf %s "$HOME"'], { timeoutMs: 2000 }),
44    $.process.run(['sh', '-c', 'claude auth status 2>/dev/null'], { timeoutMs: 5000 }),
45  ])
46  await update($, account, () => accountOf(auth.stdout, home.stdout))
47}
48
49async function refreshBranch($: EngineInterface) {
50  const r = await $.process.run(['git', 'branch', '--show-current'], { cwd: await $.session.cwd(), timeoutMs: 2000 })
51  await update($, branch, () => (r.exitCode === 0 ? r.stdout.trim() || 'HEAD' : ''))
52}
53
54async function refreshCrowd($: EngineInterface) {
55  const n = (await $.agent.list()).filter(a => a.status === 'running' || a.status === 'pending').length
56  if ((await read($, crowd)) !== n) await update($, crowd, () => n)
57}
58
59async function refreshGemini($: EngineInterface) {
60  const r = await $.process.run(
61    ['sh', '-c', `[ -f "${GEMINI_SCRIPT}" ] && sh "${GEMINI_SCRIPT}" --status "$1" 2>/dev/null`, 'sh', await $.session.cwd()],
62    { timeoutMs: 2000 },
63  )
64  const next = parseGemini(r.stdout)
65  const prev = await read($, gemini)
66  if (next?.step && next.step !== prev?.step) tell($, 'gemini', next.step)
67  if (JSON.stringify(prev) !== JSON.stringify(next)) await update($, gemini, () => next)
68}
69
70// What Zeus should do next; kept for the image-sheet mascot.
71const motion = { still: false, typedAt: 0, failedAt: 0, }
72
73// The ticker keeps the latest few, newest first.
74function tell($: EngineInterface, kind: News['kind'], detail = '') {
75  void update($, news, list => [{ kind, detail, at: Date.now() }, ...list].slice(0, 6))
76}
77
78function say($: EngineInterface, name: string) {
79  void update($, cue, c => ({ name, n: c.n + 1 }))
80}
81
82export const register: Register = on => {
83  on('session.start', async ($, e, next) => {
84    await $.command.register({ name: 'braves-panel', description: 'Show or hide the Braves usage panel' })
85    await refreshLang($)
86    void refreshAccount($)
87    await refreshBranch($)
88    await update($, now, () => Date.now())
89    // ponytail: 30 s tick only moves the clock; usage figures redraw on session.measure.
90    $.clock.every(30000, () => void update($, now, () => Date.now()))
91    // A local shell call every 3 s, same cadence class as bravesline's refreshInterval; no tokens.
92    $.clock.every(3000, () => void refreshGemini($))
93    $.clock.every(2000, () => void refreshCrowd($))
94    motion.still = (await $.settings.read()).prefersReducedMotion === true
95    await update($, isOpen, () => true)
96    void $.ui.open({ id: PANE, title: TITLE, columns: WIDTH })
97    return next(e)
98  })
99
100  on('command.run', { command: 'braves-panel' }, async ($, e) => {
101    const L = LABELS[await read($, lang)]
102    // `/braves-panel sprint`: the jog and its backdrops for 80 s (four scenes), to see them without a busy turn.
103    if (e.args.trim() === 'sprint') {
104      await update($, sprint, () => true)
105      void $.clock.sleep(80000).then(() => update($, sprint, () => false))
106      return { text: L.sprintDemo }
107    }
108    if (['peek', 'sit', 'lift', 'scratch', 'sleep', 'neo'].includes(e.args.trim())) {
109      const act = e.args.trim() as 'peek' | 'sit' | 'lift' | 'scratch' | 'sleep' | 'neo'
110      say($, act)
111      return { text: L[act] }
112    }
113    const open = !(await read($, isOpen))
114    await update($, isOpen, () => open)
115    if (open) await $.ui.open({ id: PANE, title: TITLE, columns: WIDTH })
116    else await $.ui.close({ id: PANE })
117    return { text: open ? L.opened : L.closed }
118  })
119
120  on('tool.call', async ($, e, next) => {
121    await update($, tools, all => ({ ...all, [e.tool]: (all[e.tool] ?? 0) + 1 }))
122    // Open tasks: a TodoWrite list says them all; TaskCreate opens one, a TaskUpdate to completed/deleted closes one.
123    if (e.tool === 'TodoWrite') void update($, tasks, () => (e.todos ?? []).filter((t: { status?: string }) => t.status !== 'completed').length)
124    if (e.tool === 'TaskCreate') void update($, tasks, n => n + 1)
125    if (e.tool === 'TaskUpdate' && (e.status === 'completed' || e.status === 'deleted')) void update($, tasks, n => Math.max(0, n - 1))
126    // Claude needs you (a question) or has a plan ready: the mascot says so before it shows.
127    if (e.tool === 'AskUserQuestion') say($, 'point'), tell($, 'ask', e.questions?.[0]?.question ?? '')
128    if (e.tool === 'ExitPlanMode') say($, 'lightbulb'), tell($, 'plan')
129    const r = await next(e)
130    // Answered or reviewed: the headline has nothing left to say.
131    if (e.tool === 'AskUserQuestion' || e.tool === 'ExitPlanMode') {
132      const kind = e.tool === 'AskUserQuestion' ? 'ask' : 'plan'
133      void update($, news, list => list.filter(n => n.kind !== kind))
134    }
135    if (r.deny !== undefined) say($, 'disappointed'), tell($, 'deny', e.tool)
136    else if (r.isError) {
137      // A second failure within a minute is no longer a facepalm.
138      say($, Date.now() - motion.failedAt < 60000 ? 'angry' : 'facepalm')
139      tell($, 'error', e.tool)
140      motion.failedAt = Date.now()
141    }
142    else if (e.tool === 'Bash') {
143      // ponytail: reads the command text; a commit or a merge/release that went through.
144      if (/\bgh (pr merge|release create)\b/.test(e.command)) say($, 'confetti'), tell($, 'ship')
145      else if (/\bgit commit\b/.test(e.command)) say($, 'excited'), tell($, 'commit', commitTitle(e.command))
146    }
147    return r
148  })
149
150  // Zeus watches you type; one cue per 400 ms keeps the panel from redrawing on every key.
151  on('prompt.edit', async ($, e, next) => {
152    const t = Date.now()
153    if (!motion.still && t - motion.typedAt > 400) {
154      motion.typedAt = t
155      say($, 'typing')
156    }
157    return next(e)
158  })
159
160  on('prompt.submit', async ($, e, next) => {
161    // A thank-you gets hearts instead of the usual hop.
162    say($, /\b(gracias|thanks?|thank you|genial|excelente|bien hecho|te quiero|love)\b|❤/i.test(e.text) ? 'love' : 'jump')
163    return next(e)
164  })
165
166  on('turn.start', async ($, e, next) => {
167    await update($, isWorking, () => true)
168    say($, 'thinking')
169    return next(e)
170  })
171
172  on('turn.complete', async ($, e, next) => {
173    if (!e.agentId) {
174      await update($, isWorking, () => false)
175      if (e.reason === 'answer') say($, 'celebrate')
176      else if (e.reason === 'aborted') say($, 'disappointed')
177      else if (e.reason === 'error') say($, 'facepalm')
178    }
179    const u = e.usage
180    if (u && !e.agentId) {
181      const input = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
182      await update($, turns, list => [...list, { input, output: u.output_tokens }].slice(-50))
183    }
184    void refreshBranch($)
185    return next(e)
186  })
187
188  on('session.measure', async ($, e, next) => {
189    await update($, now, () => Date.now())
190    return next(e)
191  })
192
193  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
194    const ui = $.ui.resolve(e)
195    const { Box } = ui
196    // Client and Raster are the terminal's; elsewhere the panel has nothing to draw with.
197    if (!('Client' in ui)) return <Box />
198    const { Client } = ui
199    const [usage, list, used, br, at, gem, lg, headlines, demo, agents, open] = await Promise.all([
200      $.session.usage(), read($, turns), read($, tools), read($, branch), read($, now), read($, gemini), read($, lang), read($, news), read($, sprint), read($, crowd), read($, tasks),
201    ])
202    const [acct, model, version, cwd, working, next] = await Promise.all([
203      read($, account), $.session.model(), $.session.version(), $.session.cwd(), read($, isWorking), read($, cue),
204    ])
205    // The body's own rows, as the engine gave them; no probe needed.
206    const body = e.props.scroll?.bodyRows || undefined
207    const L = LABELS[lg]
208    const t = at || Date.now()
209    const ctx = usage.context.percent
210    // The terminal's rows (taller) only until the engine reports the body's.
211    const rows = body ?? e.viewport?.rows ?? 30
212    const fresh = headlines.filter(n => t - n.at < NEWS_TTL)
213    const view: View = {
214      lang: lg,
215      still: motion.still,
216      working,
217      sprint: demo || agents >= SPRINT || (working && open >= SPRINT),
218      cue: next,
219      version: version.base ?? version.version,
220      model,
221      plan: acct?.plan ?? null,
222      cwd: tildePath(cwd, acct?.home ?? ''),
223      time: fmtDuration(t - usage.startedAt),
224      branch: br,
225      last: list.at(-1) ?? null,
226      prev: list.slice(-4, -1).reverse(),
227      ctx: ctx === undefined ? null : { percent: ctx, tokens: usage.context.tokens ?? 0, window: usage.context.window },
228      total: list.reduce((s, x) => s + x.input + x.output, 0),
229      prompts: list.length,
230      rates: usage.rateLimits.map(r => ({
231        label: r.kind === 'five_hour' ? L.last5h : r.kind === 'seven_day' ? L.last7d : r.kind,
232        pct: r.percentUsed,
233        reset: untilReset(r.resetsAt, t),
234      })),
235      billing: acct ? (acct.isSubscription ? 'subscription' : 'api') : planOf(usage.rateLimits, ctx !== undefined),
236      usd: usage.cost?.usd ?? null,
237      gemini: gem,
238      tools: Object.entries(used).sort((a, b) => b[1] - a[1]),
239      // The data above the tools takes 28 rows (36 with Gemini); the footer, the stage, its floor and the ticker (with news).
240      room: Math.max(1, rows - (gem ? 36 : 28) - STAGE_ROWS - (fresh.length ? 2 : 1)),
241      news: fresh,
242    }
243
244    return (
245      // viewport.rows is the whole terminal, not the pane: the body's own rows pin the footer.
246      <Box flexDirection="column" height={body}>
247        <Client key="panel" module="./panel.tsx" props={view} flexGrow={1} />
248      </Box>
249    )
250  })
251}
252
hooks/format.ts 152 lines
1import type { Account, Gemini, Lang, News, Turn } from '../types'
2
3// Pure helpers shared by the hooks module and the panel's drawing module.
4export const LABEL = 15
5export const BRAND = '#01b7af'
6export const GEMINI_COLOR = '#af87ff' // bravesline's 256-colour 141
7
8export const LABELS = {
9  es: {
10    session: 'SESIÓN', time: 'Tiempo', branch: 'Rama', noGit: 'Sin Git',
11    tokens: 'TOKENS', lastPrompt: 'Último pedido', prompt: 'Pedido', prompts: 'pedidos',
12    waitAnswer: 'esperando la primera respuesta', waitResponse: 'esperando la primera respuesta',
13    context: 'CONTEXTO', used: 'Usado', tokensRow: 'Tokens',
14    totals: 'TOTALES', sessionRow: 'Sesión', last5h: 'Últimas 5h', last7d: 'Últimos 7d',
15    cost: 'COSTO', billing: 'Facturación', subscription: 'Suscripción', detecting: 'detectando…',
16    wouldCost: 'Costaría', spent: 'Gastado', atApi: 'a precio de API',
17    quiet: 'en silencio', model: 'Modelo', step: 'Paso', doing: 'Haciendo', elapsed: 'Tiempo', contextRow: 'Contexto',
18    tools: 'HERRAMIENTAS', calls: 'usos', none: 'ninguna todavía',
19    opened: 'Panel de Braves abierto.', closed: 'Panel de Braves cerrado.',
20    plan: 'Plan listo para revisar', failed: 'falló', denied: 'denegado', commit: 'Commit', ship: 'Merge / release publicado', sprintDemo: 'Trotando 80 s por los cuatro paisajes.', peek: 'Se asoma por el borde.', sit: 'Se sienta en la orilla.', lift: 'Se asoma a revisar el código.', scratch: 'Se rasca la cabeza.', sleep: 'Se va a una esquina a dormir.', neo: 'Golpe arriba y onda expansiva.',
21  },
22  en: {
23    session: 'SESSION', time: 'Time', branch: 'Branch', noGit: 'No Git',
24    tokens: 'TOKENS', lastPrompt: 'Last prompt', prompt: 'Prompt', prompts: 'prompts',
25    waitAnswer: 'waiting for the first answer', waitResponse: 'waiting for the first response',
26    context: 'CONTEXT', used: 'Used', tokensRow: 'Tokens',
27    totals: 'TOTALS', sessionRow: 'Session', last5h: 'Last 5h', last7d: 'Last 7d',
28    cost: 'COST', billing: 'Billing', subscription: 'Subscription', detecting: 'detecting…',
29    wouldCost: 'Would cost', spent: 'Spent', atApi: 'at API prices',
30    quiet: 'quiet', model: 'Model', step: 'Step', doing: 'Doing', elapsed: 'Elapsed', contextRow: 'Context',
31    tools: 'TOOLS', calls: 'calls', none: 'none yet',
32    opened: 'Braves panel opened.', closed: 'Braves panel closed.',
33    plan: 'Plan ready to review', failed: 'failed', denied: 'denied', commit: 'Commit', ship: 'Merge / release shipped', sprintDemo: 'Jogging 80 s through the four backdrops.', peek: 'Peeking past the edge.', sit: 'Sitting at the edge.', lift: 'Peeking into the code.', scratch: 'Scratching its head.', sleep: 'Off to a corner to sleep.', neo: 'A punch up and a shockwave.',
34  },
35} satisfies Record<Lang, Record<string, string>>
36
37// Same thresholds as bravesline: context warns earlier than the rate windows.
38export const colorFor = (pct: number, warn = 50, crit = 80) =>
39  pct >= crit ? 'error' : pct >= warn ? 'warning' : 'success'
40
41export const fmtTokens = (n: number) =>
42  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${n}`
43
44export const fmtDuration = (ms: number) => {
45  const min = Math.max(0, Math.floor(ms / 60000))
46  return min >= 60 ? `${Math.floor(min / 60)}h ${min % 60}m` : `${min}m`
47}
48
49export const bar = (pct: number, cells = 12) => {
50  const full = Math.round((Math.min(100, Math.max(0, pct)) / 100) * cells)
51  return '▰'.repeat(full) + '▱'.repeat(cells - full)
52}
53
54// Rate-limit windows only exist on a subscription; before the first response neither shows.
55export const planOf = (rateLimits: readonly unknown[], hasResponse: boolean) =>
56  rateLimits.length > 0 ? 'subscription' : hasResponse ? 'api' : 'unknown'
57
58/** Reads `claude auth status`: a claude.ai login bills the subscription, anything else the API. */
59export const accountOf = (json: string, home: string): Account | null => {
60  try {
61    const a = JSON.parse(json) as { authMethod?: string; subscriptionType?: string }
62    const isSubscription = a.authMethod === 'claude.ai'
63    const tier = a.subscriptionType ? a.subscriptionType[0]!.toUpperCase() + a.subscriptionType.slice(1) : ''
64    return { isSubscription, plan: isSubscription ? `Claude ${tier}`.trim() : 'API', home }
65  } catch {
66    return null
67  }
68}
69
70export const tildePath = (path: string, home: string) =>
71  home && path.startsWith(home) ? '~' + path.slice(home.length) : path
72
73/**
74 * Parses `gemini-dispatch.sh --status`: "<state>\t<pct>\t<text>", or nothing when idle.
75 * The text reads "model · #step · tool Ns · elapsed · tok:▰▱ used / window".
76 */
77export const parseGemini = (raw: string): Gemini | null => {
78  const line = raw.trim()
79  if (!line) return null
80  const [state = 'run', pct = '', text = ''] = line.split('\t')
81  const parts = text.split(' · ')
82  const tok = parts.findIndex(p => p.startsWith('tok:'))
83  const tokens = tok >= 0 ? (parts.splice(tok, 1)[0] ?? '').replace(/^tok:\S*\s*/, '') : ''
84  const [model = '', step = '', doing = '', elapsed = ''] = parts
85  return { isSlow: state === 'slow', pct: pct === '' ? null : Number(pct), model, step, doing, elapsed, tokens }
86}
87
88export const untilReset = (iso: string | undefined, at: number) =>
89  iso ? ` ↺ ${fmtDuration(Date.parse(iso) - at)}` : ''
90
91// The braves toolbox's own setting: the language the user talks in. Unset means Spanish.
92export const langOf = (json: string): Lang => {
93  try {
94    return String(JSON.parse(json).language ?? 'es').toLowerCase().startsWith('en') ? 'en' : 'es'
95  } catch {
96    return 'es'
97  }
98}
99
100/** Everything the panel draws, computed by the hooks module and handed to the Client as plain data. */
101export type View = {
102  lang: Lang
103  still: boolean
104  working: boolean
105  cue: { name: string; n: number }
106  version: string
107  model: string
108  plan: string | null
109  cwd: string
110  time: string
111  branch: string
112  last: Turn | null
113  prev: Turn[]
114  ctx: { percent: number; tokens: number; window: number } | null
115  total: number
116  prompts: number
117  rates: { label: string; pct: number; reset: string }[]
118  billing: 'subscription' | 'api' | 'unknown'
119  usd: number | null
120  gemini: Gemini | null
121  tools: [string, number][]
122  room: number
123  news: News[]
124  sprint: boolean
125}
126
127export type Run = { text: string; color?: string }
128
129// A ticker window: the runs as one looping tape, `width` cells from `offset`; still when it fits.
130export function marquee(items: Run[], width: number, offset: number): Run[] {
131  const cells = items.flatMap(r => [...[...r.text].map(ch => ({ ch, color: r.color })), ...[...'   ◆   '].map(ch => ({ ch, color: undefined as string | undefined }))])
132  if (cells.length === 0 || width <= 0) return []
133  const fits = cells.length - 7 <= width
134  const shown = fits ? cells.slice(0, cells.length - 7) : Array.from({ length: width }, (_, i) => cells[(offset + i) % cells.length]!)
135  const runs: Run[] = []
136  for (const c of shown) {
137    const last = runs.at(-1)
138    if (last && last.color === c.color) last.text += c.ch
139    else runs.push({ text: c.ch, color: c.color })
140  }
141  return runs
142}
143
144// A headline stays on the ticker this long.
145export const NEWS_TTL = 5 * 60000
146
147// The title a `git commit -m` / `-qm` / `-am` carries; empty for a heredoc or an editor.
148export function commitTitle(command: string): string {
149  const m = /\s-[a-zA-Z]*m\s+(?:"([^"\n]+)|'([^'\n]+))/.exec(command)
150  return m?.[1] ?? m?.[2] ?? ''
151}
152
hooks/mascot.ts 178 lines
1// The braves mascot, drawn by Carlos in the Unicode Mascot Animator
2// (uma.sujumayas.com) and imported into sprites.ts with tools/import-sprites.py.
3// Poses are 9 x 3 cells of quadrant blocks (laptop scenes 13 x 3); some carry a
4// fourth row on top for effects: hearts, z's, a question mark.
5
6import { PALETTE, SEQUENCES, SPRITES } from './sprites'
7
8export const STAGE_COLUMNS = 9
9export const STAGE_ROWS = 4
10// 50 so a 100 ms walk step is exactly two ticks (at 70 they alternated 70/140 and the legs shimmered).
11export const TICK_MS = 50
12
13export type NeoPose = 'neo-kneel' | 'neo-kneel-2'
14export type Pose = keyof typeof SPRITES | NeoPose
15
16/** A step in a short performance: show `pose` for `ms`. */
17export type Beat = readonly [Pose, number]
18
19const { work, 'work-desktop': workDesktop, ...acts } = SEQUENCES
20/** The editor's timelines (hello, jump, celebrate, poke, idle, ...); the work routines play apart. */
21export const ACTS: Record<keyof typeof acts, readonly Beat[]> = acts
22
23// ── Working ─────────────────────────────────────────────────────────────────
24// While Claude works the mascot loops a routine at a laptop or at a desktop
25// (one or the other per turn): types, glances at the prompt, winks, scratches its head.
26export const SCENES = { laptop: work, desktop: workDesktop }
27export type Scene = keyof typeof SCENES
28
29/** Which step of `scene`'s routine plays `ms` into the loop. */
30export function workAt(ms: number, scene: Scene = 'laptop'): number {
31  const steps = SCENES[scene]
32  let t = ms % steps.reduce((sum, [, d]) => sum + d, 0)
33  for (let i = 0; i < steps.length; i++) {
34    t -= steps[i]![1]
35    if (t < 0) return i
36  }
37  return 0
38}
39
40export type Run = { text: string; color?: string; bg?: string }
41
42type Drawn = { rows: readonly string[]; ink: readonly string[]; back?: readonly string[] }
43
44// Neo's three-point landing/launch stance: body low, one fist planted on the floor, one knee flat,
45// sunglasses on. Frame 2 lifts the body off the floor by an eighth of a cell, for an energy-building tremble.
46export const KNEEL: Record<NeoPose, Drawn> = {
47  'neo-kneel': {
48    rows: ['         ', '         ', '▬▬██━██▬▬', '▗███████▄'],
49    ink: ['         ', '         ', '777777777', '000000000'],
50    back: ['         ', '         ', ' 0  0  0 ', '         '],
51  },
52  'neo-kneel-2': {
53    rows: ['         ', '         ', '▬▬██━██▬▬', '▗▇▇▇▇▇▇▇▄'],
54    ink: ['         ', '         ', '777777777', '000000000'],
55    back: ['         ', '         ', ' 0  0  0 ', '         '],
56  },
57}
58
59// ponytail: the editor gives each cell one color, but the frame of the sunglasses is black over the
60// teal face, so the glasses row is painted here: lenses, a bridge between them and arms that stick
61// out past the face, across the middle of the lenses: a heavy line (━, drawn centred in the cell) for
62// the bridge and a thicker bar (▬) for the arms, in black over a teal cell background.
63const ARMS = new Set<string>(['shades', 'shades-shine'])
64function drawn(pose: Pose): Drawn {
65  if (pose in KNEEL) return KNEEL[pose as NeoPose]
66  const { rows, ink } = SPRITES[pose as keyof typeof SPRITES]
67  if (!ARMS.has(pose)) return { rows, ink }
68  return {
69    rows: rows.map((r, i) => (i === 1 ? '▬▬██━██▬▬' : r)),
70    ink: ink.map((r, i) => (i === 1 ? '777777777' : r)),
71    back: rows.map((r, i) => (i === 1 ? ' 0  0  0 ' : ' '.repeat([...r].length))),
72  }
73}
74
75// Each quadrant block and its mirror image, so a sprite drawn facing right can face left.
76const MIRROR: Record<string, string> = { '▐': '▌', '▌': '▐', '▛': '▜', '▜': '▛', '▙': '▟', '▟': '▙', '▘': '▝', '▝': '▘', '▖': '▗', '▗': '▖', '▞': '▚', '▚': '▞' }
77
78/** A sprite flipped left to right: each row reversed, each block swapped for its mirror. */
79export function mirror({ rows, ink, back }: Drawn) {
80  const flip = (line: string) => [...line].reverse().join('')
81  return { rows: rows.map(r => [...r].reverse().map(ch => MIRROR[ch] ?? ch).join('')), ink: ink.map(flip), ...(back && { back: back.map(flip) }) }
82}
83
84/** The sprite for `pose`, or with `work` set, that step of `scene`'s routine; `flip` faces it left. */
85export function toRuns(pose: Pose, work?: number, scene: Scene = 'laptop', flip = false): Run[][] {
86  const steps = SCENES[scene]
87  const art = drawn(work === undefined ? pose : steps[work % steps.length]![0])
88  const sprite = flip ? mirror(art) : art
89  // Sprites without an effects row sit on the floor: an empty row on top keeps the stage still.
90  const pad = STAGE_ROWS - sprite.rows.length
91  const rows = [...Array<string>(pad).fill(' '), ...sprite.rows]
92  const ink = [...Array<string>(pad).fill(' '), ...sprite.ink]
93  const back = [...Array<string>(pad).fill(' '), ...(sprite.back ?? [])]
94  return rows.map((line, r) => {
95    const runs: Run[] = []
96    ;[...line].forEach((text, c) => {
97      const i = ink[r]![c]
98      const color = i === undefined || i === ' ' ? undefined : PALETTE[Number(i)]
99      const b = back[r]?.[c]
100      const bg = b === undefined || b === ' ' ? undefined : PALETTE[Number(b)]
101      const prev = runs.at(-1)
102      if (prev && prev.color === color && prev.bg === bg) prev.text += text
103      else runs.push(bg ? { text, color, bg } : { text, color })
104    })
105    return runs
106  })
107}
108
109// ── Camera ──────────────────────────────────────────────────────────────────
110// Each quadrant block as its four pixels (top-left, top-right, bottom-left, bottom-right);
111// legs and other partial blocks count as their nearest quadrants, effects glyphs drop out.
112const QUADS = ' ▘▝▀▖▌▞▛▗▚▐▜▄▙▟█'
113// Half the thickness of the centred bars, as a fraction of the cell's height.
114const BANDS: Record<string, number> = { '━': 0.15, '▬': 0.25 }
115const BITS: Record<string, number> = { ...Object.fromEntries([...QUADS].map((ch, i) => [ch, i])), '▆': 12, '▅': 12, '▂': 12, '▁': 12, '▃': 12, '▇': 15 }
116
117// The eyes on the 18 x 8 pixel grid of a padded sprite: between the two notches of the face row.
118const EYES = { x: 9, y: 3.5 }
119
120/**
121 * The stage `across` cells wide with the camera zoomed `scale` times on the mascot's eyes.
122 * At scale 1 it matches the sprite drawn at column `x`; as the scale grows the eyes slide to the
123 * middle of the stage. Nearest-pixel sampling: the quadrant blocks are the pixels.
124 */
125export function zoomed(pose: Pose, across: number, x: number, scale: number, top: number, lens?: number): Run[][] {
126  // Landed (lens 0) is simply the shades pose; in flight, the lenses ride over the bare face.
127  const base = drawn(lens === undefined ? pose : lens > 0 ? 'default' : 'shades')
128  const shades = lens ? drawn('shades') : undefined
129  type Hit = { color: string; back: boolean } | undefined
130  const sample = ({ rows, ink, back }: Drawn, sx: number, sy: number): Hit => {
131    const px = Math.floor(sx)
132    const py = Math.floor(sy)
133    const pad = STAGE_ROWS - rows.length
134    const r = Math.floor(py / 2) - pad
135    const c = Math.floor(px / 2)
136    const ch = [...(rows[r] ?? '')][c]
137    if (ch === undefined) return undefined
138    // The frame of the glasses sits across the middle of its cell, at any zoom: ━ thin, ▬ thicker.
139    const band = BANDS[ch]
140    const on = band ? Math.abs(sy / 2 - Math.floor(sy / 2) - 0.5) < band : (BITS[ch] ?? 0) >> ((py & 1) * 2 + (px & 1)) & 1
141    if (!on) {
142      const b = back?.[r]?.[c]
143      return b === undefined || b === ' ' ? undefined : { color: PALETTE[Number(b)]!, back: true }
144    }
145    const i = [...ink[r]!][c]
146    return i === undefined || i === ' ' ? undefined : { color: PALETTE[Number(i)]!, back: false }
147  }
148  // Where the eyes land on screen: from where it stands toward the middle as the camera closes in.
149  const k = (scale - 1) / Math.max(1, top - 1)
150  const ax = (2 * x + EYES.x) * (1 - k) + across * k
151  const ay = EYES.y * (1 - k) + STAGE_ROWS * k
152  return Array.from({ length: STAGE_ROWS }, (_, r) => {
153    const runs: Run[] = []
154    for (let c = 0; c < across; c++) {
155      const hits = [0, 1, 2, 3].map((q): Hit => {
156        const sx = EYES.x + (2 * c + (q & 1) + 0.5 - ax) / scale
157        const sy = EYES.y + (2 * r + (q >> 1) + 0.5 - ay) / scale
158        const lensHit = shades && sample(shades, sx, sy + lens!)
159        return lensHit?.color === PALETTE[7] && !lensHit.back ? lensHit : sample(base, sx, sy)
160      })
161      // Two colors in a cell (the frame of the glasses): the glyph takes the drawn color, the
162      // background the cell-background color, as in the sprite itself.
163      const color = (hits.find(h => h && !h.back) ?? hits.find(h => h))?.color
164      let bits = 0
165      let bg: string | undefined
166      hits.forEach((h, q) => {
167        if (h && h.color === color) bits |= 1 << q
168        else if (h) bg ??= h.color
169      })
170      const text = QUADS[bits]!
171      const prev = runs.at(-1)
172      if (prev && prev.color === color && prev.bg === bg) prev.text += text
173      else runs.push(bg ? { text, color, bg } : { text, color })
174    }
175    return runs
176  })
177}
178
hooks/panel.tsx 380 lines
1import type { ClientModule, RenderChildren } from 'claude-code'
2
3import { BRAND, GEMINI_COLOR, LABEL, LABELS, bar, colorFor, fmtTokens, marquee } from './format'
4import type { Run, View } from './format'
5import type { News, Turn } from '../types'
6import { ACTS, STAGE_COLUMNS, STAGE_ROWS, TICK_MS, toRuns, workAt, zoomed } from './mascot'
7import type { Beat, Pose, Scene } from './mascot'
8import { BACKDROP_ORDER, backdrop, ground as groundRow } from './scenery'
9import type { Cell } from './scenery'
10
11// The whole panel draws here as a Client so the pointer anywhere over it
12// reaches the mascot: it looks toward the pointer, a click makes it hop.
13
14type State = { pose: Pose; queue: Beat[]; until: number; look: -1 | 0 | 1; cue: number; blinkAt: number; idleAt: number; tool: number; work?: number; scene: Scene; wasWorking: boolean; restAt: number; reel: number; x: number; goal: number; gait: 'walk' | 'crab'; stepAt: number; trek: number; stride: number; lead: number; leadAt: number; rush: '' | 'point' | 'flee' | 'peek' | 'sit' | 'lift' | 'sleep'; seenAt: number; lift: number; liftAt: number; waveAt: number; wave: number; zoomAt: number; zoom: number; lens?: number }
15
16let latest: View | undefined
17
18// Pointing down-left at the ticker, once it got there.
19const POINT: Beat[] = [['point-1', 300], ['point-2', 300], ['point-1', 250], ['point-2', 600], ['happy', 400]]
20
21// Columns the work scenes take; the other poses take STAGE_COLUMNS.
22const SCENE_COLUMNS = 13
23
24// What it does when idle, in turn; walk and crab cross the stage instead of stepping in place.
25// peek: walks half off one edge to look past it; sit: sits down at an edge for a while.
26// ms per cell walked: a multiple of TICK_MS, or the steps come uneven.
27const STEP_MS = 100
28// Asleep on the floor: his lie frames breathing in and out (1 2 3 2), looped with no lying down in between.
29const LIE = [...new Map(ACTS.sleep.map(b => [b[0], b])).values()]
30const SNOOZE: Beat[] = [...LIE, ...LIE.slice(1, -1).reverse()]
31// Neo's three-point landing/launch stance before flight: 700 ms tremble alternating every 100 ms.
32const NEO_KNEEL: readonly Beat[] = [
33  ['neo-kneel', 100],
34  ['neo-kneel-2', 100],
35  ['neo-kneel', 100],
36  ['neo-kneel-2', 100],
37  ['neo-kneel', 100],
38  ['neo-kneel-2', 100],
39  ['neo-kneel', 100],
40]
41const NEO: readonly Beat[] = [...NEO_KNEEL, ...ACTS.neo.slice(1)]
42// Before it: camera zooms on the eyes, sunglasses drop from above, glint, and camera pulls back.
43const ZOOM = 4 // how close the camera gets
44const ZOOM_MS = 400 // to close in, and to pull back
45const DROP_MS = 700 // sunglasses drop onto eyes
46const GLINT_MS = 300 // short shine glint
47const LENS_START = 2 // source pixels above eyes, begins just out of frame at ZOOM
48const INTRO: readonly Beat[] = [
49  ['default', ZOOM_MS],
50  ['default', DROP_MS],
51  ['shades-shine', GLINT_MS],
52  ['shades', ZOOM_MS],
53]
54const INTRO_MS = INTRO.reduce((t, b) => t + b[1], 0)
55// The wave leaves with the punch: the first arms-up frame, after the crouch and the launch.
56const NEO_WAVE = NEO.slice(0, Math.max(1, NEO.findIndex(b => b[0].startsWith('arms-up')))).reduce((t, b) => t + b[1], 0)
57const WAVE_MS = 700
58// The least time between two waves: a calibration knob for how often it happens.
59const NEO_GAP_MS = 5 * 60000
60let neoAt = 0
61// The data last drawn while idle, and the copy held from a turn's start until its wave has passed.
62let lastIdle: View | undefined
63let frozen: View | undefined
64const IDLE = ['idle', 'peek', 'sway', 'turning', 'lift', 'halo', 'walk', 'sit', 'crab', 'wrench', 'hammer'] as const
65
66// lift: at the left edge it sticks its face past the border to look into the code, comes out to
67// nod its approval, looks once more and walks off. Each step: pose, ms, its column (negative: the
68// face is past the edge).
69// It leans like a person, from the hips with its feet planted: lean-2 and lean-3 are drawn one and
70// two columns left of where it stands, right at the edge (its body touching column 0).
71const LIFT: readonly (readonly [Pose, number, number])[] = [
72  ['look-left', 500, -1], ['lean-1', 180, -1], ['lean-2', 180, -2], ['lean-3', 1100, -3], ['lean-2', 160, -2], ['lean-1', 160, -1],
73  ['happy', 450, -1], ['blink', 150, -1], ['happy', 300, -1], ['blink', 150, -1], ['arms-up-one', 700, -1],
74  ['lean-1', 160, -1], ['lean-2', 160, -2], ['lean-3', 900, -3], ['lean-2', 160, -2], ['lean-1', 160, -1], ['default', 300, -1],
75]
76
77// How far past an edge it leans when it peeks.
78const PEEK = 4
79const peekAt = (right: boolean): Beat[] => {
80  const look = right ? 'look-right' : 'look-left'
81  return [[look, 900], ['default', 300], [look, 700], ['happy', 400]]
82}
83const SIT: Beat[] = [['sit-1', 400], ['sit-2', 400], ['sit-1', 400], ['sit-2', 400], ['sit-1', 1500], ['blink', 150], ['sit-1', 900], ['sit-2', 400], ['sit-1', 400], ['sit-2', 400], ['hop', 200], ['default', 300]]
84
85const Panel: ClientModule<View, State> = (view, surface) => {
86  latest = view
87  const { Box, Text } = surface.elements
88
89  if (surface.state === undefined) {
90    const now = Date.now()
91    surface.setState({ pose: 'default', queue: [...ACTS.hello], until: 0, look: 0, cue: view.cue.n, blinkAt: now + 3000, idleAt: now + 12000, tool: 0, scene: 'laptop', wasWorking: false, restAt: now, reel: 0, x: 0, goal: 0, gait: 'walk', stepAt: 0, trek: 0, stride: 0, lead: 0, leadAt: 0, rush: '', seenAt: view.news[0]?.at ?? 0, lift: -1, liftAt: 0, waveAt: 0, wave: -1, zoomAt: 0, zoom: 1 })
92
93    surface.every(TICK_MS, () => {
94      const s = surface.state
95      const v = latest
96      if (!s || !v || v.still) return
97      const now = Date.now()
98      let { queue, until, cue, blinkAt, idleAt, tool, scene, restAt, goal, gait, stepAt, lead, leadAt, rush, seenAt, lift, liftAt, waveAt, zoomAt } = s
99      // The floor it can stand on: the region's width less its own.
100      const room = Math.max(0, surface.columns - (v.working ? SCENE_COLUMNS : STAGE_COLUMNS))
101      // Past the edges only while peeking (or coming back from it), never at the laptop.
102      const reach = lift >= 0 ? PEEK : (rush === 'peek' || goal !== s.x) && !v.working ? PEEK : 0
103      let x = Math.max(-reach, Math.min(s.x, room + reach))
104      goal = Math.max(-reach, Math.min(goal, room + reach))
105      if (v.cue.n !== cue) {
106        cue = v.cue.n
107        const act = ACTS[v.cue.name as keyof typeof ACTS]
108        // A celebration arriving with a wave under way leaves Neo's punch alone.
109        if (act && !(v.cue.name === 'celebrate' && waveAt)) (queue = [...act]), (until = 0)
110        // The turn's answer is in: the jump and punch up, the wave leaving as it lands.
111        if (v.cue.name === 'neo') (queue = [...INTRO, ...NEO]), (until = 0), (zoomAt = now), (waveAt = now + INTRO_MS + NEO_WAVE)
112        // /braves-panel scratch: the head scratch from the laptop routine, a few rubs longer.
113        if (v.cue.name === 'scratch') (queue = Array.from({ length: 12 }, (_, i): Beat => [i % 2 ? 'work-7' : 'work-6', 160])), (until = 0)
114        // /braves-panel peek | sit: the explore routines on demand.
115        // A second error within a minute (angry) is a big problem: once the act ends it goes to look into the code.
116        if ((v.cue.name === 'angry' || v.cue.name === 'lift') && lift < 0) (rush = 'lift'), (gait = 'walk'), (goal = 0)
117        // /braves-panel sleep: the nap without three idle minutes.
118        if (v.cue.name === 'sleep') (queue = []), (goal = x < room / 2 ? 0 : room), (gait = 'walk'), (rush = 'sleep')
119        if (v.cue.name === 'peek' || v.cue.name === 'sit') (rush = v.cue.name), (gait = 'walk'), (goal = x < room / 2 ? room + (rush === 'peek' ? PEEK : 0) : rush === 'peek' ? -PEEK : 0)
120        idleAt = now + 12000
121        // The demo nap is a real one: it sleeps on until something wakes it.
122        restAt = v.cue.name === 'sleep' ? now - 180001 : now
123      }
124      // A new headline: it runs to the ticker's start and points at it; an error startles it a few steps back.
125      // Gemini's steps come too often to chase; a sprint keeps its jog.
126      if ((v.news[0]?.at ?? seenAt) !== seenAt) {
127        const kind = v.news[0]!.kind
128        seenAt = v.news[0]!.at
129        if (!v.sprint && kind === 'error') (queue = [['surprised', 450], ['hop', 200], ...queue]), (until = 0), (goal = Math.min(room, x + 5)), (gait = 'crab'), (rush = 'flee')
130        else if (!v.sprint && kind !== 'gemini') (goal = 0), (gait = 'walk'), (rush = 'point')
131      }
132      if (rush && x === goal) {
133        if (rush === 'point') queue = [...queue, ...POINT]
134        if (rush === 'sit') queue = [...queue, ...SIT]
135        if (rush === 'sleep') queue = [...queue, ...SNOOZE]
136        if (rush === 'lift') (lift = 0), (liftAt = 0)
137        if (rush === 'peek') (queue = [...queue, ...peekAt(x > 0)]), (goal = x > 0 ? room - PEEK : PEEK), (gait = 'walk')
138        rush = ''
139      }
140      const rushing = (rush !== '' && goal !== x) || lift >= 0
141      // Working: its routine, unless an act (a send, a click, a reaction) is playing.
142      // A sprint (a busy turn) swaps the laptop for a jog in place while the backdrop scrolls past.
143      const jog = v.sprint && queue.length === 0 && now >= until
144      const work = v.working && !v.sprint && !rushing && queue.length === 0 && now >= until ? workAt(now, scene) : undefined
145      if (v.working) (idleAt = now + 12000), (restAt = now)
146      const free = !v.working && !v.sprint && lift < 0 && queue.length === 0 && now > until
147      // Nothing going on: after a minute it meditates, after three it walks to the nearer corner and lies down to sleep.
148      // Already lying there, the next breath queues on this same tick, so it never stands up between loops.
149      if (free && now - restAt > 180000 && s.pose.startsWith('lie')) queue = [...SNOOZE]
150      else if (free && now - restAt > 180000) (goal = x < room / 2 ? 0 : room), (gait = 'walk'), (rush = 'sleep')
151      else if (free && now - restAt > 60000) queue = [...ACTS.meditate]
152      // Idle long enough: a little routine, dizzy instead once the context is past 80%.
153      else if (free && now > idleAt) {
154        const name = (v.ctx?.percent ?? 0) >= 80 ? 'dizzy' : IDLE[tool % IDLE.length]!
155        // A walk crosses to the far side of the floor; a stage too narrow keeps it in place.
156        const right = x < room / 2
157        if ((name === 'walk' || name === 'crab') && room >= 6) {
158          const r = Math.random() * 0.4 * room
159          goal = Math.round(right ? room - r : r)
160          gait = name
161        } else if (name === 'lift') {
162          rush = 'lift', gait = 'walk', goal = 0
163        } else if (name === 'peek' || name === 'sit') {
164          // To the far edge; a peek goes past it.
165          goal = right ? room + (name === 'peek' ? PEEK : 0) : name === 'peek' ? -PEEK : 0
166          gait = 'walk'
167          rush = name
168        } else if (name !== 'walk' && name !== 'crab') queue = [...ACTS[name]]
169        tool++
170        idleAt = now + 12000 + Math.random() * 8000
171      }
172      // Ticks per backdrop cell: 3 at an even pace, 1 on a surge, 4 while it drops back.
173      let pace = 3
174      const stride = v.sprint ? s.stride + 1 : 0
175      let pose: Pose = s.look < 0 ? 'look-left' : s.look > 0 ? 'look-right' : 'default'
176      if (lift >= 0 && queue.length === 0 && (lift > 0 || now >= until)) {
177        // The lift script runs on its own clock, the acts queued before it first.
178        pose = s.pose
179        if (now >= liftAt) {
180          const step = LIFT[lift]
181          if (step) (pose = step[0]), (liftAt = now + step[1]), (x = step[2]), lift++
182          else (lift = -1), (goal = x)
183        }
184      } else if (now < until) pose = s.pose
185      else if (queue[0]) {
186        const [p, ms] = queue[0]
187        pose = p, until = now + ms, queue = queue.slice(1)
188      } else if (jog) {
189        // It jogs to the middle of the stage, then now and then surges ahead and drops back.
190        if (now > leadAt) (lead = Math.round(Math.random() * 7) - 3), (leadAt = now + 4000 + Math.random() * 4000)
191        const target = Math.max(0, Math.min(room, Math.round(room / 2) + lead))
192        const far = Math.abs(target - x) > 4
193        if (x !== target && stride % (far ? 3 : 6) === 0) x += Math.sign(target - x)
194        pace = target > x ? 1 : target < x ? 4 : 3
195        // Counted in ticks, not clock time, so a late tick never skips a step; quicker legs on a surge.
196        pose = ACTS.jog[Math.floor(stride / (pace === 1 ? 3 : 4)) % ACTS.jog.length]![0]
197      } else if ((!v.working || rushing) && !v.sprint && goal !== x) {
198        // Crossing the stage: a cell and a leg frame every STEP_MS, the editor's pace (the slack absorbs timer jitter).
199        if (now >= stepAt) (x += Math.sign(goal - x)), (stepAt = now + STEP_MS - 10)
200        // A leg frame every two cells (200 ms, the editor's): swapped every 100 ms step they shimmer.
201        pose = `${gait}-${((x >> 1) & 1) + 1}` as Pose
202        idleAt = now + 12000
203      } else if (work === undefined && now > blinkAt) (pose = 'blink'), (until = now + 150), (blinkAt = now + 2500 + Math.random() * 3500)
204      // The ticker crawls a cell every 150 ms while it has news.
205      const reel = v.news.length > 0 ? Math.floor(now / 150) : s.reel
206      // The turn just ended: now and then (a coin flip, five minutes apart at least) the jump, the punch and the wave.
207      if (s.wasWorking && !v.working && now - neoAt > NEO_GAP_MS && Math.random() < 0.5)
208        (queue = [...INTRO, ...NEO]), (until = 0), (zoomAt = now), (waveAt = now + INTRO_MS + NEO_WAVE), (neoAt = now)
209      // The wave's progress in thousandths while it rises, -1 otherwise.
210      const wave = waveAt && now >= waveAt && now - waveAt < WAVE_MS ? Math.floor(((now - waveAt) / WAVE_MS) * 1000) : -1
211      if (waveAt && now - waveAt >= WAVE_MS) waveAt = 0
212      // The camera: closing in, holding on the eyes, pulling back (eased), 1 when it isn't on.
213      const shot = now - zoomAt
214      const closeness = zoomAt && shot < INTRO_MS ? Math.min(1, shot / ZOOM_MS, (INTRO_MS - shot) / ZOOM_MS) : 0
215      const zoom = Math.round((1 + (ZOOM - 1) * (1 - Math.cos(Math.PI * Math.max(0, closeness))) / 2) * 20) / 20
216      const dropping = zoomAt && shot >= ZOOM_MS && shot < ZOOM_MS + DROP_MS
217      const lens = dropping ? Math.round(LENS_START * (1 + Math.cos(Math.PI * (shot - ZOOM_MS) / DROP_MS)) / 2 * 20) / 20 : undefined
218      if (zoomAt && shot >= INTRO_MS) zoomAt = 0
219      const trek = v.sprint ? s.trek + (stride % pace === 0 ? 1 : 0) : s.trek
220      if (pose !== s.pose || work !== s.work || cue !== s.cue || queue !== s.queue || v.working !== s.wasWorking || reel !== s.reel || x !== s.x || goal !== s.goal || stride !== s.stride || rush !== s.rush || seenAt !== s.seenAt || lift !== s.lift || wave !== s.wave || zoom !== s.zoom || lens !== s.lens) surface.setState({ ...s, pose, queue, until, cue, blinkAt, idleAt, tool, work, scene, wasWorking: v.working, restAt, reel, x, goal, gait, stepAt, trek, stride, lead, leadAt, rush, seenAt, lift, liftAt, waveAt, wave, zoomAt, zoom, lens })
221    })
222
223    surface.onPointer(e => {
224      const s = surface.state
225      if (!s || latest?.still) return
226      if (e.type === 'leave') return s.look !== 0 && surface.setState({ ...s, look: 0 })
227      // The stage is the footer: STAGE_ROWS rows above the floor line and the ticker, when there is news.
228      const top = surface.rows - STAGE_ROWS - (latest?.news.length ? 2 : 1)
229      const onStage = e.y >= top && e.y < top + STAGE_ROWS
230      // A click on it makes it hop; a click on the floor elsewhere sends it there.
231      if (e.type === 'down' && onStage && e.x >= s.x && e.x < s.x + STAGE_COLUMNS) return surface.setState({ ...s, queue: [...ACTS.poke], until: 0, restAt: Date.now() })
232      if (e.type === 'down' && onStage && !latest?.working) {
233        const goal = Math.max(0, Math.min(surface.columns - STAGE_COLUMNS, Math.round(e.x - STAGE_COLUMNS / 2)))
234        return surface.setState({ ...s, goal, gait: 'walk', restAt: Date.now() })
235      }
236      const dx = (e.fine?.x ?? e.x + 0.5) - (s.x + STAGE_COLUMNS / 2)
237      const look = Math.abs(dx) < 2 ? 0 : dx < 0 ? -1 : 1
238      if (look !== s.look) surface.setState({ ...s, look, restAt: Date.now() })
239    })
240  }
241
242  // Walking left, it faces left: the walk is drawn facing right.
243  const leftward = /^(walk|crab)-/.test(surface.state?.pose ?? '') && (surface.state?.goal ?? 0) < (surface.state?.x ?? 0)
244  const sprite = toRuns(surface.state?.pose ?? 'default', surface.state?.work, surface.state?.scene, leftward)
245  // The stage, cell by cell: the backdrop (a sprint's, blank otherwise), the mascot over it at x.
246  const across = Math.max(STAGE_COLUMNS, surface.columns)
247  const trek = surface.state?.trek ?? 0
248  const scenery = BACKDROP_ORDER[Math.floor(Date.now() / 20000) % BACKDROP_ORDER.length]!
249  const ground: Cell[][] = view.sprint
250    ? backdrop(scenery, across, trek)
251    : sprite.map(() => Array.from({ length: across }, () => ({ ch: ' ' })))
252  // Cells to runs of one color over one background.
253  const toLine = (row: Cell[]) => {
254    const runs: (Run & { bg?: string })[] = []
255    for (const cell of row) {
256      const last = runs.at(-1)
257      if (last && last.color === cell.color && last.bg === cell.bg) last.text += cell.ch
258      else runs.push({ text: cell.ch, color: cell.color, bg: cell.bg })
259    }
260    return runs
261  }
262  const zoom = surface.state?.zoom ?? 1
263  const stage: (Run & { bg?: string })[][] = zoom > 1 ? zoomed(surface.state?.pose ?? 'default', across, surface.state?.x ?? 0, zoom, ZOOM, surface.state?.lens) : sprite.map((line, r) => {
264    const row = ground[r]!
265    const x = surface.state?.x ?? 0
266    const cells = line.flatMap(run => [...run.text].map(ch => ({ ch, color: run.color, back: run.bg })))
267    // Between its first and last glyph the mascot hides the backdrop, so the legs never merge into a building.
268    const from = cells.findIndex(cell => cell.ch !== ' ')
269    const to = cells.findLastIndex(cell => cell.ch !== ' ')
270    // The sky stays behind it: the mascot's cells keep the row's background.
271    for (let i = from; from >= 0 && i <= to; i++) if (x + i >= 0 && x + i < across) {
272      const bg = row[x + i]!.bg
273      const { ch, color, back } = cells[i]!
274      row[x + i] = ch === ' ' ? { ch: ' ', bg } : { ch, color, bg: back ?? bg }
275    }
276    return toLine(row)
277  })
278  const floor = view.sprint ? toLine(groundRow(scenery, across, trek)) : null
279
280  const L = LABELS[view.lang]
281  const headline = (n: News): Run => ({
282    ask: { text: `? ${n.detail}`, color: 'warning' },
283    plan: { text: `✦ ${L.plan}`, color: 'suggestion' },
284    error: { text: `✗ ${n.detail} ${L.failed}`, color: 'error' },
285    deny: { text: `⊘ ${n.detail} ${L.denied}`, color: 'warning' },
286    commit: { text: `● ${L.commit}${n.detail ? `: ${n.detail}` : ''}`, color: 'success' },
287    ship: { text: `★ ${L.ship}`, color: 'success' },
288    gemini: { text: `✦ Gemini: ${n.detail}`, color: GEMINI_COLOR },
289  })[n.kind]
290  const ticker = marquee(view.news.map(headline), surface.columns, view.still ? 0 : surface.state?.reel ?? 0)
291  const Row = (label: string, value: RenderChildren) => (
292    <Text wrap="truncate-end"><Text dimColor>{'  ' + (label.length >= LABEL ? label.slice(0, LABEL - 2) + '… ' : label.padEnd(LABEL))}</Text>{value}</Text>
293  )
294  const InOut = (x: Turn) => (
295    <Text><Text color="suggestion">↑ {fmtTokens(x.input)}</Text>  <Text color="success">↓ {fmtTokens(x.output)}</Text></Text>
296  )
297  const Gauge = (pct: number, warn?: number, crit?: number, cells?: number) => (
298    <Text><Text color={colorFor(pct, warn, crit)}>{bar(pct, cells)}</Text> <Text bold>{pct}%</Text></Text>
299  )
300  // The data as one line per element, so the wave can show one view above it and another below.
301  const rowsOf = (v: View): RenderChildren[] => {
302    const out: RenderChildren[] = [
303      <Text wrap="truncate-end"><Text bold>Claude Code</Text> <Text dimColor>v{v.version}</Text></Text>,
304      <Text wrap="truncate-end" dimColor>{v.model}{v.plan ? ` · ${v.plan}` : ''}</Text>,
305      <Text wrap="truncate-start" dimColor>{v.cwd}</Text>,
306    ]
307    const section = (icon: string, title: string, color: string, rows: (RenderChildren | false | null)[]) =>
308      out.push(<Text> </Text>, <Text color={color} bold>{icon} {title}</Text>, ...rows.filter(r => r !== false && r !== null))
309    const toolMax = v.tools[0]?.[1] ?? 1
310    const toolCount = v.tools.reduce((n, [, c]) => n + c, 0)
311    section('◷', L.session, BRAND, [
312      Row(L.time, <Text bold>{v.time}</Text>),
313      Row(L.branch, <Text color="suggestion">⎇ {v.branch || L.noGit}</Text>),
314    ])
315    section('⇅', L.tokens, BRAND, [
316      Row(L.lastPrompt, v.last ? InOut(v.last) : <Text dimColor>{L.waitAnswer}</Text>),
317      ...v.prev.map((x, i) => Row(`${L.prompt} -${i + 1}`, <Text dimColor>↑ {fmtTokens(x.input)}  ↓ {fmtTokens(x.output)}</Text>)),
318    ])
319    section('◔', L.context, BRAND, v.ctx === null
320      ? [Row(L.used, <Text dimColor>{L.waitResponse}</Text>)]
321      : [Row(L.used, Gauge(v.ctx.percent, 30, 50)), Row(L.tokensRow, <Text>{fmtTokens(v.ctx.tokens)} <Text dimColor>/ {fmtTokens(v.ctx.window)}</Text></Text>)])
322    section('Σ', L.totals, BRAND, [
323      Row(L.sessionRow, <Text><Text bold>{fmtTokens(v.total)}</Text> <Text dimColor>· {v.prompts} {v.prompts === 1 ? L.prompt.toLowerCase() : L.prompts}</Text></Text>),
324      ...v.rates.map(r => Row(r.label, <Text>{Gauge(r.pct, undefined, undefined, 10)}<Text dimColor>{r.reset}</Text></Text>)),
325    ])
326    section('$', L.cost, BRAND, [
327      Row(L.billing, v.billing === 'subscription'
328        ? <Text color="suggestion">◆ {L.subscription}</Text>
329        : v.billing === 'api' ? <Text color="warning">◆ API</Text> : <Text dimColor>{L.detecting}</Text>),
330      v.usd !== null && Row(v.billing === 'subscription' ? L.wouldCost : L.spent,
331        <Text><Text bold color={v.billing === 'api' ? 'warning' : 'success'}>${v.usd.toFixed(2)}</Text>{v.billing === 'subscription' && <Text dimColor> {L.atApi}</Text>}</Text>),
332    ])
333    if (v.gemini) section('✦', v.gemini.isSlow ? `GEMINI · ${L.quiet}` : 'GEMINI', v.gemini.isSlow ? 'warning' : GEMINI_COLOR, [
334      Row(L.model, <Text color={GEMINI_COLOR}>{v.gemini.model}</Text>),
335      !!v.gemini.step && Row(L.step, v.gemini.step),
336      !!v.gemini.doing && Row(L.doing, v.gemini.doing),
337      !!v.gemini.elapsed && Row(L.elapsed, v.gemini.elapsed),
338      v.gemini.pct !== null && Row(L.contextRow, Gauge(v.gemini.pct, 30, 50)),
339      !!v.gemini.tokens && Row(L.tokensRow, <Text dimColor>{v.gemini.tokens}</Text>),
340    ])
341    section('⚙', `${L.tools} · ${toolCount} ${L.calls}`, BRAND, v.tools.length === 0
342      ? [Row('', <Text dimColor>{L.none}</Text>)]
343      : v.tools.slice(0, v.room).map(([name, n]) => Row(name,
344        <Text><Text color="suggestion">{'▪'.repeat(Math.max(1, Math.round((n / toolMax) * 10)))}</Text> {n}</Text>)))
345    return out
346  }
347
348  // Neo: while Claude works the data holds still; when the turn ends a line rises from the stage and
349  // rewrites it, the old view above the line, the new one below (Gemini stays live throughout).
350  const wave = surface.state?.wave ?? -1
351  // Held from the turn's start until the wave has gone (a turn that just ended, or a wave on its way).
352  const holding = view.working || !!surface.state?.wasWorking || !!surface.state?.waveAt
353  if (view.working) frozen ??= lastIdle ?? view
354  else if (!holding) (frozen = undefined), (lastIdle = view)
355  const fresh = rowsOf(view)
356  const stale = holding ? rowsOf({ ...(frozen ?? view), gemini: view.gemini }) : fresh
357  const footer = STAGE_ROWS + 1 + (ticker.length > 0 ? 1 : 0)
358  const line = wave < 0 ? -1 : Math.round(Math.min(fresh.length, surface.rows - footer) * (1 - wave / 1000))
359  const body = wave < 0 ? (holding ? stale : fresh) : fresh.map((row, i) =>
360    i < line ? stale[i] ?? <Text> </Text> : i === line ? <Text color={BRAND} bold>{'━'.repeat(across)}</Text> : row)
361
362  return (
363    <Box flexDirection="column" flexGrow={1}>
364    <Box flexDirection="column" flexGrow={1} flexShrink={1} minHeight={0} overflow="hidden">
365      {body}
366    </Box>
367    <Box flexDirection="column" flexShrink={0}>
368      {stage.map(line => <Text wrap="truncate-end">{line.map(r => <Text color={r.color} backgroundColor={r.bg}>{r.text}</Text>)}</Text>)}
369      {floor
370        ? <Text wrap="truncate-end">{floor.map(r => <Text color={r.color} backgroundColor={r.bg}>{r.text}</Text>)}</Text>
371        : <Text dimColor>{'▔'.repeat(across)}</Text>}
372      {/* The ticker shows only while there is news. */}
373      {ticker.length > 0 && <Text wrap="truncate-end">{ticker.map(r => <Text color={r.color}>{r.text}</Text>)}</Text>}
374    </Box>
375    </Box>
376  )
377}
378
379export default Panel
380
hooks/sprites.ts 141 lines
1// Generated by tools/import-sprites.py from the Unicode Mascot Animator export. Edit there, not here.
2
3export const PALETTE = ["#02c0b6", "#9aa3ad", "#f2f2f2", "#f2c14e", "#e5534b", "#c678dd", "#61afef", "#1c1c22"] as const
4
5/** Each sprite: its rows of characters, and per cell an index into PALETTE (space: no color). */
6export const SPRITES = {
7  "default": { rows: ["         ", " ▐▛███▜▌ ", " ▜█████▛ ", "  ▆   ▆  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
8  "blink": { rows: ["         ", " ▐█████▌ ", " ▜█████▛ ", "  ▆   ▆  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
9  "happy": { rows: ["         ", " ▐▙███▟▌ ", " ▜█████▛ ", "  ▆   ▆  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
10  "look-left": { rows: ["         ", " ▐▜██▛█▌ ", " ▜█████▛ ", "  ▆   ▆  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
11  "look-right": { rows: ["         ", " ▐█▜██▛▌ ", " ▜█████▛ ", "  ▆   ▆  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
12  "arms-up": { rows: ["         ", " ▜▛███▜▛ ", " ▐█████▌ ", "  ▆   ▆  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
13  "reach": { rows: ["         ", " ▐█▜██▛▙ ", " ▜█████▌ ", "  ▆   ▆  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
14  "hammer": { rows: ["         ", " ▐█▜██▛▙▛", " ▜█████▌▘", "  ▆   ▆  "], ink: ["         ", " 00000001", " 00000001", "  0   0  "] },
15  "wrench": { rows: ["         ", " ▐█▜██▛▙▞", " ▜█████▌▘", "  ▆   ▆  "], ink: ["         ", " 00000001", " 00000001", "  0   0  "] },
16  "hop": { rows: ["         ", " ▜▙███▟▛ ", " ▝█▀▀▀█▘ ", "         "], ink: ["         ", " 0000000 ", " 0000000 ", "         "] },
17  "work-0": { rows: ["             ", "   ▐██▛▌    ▞", "   ▐███▛▘  ▗▘", "    ▆ ▆ ▀▀▀▀ "], ink: ["             ", "   00000    1", "   000000  12", "    0 0 1111 "] },
18  "work-1": { rows: ["             ", "   ▐██▛▌    ▞", "   ▐███▙▖  ▗▘", "    ▆ ▆ ▀▀▀▀ "], ink: ["             ", "   00000    2", "   000000  11", "    0 0 1111 "] },
19  "work-2": { rows: ["             ", "   ▐██▛▌    ▞", "   ▐███▛▘  ▗▘", "    ▆ ▆ ▀▀▀▀ "], ink: ["             ", "   00000    1", "   000000  11", "    0 0 1111 "] },
20  "work-3": { rows: ["             ", "   ▐██▛▌    ▞", "   ▐███▙▖  ▗▘", "    ▆ ▆ ▀▀▀▀ "], ink: ["             ", "   00000    1", "   000000  21", "    0 0 1111 "] },
21  "work-4": { rows: ["             ", " ▐█████▌    ▞", " ▜▟██▙█▛▘  ▗▘", "  ▆   ▆ ▀▀▀▀ "], ink: ["             ", " 0000000    1", " 00000000  11", "  0   0 1111 "] },
22  "work-5": { rows: ["             ", " ▐█████▌    ▞", " ▜▟████▙▖  ▗▘", "  ▆   ▆ ▀▀▀▀ "], ink: ["             ", " 0000000    1", " 00000000  12", "  0   0 1111 "] },
23  "work-6": { rows: ["      ▄▖     ", " ▐▙███▟▛    ▞", " ▜█████▌   ▗▘", "  ▆   ▆ ▀▀▀▀ "], ink: ["      00     ", " 0000000    1", " 0000000   11", "  0   0 1111 "] },
24  "work-7": { rows: ["     ▗▄▖     ", " ▐▙███▟▛    ▞", " ▜█████▌   ▗▘", "  ▆   ▆ ▀▀▀▀ "], ink: ["     000     ", " 0000000    1", " 0000000   11", "  0   0 1111 "] },
25  "work-8": { rows: ["             ", " ▐▙███▟▌    ▞", " ▜█████▙▖  ▗▘", "  ▆   ▆ ▀▀▀▀ "], ink: ["             ", " 0000000    2", " 00000000  11", "  0   0 1111 "] },
26  "arms-up-one": { rows: ["         ", " ▐▛███▜▙▞", " ▜█████▌ ", "  ▆   ▆  "], ink: ["         ", " 00000000", " 0000000 ", "  0   0  "] },
27  "arms-up-two": { rows: ["         ", " ▐▛███▜▙▌", " ▜█████▌ ", "  ▆   ▆  "], ink: ["         ", " 00000000", " 0000000 ", "  0   0  "] },
28  "arms-up-one-2": { rows: ["         ", " ▐▛███▜▙▘", " ▜█████▌ ", "  ▆   ▆  "], ink: ["         ", " 00000000", " 0000000 ", "  0   0  "] },
29  "think-1": { rows: ["         ", " ▐█▟██▙▌ ", " ▜█████▙ ", "  ▆   ▆  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
30  "think-2": { rows: ["         ", " ▐█▟██▙▌▗", " ▜█████▙ ", "  ▆   ▆  "], ink: ["         ", " 00000002", " 0000000 ", "  0   0  "] },
31  "think-3": { rows: ["         ", " ▐█▟██▙▌▚", " ▜█████▙ ", "  ▆   ▆  "], ink: ["         ", " 00000002", " 0000000 ", "  0   0  "] },
32  "bulb-off": { rows: ["         ", " █▙███▟█▜", " ▐█████▌ ", "  ▆   ▆  "], ink: ["         ", " 00000002", " 0000000 ", "  0   0  "] },
33  "bulb-on": { rows: ["         ", " █▙███▟█▜", " ▐█████▌ ", "  ▆   ▆  "], ink: ["         ", " 00000003", " 0000000 ", "  0   0  "] },
34  "facepalm-1": { rows: ["         ", " ▐██████ ", " ▜▙████▌ ", "  ▆   ▆  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
35  "facepalm-2": { rows: ["         ", " ▐██████ ", " ▜█████▌ ", "  ▆   ▆  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
36  "sad-1": { rows: ["         ", " ▐█████▌ ", " ▟▙███▟▙ ", "  ▆   ▆  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
37  "sad-2": { rows: ["         ", " ▐█████▌ ", " ▟█████▙ ", "  ▆   ▆  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
38  "excited-crouch": { rows: ["         ", " █▙███▟█ ", " ▐█████▌ ", "  ▀   ▀  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
39  "excited-jump": { rows: ["         ", " ▜█████▛ ", " ▝█▀▀▀█▘ ", "  ▀   ▀  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
40  "confetti-1": { rows: ["         ", "▘█▙███▟█▗", " ▐█████▌▗", "  ▆   ▆  "], ink: ["         ", "300000004", " 00000005", "  0   0  "] },
41  "confetti-2": { rows: ["         ", "▖█▙███▟█▝", "▖▐█████▌ ", "  ▆   ▆  "], ink: ["         ", "400000005", "30000000 ", "  0   0  "] },
42  "confetti-3": { rows: ["         ", "▝█▙███▟█ ", "▖▐█████▌▝", "  ▆   ▆  "], ink: ["         ", "40000000 ", "500000003", "  0   0  "] },
43  "dizzy-1": { rows: ["         ", "▘ ▛████▜ ", " ▝██████▘", "  ▐▌  ▐▌ "], ink: ["         ", "3 000000 ", " 00000000", "  00  00 "] },
44  "dizzy-2": { rows: ["         ", " ▐█▜█▛█▌▝", " ▜█████▛ ", "  ▆   ▆  "], ink: ["         ", " 00000003", " 0000000 ", "  0   0  "] },
45  "dizzy-3": { rows: ["         ", " ▛████▜ ▝", "▝██████▘ ", " ▐▌  ▐▌  "], ink: ["         ", " 000000 3", "00000000 ", " 00  00  "] },
46  "meditate-1": { rows: ["         ", " ▐█████▌ ", "▗▟▇███▇▙▖", " ▀▀▀▀▀▀▀ "], ink: ["         ", " 0000000 ", "000000000", " 0000000 "] },
47  "meditate-2": { rows: ["         ", " ▐▇███▇▌ ", "▝███████▘", "         "], ink: ["         ", " 0000000 ", "000000000", "         "] },
48  "point-1": { rows: ["         ", " ▐█████▌ ", "▄▟▟██▙█▛ ", "  ▆   ▆  "], ink: ["         ", " 0000000 ", "00000000 ", "  0   0  "] },
49  "point-2": { rows: ["         ", " ▐█████▌ ", "▗▟▟██▙█▛ ", "▘ ▆   ▆  "], ink: ["         ", " 0000000 ", "00000000 ", "0 0   0  "] },
50  "sway-left": { rows: ["         ", " █▜██▛█  ", "▝██████▘ ", " ▐▌  ▐▌  "], ink: ["         ", " 000000  ", "00000000 ", " 00  00  "] },
51  "sway-right": { rows: ["         ", "  █▜██▛█ ", " ▝██████▘", "  ▐▌  ▐▌ "], ink: ["         ", "  000000 ", " 00000000", "  00  00 "] },
52  "turn-side-left": { rows: ["         ", "  ▐▜██▌  ", "  ▜███▌  ", "   ▆ ▆   "], ink: ["         ", "  00000  ", "  00000  ", "   0 0   "] },
53  "turn-back": { rows: ["         ", " ▐█████▌ ", " ▜█████▛ ", "  ▆   ▆  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
54  "turn-side-right": { rows: ["         ", "  ▐██▛▌  ", "  ▐███▛  ", "   ▆ ▆   "], ink: ["         ", "  00000  ", "  00000  ", "   0 0   "] },
55  "walk-1": { rows: ["         ", " ▐█▜██▛▙ ", " ▟█████▌ ", "  ▆   ▀  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
56  "walk-2": { rows: ["         ", " ▟█▜██▛▌ ", " ▐█████▙ ", "  ▀   ▆  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
57  "crab-1": { rows: ["         ", "▄█▜██▛█▄ ", " ██████  ", " ▆    ▆  "], ink: ["         ", "00000000 ", " 000000  ", " 0    0  "] },
58  "crab-2": { rows: ["         ", " ▐█▜██▛█▌", "  ██████ ", "   ▆  ▆  "], ink: ["         ", " 00000000", "  000000 ", "   0  0  "] },
59  "desk-0": { rows: ["             ", " ▐█▜██▛▌  ███", " ▜█████▛▘  ▌ ", "  ▆   ▆ ▝▀▀▀▀"], ink: ["             ", " 0000000  200", " 00000000  1 ", "  0   0 11111"] },
60  "desk-1": { rows: ["             ", " ▐█▜██▛▌  ███", " ▜█████▙▖  ▌ ", "  ▆   ▆ ▝▀▀▀▀"], ink: ["             ", " 0000000  020", " 00000000  1 ", "  0   0 11111"] },
61  "desk-2": { rows: ["             ", " ▐█▜██▛▌  ███", " ▜█████▛▘  ▌ ", "  ▆   ▆ ▝▀▀▀▀"], ink: ["             ", " 0000000  002", " 00000000  1 ", "  0   0 11111"] },
62  "desk-3": { rows: ["             ", " ▐█▜██▛▌  ███", " ▜█████▙▖  ▌ ", "  ▆   ▆ ▝▀▀▀▀"], ink: ["             ", " 0000000  220", " 00000000  1 ", "  0   0 11111"] },
63  "desk-4": { rows: ["             ", " ▐█████▌  ███", " ▜▟██▙█▙▖  ▌ ", "  ▆   ▆ ▝▀▀▀▀"], ink: ["             ", " 0000000  000", " 00000000  1 ", "  0   0 11111"] },
64  "desk-5": { rows: ["             ", " ▐█████▌  ███", " ▜▟████▙▖  ▌ ", "  ▆   ▆ ▝▀▀▀▀"], ink: ["             ", " 0000000  000", " 00000000  1 ", "  0   0 11111"] },
65  "desk-6": { rows: ["             ", " ▐▙███▟█  ███", " ▜█████▌   ▌ ", "  ▆   ▆ ▝▀▀▀▀"], ink: ["             ", " 0000000  000", " 0000000   1 ", "  0   0 11111"] },
66  "desk-7": { rows: ["             ", " ▐▙███▟█▘ ███", " ▜█████▌   ▌ ", "  ▆   ▆ ▝▀▀▀▀"], ink: ["             ", " 00000000 000", " 0000000   1 ", "  0   0 11111"] },
67  "love-1": { rows: ["      ♥  ", " ▐▙███▟▌ ", " ▜█████▛ ", "  ▆   ▆  "], ink: ["      4  ", " 0000000 ", " 0000000 ", "  0   0  "] },
68  "love-2": { rows: ["     ♥ ♥ ", " ▐▙███▟▌ ", " ▜█████▛ ", "  ▆   ▆  "], ink: ["     4 4 ", " 0000000 ", " 0000000 ", "  0   0  "] },
69  "love-3": { rows: ["      ♥ ♥", " ▐▙███▟▌ ", " ▜█████▛ ", "  ▆   ▆  "], ink: ["      4 4", " 0000000 ", " 0000000 ", "  0   0  "] },
70  "sparkles-1": { rows: ["  ✦   ·  ", " ██▛██▛█ ", " ▐█████▌ ", "  ▆   ▆  "], ink: ["  6   6  ", " 0000000 ", " 0000000 ", "  0   0  "] },
71  "sparkles-2": { rows: ["   ·   ✦ ", " ██▛██▛█ ", " ▐█████▌ ", "  ▆   ▆  "], ink: ["   6   6 ", " 0000000 ", " 0000000 ", "  0   0  "] },
72  "shades": { rows: ["         ", " ▐█████▌ ", " ▜█████▛ ", "  ▆   ▆  "], ink: ["         ", " 0770770 ", " 0000000 ", "  0   0  "] },
73  "shades-shine": { rows: ["       ✦ ", " ▐█████▌ ", " ▜█████▛ ", "  ▆   ▆  "], ink: ["       2 ", " 0770770 ", " 0000000 ", "  0   0  "] },
74  "think-dots-1": { rows: ["     ·   ", " ▐█▟██▙▌ ", " ▜█████▙ ", "  ▆   ▆  "], ink: ["     2   ", " 0000000 ", " 0000000 ", "  0   0  "] },
75  "think-dots-2": { rows: ["     ··  ", " ▐█▟██▙▌ ", " ▜█████▙ ", "  ▆   ▆  "], ink: ["     22  ", " 0000000 ", " 0000000 ", "  0   0  "] },
76  "think-dots-3": { rows: ["     ··· ", " ▐█▟██▙▌ ", " ▜█████▙ ", "  ▆   ▆  "], ink: ["     222 ", " 0000000 ", " 0000000 ", "  0   0  "] },
77  "think-dots-4": { rows: ["     ···?", " ▐█▟██▙▌ ", " ▜█████▙ ", "  ▆   ▆  "], ink: ["     2223", " 0000000 ", " 0000000 ", "  0   0  "] },
78  "alert": { rows: ["    !    ", " ▐█████▌ ", "▄▟▟██▙█▛ ", "  ▆   ▆  "], ink: ["    3    ", " 0000000 ", "00000000 ", "  0   0  "] },
79  "alert-2": { rows: ["    !    ", " ▐█████▌ ", "▗▟▟██▙█▛ ", "▘ ▆   ▆  "], ink: ["    3    ", " 0000000 ", "00000000 ", "0 0   0  "] },
80  "surprised": { rows: ["    !    ", " █▀███▀█ ", " ▐█████▌ ", "  ▆   ▆  "], ink: ["    3    ", " 0000000 ", " 0000000 ", "  0   0  "] },
81  "sleep-1": { rows: ["      z  ", " ▐█████▌ ", " ▟█████▙ ", "  ▆   ▆  "], ink: ["      2  ", " 0000000 ", " 0000000 ", "  0   0  "] },
82  "sleep-2": { rows: ["      zZ ", " ▐█████▌ ", " ▟█████▙ ", "  ▆   ▆  "], ink: ["      22 ", " 0000000 ", " 0000000 ", "  0   0  "] },
83  "sleep-3": { rows: ["      zZz", " ▐█████▌ ", " ▟█████▙ ", "  ▆   ▆  "], ink: ["      222", " 0000000 ", " 0000000 ", "  0   0  "] },
84  "halo-1": { rows: ["   ═══   ", " ▐█████▌ ", " ▜█████▛ ", "  ▆   ▆  "], ink: ["   333   ", " 0000000 ", " 0000000 ", "  0   0  "] },
85  "halo-2": { rows: ["   ═══   ", " ▐▙███▟▌ ", " ▜█████▛ ", "  ▆   ▆  "], ink: ["   333   ", " 0000000 ", " 0000000 ", "  0   0  "] },
86  "angry-1": { rows: ["         ", " ▐▞███▚▌ ", " ▟█████▙ ", "  ▆   ▆  "], ink: ["         ", " 4444444 ", " 4444444 ", "  4   4  "] },
87  "angry-2": { rows: ["      #  ", " █▞███▚█ ", " ▐█████▌ ", "  ▆   ▆  "], ink: ["      4  ", " 4444444 ", " 4444444 ", "  4   4  "] },
88  "dizzy-stars-1": { rows: ["  ✦   ·  ", "  ▛████▜ ", " ▝██████▘", "  ▐▌  ▐▌ "], ink: ["  3   3  ", "  000000 ", " 00000000", "  00  00 "] },
89  "dizzy-stars-2": { rows: [" ·  ✦  · ", " ▐█▜█▛█▌ ", " ▜█████▛ ", "  ▆   ▆  "], ink: [" 3  3  3 ", " 0000000 ", " 0000000 ", "  0   0  "] },
90  "dizzy-stars-3": { rows: ["  ·   ✦  ", " ▛████▜  ", "▝██████▘ ", " ▐▌  ▐▌  "], ink: ["  3   3  ", " 000000  ", "00000000 ", " 00  00  "] },
91  "turn-quarter-left": { rows: ["         ", " ▐▜██▜█▌ ", " ▜█████▌ ", "  ▆   ▆  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
92  "turn-quarter-right": { rows: ["         ", " ▐█▛██▛▌ ", " ▐█████▛ ", "  ▆   ▆  "], ink: ["         ", " 0000000 ", " 0000000 ", "  0   0  "] },
93  "jog-1": { rows: ["▀▗▄▄▄▄▄▖ ", " ▐█▜██▛▙ ", " ▟█████▌ ", "  ▆   ▀  "], ink: ["44444444 ", " 0000000 ", " 0000000 ", "  0   0  "] },
94  "jog-2": { rows: ["▄▗▄▄▄▄▄▖ ", " ▟█▜██▛▌ ", " ▐█████▙ ", "  ▀   ▆  "], ink: ["44444444 ", " 0000000 ", " 0000000 ", "  0   0  "] },
95  "sit-1": { rows: ["         ", "         ", " ▐▛███▜▌ ", " ▜█████▙▖"], ink: ["         ", "         ", " 0000000 ", " 00000000"] },
96  "sit-2": { rows: ["         ", "         ", " ▐▛███▜▌ ", " ▜█████▙▘"], ink: ["         ", "         ", " 0000000 ", " 00000000"] },
97  "lean-1": { rows: ["         ", "▐▜██▛█▌  ", " ▜█████▛ ", "  ▆   ▆  "], ink: ["         ", "0000000  ", " 0000000 ", "  0   0  "] },
98  "lean-2": { rows: ["         ", "▐▜██▛█▌  ", " ▜█████▛ ", "   ▆   ▆ "], ink: ["         ", "0000000  ", " 0000000 ", "   0   0 "] },
99  "lean-3": { rows: ["         ", "▐▜██▛█▌  ", " ▜█████▛ ", "    ▆   ▆"], ink: ["         ", "0000000  ", " 0000000 ", "    0   0"] },
100  "lie-1": { rows: ["         ", " z  ▃    ", " █▟████  ", " ██████▄▄"], ink: ["         ", " 2  0    ", " 000000  ", " 00000000"] },
101  "lie-2": { rows: ["  Z      ", " z  ▄    ", " █▟████  ", " ██████▄▄"], ink: ["  2      ", " 2  0    ", " 000000  ", " 00000000"] },
102  "lie-3": { rows: ["  Zz     ", " z  ▅    ", " █▟████  ", " ██████▄▄"], ink: ["  22     ", " 2  0    ", " 000000  ", " 00000000"] },
103  "work-to-back": { rows: ["             ", "  ▐████▌    ▞", "  ▜████▛▘  ▗▘", "   ▆  ▆ ▀▀▀▀ "], ink: ["             ", "  000000    1", "  0000000  12", "   0  0 1111 "] },
104  "work-to-front": { rows: ["             ", " ▐█▛██▛▌    ▞", " ▐█████▛▘  ▗▘", "  ▆   ▆ ▀▀▀▀ "], ink: ["             ", " 0000000    1", " 00000000  12", "  0   0 1111 "] },
105  "walk-3": { rows: [" ▐█▜██▛▌ ", " ▜█████▛ ", "    ▆    "], ink: [" 0000000 ", " 0000000 ", "    0    "] },
106  "neo-crouch": { rows: ["         ", " ▗▄▄▄▄▄▖ ", "▐█▙███▟█▌", " ▀█▀▀▀█▀ "], ink: ["         ", " 0000000 ", "000000000", " 0000000 "] },
107} as const
108
109export const SEQUENCES = {
110  "work": [["work-0", 220], ["work-1", 220], ["work-2", 220], ["work-1", 220], ["work-0", 220], ["work-3", 220], ["work-0", 220], ["work-1", 220], ["work-2", 220], ["work-1", 220], ["work-0", 220], ["work-3", 220], ["work-0", 220], ["work-1", 220], ["work-to-back", 130], ["work-4", 1400], ["work-5", 250], ["work-4", 700], ["work-to-back", 130], ["work-0", 220], ["work-1", 220], ["work-2", 220], ["work-1", 220], ["work-0", 220], ["work-3", 220], ["work-0", 220], ["work-1", 220], ["work-2", 220], ["work-1", 220], ["work-0", 220], ["work-3", 220], ["work-to-back", 130], ["work-4", 900], ["work-to-back", 130], ["work-0", 220], ["work-1", 220], ["work-2", 220], ["work-1", 220], ["work-0", 220], ["work-3", 220], ["work-0", 220], ["work-1", 220], ["work-2", 220], ["work-1", 220], ["work-to-front", 130], ["work-6", 160], ["work-7", 160], ["work-6", 160], ["work-7", 160], ["work-6", 160], ["work-7", 160], ["work-6", 160], ["work-7", 160], ["work-8", 600], ["work-to-front", 130], ["work-0", 220], ["work-1", 220], ["work-2", 220], ["work-1", 220], ["work-0", 220], ["work-3", 220], ["work-0", 220], ["work-1", 220], ["work-2", 220], ["work-1", 220], ["work-0", 220], ["work-3", 220], ["work-0", 220], ["work-1", 220], ["work-to-back", 130], ["work-4", 1600], ["work-to-back", 130]],
111  "hello": [["arms-up", 600], ["arms-up-one", 300], ["arms-up-two", 300], ["arms-up-one", 300], ["arms-up-two", 400], ["happy", 700]],
112  "jump": [["hop", 220], ["default", 120], ["default", 150], ["happy", 400]],
113  "celebrate": [["default", 150], ["arms-up-one-2", 300], ["default", 350], ["arms-up-one-2", 400], ["default", 350], ["arms-up", 500], ["happy", 900]],
114  "wrench": [["reach", 250], ["wrench", 1100], ["reach", 200]],
115  "hammer": [["reach", 250], ["hammer", 1100], ["reach", 200]],
116  "idle": [["default", 800], ["look-left", 1000], ["default", 300], ["look-right", 1000], ["default", 300], ["arms-up", 1200], ["default", 400]],
117  "lightbulb": [["bulb-off", 300], ["bulb-on", 200], ["bulb-off", 150], ["bulb-on", 900], ["happy", 500]],
118  "facepalm": [["facepalm-1", 400], ["facepalm-2", 900], ["facepalm-1", 300], ["default", 200]],
119  "disappointed": [["sad-1", 900], ["sad-2", 200], ["sad-1", 900]],
120  "meditate": [["meditate-1", 1400], ["meditate-2", 1400]],
121  "sway": [["sway-left", 500], ["default", 300], ["sway-right", 500], ["default", 300], ["sway-left", 500], ["default", 300]],
122  "walk": [["walk-1", 200], ["walk-2", 200], ["walk-1", 200], ["walk-2", 200], ["walk-1", 200], ["walk-2", 200], ["walk-1", 200], ["walk-2", 200], ["default", 300]],
123  "crab": [["crab-1", 180], ["crab-2", 180], ["crab-1", 180], ["crab-2", 180], ["crab-1", 180], ["crab-2", 180], ["crab-1", 180], ["crab-2", 180], ["default", 300]],
124  "work-desktop": [["desk-0", 220], ["desk-1", 220], ["desk-2", 220], ["desk-1", 220], ["desk-0", 220], ["desk-1", 220], ["desk-2", 220], ["desk-1", 220], ["desk-0", 220], ["desk-1", 220], ["desk-2", 220], ["desk-1", 220], ["desk-0", 220], ["desk-1", 220], ["desk-4", 1400], ["desk-5", 250], ["desk-4", 700], ["desk-0", 220], ["desk-1", 220], ["desk-2", 220], ["desk-1", 220], ["desk-0", 220], ["desk-1", 220], ["desk-2", 220], ["desk-1", 220], ["desk-0", 220], ["desk-1", 220], ["desk-2", 220], ["desk-1", 220], ["desk-6", 160], ["desk-7", 160], ["desk-6", 160], ["desk-7", 160], ["desk-6", 160], ["desk-7", 160], ["desk-6", 160], ["desk-7", 160], ["desk-0", 220], ["desk-1", 220], ["desk-2", 220], ["desk-1", 220], ["desk-0", 220], ["desk-1", 220], ["desk-2", 220], ["desk-1", 220], ["desk-0", 220], ["desk-1", 220], ["desk-2", 220], ["desk-1", 220], ["desk-0", 220], ["desk-1", 220], ["desk-4", 1600]],
125  "love": [["love-1", 350], ["love-2", 350], ["love-3", 350], ["love-2", 350], ["happy", 600]],
126  "excited": [["excited-crouch", 150], ["excited-jump", 250], ["excited-crouch", 120], ["excited-jump", 250], ["sparkles-1", 300], ["sparkles-2", 300], ["sparkles-1", 300], ["happy", 400]],
127  "confetti": [["confetti-1", 220], ["confetti-2", 220], ["confetti-3", 220], ["confetti-1", 220], ["confetti-2", 220], ["shades", 700], ["shades-shine", 300], ["shades", 900]],
128  "thinking": [["think-dots-1", 350], ["think-dots-2", 350], ["think-dots-3", 450], ["think-dots-4", 800]],
129  "point": [["alert", 350], ["alert-2", 350], ["alert", 350], ["alert-2", 350], ["alert", 700]],
130  "poke": [["surprised", 450], ["hop", 200], ["happy", 600]],
131  "sleep": [["lie-1", 900], ["lie-2", 900], ["lie-3", 1300], ["lie-1", 900], ["lie-2", 900], ["lie-3", 1300]],
132  "halo": [["halo-1", 1200], ["halo-2", 900], ["default", 300]],
133  "angry": [["angry-1", 400], ["angry-2", 300], ["angry-1", 300], ["angry-2", 300], ["angry-1", 600]],
134  "dizzy": [["dizzy-stars-1", 300], ["dizzy-stars-2", 300], ["dizzy-stars-3", 300], ["dizzy-stars-2", 300], ["dizzy-stars-1", 300], ["default", 200]],
135  "turning": [["default", 400], ["turn-quarter-left", 180], ["turn-side-left", 180], ["turn-back", 350], ["turn-side-right", 180], ["turn-quarter-right", 180], ["default", 400], ["turn-quarter-left", 180], ["turn-side-left", 180], ["turn-back", 350], ["turn-side-right", 180], ["turn-quarter-right", 180], ["default", 200], ["happy", 500]],
136  "_gallery": [["default", 1000], ["blink", 1000], ["happy", 1000], ["look-left", 1000], ["look-right", 1000], ["arms-up", 1000], ["reach", 1000], ["hammer", 1000], ["wrench", 1000], ["hop", 1000], ["work-0", 1000], ["work-1", 1000], ["work-2", 1000], ["work-3", 1000], ["work-4", 1000], ["work-5", 1000], ["work-6", 1000], ["work-7", 1000], ["work-8", 1000], ["arms-up-one", 1000], ["arms-up-two", 1000], ["arms-up-one-2", 1000], ["think-1", 1000], ["think-2", 1000], ["think-3", 1000], ["bulb-off", 1000], ["bulb-on", 1000], ["facepalm-1", 1000], ["facepalm-2", 1000], ["sad-1", 1000], ["sad-2", 1000], ["excited-crouch", 1000], ["excited-jump", 1000], ["confetti-1", 1000], ["confetti-2", 1000], ["confetti-3", 1000], ["dizzy-1", 1000], ["dizzy-2", 1000], ["dizzy-3", 1000], ["meditate-1", 1000], ["meditate-2", 1000], ["point-1", 1000], ["point-2", 1000], ["sway-left", 1000], ["sway-right", 1000], ["turn-side-left", 1000], ["turn-back", 1000], ["turn-side-right", 1000], ["walk-1", 1000], ["walk-2", 1000], ["crab-1", 1000], ["crab-2", 1000], ["desk-0", 1000], ["desk-1", 1000], ["desk-2", 1000], ["desk-3", 1000], ["desk-4", 1000], ["desk-5", 1000], ["desk-6", 1000], ["desk-7", 1000], ["love-1", 1000], ["love-2", 1000], ["love-3", 1000], ["sparkles-1", 1000], ["sparkles-2", 1000], ["shades", 1000], ["shades-shine", 1000], ["think-dots-1", 1000], ["think-dots-2", 1000], ["think-dots-3", 1000], ["think-dots-4", 1000], ["alert", 1000], ["alert-2", 1000], ["surprised", 1000], ["sleep-1", 1000], ["sleep-2", 1000], ["sleep-3", 1000], ["halo-1", 1000], ["halo-2", 1000], ["angry-1", 1000], ["angry-2", 1000], ["dizzy-stars-1", 1000], ["dizzy-stars-2", 1000], ["dizzy-stars-3", 1000], ["turn-quarter-left", 1000], ["turn-quarter-right", 1000]],
137  "jog": [["jog-1", 130], ["jog-2", 130]],
138  "neo-intro": [["default", 600], ["blink", 150], ["default", 250], ["shades", 500], ["shades-shine", 500], ["shades", 600]],
139  "neo": [["neo-crouch", 700], ["hop", 180], ["arms-up-one", 600], ["happy", 400]],
140} as const
141
hooks/scenery.ts 111 lines
1// Backdrops for the task sprint: the mascot jogs in place while one of these
2// scrolls past behind it. Each paints a sky (a background color per row, a
3// gradient), then layers from far to near: the far ones crawl, the near one
4// rushes by, so the scene has depth. A layer is a tile of 4 rows that repeats
5// sideways; a space lets what is behind it show. Some characters animate:
6// stars twinkle, windows go dark and light up, wheat sways.
7//
8// Below the stage the ground runs at the near layer's pace.
9
10export type Cell = { ch: string; color?: string; bg?: string }
11
12type Layer = {
13  rows: readonly string[]
14  // Columns the tile moves per 4 steps of the trek: 1 is far, 4 is near.
15  speed: number
16  color: string
17  ink?: Readonly<Record<string, string>>
18}
19
20type Backdrop = {
21  sky: readonly string[]
22  layers: readonly Layer[]
23  ground: { tile: string; color: string; bg: string }
24}
25
26export const BACKDROPS = {
27  // A city at night: a moon and twinkling stars, a far skyline, lit windows rushing past.
28  city: {
29    sky: ['#0b1026', '#0f1838', '#141e45', '#18234f'],
30    layers: [
31      { rows: ['   ·        ●         ·    ✦     ·  ', '        ·                           ', '                                    ', '                                    '], speed: 0, color: '#c9d1ff', ink: { '●': '#f5f3ce' } },
32      { rows: ['                ▗▖                  ', '   ▄▄      ▗▄▄  ██   ▄▄▄       ▗▄    ', '  ▐██▌ ▄▄  ███▌ ██  ▐███▌ ▄▄   ██▌   ', '  ▐██▌▐██▌ ███▌ ██  ▐███▌▐██▌  ██▌   '], speed: 1, color: '#55679f' },
33      { rows: ['      ▄       ◆           ▄▄       ', ' ▐▀▀▌ █   ▐▀▀▀▀▌      ▐▀▀▀▌██  ▐▀▌  ', ' ▐▪▫▌ █▪  ▐▪▫▪▪▌  ▄▄  ▐▪▪▫▌▪█  ▐▪▌  ', ' ▐▫▪▌ █▫  ▐▪▪▫▪▌ ▐▪▪▌ ▐▫▪▪▌▫█  ▐▪▌  '], speed: 4, color: '#8a93bd', ink: { '▪': '#ffd166', '▫': '#ffd166', '◆': '#ff4fd8' } },
34    ],
35    ground: { tile: '▬▬▬   ▬▬▬   ', color: '#f2c14e', bg: '#2a2d36' },
36  },
37  // A park at sunset: the sun sinking behind purple hills, birds, trees and lamps going by.
38  park: {
39    sky: ['#3a2560', '#6a3a78', '#a04a78', '#e07a6a'],
40    layers: [
41      { rows: ['                ◖◗                    ', '                                      ', '                                      ', '                                      '], speed: 0, color: '#ffe08a' },
42      { rows: ['                                      ', '         ᵛ               ᵛ ᵛ          ', '                                      ', '▂▃▄▅▅▄▃▂▁    ▁▂▃▄▃▂▁      ▂▃▄▅▆▅▄▃▂▁▁  '], speed: 2, color: '#5a2f5f', ink: { ᵛ: '#1a1020' } },
43      { rows: ['   ▄██▄          ●          ▄▄       ', '  ▐████▌         ╿         ▟██▙      ', '   ▀██▀    ✿     ╿    ✿   ▜██▛    ✿ ', '    ▕▏           ╿          ▕▏       '], speed: 4, color: '#7ccf60', ink: { '▕': '#3e2416', '▏': '#3e2416', '●': '#ffd88a', '╿': '#15151c', '✿': '#fff1a8' } },
44    ],
45    ground: { tile: '▁ ▁  ▁   ▁ ', color: '#f0d6b0', bg: '#6a4a38' },
46  },
47  // A field by day: clouds drifting, green hills, a fence and wheat swaying past.
48  field: {
49    sky: ['#1f6fb8', '#3388cf', '#7cbde8', '#b5dcf3'],
50    layers: [
51      { rows: ['   ▗▄▄▖           ☀            ▗▄▖   ', '  ▀▀▀▀▀▀                     ▀▀▀▀▀  ', '                                      ', '                                      '], speed: 1, color: '#ffffff', ink: { '☀': '#ffd23f' } },
52      { rows: ['                                      ', '                                      ', '        ▁▂▃▃▂▁          ▁▂▂▁          ', '▁▂▃▄▄▃▂▂▃▄▅▅▄▃▂▁▁▂▃▄▄▃▂▃▄▅▅▅▄▃▂▁▁▁▂▂▁  '], speed: 2, color: '#3a7a2a' },
53      { rows: ['                                      ', '  ┬─┬─┬─┬              ψ ψ           ', '  │ │ │ │     ψ ψ ψ   ψψψψψ   ψ ψ    ', '  │ │ │ │    ψψψψψψ  ψψψψψψψ ψψψψψ   '], speed: 4, color: '#5a3f22', ink: { ψ: '#7a4a05' } },
54    ],
55    ground: { tile: 'ʷ  ʷʷ   ʷ  ', color: '#b8eb80', bg: '#3f7a2e' },
56  },
57  // Mountains at dawn: snowy peaks far away, pines and a river close by.
58  nature: {
59    sky: ['#3b2d6e', '#a8709a', '#eba08a', '#ffd6a5'],
60    layers: [
61      { rows: ['  ·        ▲              ▲          ', '          ▟▲▙       ▲    ▟▲▙         ', '        ▗▟███▙▖    ▟█▙  ▟███▙▖       ', '     ▗▄▟███████▙▄▄▟███▙▟███████▙▄▖   '], speed: 1, color: '#3a3358', ink: { '▲': '#f0f0f5', '·': '#ffe6c7' } },
62      { rows: ['                                     ', '    ▲          ▲▲           ▲         ', '   ▟█▙   ▲    ▟██▙    ▲    ▟█▙   ▲    ', '  ▟███▙ ▟█▙  ▟████▙  ▟█▙  ▟███▙ ▟█▙   '], speed: 2, color: '#1a4030' },
63      { rows: ['                                     ', '                       ▲             ', '  ▲                   ▟█▙        ≈≈   ', ' ▟█▙   ≈≈≈≈          ▟███▙   ≈≈≈≈≈≈≈  '], speed: 4, color: '#16382a', ink: { '≈': '#1f5fc0' } },
64    ],
65    ground: { tile: '░ ░░  ░ ', color: '#c9a27a', bg: '#4a3628' },
66  },
67} satisfies Record<string, Backdrop>
68
69export type BackdropName = keyof typeof BACKDROPS
70export const BACKDROP_ORDER = Object.keys(BACKDROPS) as BackdropName[]
71
72// ponytail: a cheap per-cell coin, stable for a stretch of time, for twinkling and windows.
73const coin = (col: number, beat: number) => ((col * 7919 + beat * 104729) % 97) / 97
74
75/** What a character draws at `col` on `beat`: stars and windows blink, wheat sways. */
76function animate(ch: string, col: number, beat: number): string {
77  if (ch === '·' || ch === '✦') return coin(col, beat) < 0.2 ? ' ' : ch
78  if (ch === '▪' || ch === '▫') return coin(col, beat >> 2) < 0.15 ? ' ' : '▪'
79  if (ch === 'ψ') return (col + beat) % 3 === 0 ? 'Ψ' : 'ψ'
80  return ch
81}
82
83const tile = (row: string, len: number) => [...row.padEnd(len)]
84
85/** The stage's 4 rows `width` wide, `trek` steps into the run. */
86export function backdrop(name: BackdropName, width: number, trek: number): Cell[][] {
87  const { sky, layers } = BACKDROPS[name] as Backdrop
88  const beat = Math.floor(trek / 4)
89  return sky.map((bg, r) => {
90    const row: Cell[] = Array.from({ length: width }, () => ({ ch: ' ', bg }))
91    for (const layer of layers) {
92      const len = Math.max(...layer.rows.map(x => [...x].length))
93      const chars = tile(layer.rows[r] ?? '', len)
94      const offset = Math.floor((trek * layer.speed) / 4)
95      for (let i = 0; i < width; i++) {
96        const at = (offset + i) % len
97        const ch = animate(chars[at]!, at, beat)
98        if (ch !== ' ') row[i] = { ch, color: layer.ink?.[chars[at]!] ?? layer.color, bg }
99      }
100    }
101    return row
102  })
103}
104
105/** The ground under the stage, running at the near layer's pace. */
106export function ground(name: BackdropName, width: number, trek: number): Cell[] {
107  const { tile: t, color, bg } = (BACKDROPS[name] as Backdrop).ground
108  const chars = [...t]
109  return Array.from({ length: width }, (_, i) => ({ ch: chars[(trek + i) % chars.length]!, color, bg }))
110}
111
types/index.d.ts 40 lines
1export type Lang = 'es' | 'en'
2
3export type Account = { isSubscription: boolean; plan: string; home: string }
4
5export type Turn = { input: number; output: number }
6
7export type Gemini = {
8  isSlow: boolean
9  pct: number | null
10  model: string
11  step: string
12  doing: string
13  elapsed: string
14  tokens: string
15}
16
17// One headline of the ticker under the mascot's floor.
18export type News = { kind: 'ask' | 'plan' | 'error' | 'deny' | 'commit' | 'ship' | 'gemini'; detail: string; at: number }
19
20declare module 'claude-code' {
21  interface PluginState {
22    'braves-panel': {
23      turns: Turn[]
24      tools: Record<string, number>
25      branch: string
26      now: number
27      isOpen: boolean
28      news: News[]
29      sprint: boolean
30      crowd: number
31      tasks: number
32      gemini: Gemini | null
33      lang: Lang
34      account: Account | null
35      isWorking: boolean
36      cue: { name: string; n: number }
37    }
38  }
39}
40