SLOPSHOPPER

token-bar

Límites del plan, tokens, contexto, modelo y repo en una banda animada sobre el prompt, con Clawd como compañero de desarrollo. No gasta tokens.

newpanebandrowsguardcommand
v0.3.0MITupdated 2026-10-23guille-nat/token-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-bar
│ ┃ Token bar ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Límites 2: Tokens 3: Ajustes 4: Repo │ ┃ ⏺ Read(src/auth.ts) │ ┃ Modelo ✻ Opus 5.5 ⎿ Read 6 lines │ ┃ Sesión ──────────────────── 31% ⏺ Update(src/auth.ts) │ ┃ se reinicia -- ⎿ Added 2 lines, removed 1 line │ ┃ Contexto ──────────────────── 49% ⏺ Bash(bun test) │ ┃ 97,4k / 200k ⎿ 3 pass, 1 fail │ ┃ Costo de la sesión US$ 0,42 │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Fable semanal: no viene en rateLimits │ ┃ (llegaron: five_hour). No se muestra un ✻ Worked for 42s · done 4:20 PM │ ┃ valor inventado. │ ┃ 5 abre la leyenda de colores y símbolos. › /token-bar │ ⎿ token-bar: Panel de token-bar abierto (Esc cierra). │ │ ▣ client module ./clawd-client.ts ✻ Opus 5.5 │ Sesión ░░░░░░░░░░ 31% ↻ -- │ ctx ░░░░░░░░░░ 49% ╰ ⇡ ¡pusheado! │ ↑97,4k ↓1,5k │ ? │ t: panel /work/app ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ client module ./clawd-client.ts ✻ Opus 5.5 │ Sesión ░░░░░░░░░░ 31% ↻ -- │ ctx ░░░ ╰ ⇡ ¡pusheado! │ ↑97,4k ↓1,5k │ ? │ t: panel /work/app ⟨Claude Code's own drawing⟩
Pane · Token bar
1: Límites 2: Tokens 3: Ajustes 4: Repo 5: Leyenda 6: I Modelo ✻ Opus 5.5 Sesión ──────────────────── 31% se reinicia -- Contexto ──────────────────── 49% 97,4k / 200k Costo de la sesión US$ 0,42 Fable semanal: no viene en rateLimits (llegaron: five_hour). No se muestra un valor inventado. 5 abre la leyenda de colores y símbolos.
README

<img src="assets/clawd.svg" width="260" alt="Clawd, el compañero de token-bar, saludando y poniéndose los lentes">

token-bar

Tus límites, tus tokens, tu repo y un compañero que te hace el aguante, todo sobre el prompt de Claude Code.

Claude Code mod 0 tokens TypeScript Tests GitHub y GitLab Versión Licencia MIT

Instalación · Qué hace · Clawd · Panel · Configuración · FAQ · Desarrollo


En una línea: token-bar es un mod de Claude Code que dibuja, arriba del prompt, cuánto te queda del plan (sesión, semana, contexto), cuántos tokens llevás, en qué modelo, rama, PR y CI estás, y a Clawd, un bichito animado que cuenta lo que está haciendo Claude, festeja tus tests y tus push, y se rasca la cabeza cuando piensa.

token-bar is a Claude Code mod that draws a live, animated band above the prompt: plan rate limits (session, weekly, Fable), context window, token counters, cost, active model, working folder and repo status (branch, ahead/behind, changes, PR/MR, CI for GitHub and GitLab). It ships Clawd, an animated companion that narrates what Claude is doing, celebrates passing tests and pushes, shakes on errors, watches you type, sleeps when idle and puts on deal-with-it shades. It never calls a model and adds nothing to the context: it costs zero tokens. Install with /plugin install token-bar --marketplace guille-nat/token-bar. The UI is in Spanish.


✨ Qué hace

Límites del plan en vivoSesión de 5 h, semana y Fable con barras finas ━━╸──, porcentaje y hora de reinicio (↻ 1:50 p. m.). Se llenan con animación al abrir y se deslizan cuando cambian.
Colores que avisanVerde < 50 %, amarillo < 75 %, naranja < 90 %, rojo desde 90 %. En rojo el porcentaje late y Clawd te avisa cuál está al límite. Los cortes se pueden cambiar.
Contextoctx muestra cuánto de la ventana de contexto ocupa la conversación.
Tokens y costo↑ entrada, ↓ salida de la sesión, separando el hilo principal de los subagentes, y el gasto estimado en US$.
Modelo✻ Opus 5.5 con el destello de Claude que gira y un brillo que recorre el nombre mientras trabaja.
Dónde estásLa carpeta de trabajo (~/workdir/mi-proyecto).
Tu repo de un vistazo⎇ main ⇡2 ±3 #12 ✓: rama, commits por pushear o traer, cambios sin commitear, el PR o MR de la rama y el estado del CI. Funciona con GitHub (gh) y GitLab (glab).
ClawdUn compañero animado que reacciona a lo que pasa en la sesión. Ver todo lo que hace ↓
Panel completo/token-bar abre un panel con 6 pestañas: límites, tokens, ajustes, repo, leyenda e info.
Se adapta al anchoEn terminales angostas se compacta sola: primero suelta la hora de reinicio, después achica barras y, si no entra, pasa los datos debajo de Clawd.
Config portableExportás tu configuración a un JSON y la importás en otra máquina con un comando.

Cuesta cero tokens. token-bar no llama a ningún modelo, no agrega nada al system prompt ni al contexto y no modifica las herramientas: solo lee datos de la sesión y dibuja en tu terminal.


🚀 Instalación

Necesitás Claude Code con soporte de mods (probado desde la 2.1.303).

Desde el prompt de Claude Code

/plugin install token-bar --marketplace guille-nat/token-bar
  1. Aceptá agregar el marketplace guille-nat/token-bar.
  2. Elegí el scope user para tenerlo en todos tus proyectos (con project queda solo en esa carpeta).
  3. Elegí un preset inicial (minima, normal o completa). Si ya tenés una config exportada, poné su ruta en Archivo de configuración.
  4. Corré /reload-plugins o abrí una sesión nueva. La banda aparece arriba del prompt.

Desde la terminal

claude plugin marketplace add guille-nat/token-bar
claude plugin install token-bar@guille-nat --scope user

Actualizar

claude plugin update token-bar@guille-nat

Después, /reload-plugins en las sesiones abiertas.

Desinstalar

claude plugin uninstall token-bar@guille-nat
claude plugin marketplace remove guille-nat

Opcional: datos de GitHub o GitLab

La rama, los cambios y los commits por pushear salen de git y andan siempre. Para ver PR/MR, CI, issues y reviews pendientes hace falta la CLI de tu forge con sesión iniciada:

gh auth login      # GitHub
glab auth login    # GitLab

No hay tokens que configurar: token-bar usa la sesión que ya tienen gh y glab.


👾 Clawd, tu compañero de desarrollo

Clawd vive a la izquierda de la banda. No es decorado: sabe lo que está pasando y reacciona.

Mientras Claude trabaja, Clawd te cuenta qué hace

Cuando Claude…Clawd…
piensase rasca la cabeza como el meme, mira de reojo y le titila un ? ("pensando…", "mmm…", "a ver, a ver…")
lee un archivomueve los ojos de lado a lado: "leyendo register.tsx"
busca en el código"buscando «useState»"
edita o escribeteclea con los dos brazos: "editando mascot.ts"
corre un comando"corriendo npm"
usa git"git rebase…"
navega la web"mirando docs.github.com"
lanza un subagentesaluda al ayudante: "ayudante: revisar tests"
lleva más de 90 stranspira ° y cada tanto lo comenta ("esto viene largo…")

Todo el tiempo el destello de Claude ✻ orbita a su alrededor.

Cuando algo sale bien, o mal

EventoReacción
Pasan los testsbrazos arriba, saltos y confeti de colores: "¡tests en verde!"
Fallan los teststriste, sudando: "tests en rojo…"
git commit"¡commit hecho!"
git push**se pone los lentes de *deal with it*** y suben flechas ⇡: "¡pusheado!"
Termina la respuestafesteja con confeti: "¡listo!", "¡hecho!"
Termina un turno de más de 1 minutose calza los lentes: "todo bajo control"
Una herramienta da errorse sacude con los ojos bien abiertos: "uy…", "mmm, falló"
Lo frenás con Esc"ok, freno"

Con vos

Vos…Clawd…
abrís una sesiónte saluda según la hora: "¡Hola! ¡buen día!", "¿noche de código?"
escribís en el promptte sigue con los ojos mientras tipeás: "te leo…"
mandás el promptse agacha y salta: "¡a darle!"
le hacés clicse pone contento y le salen corazones ♥: "¡jeje!"
le hacés tres clics seguidos**se pone los lentes de *deal with it***
lo dejás solo 3 minutosse duerme con zzz que suben, y se despierta con lo próximo que pase: "¡eh! ¡volviste!"

En reposo parpadea, mira a los costados, salta, baila con ♪ ♫, se estira y cada tanto se pone los lentes. Si un límite pasa el 90 %, le titila un ! rojo y te dice cuál: "¡ojo! Semana al 93%".

Clawd se apaga con m en la pestaña Info, sus comentarios con s y todas las animaciones con n. Sin animaciones queda quieto, pero igual te avisa de los límites.


🎛️ El panel /token-bar

/token-bar

También se abre con t cuando la banda tiene el foco (un clic sobre ella o ctrl+x tab). Esc lo cierra.

TeclaPestañaQué muestra
1LímitesModelo y cada límite del plan con barra, porcentaje, hora de reinicio y contexto.
2TokensEntrada, salida y caché de la sesión, el último turno y lo que gastaron los subagentes.
3AjustesBanda, línea de texto, densidad, puntos de color, repo y reinicio de contadores.
4RepoRama, sync, cambios, último commit, PR/MR con review, CI, issues y PRs abiertos, asignados a vos y con review pedida.
5LeyendaQué significa cada color y cada símbolo.
6InfoQué se muestra, presets, cortes de color, Clawd y export/import de la config.

u refresca todo al instante, desde cualquier pestaña.

Los símbolos

SímboloSignificado
━━━╸────Barra de uso (media celda de precisión)
↻ 1:50 p. m.Cuándo se reinicia el límite
↑12,3k ↓4,5kTokens de entrada y salida de la sesión
⎇ mainRama actual
⇡2 ⇣1Commits por pushear y por traer
±3Archivos con cambios sin commitear
#12 / !12PR (GitHub) o MR (GitLab) de la rama
✓ ✗ ○CI en verde, fallando o corriendo

⚙️ Configuración

Todo se elige desde la pestaña Info del panel (/token-bar → 6) y se guarda solo.

Presets

TeclaPresetQué muestra
zmínimaModelo, sesión, semana, contexto y rama. Sin Clawd.
xnormalLo de siempre, con Clawd. Es el preset por defecto.
ccompletaTodo: costo, barras largas, cada dato del repo.

Interruptores

Cada dato se prende y se apaga por separado: Modelo, Sesión, Semana, Fable, Contexto, Tokens, Costo, Hora de reinicio, Ruta de trabajo, Rama, Sync, Cambios, PR/MR y CI. Además: Clawd (m), Comentarios de Clawd (s) y Animaciones (n).

Un dato oculto no da alertas: si ocultás Fable, Clawd no te avisa de Fable.

Cortes de color

Con l elegís desde dónde una barra pasa a amarillo, naranja y rojo: 50/75/90 (por defecto), 60/80/95 o 40/65/85. El rojo es también el umbral donde el porcentaje late y Clawd avisa.

Llevar tu configuración a otra máquina

/token-bar-config export                      # guarda ~/.config/token-bar/config.json y lo copia al portapapeles
/token-bar-config export ~/dotfiles/tb.json   # o donde quieras
/token-bar-config import ~/dotfiles/tb.json   # en la otra máquina
/token-bar-config import {"mascotOn":false}   # o pegando el JSON
/token-bar-config preset completa             # aplica un preset
/token-bar-config reset                       # vuelve a los valores por defecto

También podés poner la ruta del JSON en Archivo de configuración al instalar el mod: se importa sola la primera vez. Un JSON hecho a mano puede traer solo los campos que quieras cambiar:

{ "show": { "tokens": false, "cost": true }, "mascotOn": true, "levels": [60, 80, 95] }

Línea de texto bajo el prompt

Si preferís la línea clásica de texto, prendela con b en Ajustes. Viene apagada porque Claude Code no le permite colores.


❓ Preguntas frecuentes

No gasta tokens. token-bar no llama a ningún modelo, no agrega texto al contexto ni al system prompt y deja pasar cada herramienta sin tocarla. Lo único que corre es local: un reloj de 100 ms que redibuja solo cuando algo cambia, git status cada 30 s y una consulta a GitHub o GitLab cada 2 minutos o cuando la rama se mueve.

Lee el uso de la sesión que expone Claude Code (límites, contexto, tokens, costo, modelo), la carpeta de trabajo y el estado del repo con git, gh y glab. No manda nada a ningún servidor propio: las únicas llamadas de red son las de gh y glab a tu propio GitHub o GitLab, con tu sesión.

Hace falta gh (GitHub) o glab (GitLab) instalados y con sesión (gh auth login). La pestaña Repo del panel te dice exactamente qué falta. Si el commit no está pusheado, el CI aparece como "sin checks".

Los límites los informa Claude Code solo en planes con límites (Pro, Max). Sin datos, la banda muestra -- en vez de inventar un número. /token-bar-debug muestra los datos crudos que llegan.

token-bar usa solo caracteres de cajas y bloques Unicode (━ ╸ ▐ ▛ ✻ ⎇), sin emojis ni Nerd Fonts, así que debería verse bien en cualquier fuente monoespaciada moderna (JetBrains Mono, Fira Code, Cascadia, etc.). Clawd está pensado para terminales con fondo oscuro.

m en la pestaña Info lo apaga, s apaga solo sus comentarios y n congela todas las animaciones. El preset mínima lo saca de una.

  1. Corré /reload-plugins.
  2. Corré /token-bar-debug: muestra el uso crudo, el modelo, el repo, el ancho de la terminal y tus ajustes.
  3. Abrí un issue con esa salida.

🛠️ Desarrollo

git clone https://github.com/guille-nat/token-bar.git
cd token-bar

claude --plugin-dir .        # carga el mod solo en esa sesión, con recarga en caliente
claude plugin validate .     # valida manifiesto, marketplace y hooks
claude plugin test .         # corre los tests (sin red ni sesión)

Para usarlo instalado desde tu clon, de modo que cada cambio se vea con /reload-plugins:

claude plugin marketplace add "$PWD"
claude plugin install token-bar@guille-nat --scope user

Estructura

.claude-plugin/
  plugin.json        manifiesto y opciones de instalación (preset, archivo de config)
  marketplace.json   este mismo repo es su marketplace
hooks/
  register.tsx       eventos, comandos, banda y panel
  mascot.ts          Clawd: poses, partículas, reacciones (puro)
  clawd-client.ts    región propia de Clawd: recibe los clics
  format.ts          barras, colores, horas, modelo, línea de estado (puro)
  config.ts          ajustes, presets, export/import (puro)
  repo.ts            git, GitHub y GitLab: parseo y textos (puro)
tests/               unitarios por módulo e integración con el kit de Claude Code
scripts/readme-art.ts  genera las imágenes de assets/ desde el código real de Clawd
assets/              imágenes animadas del README (SVG)
Doc/                 documentación técnica y decisiones de diseño

La documentación técnica (eventos que usa, dónde vive el estado, por qué cada decisión) está en Doc/.

Contribuir

Los PRs son bienvenidos. Leé CONTRIBUTING.md: cómo armar el entorno, las convenciones y cómo se revisan los cambios.


Hecho con ✻ por Guillermo Natali Ulla · Licencia MIT

Source 6 files
hooks/register.tsx 1252 lines
1import type { EngineInterface, PluginOptions, Register, RenderChildren } from 'claude-code'
2
3import {
4  applyPreset,
5  DEFAULT_CONFIG_PATH,
6  exportConfig,
7  expandHome,
8  formatLevels,
9  isPreset,
10  limitKey,
11  nextLevels,
12  parseConfig,
13  PRESET_NAMES,
14  readSettings,
15  type Settings,
16  SHOW_KEYS,
17  SHOW_LABELS,
18  type ShowKey,
19  toStored,
20} from './config'
21import {
22  addTokens,
23  CLAUDE_GLOW,
24  CLAUDE_ORANGE,
25  CONTEXT_COLOR,
26  currentLevelMins,
27  type Counters,
28  type Level,
29  easeToward,
30  emptyCounters,
31  fitStatusLine,
32  formatInt,
33  formatModel,
34  formatPct,
35  formatReset,
36  formatTokens,
37  formatUsd,
38  hasFable,
39  kindColor,
40  labelFor,
41  barParts,
42  lineBar,
43  nextDensity,
44  numericFields,
45  shimmerIndex,
46  shortModel,
47  setLevelMins,
48  shortLabelFor,
49  sortLimits,
50  SPARK_IDLE,
51  sparkFrame,
52  toneFor,
53  totalInput,
54  type TurnTokens,
55  type Usage,
56  USAGE_LEVELS,
57} from './format'
58import {
59  type Activity,
60  activityOf,
61  MASCOT_WIDTH,
62  mascotFrame,
63  type Mood,
64  outcomeOf,
65  type Reaction,
66  type ReactionKind,
67  SLEEP_AFTER,
68  TONE_COLOR,
69  type Seg,
70} from './mascot'
71import type { ClawdProps, ClawdSeg } from './clawd-client'
72import {
73  type ChangeState,
74  changeLabel,
75  changeRef,
76  type CiState,
77  firstLine,
78  type ForgeData,
79  formatAgo,
80  formatBranch,
81  formatPath,
82  formatChanges,
83  formatCi,
84  formatCount,
85  type GitState,
86  GITHUB_QUERY,
87  githubVariables,
88  gitKey,
89  gitlabPaths,
90  parseGithub,
91  parseGitlab,
92  parseRemote,
93  parseStatus,
94  parseSubject,
95  type Remote,
96  type RepoInfo,
97  repoSummary,
98} from './repo'
99
100// $.session, $.ui, $.store, $.clock, $.command y, para el repo, $.process
101// (ver Doc/arquitectura.md): sin $.state, así que el estado vive en el módulo
102// y se redibuja con $.ui.invalidate; lo que debe sobrevivir a un hot-reload va
103// a $.store.
104
105type Tab = 'limits' | 'tokens' | 'settings' | 'repo' | 'legend' | 'info'
106
107const PANE = 'token-bar'
108const REFRESH_MS = 30_000
109/** Cada cuánto se vuelve a preguntar a GitHub/GitLab si la rama no se movió. */
110const FORGE_MS = 120_000
111/** Cuadro de las animaciones: destello, barrido del modelo y barras que se llenan. */
112const FRAME_MS = 100
113/** Por debajo de este ancho de banda, Clawd no entra ni con su comentario. */
114const MASCOT_MIN_COLUMNS = 40
115/** Ancho de la columna donde Clawd dice su comentario (y debajo del modelo). */
116const SAY_WIDTH = 28
117const MASCOT_COLUMNS = MASCOT_WIDTH
118
119let usage: Usage | null = null
120let model: string | null = null
121let counters: Counters = emptyCounters()
122let settings: Settings = readSettings(undefined)
123let tab: Tab = 'limits'
124/** null: todavía no se mandó nada, así el primer status siempre llega (y limpia el de un módulo anterior). */
125let lastStatus: string | undefined | null = null
126let statusLevel: string = 'media'
127let lastTurnRaw: Record<string, number> | null = null
128let repo: RepoInfo | null = null
129let repoJob: Promise<void> | null = null
130/** Dónde trabaja la sesión (absoluta) y el HOME para abreviarla con `~`. */
131let cwd: string | null = null
132let home: string | null = null
133let frame = 0
134let isWorking = false
135let mascotKey = ''
136/** Lo que vive Clawd: lo último que pasó, qué corre, si escribís y desde cuándo no pasa nada. */
137let reaction: Reaction | null = null
138let reactionSeed = 0
139let activity: Activity | null = null
140let typing: { at: number; ratio: number } | null = null
141let lastActive = 0
142let workStart = 0
143/** Clics seguidos sobre Clawd: con muchos, se queja. */
144let pets = { count: 0, at: -Infinity }
145/** Lo que muestra cada barra mientras se acerca a su valor (clave: kind o "ctx"). */
146const shown = new Map<string, number>()
147const columns: { status: number | null; band: number | null; pane: number | null; command: number | null } = {
148  status: null,
149  band: null,
150  pane: null,
151  command: null,
152}
153
154const offsetMin = (iso: string): number => new Date(iso).getTimezoneOffset()
155
156const saveSettings = ($: EngineInterface) => $.store.set('settings', toStored(settings))
157
158/** Aplica ajustes: son los de la sesión y fijan los cortes de color. */
159const applySettings = (next: Settings) => {
160  settings = next
161  setLevelMins(next.levels)
162}
163
164/** Desde dónde el medidor es "al límite": late y hace que Clawd avise. */
165const alertAt = (): number => currentLevelMins()[3] ?? 90
166
167type Loaded = { settings: Settings; ignored: string[]; path: string } | { error: string }
168
169/** Lee un config.json (`~` vale HOME); no lo aplica. */
170const readConfigFile = async ($: EngineInterface, path: string): Promise<Loaded> => {
171  const full = expandHome(path, await $.env.get('HOME'))
172  if (full === null) return { error: `no se pudo resolver ${path}: falta HOME` }
173  const text = await $.fs.read(full).catch(() => null)
174  if (typeof text !== 'string') return { error: `no se pudo leer ${full}` }
175  const parsed = parseConfig(text)
176  return 'error' in parsed ? { error: `${full}: ${parsed.error}` } : { ...parsed, path: full }
177}
178
179const writeConfigFile = async ($: EngineInterface, path: string): Promise<string> => {
180  const full = expandHome(path, await $.env.get('HOME'))
181  if (full === null) throw new Error(`no se pudo resolver ${path}: falta HOME`)
182  await $.fs.write(full, exportConfig(settings))
183  return full
184}
185
186/**
187 * La primera vez en una máquina (sin nada guardado) la configuración sale de
188 * las opciones del mod: `configFile` si apunta a un config.json que se pueda
189 * leer, si no el preset elegido. Después manda lo que se guarda desde el panel.
190 */
191const seedSettings = async ($: EngineInterface, options: PluginOptions): Promise<Settings> => {
192  const file = typeof options.configFile === 'string' ? options.configFile.trim() : ''
193  if (file !== '') {
194    const loaded = await readConfigFile($, file)
195    if (!('error' in loaded)) return loaded.settings
196    $.ui.toast(`token-bar: ${loaded.error}`)
197  }
198  return isPreset(options.preset) ? applyPreset(readSettings(undefined), options.preset) : readSettings(undefined)
199}
200
201/** Aplica, guarda y redibuja; si cambió lo del repo, lo vuelve a leer. */
202const applyAndSave = async ($: EngineInterface, next: Settings) => {
203  const repoChanged = next.repoOn !== settings.repoOn
204  applySettings(next)
205  await saveSettings($)
206  if (repoChanged) await refreshRepo($, true)
207  await repaint($)
208}
209
210const CONFIG_HELP = [
211  `/token-bar-config export [ruta]   guarda la config en un JSON (por defecto ${DEFAULT_CONFIG_PATH}) y la copia`,
212  '/token-bar-config import [ruta]   lee ese JSON (o un JSON pegado a continuación) y lo aplica',
213  '/token-bar-config preset <minima|normal|completa>',
214  '/token-bar-config reset           vuelve a los valores por defecto',
215].join('\n')
216
217/** Los subcomandos de /token-bar-config; devuelve el texto que se muestra. */
218const runConfig = async ($: EngineInterface, args: string): Promise<string> => {
219  const trimmed = args.trim()
220  const sub = trimmed.split(/\s+/)[0] ?? ''
221  const arg = trimmed.slice(sub.length).trim()
222
223  if (sub === '' || sub === 'export') {
224    const path = await writeConfigFile($, arg === '' ? DEFAULT_CONFIG_PATH : arg)
225    const json = exportConfig(settings)
226    const copied = await $.ui.copy({ text: json }).catch(() => ({ isCopied: false }))
227    return `Config guardada en ${path}${copied.isCopied ? ' y copiada al portapapeles' : ''}.\n\n${json}`
228  }
229  if (sub === 'import') {
230    const loaded = arg.startsWith('{') ? parseConfig(arg) : await readConfigFile($, arg === '' ? DEFAULT_CONFIG_PATH : arg)
231    if ('error' in loaded) return `token-bar: ${loaded.error}`
232    await applyAndSave($, loaded.settings)
233    const ignored = loaded.ignored.length === 0 ? '' : ` Claves ignoradas: ${loaded.ignored.join(', ')}.`
234    return `Config importada y guardada.${ignored}`
235  }
236  if (sub === 'preset') {
237    if (!isPreset(arg)) return `token-bar: preset desconocido. Los hay: ${PRESET_NAMES.join(', ')}.`
238    await applyAndSave($, applyPreset(settings, arg))
239    return `Preset ${arg} aplicado.`
240  }
241  if (sub === 'reset') {
242    await applyAndSave($, readSettings(undefined))
243    return 'Config restablecida a los valores por defecto.'
244  }
245  return `Subcomandos de /token-bar-config:\n${CONFIG_HELP}`
246}
247
248/** Los valores de las barras: cada límite y el contexto. */
249const targets = (): [string, number][] => [
250  ...(usage?.rateLimits ?? []).map(l => [l.kind, l.percentUsed] as [string, number]),
251  ...(usage?.context.percent === undefined ? [] : [['ctx', usage.context.percent] as [string, number]]),
252]
253
254/** El valor animado de una barra; arranca en 0 para que se llene al abrir. */
255const shownPct = (key: string, pct: number): number => {
256  if (!settings.animations) return pct
257  return shown.get(key) ?? (shown.set(key, 0), 0)
258}
259
260/** Arriba de 90 % el porcentaje late (medio segundo encendido, medio atenuado). */
261const isPulseDim = (pct: number | undefined): boolean =>
262  settings.animations && pct !== undefined && pct >= alertAt() && Math.floor(frame / 5) % 2 === 1
263
264/**
265 * Un cuadro: avanza las barras hacia su valor y redibuja solo si algo se mueve
266 * (un turno corriendo, una barra llenándose, un medidor al límite latiendo).
267 */
268const tick = ($: EngineInterface) => {
269  if (!settings.animations) return
270  frame += 1
271  let isMoving = false
272  for (const [key, pct] of targets()) {
273    const from = shown.get(key)
274    if (from === undefined) continue
275    const to = easeToward(from, pct)
276    if (to !== from) {
277      shown.set(key, to)
278      isMoving = true
279    }
280  }
281  const isAlert = targets().some(([, pct]) => pct >= alertAt())
282  // Clawd cambia de pose cada tantos ticks: se redibuja solo cuando cambia.
283  const nextMascot = settings.bandOn && settings.mascotOn ? mascotFrame(frame, mood()).key : ''
284  const isMascotMoving = nextMascot !== mascotKey
285  mascotKey = nextMascot
286  if (isMoving || isWorking || isMascotMoving || (isAlert && frame % 5 === 0)) $.ui.invalidate('ui.render')
287}
288
289/** Cómo está Clawd: trabajando, al límite (el medidor más alto ≥90 %) o tranquilo. */
290const mood = (): Mood => {
291  // Un dato que el usuario ocultó no da alertas.
292  const limits = (usage?.rateLimits ?? [])
293    .filter(l => settings.show[limitKey(labelFor(l.kind))])
294    .map(l => ({ label: labelFor(l.kind), pct: l.percentUsed }))
295  if (settings.show.context && usage?.context.percent !== undefined) limits.push({ label: 'el contexto', pct: usage.context.percent })
296  const top = limits.reduce<{ label: string; pct: number } | null>((a, b) => (a === null || b.pct > a.pct ? b : a), null)
297  return {
298    isWorking,
299    alert: top !== null && top.pct >= alertAt() ? top : null,
300    maxPct: top?.pct ?? 0,
301    isStill: !settings.animations,
302    activity,
303    reaction,
304    typing,
305    idleTicks: frame - lastActive,
306    workTicks: frame - workStart,
307  }
308}
309
310/** Colores de los datos de la banda, para que no se lean como un solo bloque gris. */
311const TOKENS_IN_COLOR = '#7DCFFF'
312const TOKENS_OUT_COLOR = '#9ECE6A'
313const COST_COLOR = '#E0AF68'
314const CWD_COLOR = '#BB9AF7'
315
316/** Un tramo de Clawd con los colores resueltos, para su región propia. */
317const clawdSeg = (seg: Seg): ClawdSeg => ({
318  t: seg.text,
319  ...(seg.tone === null ? {} : { c: TONE_COLOR[seg.tone] }),
320  ...(seg.bg === undefined ? {} : { b: TONE_COLOR[seg.bg] }),
321})
322
323/** Algo pasó: si estaba dormido se despierta, y no se duerme hasta un rato después. */
324const touch = () => {
325  if (settings.animations && !isWorking && frame - lastActive >= SLEEP_AFTER) react('wake')
326  lastActive = frame
327}
328
329/** Clawd reacciona: dura unos ticks y el reloj lo redibuja. */
330const react = (kind: ReactionKind, extra: Partial<Reaction> = {}) => {
331  if (!settings.animations || !settings.mascotOn) return
332  reaction = { kind, at: frame, seed: reactionSeed++, ...extra }
333}
334
335/** Clics seguidos (cada uno a menos de 1,5 s del anterior) que hacen falta para los lentes. */
336const TAPS_FOR_SHADES = 3
337
338/** Un clic sobre Clawd: corazones; varios seguidos, se pone los lentes. */
339const pet = () => {
340  pets = frame - pets.at < 15 ? { count: pets.count + 1, at: frame } : { count: 1, at: frame }
341  touch()
342  if (pets.count >= TAPS_FOR_SHADES) {
343    pets = { count: 0, at: -Infinity }
344    react('cool')
345  } else react('pet')
346}
347
348const saveCounters = async ($: EngineInterface) =>
349  $.store.set('counters', { sessionId: await $.session.id(), counters })
350
351const loadCounters = async ($: EngineInterface): Promise<Counters> => {
352  const raw = (await $.store.get('counters')) as { sessionId?: string; counters?: Counters } | undefined
353  return raw?.sessionId === (await $.session.id()) && raw.counters ? raw.counters : emptyCounters()
354}
355
356const updateStatus = async ($: EngineInterface) => {
357  const fit = fitStatusLine({
358    usage,
359    counters,
360    density: settings.density,
361    columns: columns.status ?? columns.command,
362    nowMs: await $.clock.now(),
363    offsetMin,
364    model,
365    dots: settings.dotsOn,
366    repo: settings.repoOn ? repo : null,
367  })
368  statusLevel = fit.level
369  const text = settings.statusOn ? fit.text : undefined
370  if (text === lastStatus) return
371  lastStatus = text
372  $.ui.status(text)
373}
374
375const repaint = async ($: EngineInterface) => {
376  await updateStatus($)
377  $.ui.invalidate('ui.render')
378}
379
380const refresh = async ($: EngineInterface) => {
381  const [nextUsage, nextModel] = await Promise.all([$.session.usage(), $.session.model().catch(() => model)])
382  usage = nextUsage
383  model = nextModel
384  cwd = await $.session.cwd().catch(() => cwd)
385  home = (await $.env.get('HOME').catch(() => undefined)) ?? home
386  await repaint($)
387}
388
389/** Corre un comando; si no arranca (no instalado), lo dice con su nombre. */
390const cli = async ($: EngineInterface, argv: string[], timeoutMs = 20_000) => {
391  try {
392    return await $.process.run(argv, { timeoutMs })
393  } catch {
394    throw new Error(`${argv[0]} no está instalado o no se pudo ejecutar`)
395  }
396}
397
398const readGit = async ($: EngineInterface): Promise<GitState | null> => {
399  // --no-optional-locks: no pelea el index.lock con los git que corre Claude.
400  const status = await cli($, ['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch'], 10_000).catch(() => null)
401  if (status === null || status.exitCode !== 0) return null
402  const base = parseStatus(status.stdout)
403  const log = base.sha === null ? null : await cli($, ['git', 'log', '-1', '--format=%s'], 10_000).catch(() => null)
404  return { ...base, subject: log?.exitCode === 0 ? parseSubject(log.stdout) : null }
405}
406
407const readGithub = async ($: EngineInterface, remote: Remote, git: GitState): Promise<ForgeData> => {
408  const vars = Object.entries(githubVariables(remote.path, git)).flatMap(([k, v]) => ['-f', `${k}=${v}`])
409  const r = await cli($, ['gh', 'api', 'graphql', '--hostname', remote.host, '-f', `query=${GITHUB_QUERY}`, ...vars])
410  if (r.exitCode === 4) throw new Error('gh sin sesión: corré gh auth login')
411  if (r.exitCode !== 0) throw new Error(firstLine(r.stderr) || `gh salió con ${r.exitCode}`)
412  return parseGithub(JSON.parse(r.stdout), git)
413}
414
415const readGitlab = async ($: EngineInterface, remote: Remote, git: GitState): Promise<ForgeData> => {
416  const api = async (path: string): Promise<unknown> => {
417    const r = await cli($, ['glab', 'api', '--hostname', remote.host, path])
418    if (r.exitCode !== 0) throw new Error(firstLine(r.stderr) || `glab salió con ${r.exitCode}`)
419    return JSON.parse(r.stdout)
420  }
421  const paths = gitlabPaths(remote.path, git)
422  const [user, mrs, pipelines, issues, assigned, openMrs] = await Promise.all([
423    api(paths.user),
424    api(paths.mrs),
425    // Sin sha, `pipelines?sha=` traería los de cualquier commit.
426    git.sha === null ? Promise.resolve([]) : api(paths.pipelines),
427    api(paths.issues),
428    api(paths.assigned),
429    api(paths.openMrs),
430  ])
431  const username = (user as { username?: unknown }).username
432  const reviewMrs = typeof username === 'string' ? await api(paths.reviewMrs(username)) : null
433  return parseGitlab({ mrs, pipelines, issues, assigned, openMrs, reviewMrs }, git)
434}
435
436/**
437 * git local en cada llamada; GitHub/GitLab solo si `force`, si la rama se movió
438 * (rama, commit, adelante/atrás) o si pasaron FORGE_MS desde la última consulta.
439 */
440const readRepo = async ($: EngineInterface, force: boolean) => {
441  if (!settings.repoOn) {
442    repo = null
443    return repaint($)
444  }
445  const found = await $.session.repo().catch(() => null)
446  if (found === null) {
447    repo = null
448    return repaint($)
449  }
450  const prev = repo?.root === found.root ? repo : null
451  const remote = found.remote === null ? null : parseRemote(found.remote)
452  const git = await readGit($)
453  const base: RepoInfo = {
454    root: found.root,
455    remote,
456    git,
457    forge: prev?.forge ?? null,
458    forgeError: prev?.forgeError ?? null,
459    updatedAt: prev?.updatedAt ?? null,
460  }
461  repo = base
462  if (remote?.forge == null || git === null) {
463    repo = { ...base, forge: null, forgeError: null }
464    return repaint($)
465  }
466  await repaint($)
467
468  const now = await $.clock.now()
469  const isStale = prev?.updatedAt == null || now - prev.updatedAt >= FORGE_MS
470  if (!force && !isStale && gitKey(prev?.git ?? null) === gitKey(git)) return
471
472  try {
473    const forge = remote.forge === 'github' ? await readGithub($, remote, git) : await readGitlab($, remote, git)
474    repo = { ...base, forge, forgeError: null, updatedAt: now }
475  } catch (err) {
476    repo = { ...base, forge: null, forgeError: err instanceof Error ? err.message : String(err), updatedAt: now }
477  }
478  await repaint($)
479}
480
481/** Una lectura a la vez; `force` con una en curso espera y lee de nuevo. */
482const refreshRepo = ($: EngineInterface, force = false): Promise<void> => {
483  if (repoJob !== null) return force ? repoJob.then(() => refreshRepo($, true)) : repoJob
484  repoJob = readRepo($, force)
485    .catch(() => {})
486    .finally(() => {
487      repoJob = null
488    })
489  return repoJob
490}
491
492const tokensOf = (u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): TurnTokens => ({
493  input: u.input_tokens,
494  output: u.output_tokens,
495  cacheRead: u.cache_read_input_tokens,
496  cacheWrite: u.cache_creation_input_tokens,
497})
498
499const CI_TONE: Record<CiState, string> = { ok: 'success', falla: 'error', corriendo: 'warning' }
500const CHANGE_TONE: Record<ChangeState, string | undefined> = {
501  abierto: 'success',
502  borrador: undefined,
503  mergeado: 'merged',
504  cerrado: 'error',
505}
506
507export const register: Register = (on, options) => {
508  on('session.start', async ($, e, next) => {
509    const stored = await $.store.get('settings')
510    applySettings(stored === undefined ? await seedSettings($, options) : readSettings(stored))
511    if (stored === undefined) await saveSettings($)
512    counters = await loadCounters($)
513    await $.command.register({ name: 'token-bar', description: 'Panel de consumo, límites, modelo y repo' })
514    await $.command.register({ name: 'token-bar-debug', description: 'Datos crudos de token-bar para depurar' })
515    await $.command.register({ name: 'token-bar-config', description: 'Exportar, importar o cambiar la config de token-bar' })
516    await refresh($)
517    // git y gh pueden tardar: el inicio de la sesión no los espera.
518    void refreshRepo($)
519    lastActive = frame
520    react('greet', { hour: new Date(await $.clock.now()).getHours() })
521    $.clock.every(FRAME_MS, () => tick($))
522    $.clock.every(REFRESH_MS, () => {
523      void refresh($)
524      void refreshRepo($)
525    })
526
527    return next(e)
528  })
529
530  on('session.end', async ($, e, next) => {
531    if (e.reason === 'clear') {
532      counters = emptyCounters()
533      lastTurnRaw = null
534    }
535
536    return next(e)
537  })
538
539  on('session.measure', async ($, e, next) => {
540    await refresh($)
541
542    return next(e)
543  })
544
545  on('turn.start', async ($, e, next) => {
546    if (e.agentId === undefined) {
547      touch()
548      workStart = frame
549      activity = { kind: 'think', detail: '' }
550    }
551
552    return next(e)
553  })
554
555  // Clawd mira qué herramienta corre y festeja o lamenta cómo salió. Nunca
556  // toca la llamada: si algo de esto fallara, la herramienta corre igual.
557  on('tool.call', async ($, e, next) => {
558    const input = e as unknown as Record<string, unknown>
559    try {
560      activity = activityOf(e.tool, input)
561    } catch {}
562    const ran = await next(e)
563    try {
564      if (ran.deny === undefined) {
565        const outcome = outcomeOf(e.tool, input, ran.isError === true)
566        if (outcome !== null) react(outcome)
567      }
568      if (isWorking) activity = { kind: 'think', detail: '' }
569    } catch {}
570
571    return ran
572  })
573
574  on('prompt.edit', async ($, e, next) => {
575    touch()
576    // Los ojos siguen lo que escribís, de izquierda a derecha.
577    typing = { at: frame, ratio: ((e.start + e.inputText.length) % 40) / 40 }
578
579    return next(e)
580  })
581
582  // Los clics sobre Clawd llegan desde su región (clawd-client.ts).
583  on('ui.message', async ($, e, next) => {
584    if (e.element === 'clawd') pet()
585
586    return next(e)
587  })
588
589  on('prompt.submit', async ($, e, next) => {
590    touch()
591    typing = null
592    if (e.text.trim() !== '') react('submit')
593
594    return next(e)
595  })
596
597  on('turn.complete', async ($, e, next) => {
598    if (e.agentId === undefined) {
599      activity = null
600      lastActive = frame
601      // Si no se dijo nada más fuerte (un test, un push) en el último segundo, reacciona al final.
602      const isFresh = reaction !== null && frame - reaction.at < 10 && reaction.kind !== 'submit'
603      // Un turno largo que salió bien merece los lentes.
604      const won = frame - workStart >= 600 ? 'cool' : 'done'
605      if (!isFresh) react(e.reason === 'answer' ? won : e.reason === 'aborted' ? 'abort' : 'fail')
606    }
607    lastTurnRaw = numericFields(e)
608    if (e.usage !== undefined) {
609      const turn = tokensOf(e.usage)
610      counters =
611        e.agentId === undefined
612          ? { ...counters, turns: counters.turns + 1, main: addTokens(counters.main, turn), last: { ...turn, model: e.usage.model } }
613          : { ...counters, agents: addTokens(counters.agents, turn) }
614      await saveCounters($)
615      await repaint($)
616    }
617    // Un turno pudo commitear, pushear o cambiar de rama.
618    if (e.agentId === undefined) void refreshRepo($)
619
620    return next(e)
621  })
622
623  on('command.run', { command: 'token-bar' }, async ($, e) => {
624    columns.command = e.presentation?.columns ?? columns.command
625    const opened = await $.ui.open({ id: PANE, title: 'Token bar', focus: true, closeOnEscape: true })
626
627    return { text: opened.isPlaced ? 'Panel de token-bar abierto (Esc cierra).' : `token-bar: el panel no se pudo ubicar (${opened.reason}).` }
628  })
629
630  on('command.run', { command: 'token-bar-config' }, async ($, e) => {
631    try {
632      return { text: await runConfig($, e.args) }
633    } catch (err) {
634      return { text: `token-bar: ${err instanceof Error ? err.message : String(err)}` }
635    }
636  })
637
638  on('command.run', { command: 'token-bar-debug' }, async ($, e) => {
639    columns.command = e.presentation?.columns ?? columns.command
640    const raw = await $.session.usage()
641    const sample = new Date(await $.clock.now())
642    const report = {
643      usage: raw,
644      modelo: { crudo: model, mostrado: formatModel(model) },
645      rateLimitKinds: raw.rateLimits.map(l => ({ kind: l.kind, etiqueta: labelFor(l.kind) })),
646      fableEnRateLimits: hasFable(raw.rateLimits),
647      ultimoTurnComplete: lastTurnRaw,
648      contadores: counters,
649      repo,
650      columnas: columns,
651      densidadEfectiva: statusLevel,
652      ajustes: settings,
653      zonaHoraria: { offsetMin: sample.getTimezoneOffset(), local: sample.toString() },
654      lineaDeEstado: lastStatus ?? null,
655    }
656
657    return { text: JSON.stringify(report, null, 2) }
658  })
659
660  // La fila de salida de un comando junta las líneas: el JSON del debug se
661  // dibuja como bloque de código para que se lea.
662  on('ui.render', { component: 'CommandOutput', props: { command: 'token-bar-debug' } }, async ($, e, next) => {
663    if (e.props.isErrored || !e.props.text.trimStart().startsWith('{')) return next(e)
664    const { Code } = $.ui.resolve(e)
665
666    return <Code source={e.props.text} language="json" />
667  })
668
669  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
670    // Cualquier dibujo de la banda trae el ancho de la terminal: de acá sale
671    // el ancho de la línea de estado, aunque la banda esté apagada.
672    const width = e.viewport?.columns ?? null
673    columns.band = e.props.bodyColumns
674    if (width !== columns.status) {
675      columns.status = width
676      void updateStatus($).catch(() => {})
677    }
678
679    isWorking = e.props.isWorking
680    if (!settings.bandOn || e.props.hasSurvey) return next(e)
681
682    const elements = $.ui.resolve(e)
683    const { Box, Text, Button } = elements
684    // Solo la terminal (y el desktop) tienen Client; en el resto Clawd va en Text.
685    const Client = 'Client' in elements ? elements.Client : undefined
686    const others = await next(e)
687    const now = await $.clock.now()
688    let stacked = false
689    const showMascot = settings.mascotOn && e.props.bodyColumns >= MASCOT_MIN_COLUMNS
690    // A la izquierda Clawd y su comentario (ancho fijo); luego una raya; el resto
691    // es de los datos, que se miden con lo que sobra.
692    const roomFor = (withMascot: boolean) => e.props.bodyColumns - (withMascot ? MASCOT_COLUMNS + 1 + SAY_WIDTH + 3 : 0)
693    const show = settings.show
694    // Cada límite responde al interruptor de su familia (Sesión, Semana, Fable).
695    const limits = sortLimits(usage?.rateLimits ?? []).filter(l => show[limitKey(labelFor(l.kind))])
696    const withSeps = (prefix: string, nodes: RenderChildren[]) =>
697      nodes.flatMap((node, i) => (i === 0 ? [node] : [<Text key={`${prefix}-sep-${i}`} dimColor> │ </Text>, node]))
698
699    // Acomoda piezas de ancho conocido en líneas de a lo sumo `max` columnas, sin
700    // partir ninguna: lo que no entra baja a la línea siguiente.
701    const pack = (prefix: string, items: [number, RenderChildren][], max: number): RenderChildren => {
702      const lines: [number, RenderChildren][][] = [[]]
703      let used = 0
704      for (const item of items) {
705        const line = lines[lines.length - 1]!
706        const need = item[0] + (line.length === 0 ? 0 : 3)
707        if (line.length > 0 && used + need > max) {
708          lines.push([item])
709          used = item[0]
710        } else {
711          line.push(item)
712          used += need
713        }
714      }
715      return (
716        <Box key={`${prefix}-pack`} flexDirection="column">
717          {lines.map((line, i) => (
718            <Box key={`${prefix}-line-${i}`} flexDirection="row">
719              {withSeps(`${prefix}-${i}`, line.map(([, node]) => node))}
720            </Box>
721          ))}
722        </Box>
723      )
724    }
725
726    // El modelo con un brillo que lo recorre mientras Claude trabaja.
727    const brandOf = (modelText: string): RenderChildren => {
728      const isLive = isWorking && settings.animations
729      const glow = isLive ? shimmerIndex(frame, modelText.length) : modelText.length
730      return (
731        <Box key="brand" flexDirection="row">
732          <Text color={CLAUDE_ORANGE} bold>{isLive ? sparkFrame(frame) : SPARK_IDLE} </Text>
733          <Text color={CLAUDE_ORANGE} bold>{modelText.slice(0, glow)}</Text>
734          <Text color={CLAUDE_GLOW} bold>{modelText.slice(glow, glow + 1)}</Text>
735          <Text color={CLAUDE_ORANGE} bold>{modelText.slice(glow + 1)}</Text>
736        </Box>
737      )
738    }
739
740    // Fila de medidores, con el detalle que entre en `room`. Sin Clawd, el
741    // modelo abre la fila; con Clawd, va a su lado.
742    const gaugesRow = ([level, withResets]: readonly [Level, boolean]) => {
743      const isCompact = level === 'compacta'
744      const barWidth = level === 'completa' ? 10 : level === 'media' ? 6 : 0
745
746      const gauge = (key: string, kind: string, labelColor: string, pct: number | undefined, resetsAt?: string): [number, RenderChildren] => {
747        const label = key === 'ctx' ? 'ctx' : isCompact ? shortLabelFor(kind) : labelFor(kind)
748        const tone = pct === undefined ? undefined : toneFor(pct)
749        const parts = barParts(pct === undefined ? 0 : shownPct(key, pct), barWidth)
750        const reset = !isCompact && withResets && resetsAt !== undefined ? formatReset(resetsAt, now, offsetMin(resetsAt)) : ''
751        const length = label.length + 1 + barWidth + (barWidth > 0 ? 1 : 0) + formatPct(pct).length + (reset === '' ? 0 : reset.length + 3)
752        return [
753          length,
754          <Box key={`band-${key}`} flexDirection="row" flexShrink={0}>
755            <Text color={labelColor}>{label} </Text>
756            {barWidth > 0 && <Text color={tone}>{parts.filled}</Text>}
757            {barWidth > 0 && <Text dimColor>{parts.empty}</Text>}
758            <Text color={tone} bold dimColor={isPulseDim(pct)}>{barWidth > 0 ? ' ' : ''}{formatPct(pct)}</Text>
759            {reset !== '' && <Text dimColor> ↻ {reset}</Text>}
760          </Box>,
761        ]
762      }
763
764      const items: [number, RenderChildren][] = [
765        ...((usage?.rateLimits.length ?? 0) === 0
766          ? [
767              ...(show.session ? [gauge('five_hour', 'five_hour', kindColor('five_hour'), undefined)] : []),
768              ...(show.week ? [gauge('seven_day', 'seven_day', kindColor('seven_day'), undefined)] : []),
769            ]
770          : limits.map(l => gauge(l.kind, l.kind, kindColor(l.kind), l.percentUsed, l.resetsAt))),
771        ...(show.context ? [gauge('ctx', 'ctx', CONTEXT_COLOR, usage?.context.percent)] : []),
772      ]
773      return {
774        level,
775        length: items.reduce((n, [len]) => n + len, 0) + 3 * Math.max(0, items.length - 1),
776        node: pack('gauges', items, stacked ? e.props.bodyColumns : roomFor(showMascot)),
777      }
778    }
779    // Con `auto` se prefiere el más detallado que entra; la hora de reinicio es lo
780    // primero que se suelta, antes de achicar las barras o pasar a compacta.
781    const candidates: (readonly [Level, boolean])[] =
782      settings.density === 'auto'
783        ? [['completa', show.resets], ['media', show.resets], ['media', false], ['compacta', false]]
784        : [[settings.density, show.resets]]
785    let rows = candidates.map(gaugesRow)
786    // Si ni la fila más compacta entra al lado de Clawd, la terminal es angosta:
787    // Clawd queda arriba con su comentario y los datos van debajo, a todo el ancho.
788    if (showMascot && rows[rows.length - 1]!.length > roomFor(true)) {
789      stacked = true
790      rows = candidates.map(gaugesRow)
791    }
792    const room = stacked ? e.props.bodyColumns : roomFor(showMascot)
793    const top = rows.find(r => r.length <= room) ?? rows[rows.length - 1]!
794
795    const t = addTokens(counters.main, counters.agents)
796    const repoText = settings.repoOn
797      ? repoSummary(repo, top.level === 'compacta', {
798          branch: show.repoBranch,
799          sync: show.repoSync,
800          changes: show.repoChanges,
801          change: show.repoChange,
802          ci: show.repoCi,
803        })
804      : ''
805    // Con Clawd, la ruta va debajo de él; sin Clawd, entre los detalles.
806    const cwdText = <Text key="band-cwd" color={CWD_COLOR}>{cwd === null ? '' : formatPath(cwd, home, top.level === 'compacta' ? 24 : 40)}</Text>
807    const repoSegments: [number, RenderChildren][] = repoText === '' ? [] : [[repoText.length, <Text key="band-repo" color="suggestion">{repoText}</Text>]]
808    const cwdLabel = cwd === null ? '' : formatPath(cwd, home, top.level === 'compacta' ? 24 : 40)
809    const usageItems: [number, RenderChildren][] = [
810      ...(show.tokens
811        ? ([[
812            formatTokens(totalInput(t)).length + formatTokens(t.output).length + 4,
813            <Box key="band-tokens" flexDirection="row" flexShrink={0}>
814              <Text color={TOKENS_IN_COLOR}>↑{formatTokens(totalInput(t))}</Text>
815              <Text color={TOKENS_OUT_COLOR}> ↓{formatTokens(t.output)}</Text>
816            </Box>,
817          ]] as [number, RenderChildren][])
818        : []),
819      ...(show.cost && usage?.cost !== undefined
820        ? ([[formatUsd(usage.cost.usd).length, <Text key="band-cost" color={COST_COLOR}>{formatUsd(usage.cost.usd)}</Text>]] as [number, RenderChildren][])
821        : []),
822    ]
823    const cwdItems: [number, RenderChildren][] = show.cwd && cwd !== null ? [[cwdLabel.length, cwdText]] : []
824    const panelItem: [number, RenderChildren] = [
825      8,
826      <Button key="band-panel" plain dimColor hotkey="t" label="panel" onPress={() => $.ui.open({ id: PANE, title: 'Token bar', focus: true, closeOnEscape: true })} />,
827    ]
828    const details = [...usageItems, ...repoSegments, panelItem]
829    const info = (
830      <Box key="band-info" flexDirection="column" flexShrink={1}>
831        {top.node}
832        {pack('details', details, room)}
833      </Box>
834    )
835
836    // Sin Clawd, tres líneas que agrupan por tema: qué corre (modelo, tokens,
837    // costo), cuánto queda (los límites) y dónde se trabaja (ruta, repo, panel).
838    if (!showMascot) {
839      const modelText = top.level === 'compacta' ? shortModel(formatModel(model)) : formatModel(model)
840      const runItems: [number, RenderChildren][] = [
841        ...(show.model ? ([[2 + modelText.length, brandOf(modelText)]] as [number, RenderChildren][]) : []),
842        ...usageItems,
843      ]
844      const whereItems = [...cwdItems, ...repoSegments, panelItem]
845      return (
846        <Box flexDirection="column">
847          {runItems.length > 0 && pack('run', runItems, room)}
848          {top.node}
849          {pack('where', whereItems, room)}
850          {others}
851        </Box>
852      )
853    }
854
855    // Clawd y su comentario van juntos: así se entiende que lo hace Claude. El
856    // comentario tiene su fila reservada y un ancho fijo, y la raya lo separa de
857    // los datos, que por eso no se mueven cuando Clawd habla.
858    const clawd = mascotFrame(frame, mood())
859    const say = clawd.say === null ? ' ' : `╰ ${clawd.mark === '' ? '' : `${clawd.mark} `}${clawd.say}`
860    return (
861      <Box flexDirection="column">
862        <Box flexDirection="row">
863          {/* En la terminal, Clawd va en una región propia (Client): un clic llega
864              sin que el engine invierta las celdas, como pasa con un Button. */}
865          {Client !== undefined ? (
866            <Client
867              key="clawd"
868              module="./clawd-client.ts"
869              width={MASCOT_COLUMNS}
870              height={3}
871              props={{ rows: clawd.rows.map(row => row.map(seg => clawdSeg(seg))) } satisfies ClawdProps}
872            />
873          ) : (
874            <Box key="clawd" flexDirection="column" flexShrink={0}>
875              {clawd.rows.map((row, i) => (
876                <Box key={`clawd-${i}`} flexDirection="row">
877                  {row.map((seg, j) => (
878                    <Text
879                      key={`clawd-${i}-${j}`}
880                      color={seg.tone === null ? undefined : TONE_COLOR[seg.tone]}
881                      backgroundColor={seg.bg === undefined ? undefined : TONE_COLOR[seg.bg]}
882                    >
883                      {seg.text}
884                    </Text>
885                  ))}
886                </Box>
887              ))}
888            </Box>
889          )}
890          <Box key="clawd-text" flexDirection="column" flexShrink={0} width={SAY_WIDTH} marginLeft={1}>
891            {show.model ? brandOf(formatModel(model)) : <Text key="brand-off"> </Text>}
892            <Text key="clawd-say" color={TONE_COLOR[clawd.sayTone]} italic wrap="truncate-end">{settings.clawdSay ? say : ' '}</Text>
893          </Box>
894          {!stacked && (
895            <Box key="band-rule" flexDirection="column" flexShrink={0}>
896              {[0, 1].map(i => (
897                <Text key={`rule-${i}`} dimColor> │ </Text>
898              ))}
899            </Box>
900          )}
901          {!stacked && info}
902        </Box>
903        {show.cwd && cwd !== null && cwdText}
904        {stacked && info}
905        {others}
906      </Box>
907    )
908  })
909
910  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
911    const { Box, Text, Button } = $.ui.resolve(e)
912    columns.pane = e.props.bodyColumns
913    const now = await $.clock.now()
914    // Ancho: etiqueta 15 + barra + porcentaje 5 + cola ~30 ("se reinicia lun 7:00 a. m.").
915    // Por debajo de 80 columnas la cola baja a su propia línea.
916    const isWide = e.props.bodyColumns >= 80
917    const barWidth = isWide
918      ? Math.max(10, Math.min(30, e.props.bodyColumns - 52))
919      : Math.max(6, Math.min(20, e.props.bodyColumns - 22))
920    const limits = sortLimits(usage?.rateLimits ?? [])
921
922    const setTab = (next: Tab) => () => {
923      tab = next
924      $.ui.invalidate('ui.render')
925    }
926    const changeSettings = (change: Partial<Settings>) => () => applyAndSave($, { ...settings, ...change })
927    const toggleShow = (key: ShowKey) => () => applyAndSave($, { ...settings, show: { ...settings.show, [key]: !settings.show[key] } })
928    const notify = (text: string) => $.ui.toast(text)
929
930    const meter = (key: string, label: string, labelColor: string, pct: number | undefined, tail: string) => {
931      const parts = lineBar(pct === undefined ? 0 : shownPct(key.replace(/^lim-/, ''), pct), barWidth)
932      const tone = pct === undefined ? undefined : toneFor(pct)
933      return (
934        <Box key={key} flexDirection={isWide ? 'row' : 'column'}>
935          <Box flexDirection="row" flexShrink={0}>
936            <Text color={labelColor}>{label.padEnd(15)}</Text>
937            <Text color={tone}>{pct === undefined ? '' : parts.filled}</Text>
938            <Text dimColor>{parts.empty}</Text>
939            <Text color={tone} weight="semibold" dimColor={isPulseDim(pct)}> {formatPct(pct).padStart(4)}</Text>
940          </Box>
941          {tail !== '' && (
942            <Box flexShrink={1} paddingLeft={isWide ? 0 : 15}>
943              <Text dimColor wrap="truncate-end">{isWide ? tail : tail.trimStart()}</Text>
944            </Box>
945          )}
946        </Box>
947      )
948    }
949
950    const row = (key: string, label: string, value: string) => (
951      <Box key={key} flexDirection="row">
952        <Text dimColor>{label.padEnd(18)}</Text>
953        <Text>{value.padStart(12)}</Text>
954      </Box>
955    )
956
957    // Etiqueta fija y valor que se corta con "…" si no entra.
958    const field = (key: string, label: string, value: string, color?: string) => (
959      <Box key={key} flexDirection="row">
960        <Box flexShrink={0}>
961          <Text dimColor>{label.padEnd(12)}</Text>
962        </Box>
963        <Box flexShrink={1}>
964          <Text color={color} wrap="truncate-end">{value}</Text>
965        </Box>
966      </Box>
967    )
968
969    const tokenRows = (prefix: string, t: TurnTokens) => [
970      row(`${prefix}-in`, 'Entrada', formatInt(t.input)),
971      row(`${prefix}-out`, 'Salida', formatInt(t.output)),
972      row(`${prefix}-cr`, 'Caché leída', formatInt(t.cacheRead)),
973      row(`${prefix}-cw`, 'Caché escrita', formatInt(t.cacheWrite)),
974      row(`${prefix}-tot`, 'Entrada total', formatTokens(totalInput(t))),
975    ]
976
977    const limitsTab = (
978      <Box flexDirection="column">
979        <Box flexDirection="row">
980          <Text dimColor>{'Modelo'.padEnd(15)}</Text>
981          <Text color={CLAUDE_ORANGE} weight="semibold">{SPARK_IDLE} {formatModel(model)}</Text>
982        </Box>
983        {limits.length === 0 &&
984          ['five_hour', 'seven_day'].map(kind => meter(`lim-${kind}`, labelFor(kind), kindColor(kind), undefined, '  sin datos'))}
985        {limits.map(l =>
986          meter(
987            `lim-${l.kind}`,
988            labelFor(l.kind),
989            kindColor(l.kind),
990            l.percentUsed,
991            l.resetsAt === undefined ? '' : `  se reinicia ${formatReset(l.resetsAt, now, offsetMin(l.resetsAt))}`,
992          ),
993        )}
994        {meter(
995          'ctx',
996          'Contexto',
997          CONTEXT_COLOR,
998          usage?.context.percent,
999          `  ${usage?.context.tokens === undefined ? '--' : formatTokens(usage.context.tokens)} / ${formatTokens(usage?.context.window ?? 0)}`,
1000        )}
1001        {usage?.cost !== undefined && row('cost', 'Costo de la sesión', formatUsd(usage.cost.usd))}
1002        <Box marginTop={1} flexDirection="column">
1003          {limits.length === 0 && (
1004            <Text dimColor>Los límites llegan con la primera respuesta del modelo; hasta entonces, "--".</Text>
1005          )}
1006          {limits.length > 0 && !hasFable(limits) && (
1007            <Text color="warning">
1008              Fable semanal: no viene en rateLimits (llegaron: {limits.map(l => l.kind).join(', ')}). No se muestra un valor inventado.
1009            </Text>
1010          )}
1011          <Text dimColor>5 abre la leyenda de colores y símbolos.</Text>
1012        </Box>
1013      </Box>
1014    )
1015
1016    const tokensTab = (
1017      <Box flexDirection="column">
1018        <Text weight="semibold">Último turno{counters.last ? ` · ${formatModel(counters.last.model)}` : ''}</Text>
1019        {counters.last === null ? <Text dimColor>Todavía no terminó ningún turno.</Text> : tokenRows('last', counters.last)}
1020        <Box marginTop={1}>
1021          <Text weight="semibold">Sesión, hilo principal · {counters.turns} turnos</Text>
1022        </Box>
1023        {tokenRows('main', counters.main)}
1024        <Box marginTop={1}>
1025          <Text weight="semibold">Subagentes</Text>
1026        </Box>
1027        {tokenRows('agents', counters.agents)}
1028      </Box>
1029    )
1030
1031    const repoTab = (() => {
1032      if (!settings.repoOn) return <Text dimColor>Los datos del repo están apagados (Ajustes, g).</Text>
1033      if (repo === null) return <Text dimColor>La sesión no corre dentro de un repo git (o todavía no se leyó: u refresca).</Text>
1034      const { remote, git, forge, forgeError } = repo
1035      const kind = remote?.forge ?? null
1036      const label = changeLabel(kind)
1037      const where = remote === null ? 'sin remoto origin' : `${remote.host}/${remote.path}`
1038      const lines = [field('repo-remote', 'Remoto', where, kind === null ? undefined : 'suggestion')]
1039
1040      if (git === null) {
1041        lines.push(field('repo-git', 'git', 'no se pudo leer git status', 'warning'))
1042      } else {
1043        lines.push(field('repo-branch', 'Rama', formatBranch(git), git.behind > 0 ? 'warning' : undefined))
1044        lines.push(
1045          field(
1046            'repo-changes',
1047            'Cambios',
1048            formatChanges(git),
1049            git.conflicts > 0 ? 'error' : formatChanges(git) === 'limpio' ? 'success' : 'warning',
1050          ),
1051        )
1052        lines.push(field('repo-commit', 'Commit', git.sha === null ? 'sin commits' : `${git.sha.slice(0, 7)} ${git.subject ?? ''}`))
1053      }
1054
1055      if (remote !== null && kind === null) {
1056        lines.push(field('repo-forge', 'Remoto', `${remote.host} no es GitHub ni GitLab: solo datos locales`, 'warning'))
1057      }
1058      if (forgeError !== null) lines.push(field('repo-error', kind === 'gitlab' ? 'GitLab' : 'GitHub', forgeError, 'error'))
1059
1060      if (forge !== null) {
1061        const c = forge.change
1062        lines.push(
1063          c === null
1064            ? field('repo-change', label, git?.branch == null ? 'HEAD separado: sin rama' : `la rama no tiene ${label}`)
1065            : field('repo-change', `${label} ${changeRef(kind, c.number)}`, c.title),
1066        )
1067        if (c !== null) {
1068          lines.push(
1069            field('repo-change-state', '', [c.state, c.review].filter(Boolean).join(' · '), CHANGE_TONE[c.state]),
1070            field('repo-change-url', '', c.url),
1071          )
1072        }
1073        lines.push(
1074          forge.ci === null
1075            ? field('repo-ci', 'CI', 'sin checks (o el commit no está en el remoto)')
1076            : field('repo-ci', 'CI', formatCi(forge.ci), CI_TONE[forge.ci.state]),
1077          field('repo-issues', 'Issues', `${formatCount(forge.openIssues)} abiertos · ${formatCount(forge.assigned)} asignados a vos`),
1078          field(
1079            'repo-changes-open',
1080            `${label}s`,
1081            `${formatCount(forge.openChanges, forge.isCapped)} abiertos · ${formatCount(forge.reviewRequested)} te piden review`,
1082          ),
1083        )
1084      }
1085      if (kind !== null) {
1086        lines.push(
1087          field('repo-updated', '', repo.updatedAt === null ? 'consultando…' : `${kind === 'gitlab' ? 'GitLab' : 'GitHub'} consultado ${formatAgo(repo.updatedAt, now)} · u refresca`),
1088        )
1089      }
1090
1091      return <Box flexDirection="column">{lines}</Box>
1092    })()
1093
1094    const range = (i: number) => {
1095      const mins = currentLevelMins()
1096      const min = mins[i] ?? 0
1097      const nextMin = mins[i + 1]
1098      return nextMin === undefined ? `≥${min}%` : i === 0 ? `<${nextMin}%` : `${min}–${nextMin - 1}%`
1099    }
1100    const legendRow = (key: string, sign: string, text: string, color?: string) => (
1101      <Box key={key} flexDirection="row">
1102        <Box flexShrink={0}>
1103          <Text color={color}>{sign.padEnd(15)}</Text>
1104        </Box>
1105        <Box flexShrink={1}>
1106          <Text dimColor>{text}</Text>
1107        </Box>
1108      </Box>
1109    )
1110    const legendTab = (
1111      <Box flexDirection="column">
1112        <Text weight="semibold">Medidores (el color del nombre dice cuál es)</Text>
1113        {legendRow('lg-model', 'Modelo', 'el modelo del hilo principal (/model)', 'claude')}
1114        {legendRow('lg-session', 'Sesión', 'ventana de 5 h del plan', kindColor('five_hour'))}
1115        {legendRow('lg-week', 'Semana', 'límite de 7 días del plan', kindColor('seven_day'))}
1116        {legendRow('lg-fable', 'Fable semanal', 'límite semanal de Fable, si viene en rateLimits', kindColor('seven_day_fable'))}
1117        {legendRow('lg-ctx', 'Contexto / ctx', 'cuánto de la ventana de contexto ocupa la conversación', CONTEXT_COLOR)}
1118        <Box marginTop={1}>
1119          <Text weight="semibold">Niveles de uso (color de la barra; bajo el prompt, el punto)</Text>
1120        </Box>
1121        {USAGE_LEVELS.map((l, i) => legendRow(`lg-level-${l.name}`, `${l.dot} ${range(i)}`, `${l.name}: ${l.meaning}`, l.tone))}
1122        {legendRow('lg-level-none', '⚪ --', 'todavía sin dato')}
1123        <Box marginTop={1}>
1124          <Text weight="semibold">Símbolos</Text>
1125        </Box>
1126        {legendRow('lg-tokens', '↑ ↓', 'tokens de entrada / salida de la sesión')}
1127        {legendRow('lg-sync', '⇡ ⇣', 'commits sin pushear / por traer del remoto')}
1128        {legendRow('lg-dirty', '±N', 'archivos con cambios sin commitear')}
1129        {legendRow('lg-change', '#N  !N', 'PR de GitHub / MR de GitLab de la rama')}
1130        {legendRow('lg-ci', '✓ ✗ ○', 'CI del último commit: ok / fallando / corriendo')}
1131      </Box>
1132    )
1133
1134    const dot = (isOn: boolean) => (isOn ? '●' : '○')
1135    const toggle = (key: string, label: string, isOn: boolean, onPress: () => unknown, hotkey?: string) => (
1136      <Button key={key} plain hotkey={hotkey} dimColor={!isOn} label={`${dot(isOn)} ${label}`} onPress={onPress} />
1137    )
1138    const group = (title: string, items: RenderChildren[]) => (
1139      <Box key={`group-${title}`} flexDirection="column" marginTop={1}>
1140        <Text weight="semibold">{title}</Text>
1141        <Box flexDirection="row" flexWrap="wrap" columnGap={3}>
1142          {items}
1143        </Box>
1144      </Box>
1145    )
1146    const showToggles = (keys: readonly ShowKey[]) => keys.map(k => toggle(`show-${k}`, SHOW_LABELS[k], settings.show[k], toggleShow(k)))
1147    const exportNow = async () => {
1148      try {
1149        const path = await writeConfigFile($, DEFAULT_CONFIG_PATH)
1150        const copied = await $.ui.copy({ text: exportConfig(settings), surface: e.surface }).catch(() => ({ isCopied: false }))
1151        notify(`Config guardada en ${path}${copied.isCopied ? ' y copiada' : ''}`)
1152      } catch (err) {
1153        notify(`token-bar: ${err instanceof Error ? err.message : String(err)}`)
1154      }
1155    }
1156    const importNow = async () => {
1157      const loaded = await readConfigFile($, DEFAULT_CONFIG_PATH)
1158      if ('error' in loaded) return notify(`token-bar: ${loaded.error}`)
1159      await applyAndSave($, loaded.settings)
1160      notify(`Config importada de ${loaded.path}`)
1161    }
1162    const infoTab = (
1163      <Box flexDirection="column">
1164        <Text dimColor>Qué se ve en la banda. Se guarda solo; los presets cambian varias cosas de una vez.</Text>
1165        {group(
1166          'Presets',
1167          PRESET_NAMES.map((name, i) => <Button key={`preset-${name}`} plain hotkey={'zxc'[i]} label={name} onPress={() => applyAndSave($, applyPreset(settings, name))} />),
1168        )}
1169        {group('Medidores', showToggles(['model', 'session', 'week', 'fable', 'context']))}
1170        {group('Datos', showToggles(['tokens', 'cost', 'resets', 'cwd']))}
1171        {group('Repo', showToggles(['repoBranch', 'repoSync', 'repoChanges', 'repoChange', 'repoCi']))}
1172        {group('Clawd y estilo', [
1173          toggle('toggle-mascot', 'Clawd', settings.mascotOn, changeSettings({ mascotOn: !settings.mascotOn }), 'm'),
1174          toggle('toggle-say', 'Comentarios', settings.clawdSay, changeSettings({ clawdSay: !settings.clawdSay }), 's'),
1175          toggle('toggle-animations', 'Animaciones', settings.animations, changeSettings({ animations: !settings.animations }), 'n'),
1176          <Button key="levels" plain hotkey="l" label={`Cortes de color: ${formatLevels(settings.levels)} %`} onPress={changeSettings({ levels: nextLevels(settings.levels) })} />,
1177        ])}
1178        {group('Configuración portable', [
1179          <Button key="cfg-export" plain hotkey="e" label="Exportar" onPress={exportNow} />,
1180          <Button key="cfg-import" plain hotkey="i" label="Importar" onPress={importNow} />,
1181        ])}
1182        <Box marginTop={1}>
1183          <Text dimColor wrap="truncate-end">{DEFAULT_CONFIG_PATH} · también /token-bar-config</Text>
1184        </Box>
1185      </Box>
1186    )
1187
1188    const onOff = (isOn: boolean) => (isOn ? 'activada' : 'desactivada')
1189    const settingsTab = (
1190      <Box flexDirection="column">
1191        <Button key="toggle-status" plain hotkey="b" label={`Barra bajo el prompt: ${onOff(settings.statusOn)}`} onPress={changeSettings({ statusOn: !settings.statusOn })} />
1192        <Button key="toggle-band" plain hotkey="a" label={`Banda sobre el prompt: ${onOff(settings.bandOn)}`} onPress={changeSettings({ bandOn: !settings.bandOn })} />
1193        <Button
1194          key="density"
1195          plain
1196          hotkey="d"
1197          label={`Densidad: ${settings.density} (barra: ${statusLevel})`}
1198          onPress={changeSettings({ density: nextDensity(settings.density) })}
1199        />
1200        <Button key="toggle-dots" plain hotkey="p" label={`Puntos de color en la barra: ${onOff(settings.dotsOn)}`} onPress={changeSettings({ dotsOn: !settings.dotsOn })} />
hooks/config.ts 223 lines
1// Configuración de token-bar: qué se muestra y cómo. Puro: sin `$`, todo
2// testeable. Es lo que se guarda en `$.store`, lo que se exporta a un JSON
3// (para llevarlo a otra máquina o compartirlo) y lo que importa un preset.
4
5import type { Density } from './format'
6
7/** Cada dato que la banda puede mostrar u ocultar. */
8export const SHOW_KEYS = [
9  'model',
10  'session',
11  'week',
12  'fable',
13  'context',
14  'tokens',
15  'cost',
16  'resets',
17  'cwd',
18  'repoBranch',
19  'repoSync',
20  'repoChanges',
21  'repoChange',
22  'repoCi',
23] as const
24
25export type ShowKey = (typeof SHOW_KEYS)[number]
26export type Show = Record<ShowKey, boolean>
27
28export const SHOW_LABELS: Record<ShowKey, string> = {
29  model: 'Modelo',
30  session: 'Sesión',
31  week: 'Semana',
32  fable: 'Fable',
33  context: 'Contexto',
34  tokens: 'Tokens ↑↓',
35  cost: 'Costo',
36  resets: 'Hora de reinicio',
37  cwd: 'Ruta de trabajo',
38  repoBranch: 'Rama',
39  repoSync: 'Sync ⇡⇣',
40  repoChanges: 'Cambios ±',
41  repoChange: 'PR / MR',
42  repoCi: 'CI',
43}
44
45/** Los cortes de color: desde dónde es amarillo, naranja y rojo. */
46export type Levels = readonly [number, number, number]
47
48export const LEVEL_SETS: readonly Levels[] = [
49  [50, 75, 90],
50  [60, 80, 95],
51  [40, 65, 85],
52]
53
54export type Settings = {
55  statusOn: boolean
56  bandOn: boolean
57  density: Density
58  repoOn: boolean
59  dotsOn: boolean
60  /** Clawd dibujado en la banda. */
61  mascotOn: boolean
62  /** Los comentarios de Clawd ("¡Hola!", "trabajando…"). */
63  clawdSay: boolean
64  /** Destello, barras que se llenan, pulso y poses de Clawd. */
65  animations: boolean
66  show: Show
67  levels: Levels
68}
69
70/**
71 * v2: la banda (con colores y animaciones) pasa a ser la vista por defecto y la
72 * línea de texto se apaga, porque el engine la dibuja toda en amarillo con "⚠".
73 */
74export const SETTINGS_VERSION = 2
75
76export const DEFAULT_SHOW: Show = {
77  model: true,
78  session: true,
79  week: true,
80  fable: true,
81  context: true,
82  tokens: true,
83  cost: false,
84  resets: true,
85  cwd: true,
86  repoBranch: true,
87  repoSync: true,
88  repoChanges: true,
89  repoChange: true,
90  repoCi: true,
91}
92
93export const DEFAULTS: Settings = {
94  statusOn: false,
95  bandOn: true,
96  density: 'auto',
97  repoOn: true,
98  dotsOn: true,
99  mascotOn: true,
100  clawdSay: true,
101  animations: true,
102  show: DEFAULT_SHOW,
103  levels: LEVEL_SETS[0] ?? [50, 75, 90],
104}
105
106export type PresetName = 'minima' | 'normal' | 'completa'
107export const PRESET_NAMES: readonly PresetName[] = ['minima', 'normal', 'completa']
108
109const only = (keys: readonly ShowKey[]): Show =>
110  Object.fromEntries(SHOW_KEYS.map(k => [k, keys.includes(k)])) as Show
111
112/** Lo que cambia cada preset; el resto de los ajustes (barra de texto, puntos…) queda. */
113const PRESETS: Record<PresetName, Pick<Settings, 'density' | 'mascotOn' | 'clawdSay' | 'animations' | 'show'>> = {
114  minima: {
115    density: 'compacta',
116    mascotOn: false,
117    clawdSay: false,
118    animations: true,
119    show: only(['model', 'session', 'week', 'context', 'repoBranch']),
120  },
121  normal: { density: 'auto', mascotOn: true, clawdSay: true, animations: true, show: { ...DEFAULT_SHOW } },
122  completa: {
123    density: 'completa',
124    mascotOn: true,
125    clawdSay: true,
126    animations: true,
127    show: only(SHOW_KEYS),
128  },
129}
130
131export const isPreset = (name: unknown): name is PresetName => PRESET_NAMES.includes(name as PresetName)
132
133export const applyPreset = (settings: Settings, name: PresetName): Settings => {
134  const p = PRESETS[name]
135  return { ...settings, ...p, show: { ...p.show } }
136}
137
138const DENSITY_VALUES: readonly string[] = ['auto', 'compacta', 'media', 'completa']
139
140const readLevels = (raw: unknown): Levels => {
141  const a = Array.isArray(raw) ? raw : []
142  const [x, y, z] = a
143  const ok = [x, y, z].every(n => typeof n === 'number' && Number.isFinite(n)) && x < y && y < z && x > 0 && z <= 100
144  return ok ? [x, y, z] : DEFAULTS.levels
145}
146
147/**
148 * Lee ajustes de donde vengan (el store, un archivo importado) sin confiar en
149 * nada: lo que falta o no es del tipo esperado queda en su valor por defecto.
150 * Los ajustes guardados antes de v2 conservan todo menos dónde se dibuja.
151 */
152export const readSettings = (raw: unknown): Settings => {
153  const s = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>
154  const bool = (v: unknown, fallback: boolean) => (typeof v === 'boolean' ? v : fallback)
155  const isCurrent = s.v === SETTINGS_VERSION
156  const rawShow = (typeof s.show === 'object' && s.show !== null ? s.show : {}) as Record<string, unknown>
157  return {
158    statusOn: isCurrent ? bool(s.statusOn, DEFAULTS.statusOn) : DEFAULTS.statusOn,
159    bandOn: isCurrent ? bool(s.bandOn, DEFAULTS.bandOn) : DEFAULTS.bandOn,
160    density: DENSITY_VALUES.includes(String(s.density)) ? (s.density as Density) : DEFAULTS.density,
161    repoOn: bool(s.repoOn, DEFAULTS.repoOn),
162    dotsOn: bool(s.dotsOn, DEFAULTS.dotsOn),
163    mascotOn: bool(s.mascotOn, DEFAULTS.mascotOn),
164    clawdSay: bool(s.clawdSay, DEFAULTS.clawdSay),
165    animations: bool(s.animations, DEFAULTS.animations),
166    show: Object.fromEntries(SHOW_KEYS.map(k => [k, bool(rawShow[k], DEFAULT_SHOW[k])])) as Show,
167    levels: readLevels(s.levels),
168  }
169}
170
171/** Lo que se guarda y se exporta. */
172export const toStored = (settings: Settings) => ({ v: SETTINGS_VERSION, ...settings })
173
174/** El JSON portable: se pega en otra máquina, se guarda en los dotfiles o se comparte. */
175export const exportConfig = (settings: Settings): string =>
176  `${JSON.stringify({ tokenBar: 'config', ...toStored(settings) }, null, 2)}\n`
177
178export type ImportResult = { settings: Settings; ignored: string[] } | { error: string }
179
180/**
181 * Lee un JSON de `exportConfig`. Acepta archivos a mano con solo algunos campos
182 * (el resto queda por defecto) y avisa de las claves que no conoce, que se ignoran.
183 */
184export const parseConfig = (text: string): ImportResult => {
185  let raw: unknown
186  try {
187    raw = JSON.parse(text)
188  } catch {
189    return { error: 'no es un JSON válido' }
190  }
191  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return { error: 'el JSON tiene que ser un objeto' }
192  const obj = raw as Record<string, unknown>
193  const known = new Set(['tokenBar', 'v', ...Object.keys(toStored(DEFAULTS))])
194  const ignored = Object.keys(obj).filter(k => !known.has(k))
195  const show = typeof obj.show === 'object' && obj.show !== null ? (obj.show as Record<string, unknown>) : {}
196  ignored.push(...Object.keys(show).filter(k => !(SHOW_KEYS as readonly string[]).includes(k)).map(k => `show.${k}`))
197  // Un archivo importado manda también sobre dónde se dibuja.
198  return { settings: readSettings({ ...obj, v: SETTINGS_VERSION }), ignored }
199}
200
201/** "50/75/90" para mostrarlo. */
202export const formatLevels = (l: Levels): string => l.join('/')
203
204export const nextLevels = (l: Levels): Levels => {
205  const i = LEVEL_SETS.findIndex(set => set.join() === l.join())
206  return LEVEL_SETS[(i + 1) % LEVEL_SETS.length] ?? DEFAULTS.levels
207}
208
209/** A qué interruptor de `show` responde un límite. */
210export const limitKey = (label: string): ShowKey => {
211  if (label.startsWith('Sesión')) return 'session'
212  if (label === 'Fable semanal') return 'fable'
213  return 'week'
214}
215
216/** "~/x" → "/home/yo/x"; sin HOME, null. */
217export const expandHome = (path: string, home: string | undefined): string | null => {
218  if (!path.startsWith('~')) return path
219  return home === undefined || home === '' ? null : `${home}${path.slice(1)}`
220}
221
222export const DEFAULT_CONFIG_PATH = '~/.config/token-bar/config.json'
223
hooks/format.ts 373 lines
1// Formateo puro de token-bar: sin `$`, sin efectos, todo testeable.
2// Las formas de entrada copian los tipos de la 2.1.303 (SessionRateLimit,
3// SessionContextUsage, ModelUsage) para no depender del runtime.
4
5import { type RepoInfo, repoSummary } from './repo'
6
7export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
8
9export type Usage = {
10  startedAt: number
11  context: { tokens?: number; window: number; percent?: number }
12  rateLimits: readonly RateLimit[]
13  cost?: { usd: number }
14}
15
16export type TurnTokens = {
17  input: number
18  output: number
19  cacheRead: number
20  cacheWrite: number
21}
22
23export type Counters = {
24  turns: number
25  main: TurnTokens
26  agents: TurnTokens
27  last: (TurnTokens & { model: string }) | null
28}
29
30export type Density = 'auto' | 'compacta' | 'media' | 'completa'
31export type Level = Exclude<Density, 'auto'>
32export type Tone = 'success' | 'warning' | 'claude' | 'error'
33
34export const DENSITIES: readonly Density[] = ['auto', 'compacta', 'media', 'completa']
35
36export const ZERO: TurnTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
37
38export const emptyCounters = (): Counters => ({
39  turns: 0,
40  main: { ...ZERO },
41  agents: { ...ZERO },
42  last: null,
43})
44
45export const addTokens = (a: TurnTokens, b: TurnTokens): TurnTokens => ({
46  input: a.input + b.input,
47  output: a.output + b.output,
48  cacheRead: a.cacheRead + b.cacheRead,
49  cacheWrite: a.cacheWrite + b.cacheWrite,
50})
51
52/** Entrada total = sin caché + caché leída + caché escrita, como la cuenta el contexto. */
53export const totalInput = (t: TurnTokens): number => t.input + t.cacheRead + t.cacheWrite
54
55export const nextDensity = (d: Density): Density =>
56  DENSITIES[(DENSITIES.indexOf(d) + 1) % DENSITIES.length] ?? 'auto'
57
58/** auto: <80 columnas compacta, <120 media, si no completa; sin medir, media. */
59export const levelFor = (density: Density, columns: number | null): Level => {
60  if (density !== 'auto') return density
61  if (columns === null) return 'media'
62  if (columns < 80) return 'compacta'
63  if (columns < 120) return 'media'
64  return 'completa'
65}
66
67/**
68 * Los 4 niveles de uso, de menos a más. `tone` es la clave del tema (sigue al
69 * tema del usuario; `claude` es su naranja) y `dot` el punto para la barra,
70 * que no admite colores.
71 */
72export const USAGE_LEVELS = [
73  { min: 0, name: 'holgado', tone: 'success', dot: '🟢', color: 'verde', meaning: 'sin preocupación' },
74  { min: 50, name: 'a mitad', tone: 'warning', dot: '🟡', color: 'amarillo', meaning: 'ya se usó la mitad' },
75  { min: 75, name: 'atención', tone: 'claude', dot: '🟠', color: 'naranja', meaning: 'conviene dosificar' },
76  { min: 90, name: 'al límite', tone: 'error', dot: '🔴', color: 'rojo', meaning: 'está por cortarse' },
77] as const
78
79export type UsageLevel = (typeof USAGE_LEVELS)[number]
80
81/** Desde dónde es amarillo, naranja y rojo; configurable (ver config.ts). */
82let levelMins: readonly [number, number, number] = [50, 75, 90]
83export const setLevelMins = (mins: readonly [number, number, number]) => {
84  levelMins = mins
85}
86/** Los cuatro cortes: 0 y los tres configurables. */
87export const currentLevelMins = (): readonly number[] => [0, ...levelMins]
88
89export const usageLevel = (pct: number): UsageLevel =>
90  USAGE_LEVELS[pct >= levelMins[2] ? 3 : pct >= levelMins[1] ? 2 : pct >= levelMins[0] ? 1 : 0]
91
92/** Verde <50, amarillo 50–74, naranja 75–89, rojo ≥90. */
93export const toneFor = (pct: number): Tone => usageLevel(pct).tone
94
95/** El punto de color del nivel; ⚪ sin dato. */
96export const dotFor = (pct: number | undefined): string =>
97  pct === undefined || !Number.isFinite(pct) ? '⚪' : usageLevel(pct).dot
98
99/** Color fijo del nombre de cada medidor, para distinguirlos de un vistazo. */
100export const CONTEXT_COLOR = 'ide'
101export const kindColor = (kind: string): string => {
102  const label = labelFor(kind)
103  if (label.startsWith('Sesión')) return 'suggestion'
104  if (label === 'Fable semanal') return 'planMode'
105  if (label.startsWith('Semana')) return 'merged'
106  if (label === 'Gasto') return 'permission'
107  return 'text'
108}
109
110/**
111 * claude-opus-5-5 → "Opus 5.5", claude-sonnet-5-5[1m] → "Sonnet 5.5 1M",
112 * claude-3-5-haiku-20241022 → "Haiku 3.5"; lo que no empieza con claude- queda igual.
113 */
114export const formatModel = (raw: string | null | undefined): string => {
115  if (!raw) return '--'
116  const m = raw.trim().match(/^claude-([a-z0-9-]+?)(?:-\d{8})?(\[1m\])?$/i)
117  if (!m?.[1]) return raw.trim()
118  const parts = m[1].split('-')
119  const family = parts.find(p => /^[a-z]+$/i.test(p))
120  const version = parts.filter(p => /^\d+$/.test(p)).join('.')
121  if (family === undefined) return raw.trim()
122  return [capitalize(family), version, m[2] ? '1M' : ''].filter(Boolean).join(' ')
123}
124
125/** "Opus 5.5" → "Opus", para el nivel compacto. */
126export const shortModel = (model: string): string => model.split(' ')[0] ?? model
127
128const WORDS: Record<string, string> = {
129  five: '5',
130  hour: 'h',
131  seven: '7',
132  day: 'd',
133  spend: 'Gasto',
134  limit: 'límite',
135  opus: 'Opus',
136  sonnet: 'Sonnet',
137  haiku: 'Haiku',
138  oauth: 'OAuth',
139}
140
141/** five_hour → Sesión, seven_day → Semana, *fable* → Fable semanal, el resto legible. */
142export const labelFor = (kind: string): string => {
143  const k = kind.toLowerCase()
144  if (k.includes('fable')) return 'Fable semanal'
145  if (k === 'five_hour') return 'Sesión'
146  if (k === 'seven_day') return 'Semana'
147  if (k === 'spend_limit') return 'Gasto'
148  if (k.startsWith('seven_day_')) return `Semana ${readable(k.slice('seven_day_'.length))}`
149  if (k.startsWith('five_hour_')) return `Sesión ${readable(k.slice('five_hour_'.length))}`
150  return capitalize(readable(k))
151}
152
153const readable = (k: string): string =>
154  k
155    .split(/[_\-\s]+/)
156    .filter(Boolean)
157    .map(w => WORDS[w] ?? capitalize(w))
158    .join(' ')
159
160const capitalize = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
161
162/** Etiqueta corta para el nivel compacto. */
163export const shortLabelFor = (kind: string): string => {
164  const label = labelFor(kind)
165  if (label === 'Sesión') return 'S'
166  if (label === 'Semana') return 'W'
167  if (label === 'Fable semanal') return 'F'
168  return label.slice(0, 3)
169}
170
171export const hasFable = (limits: readonly RateLimit[]): boolean =>
172  limits.some(l => l.kind.toLowerCase().includes('fable'))
173
174/** Orden estable: Sesión, Semana, Fable, el resto por nombre. */
175export const sortLimits = (limits: readonly RateLimit[]): RateLimit[] => {
176  const rank = (l: RateLimit) => {
177    const label = labelFor(l.kind)
178    return label === 'Sesión' ? 0 : label === 'Semana' ? 1 : label === 'Fable semanal' ? 2 : 3
179  }
180  return [...limits].sort((a, b) => rank(a) - rank(b) || a.kind.localeCompare(b.kind))
181}
182
183export const bar = (pct: number, width: number): string => {
184  const filled = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
185  return '█'.repeat(filled) + '░'.repeat(width - filled)
186}
187
188/** El naranja de Claude y su brillo, para el destello y el nombre del modelo. */
189export const CLAUDE_ORANGE = '#D97757'
190export const CLAUDE_GLOW = '#FBD3BF'
191
192/** El destello de Claude mientras corre un turno, de ida y vuelta como su spinner. */
193export const SPARK_FRAMES = ['·', '✢', '✳', '✶', '✻', '✽', '✻', '✶', '✳', '✢'] as const
194export const SPARK_IDLE = '✻'
195
196export const sparkFrame = (frame: number): string =>
197  SPARK_FRAMES[((frame % SPARK_FRAMES.length) + SPARK_FRAMES.length) % SPARK_FRAMES.length] ?? SPARK_IDLE
198
199/**
200 * Barra fina con media celda: "━━━━╸────". `filled` va con el color del nivel
201 * y `empty` atenuada; solo usa caracteres de cajas, que traen todas las fuentes.
202 */
203export const lineBar = (pct: number, width: number): { filled: string; empty: string } => {
204  const halves = Math.max(0, Math.min(width * 2, Math.round((pct / 100) * width * 2)))
205  const full = Math.floor(halves / 2)
206  const half = halves % 2
207  return { filled: '━'.repeat(full) + (half === 1 ? '╸' : ''), empty: '─'.repeat(width - full - half) }
208}
209
210/**
211 * El carácter que brilla en el barrido sobre un texto de `length` letras;
212 * `length` o más es la pausa entre pasadas (no brilla ninguno).
213 */
214export const shimmerIndex = (frame: number, length: number): number => frame % (length + 8)
215
216/** Un paso de una barra hacia su valor: rápido al principio, suave al llegar. */
217export const easeToward = (from: number, to: number): number => {
218  const d = to - from
219  return Math.abs(d) < 0.4 ? to : from + d * 0.3
220}
221
222/** Las dos mitades de una barra, para colorear la llena y atenuar la vacía. */
223export const barParts = (pct: number, width: number): { filled: string; empty: string } => {
224  const b = bar(pct, width)
225  const n = b.indexOf('░') === -1 ? width : b.indexOf('░')
226  return { filled: b.slice(0, n), empty: b.slice(n) }
227}
228
229export const formatPct = (pct: number | undefined): string =>
230  pct === undefined || !Number.isFinite(pct) ? '--' : `${Math.round(pct)}%`
231
232/** 1234 → "1,2k", 1234567 → "1,2M", con coma decimal es-AR. */
233export const formatTokens = (n: number): string => {
234  const one = (x: number) => (Math.round(x * 10) / 10).toString().replace('.', ',')
235  if (n >= 1_000_000) return `${one(n / 1_000_000)}M`
236  if (n >= 1_000) return `${one(n / 1_000)}k`
237  return String(n)
238}
239
240/** 1234567 → "1.234.567" (separador de miles es-AR). */
241export const formatInt = (n: number): string =>
242  String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, '.')
243
244export const formatUsd = (usd: number): string => `US$ ${usd.toFixed(2).replace('.', ',')}`
245
246const DAYS = ['dom', 'lun', 'mar', 'mié', 'jue', 'vie', 'sáb']
247
248/**
249 * Hora de reinicio en 12 h es-AR: "1:50 p. m." si es hoy, "lun 7:00 a. m." si
250 * es otro día de los próximos 7, "19/10 7:00 a. m." más lejos. `offsetMin` es el de
251 * `Date#getTimezoneOffset` (180 en Argentina): se pasa para que sea pura.
252 */
253export const formatReset = (
254  iso: string | undefined,
255  nowMs: number,
256  offsetMin: number,
257): string => {
258  if (iso === undefined) return '--'
259  const at = Date.parse(iso)
260  if (!Number.isFinite(at)) return '--'
261  const local = new Date(at - offsetMin * 60_000)
262  const today = new Date(nowMs - offsetMin * 60_000)
263  const h24 = local.getUTCHours()
264  const h12 = h24 % 12 === 0 ? 12 : h24 % 12
265  const mm = String(local.getUTCMinutes()).padStart(2, '0')
266  const time = `${h12}:${mm} ${h24 < 12 ? 'a. m.' : 'p. m.'}`
267  const dayOf = (d: Date) => Math.floor(d.getTime() / 86_400_000)
268  const diff = dayOf(local) - dayOf(today)
269  if (diff === 0) return time
270  if (diff > 0 && diff <= 7) return `${DAYS[local.getUTCDay()]} ${time}`
271  return `${local.getUTCDate()}/${local.getUTCMonth() + 1} ${time}`
272}
273
274/** Los dos límites que se esperan antes de la primera respuesta del modelo. */
275const PLACEHOLDER_KINDS = ['five_hour', 'seven_day']
276
277/**
278 * La línea de estado bajo el prompt, en texto plano, para un nivel:
279 * compacta  "S 23% · W 41% · ctx 12%"
280 * media     "Sesión ██████░░ 23% 1:50 p. m. · Semana … · ctx 12%"
281 * completa  lo de media con barras más largas, más "↑12,3k ↓4,5k" y costo.
282 * Con `model` va primero ("Opus 5.5", "Opus" en compacta); con `dots`, cada
283 * medidor lleva el punto de su nivel (🟢 Sesión …); con `repo`, el tramo del
284 * repo va al final ("main ⇡2 ±3 #12 ✓").
285 */
286export const statusLine = (args: {
287  usage: Usage | null
288  counters: Counters
289  level: Level
290  nowMs: number
291  offsetMin: (iso: string) => number
292  model?: string | null
293  dots?: boolean
294  repo?: RepoInfo | null
295}): string => {
296  const { usage, counters, level, nowMs } = args
297  const limits = sortLimits(usage?.rateLimits ?? [])
298  const parts: string[] = []
299  const isCompact = level === 'compacta'
300  // En compacta el punto va pegado ("🟢S 23%"); en las otras, separado.
301  const dot = (pct: number | undefined) => (args.dots ? `${dotFor(pct)}${isCompact ? '' : ' '}` : '')
302
303  if (args.model) {
304    const model = formatModel(args.model)
305    parts.push(isCompact ? shortModel(model) : model)
306  }
307
308  if (limits.length === 0) {
309    for (const kind of PLACEHOLDER_KINDS) {
310      parts.push(`${dot(undefined)}${isCompact ? shortLabelFor(kind) : labelFor(kind)} --`)
311    }
312  } else {
313    for (const l of limits) {
314      const pct = formatPct(l.percentUsed)
315      if (isCompact) {
316        parts.push(`${dot(l.percentUsed)}${shortLabelFor(l.kind)} ${pct}`)
317        continue
318      }
319      const width = level === 'completa' ? 10 : 6
320      const reset = l.resetsAt === undefined ? '' : ` ${formatReset(l.resetsAt, nowMs, args.offsetMin(l.resetsAt))}`
321      parts.push(`${dot(l.percentUsed)}${labelFor(l.kind)} ${bar(l.percentUsed, width)} ${pct}${reset}`)
322    }
323  }
324
325  parts.push(`${dot(usage?.context.percent)}ctx ${formatPct(usage?.context.percent)}`)
326
327  if (!isCompact) {
328    const t = addTokens(counters.main, counters.agents)
329    parts.push(`↑${formatTokens(totalInput(t))} ↓${formatTokens(t.output)}`)
330  }
331  if (level === 'completa' && usage?.cost !== undefined) {
332    parts.push(formatUsd(usage.cost.usd))
333  }
334
335  const repo = repoSummary(args.repo, isCompact)
336  if (repo !== '') parts.push(repo)
337
338  return parts.join(' · ')
339}
340
341/** Lo que el engine antepone a la línea de un plugin: "  ⚠ token-bar: ". */
342export const STATUS_PREFIX = '  ⚠ token-bar: '.length
343
344/**
345 * La línea de estado para una densidad. En `auto`, el nivel más detallado que
346 * entra en las columnas que quedan tras el prefijo del engine (que corta con
347 * "…" lo que sobra); sin medir, media.
348 */
349export const fitStatusLine = (
350  args: Omit<Parameters<typeof statusLine>[0], 'level'> & { density: Density; columns: number | null },
351): { text: string; level: Level } => {
352  const { density, columns, ...rest } = args
353  if (density !== 'auto') return { text: statusLine({ ...rest, level: density }), level: density }
354  if (columns === null) return { text: statusLine({ ...rest, level: 'media' }), level: 'media' }
355  for (const level of ['completa', 'media'] as const) {
356    const text = statusLine({ ...rest, level })
357    if (text.length <= columns - STATUS_PREFIX) return { text, level }
358  }
359  return { text: statusLine({ ...rest, level: 'compacta' }), level: 'compacta' }
360}
361
362/** Los campos numéricos de un objeto, aplanados con punto ("usage.input_tokens"). */
363export const numericFields = (value: unknown, prefix = ''): Record<string, number> => {
364  const out: Record<string, number> = {}
365  if (typeof value !== 'object' || value === null) return out
366  for (const [k, v] of Object.entries(value)) {
367    const path = prefix === '' ? k : `${prefix}.${k}`
368    if (typeof v === 'number') out[path] = v
369    else if (typeof v === 'object' && v !== null && !Array.isArray(v)) Object.assign(out, numericFields(v, path))
370  }
371  return out
372}
373
hooks/mascot.ts 591 lines
1// Clawd, el bichito de Claude Code, como compañero de desarrollo. Se dibuja con
2// medios bloques en un lienzo de 3 filas × 11 columnas: el cuerpo ocupa las 9 del
3// medio y los bordes quedan para las partículas (corazones, confeti, zzz, el
4// destello que orbita). Reacciona a lo que pasa: qué herramienta corre Claude,
5// si un test pasó, un commit, un push, un error, que escribas o que le hagas
6// clic. Puro: el cuadro sale del número de tick y del ánimo, así se testea sin
7// reloj.
8
9import { CLAUDE_GLOW, CLAUDE_ORANGE, sparkFrame } from './format'
10
11/** Ancho del lienzo: el cuerpo (9) y una columna de partículas a cada lado. */
12export const MASCOT_WIDTH = 11
13const BODY_WIDTH = 9
14
15export type Tone = 'clawd' | 'glow' | 'heart' | 'ok' | 'error' | 'sleep' | 'sweat' | 'gold' | 'violet' | 'shades' | 'shine'
16
17/** Colores de cada tono: hex o nombres del tema. */
18export const TONE_COLOR: Record<Tone, string> = {
19  clawd: CLAUDE_ORANGE,
20  glow: CLAUDE_GLOW,
21  heart: '#F7768E',
22  ok: '#9ECE6A',
23  error: 'error',
24  sleep: 'subtle',
25  sweat: '#7DCFFF',
26  gold: '#E0AF68',
27  violet: '#BB9AF7',
28  shades: '#000000',
29  shine: '#FFFFFF',
30}
31
32/** Un tramo de una fila con un solo color (y un fondo, en los lentes). */
33export type Seg = { text: string; tone: Tone | null; bg?: Tone }
34
35export type MascotFrame = {
36  /** Las 3 filas del lienzo, cada una de 11 columnas en tramos de color. */
37  rows: readonly [Seg[], Seg[], Seg[]]
38  /** El símbolo que antecede al comentario (♪, z, !, el destello), o ''. */
39  mark: string
40  /** Lo que dice al lado, o null. */
41  say: string | null
42  /** El color del comentario. */
43  sayTone: Tone
44  /** Cambia solo cuando cambia el dibujo: el reloj redibuja si cambia. */
45  key: string
46}
47
48/** Lo que está haciendo Claude, sacado de la herramienta que corre. */
49export type ActivityKind = 'think' | 'read' | 'search' | 'edit' | 'bash' | 'test' | 'git' | 'web' | 'agent'
50export type Activity = { kind: ActivityKind; detail: string }
51
52export type ReactionKind =
53  | 'greet'
54  | 'pet'
55  | 'submit'
56  | 'done'
57  | 'abort'
58  | 'fail'
59  | 'oops'
60  | 'testsOk'
61  | 'testsFail'
62  | 'commit'
63  | 'push'
64  | 'wake'
65  | 'cool'
66
67/** Algo que pasó: dura unos ticks desde `at`. `seed` elige la frase. */
68export type Reaction = { kind: ReactionKind; at: number; seed: number; hour?: number }
69
70export type Mood = {
71  isWorking: boolean
72  /** El medidor más alto ≥90 %, con su etiqueta, o null. */
73  alert: { label: string; pct: number } | null
74  /** El porcentaje más alto entre los medidores (0 sin datos). */
75  maxPct: number
76  /** Sin animaciones: Clawd queda quieto en su pose base. */
77  isStill?: boolean
78  /** Qué herramienta corre mientras trabaja. */
79  activity?: Activity | null
80  /** Lo último que pasó. */
81  reaction?: Reaction | null
82  /** Escribís en el prompt: `at` es el tick de la última tecla, `ratio` (0–1) hacia dónde mira. */
83  typing?: { at: number; ratio: number } | null
84  /** Ticks sin que pase nada: pasado `SLEEP_AFTER`, se duerme. */
85  idleTicks?: number
86  /** Ticks desde que arrancó el turno. */
87  workTicks?: number
88}
89
90/** 3 minutos sin nada (en ticks de 100 ms) y se echa una siesta. */
91export const SLEEP_AFTER = 1_800
92/** Desde los 90 s de turno, transpira. */
93const LONG_WORK = 900
94/** Cuánto sigue mirando el prompt después de la última tecla. */
95const TYPING_TICKS = 25
96
97export const REACTION_TICKS: Record<ReactionKind, number> = {
98  greet: 40,
99  pet: 25,
100  submit: 12,
101  done: 28,
102  abort: 15,
103  fail: 25,
104  oops: 15,
105  testsOk: 30,
106  testsFail: 30,
107  commit: 25,
108  push: 30,
109  wake: 15,
110  cool: 40,
111}
112
113// --- El cuerpo -------------------------------------------------------------
114
115const BLANK = '         '
116const EYES = {
117  base: ' ▐▛███▜▌ ',
118  blink: ' ▐█████▌ ',
119  left: ' ▐▛███▛▌ ',
120  right: ' ▐▜███▜▌ ',
121  happy: ' ▐▀███▀▌ ',
122  sad: ' ▐▄███▄▌ ',
123  // Ojos altos (media celda de ancho, toda la altura): sorprendido.
124  wide: ' ▐▌███▐▌ ',
125}
126const BODY = '▝▜█████▛▘'
127const LEGS = '  ▘▘ ▝▝  '
128
129type Pose = { head: string; body: string; legs: string }
130
131const pose = (head: string, body = BODY, legs = LEGS): Pose => ({ head, body, legs })
132
133/** Brazos arriba, festejando. */
134const cheer = (eyes = EYES.happy): Pose => pose(`▝${eyes.slice(1, 8)}▘`, ' ▜█████▛ ')
135const waveUp = (eyes = EYES.base): Pose => pose(`${eyes.slice(0, 8)}▝`, '▝▜█████▛ ')
136const waveDown = (eyes = EYES.base): Pose => pose(`${eyes.slice(0, 8)}▗`, '▝▜█████▛ ')
137/** Tecleando: los brazos suben y bajan alternados. */
138const typing = (t: number, eyes = EYES.base): Pose =>
139  Math.floor(t / 2) % 2 === 0 ? pose(eyes, '▗▜█████▛▘', LEGS) : pose(eyes, '▝▜█████▛▖', LEGS)
140/** Rascándose la cabeza, como el meme: la mano sobre la cabeza va y viene. */
141const scratch = (t: number): Pose =>
142  pose(`${(Math.floor(t / 12) % 2 === 0 ? EYES.left : EYES.right).slice(0, 8)}${Math.floor(t / 2) % 2 === 0 ? '▝' : '▘'}`, '▝▜█████▛ ')
143const crouch = (eyes = EYES.base): Pose => pose(eyes, '▗▟█████▙▖', '  ▀▀ ▀▀  ')
144const jump = (eyes = EYES.base): Pose => pose(eyes, BODY, BLANK)
145const dance = (t: number): Pose =>
146  Math.floor(t / 3) % 2 === 0
147    ? pose('▗▐▛███▜▌ ', ' ▜█████▛▘', '   ▘▘▝▝  ')
148    : pose(' ▐▛███▜▌▖', '▝▜█████▛ ', '  ▘▘ ▝▝  ')
149
150// --- El lienzo y las partículas ---------------------------------------------
151
152/** Una partícula va en un hueco del lienzo, nunca encima del cuerpo. */
153type Particle = { r: number; c: number; ch: string; tone: Tone }
154
155/**
156 * Los lentes de "deal with it" en pixel art: cada celda son 2 píxeles
157 * verticales (el de arriba es el color del carácter `▀`, el de abajo el fondo),
158 * así entran tres colores en la cara: la frente naranja arriba, la banda negra
159 * con un brillo blanco por cristal, y los cristales con el puente naranja en el
160 * medio. Píxeles del lienzo: filas 0–1 son la cabeza, 2–3 el cuerpo. En su
161 * lugar (`drop` 0) la banda va en la fila 1, donde están los ojos; con `drop`
162 * -1 o -2 están más arriba, cayendo.
163 */
164const SHADES_PX: readonly (readonly [number, number, Tone])[] = [
165  ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map(c => [1, c, c === 2 || c === 6 ? 'shine' : 'shades'] as const),
166  [2, 2, 'shades'], [2, 3, 'shine'], [2, 4, 'shades'],
167  [2, 6, 'shades'], [2, 7, 'shine'], [2, 8, 'shades'],
168]
169
170/** El color de la cara debajo de los lentes: naranja entre las columnas 2 y 8. */
171const facePx = (c: number): Tone | null => (c >= 2 && c <= 8 ? 'clawd' : null)
172
173type Cell = { ch: string; tone: Tone | null; bg?: Tone }
174
175/** Una celda con dos píxeles: arriba y abajo. */
176const halfBlock = (top: Tone | null, bottom: Tone | null): Cell => {
177  if (top === null && bottom === null) return { ch: ' ', tone: null }
178  if (bottom === null) return { ch: '▀', tone: top }
179  if (top === null) return { ch: '▄', tone: bottom }
180  if (top === bottom) return { ch: '█', tone: top }
181  return { ch: '▀', tone: top, bg: bottom }
182}
183
184const putShades = (grid: Cell[][], drop: number) => {
185  const px = new Map(SHADES_PX.map(([r, c, tone]) => [`${r + drop},${c}`, tone]))
186  for (const r of [0, 1]) {
187    for (let c = 0; c < MASCOT_WIDTH; c++) {
188      const top = px.get(`${2 * r},${c}`)
189      const bottom = px.get(`${2 * r + 1},${c}`)
190      if (top === undefined && bottom === undefined) continue
191      grid[r]![c] = halfBlock(top ?? facePx(c), bottom ?? facePx(c))
192    }
193  }
194}
195
196/** Arma las filas: el cuerpo corrido `shift` columnas, los lentes y las partículas en los huecos. */
197const paint = (p: Pose, particles: readonly Particle[] = [], shift = 0, shades?: number): MascotFrame['rows'] => {
198  const grid = [p.head, p.body, p.legs].map(row => {
199    const cells: Cell[] = Array.from({ length: MASCOT_WIDTH }, () => ({ ch: ' ', tone: null }))
200    ;[...row].slice(0, BODY_WIDTH).forEach((ch, i) => {
201      const c = 1 + shift + i
202      if (c >= 0 && c < MASCOT_WIDTH && ch !== ' ') cells[c] = { ch, tone: 'clawd' }
203    })
204    return cells
205  })
206  if (shades !== undefined) putShades(grid, shades)
207  for (const { r, c, ch, tone } of particles) {
208    if (grid[r]?.[c]?.ch === ' ') grid[r]![c] = { ch, tone }
209  }
210  const rows = grid.map(cells => {
211    const segs: Seg[] = []
212    for (const { ch, tone, bg } of cells) {
213      const last = segs[segs.length - 1]
214      // Los espacios se pegan al tramo anterior si no tiene fondo: menos nodos para dibujar.
215      if (last !== undefined && last.bg === bg && (last.tone === tone || (ch === ' ' && bg === undefined))) last.text += ch
216      else segs.push({ text: ch, tone: ch === ' ' ? null : tone, ...(bg === undefined ? {} : { bg }) })
217    }
218    return segs
219  })
220  return [rows[0]!, rows[1]!, rows[2]!]
221}
222
223/** Pseudoaleatorio estable: el mismo tick da el mismo confeti. */
224const hash = (n: number): number => {
225  let x = (n ^ 0x9e3779b9) >>> 0
226  x = Math.imul(x ^ (x >>> 16), 0x85ebca6b) >>> 0
227  x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35) >>> 0
228  return (x ^ (x >>> 16)) >>> 0
229}
230
231/** Las celdas de alrededor del cuerpo donde caben partículas. */
232const AROUND: readonly (readonly [number, number])[] = [
233  [0, 0], [0, 1], [0, 9], [0, 10], [1, 0], [1, 10], [2, 0], [2, 1], [2, 9], [2, 10],
234]
235const CONFETTI = ['✦', '✧', '*', '+', '·', '°'] as const
236const CONFETTI_TONES: readonly Tone[] = ['gold', 'violet', 'sweat', 'ok', 'heart', 'glow']
237
238const confetti = (t: number, count = 5): Particle[] =>
239  Array.from({ length: count }, (_, i) => {
240    const h = hash(Math.floor(t / 2) * 31 + i * 7)
241    const [r, c] = AROUND[h % AROUND.length]!
242    return { r, c, ch: CONFETTI[(h >>> 8) % CONFETTI.length]!, tone: CONFETTI_TONES[(h >>> 16) % CONFETTI_TONES.length]! }
243  })
244
245/** Corazones que suben por los costados. */
246const hearts = (t: number): Particle[] => {
247  const rise = (phase: number) => 2 - (Math.floor(phase / 3) % 3)
248  return [
249    { r: rise(t), c: 10, ch: '♥', tone: 'heart' },
250    { r: rise(t + 4), c: 0, ch: '♥', tone: 'heart' },
251  ]
252}
253
254/** Las z suben por la derecha, de chica a grande. */
255const zzz = (t: number): Particle[] => {
256  const step = Math.floor(t / 6) % 4
257  const all: Particle[] = [
258    { r: 2, c: 10, ch: 'z', tone: 'sleep' },
259    { r: 1, c: 10, ch: 'z', tone: 'sleep' },
260    { r: 0, c: 10, ch: 'Z', tone: 'sleep' },
261  ]
262  return all.slice(0, step)
263}
264
265/** Las flechas del push suben por los dos costados. */
266const arrows = (t: number): Particle[] => {
267  const r = 2 - (Math.floor(t / 2) % 3)
268  return [
269    { r, c: 0, ch: '⇡', tone: 'ok' },
270    { r, c: 10, ch: '⇡', tone: 'ok' },
271  ]
272}
273
274/** El destello de Claude orbitando alrededor mientras trabaja. */
275const ORBIT: readonly (readonly [number, number])[] = [
276  [0, 9], [0, 10], [1, 10], [2, 10], [2, 9], [2, 1], [2, 0], [1, 0], [0, 0], [0, 1],
277]
278const orbit = (t: number): Particle => {
279  const [r, c] = ORBIT[Math.floor(t / 2) % ORBIT.length]!
280  return { r, c, ch: sparkFrame(t), tone: 'clawd' }
281}
282
283/** Una gota de sudor que cae por la izquierda. */
284const sweat = (t: number): Particle => ({ r: Math.floor(t / 4) % 3, c: 0, ch: '°', tone: 'sweat' })
285
286/** Los lentes caen desde arriba (dos pasos) y se calzan sobre los ojos. */
287const shadesDrop = (t: number): number => (t < 2 ? -2 : t < 4 ? -1 : 0)
288/** Al calzarse, un destello. */
289const shadesSpark = (t: number): Particle[] => (t >= 4 && t < 12 ? [{ r: 0, c: 9, ch: t % 4 < 2 ? '✦' : '✧', tone: 'shine' }] : [])
290
291const notes = (t: number): Particle[] =>
292  Math.floor(t / 3) % 2 === 0 ? [{ r: 0, c: 10, ch: '♪', tone: 'glow' }] : [{ r: 0, c: 0, ch: '♫', tone: 'glow' }]
293
294// --- Frases --------------------------------------------------------------------
295
296const pick = <T,>(list: readonly T[], seed: number): T => list[((seed % list.length) + list.length) % list.length]!
297
298const greeting = (hour: number | undefined): string => {
299  if (hour === undefined) return '¡Hola!'
300  if (hour < 6) return '¡Hola! ¿trasnochando?'
301  if (hour < 12) return '¡Hola! ¡buen día!'
302  if (hour < 20) return '¡Hola! ¡buenas tardes!'
303  return '¡Hola! ¿noche de código?'
304}
305
306const PHRASES = {
307  pet: ['¡jeje!', '¡gracias!', '¡me hacés cosquillas!', 'prr…', '¡otra vez!'],
308  submit: ['¡a darle!', '¡vamos!', 'dale, me pongo', '¡manos a la obra!'],
309  done: ['¡listo!', '¡terminé!', 'ahí lo tenés', '¡hecho!'],
310  abort: ['ok, freno', 'listo, paro', 'frené'],
311  fail: ['uy, algo salió mal', 'no pude…', 'se complicó'],
312  oops: ['uy…', 'mmm, falló', 'eso no anduvo', 'probemos otra'],
313  long: ['esto viene largo…', 'sigo en eso…', 'ya casi…', 'paciencia…'],
314  watch: ['te leo…', 'ajá…', 'mhm…'],
315  think: ['pensando…', 'mmm…', 'a ver, a ver…'],
316  cool: ['deal with it', 'todo bajo control', 'tranqui, lo tengo', 'con estilo', 'fachero, ¿no?'],
317} as const
318
319const sayOf = (a: Activity): string => {
320  const d = a.detail
321  switch (a.kind) {
322    case 'think': return 'pensando…'
323    case 'read': return d === '' ? 'leyendo…' : `leyendo ${d}`
324    case 'search': return d === '' ? 'buscando…' : `buscando ${d}`
325    case 'edit': return d === '' ? 'escribiendo…' : `editando ${d}`
326    case 'bash': return d === '' ? 'corriendo…' : `corriendo ${d}`
327    case 'test': return 'corriendo tests…'
328    case 'git': return `git ${d}…`
329    case 'web': return d === '' ? 'buscando en la web' : `mirando ${d}`
330    case 'agent': return d === '' ? 'mandé un ayudante' : `ayudante: ${d}`
331  }
332}
333
334// --- Qué hace cada herramienta ------------------------------------------------------
335
336const basename = (path: unknown): string => (typeof path === 'string' ? (path.split('/').pop() ?? '') : '')
337const str = (v: unknown): string => (typeof v === 'string' ? v : '')
338const TEST_RE = /\b(test|tests|vitest|jest|pytest|mocha|rspec|phpunit)\b/
339
340/** Los tramos de un comando de shell sin los `cd` ni las variables del principio. */
341const commandParts = (command: string): string[] =>
342  command
343    .split(/&&|\|\||;|\|/)
344    .map(s => s.trim().replace(/^(\w+=\S*\s+)+/, ''))
345    .filter(s => s !== '' && !/^cd\b/.test(s))
346
347/** Qué hace Claude según la herramienta y sus argumentos. */
348export const activityOf = (tool: string, input: Record<string, unknown>): Activity => {
349  switch (tool) {
350    case 'Read':
351    case 'NotebookRead':
352      return { kind: 'read', detail: basename(input.file_path ?? input.notebook_path) }
353    case 'Grep':
354    case 'Glob':
355      return { kind: 'search', detail: str(input.pattern) === '' ? '' : `«${str(input.pattern)}»` }
356    case 'Edit':
357    case 'MultiEdit':
358    case 'Write':
359    case 'NotebookEdit':
360      return { kind: 'edit', detail: basename(input.file_path ?? input.notebook_path) }
361    case 'WebFetch': {
362      const host = /^\w+:\/\/([^/]+)/.exec(str(input.url))?.[1] ?? ''
363      return { kind: 'web', detail: host }
364    }
365    case 'WebSearch':
366      return { kind: 'web', detail: '' }
367    case 'Agent':
368    case 'Task':
369      return { kind: 'agent', detail: str(input.description) }
370    case 'Bash': {
371      const parts = commandParts(str(input.command))
372      if (parts.some(p => TEST_RE.test(p))) return { kind: 'test', detail: '' }
373      const git = parts.find(p => /^git\s/.test(p))
374      if (git !== undefined) return { kind: 'git', detail: git.split(/\s+/)[1] ?? '' }
375      return { kind: 'bash', detail: (parts[0] ?? '').split(/\s+/)[0] ?? '' }
376    }
377    default:
378      return { kind: 'think', detail: '' }
379  }
380}
381
382/** Cómo reacciona cuando la herramienta terminó, o null si no hay nada que festejar ni lamentar. */
383export const outcomeOf = (tool: string, input: Record<string, unknown>, isError: boolean): ReactionKind | null => {
384  if (tool === 'Bash') {
385    const parts = commandParts(str(input.command))
386    if (parts.some(p => TEST_RE.test(p))) return isError ? 'testsFail' : 'testsOk'
387    if (!isError && parts.some(p => /^git\s+push\b/.test(p))) return 'push'
388    if (!isError && parts.some(p => /^git\s+commit\b/.test(p))) return 'commit'
389  }
390  return isError ? 'oops' : null
391}
392
393// --- El bucle en reposo ---------------------------------------------------------
394
395type IdleStep = 'base' | 'blink' | 'lookLeft' | 'lookRight' | 'crouch' | 'jump' | 'dance' | 'stretch' | 'cool'
396
397/** El bucle en reposo, en ticks de 100 ms: unos 17 s por vuelta. */
398const LOOP: readonly (readonly [IdleStep, number])[] = [
399  ['base', 20], ['blink', 2], ['base', 15],
400  ['lookLeft', 8], ['lookRight', 8], ['base', 6], ['blink', 2], ['base', 10],
401  ['crouch', 2], ['jump', 3], ['crouch', 2], ['jump', 3], ['base', 12],
402  ['dance', 24],
403  ['base', 12], ['blink', 2], ['base', 8],
404  ['stretch', 12], ['base', 10], ['blink', 2], ['base', 10],
405  ['cool', 40], ['base', 10],
406]
407
408export const LOOP_TICKS = LOOP.reduce((n, [, t]) => n + t, 0)
409
410/** El paso del bucle en un tick y cuántos ticks lleva en él. */
411const loopStep = (tick: number): [IdleStep, number] => {
412  let t = ((tick % LOOP_TICKS) + LOOP_TICKS) % LOOP_TICKS
413  for (const [name, ticks] of LOOP) {
414    if (t < ticks) return [name, t]
415    t -= ticks
416  }
417  return ['base', 0]
418}
419
420export const loopPose = (tick: number): IdleStep => loopStep(tick)[0]
421
422type Scene = { pose: Pose; particles?: Particle[]; shift?: number; shades?: number; mark?: string; say?: string | null; sayTone?: Tone }
423
424const coolScene = (t: number, seed: number): Scene => ({
425  pose: pose(EYES.base),
426  shades: shadesDrop(t),
427  particles: shadesSpark(t),
428  say: t < 4 ? null : pick(PHRASES.cool, seed),
429  sayTone: 'gold',
430})
431
432const idleScene = (tick: number): Scene => {
433  const [step, t] = loopStep(tick)
434  switch (step) {
435    case 'blink': return { pose: pose(EYES.blink) }
436    case 'lookLeft': return { pose: pose(EYES.left) }
437    case 'lookRight': return { pose: pose(EYES.right) }
438    case 'crouch': return { pose: crouch() }
439    case 'jump': return { pose: jump(), say: '¡hop!' }
440    case 'dance': return { pose: dance(tick), particles: notes(tick), mark: Math.floor(tick / 3) % 2 === 0 ? '♪' : '♫', sayTone: 'glow' }
441    case 'stretch': return { pose: cheer(EYES.blink), say: 'me estiro un poco' }
442    case 'cool': return coolScene(t, Math.floor(tick / LOOP_TICKS))
443    default: return { pose: pose(EYES.base) }
444  }
445}
446
447/** Lo que dice según cómo vienen los límites, cuando la escena no dice nada. */
448const remark = (mood: Mood, scene: Scene): string | null => {
449  if (mood.maxPct >= 75) return 'conviene dosificar'
450  if (scene.mark !== undefined && mood.maxPct < 50) return 'todo tranqui'
451  return null
452}
453
454const reactionScene = (r: Reaction, t: number): Scene => {
455  const shake = t < 6 ? (t % 2 === 0 ? -1 : 1) : 0
456  switch (r.kind) {
457    case 'greet':
458      return { pose: Math.floor(t / 3) % 2 === 0 ? waveUp() : waveDown(), say: greeting(r.hour) }
459    case 'pet':
460      return {
461        pose: t % 4 < 2 ? pose(EYES.happy) : crouch(EYES.happy),
462        particles: hearts(t),
463        mark: '♥',
464        say: pick(PHRASES.pet, r.seed),
465        sayTone: 'heart',
466      }
467    case 'submit':
468      return { pose: t < 4 ? crouch() : jump(EYES.happy), say: pick(PHRASES.submit, r.seed) }
469    case 'done':
470      return {
471        pose: Math.floor(t / 3) % 2 === 0 ? cheer() : jump(EYES.happy),
472        particles: confetti(t),
473        mark: '✦',
474        say: pick(PHRASES.done, r.seed),
475        sayTone: 'gold',
476      }
477    case 'abort':
478      return { pose: pose(EYES.base), mark: '■', say: pick(PHRASES.abort, r.seed) }
479    case 'fail':
480      return { pose: pose(EYES.sad), shift: shake, mark: '✗', say: pick(PHRASES.fail, r.seed), sayTone: 'error' }
481    case 'oops':
482      return { pose: pose(t < 6 ? EYES.wide : EYES.sad), shift: shake, mark: '✗', say: pick(PHRASES.oops, r.seed), sayTone: 'error' }
483    case 'testsOk':
484      return { pose: Math.floor(t / 3) % 2 === 0 ? cheer() : jump(EYES.happy), particles: confetti(t), mark: '✓', say: '¡tests en verde!', sayTone: 'ok' }
485    case 'testsFail':
486      return { pose: pose(EYES.sad), shift: shake, particles: [sweat(t)], mark: '✗', say: 'tests en rojo…', sayTone: 'error' }
487    case 'commit':
488      return { pose: t < 10 ? cheer() : pose(EYES.happy), particles: t < 10 ? confetti(t, 3) : [], mark: '✓', say: '¡commit hecho!', sayTone: 'ok' }
489    case 'push':
490      return { pose: pose(EYES.base), shades: shadesDrop(t), particles: [...shadesSpark(t), ...arrows(t)], mark: '⇡', say: '¡pusheado!', sayTone: 'ok' }
491    case 'cool':
492      return coolScene(t, r.seed)
493    case 'wake':
494      return { pose: pose(EYES.wide), particles: [{ r: 0, c: 10, ch: '!', tone: 'gold' }], say: '¡eh! ¡volviste!' }
495  }
496}
497
498const workScene = (tick: number, mood: Mood): Scene => {
499  const activity = mood.activity ?? { kind: 'think', detail: '' }
500  const work = mood.workTicks ?? 0
501  const isLong = work >= LONG_WORK
502  const particles = [orbit(tick), ...(isLong ? [sweat(tick)] : [])]
503  // Cada tanto, en un turno largo, comenta en vez de decir qué hace.
504  const say =
505    isLong && Math.floor(work / 40) % 4 === 3
506      ? pick(PHRASES.long, Math.floor(work / 160))
507      : activity.kind === 'think'
508        ? pick(PHRASES.think, Math.floor(tick / 30))
509        : sayOf(activity)
510  const scan = Math.floor(tick / 3) % 2 === 0 ? EYES.left : EYES.right
511  let body: Pose
512  switch (activity.kind) {
513    case 'read':
514    case 'search':
515    case 'web':
516      body = pose(scan)
517      break
518    case 'think':
519      body = scratch(tick)
520      // El signo de pregunta titila arriba, antes que el destello.
521      if (Math.floor(tick / 6) % 2 === 0) particles.unshift({ r: 0, c: 10, ch: '?', tone: 'gold' })
522      break
523    case 'agent':
524      body = Math.floor(tick / 3) % 2 === 0 ? waveUp() : waveDown()
525      break
526    default:
527      body = typing(tick)
528  }
529  return { pose: body, particles, mark: sparkFrame(tick), say, sayTone: 'clawd' }
530}
531
532const keyOf = (f: Omit<MascotFrame, 'key'>): string =>
533  `${f.rows.map(r => r.map(s => `${s.tone ?? ''}:${s.bg ?? ''}:${s.text}`).join('|')).join('/')}#${f.mark}#${f.say ?? ''}#${f.sayTone}`
534
535const frameOf = (scene: Scene, say: string | null): MascotFrame => {
536  const f = {
537    rows: paint(scene.pose, scene.particles, scene.shift, scene.shades),
538    mark: scene.mark ?? '',
539    say,
540    sayTone: scene.sayTone ?? 'glow',
541  }
542  return { ...f, key: keyOf(f) }
543}
544
545export const mascotFrame = (tick: number, mood: Mood): MascotFrame => {
546  if (mood.isStill) {
547    const say = mood.isWorking
548      ? 'trabajando…'
549      : mood.alert !== null
550        ? `¡ojo! ${mood.alert.label} al ${Math.round(mood.alert.pct)}%`
551        : remark(mood, { pose: pose(EYES.base) })
552    return frameOf({ pose: pose(EYES.base), sayTone: mood.alert !== null && !mood.isWorking ? 'error' : 'glow' }, say)
553  }
554
555  const r = mood.reaction
556  if (r != null && tick - r.at >= 0 && tick - r.at < REACTION_TICKS[r.kind]) {
557    const scene = reactionScene(r, tick - r.at)
558    return frameOf(scene, scene.say ?? null)
559  }
560
561  if (mood.isWorking) {
562    const scene = workScene(tick, mood)
563    return frameOf(scene, scene.say ?? null)
564  }
565
566  if (mood.alert !== null) {
567    const scene = idleScene(tick)
568    const blink = Math.floor(tick / 5) % 2 === 0
569    return frameOf(
570      { ...scene, particles: blink ? [{ r: 0, c: 10, ch: '!', tone: 'error' }] : [], mark: '', sayTone: 'error' },
571      `¡ojo! ${mood.alert.label} al ${Math.round(mood.alert.pct)}%`,
572    )
573  }
574
575  const typed = mood.typing
576  if (typed != null && tick - typed.at >= 0 && tick - typed.at < TYPING_TICKS) {
577    const eyes = typed.ratio < 0.35 ? EYES.left : typed.ratio > 0.65 ? EYES.right : EYES.base
578    return frameOf({ pose: pose(eyes) }, pick(PHRASES.watch, Math.floor(tick / 30)))
579  }
580
581  if ((mood.idleTicks ?? 0) >= SLEEP_AFTER) {
582    return frameOf(
583      { pose: pose(EYES.blink), particles: zzz(tick), mark: 'z', sayTone: 'sleep' },
584      Math.floor(tick / 10) % 2 === 0 ? 'zzz…' : 'zZz…',
585    )
586  }
587
588  const scene = idleScene(tick)
589  return frameOf(scene, scene.say ?? remark(mood, scene))
590}
591
hooks/clawd-client.ts 33 lines
1// Clawd dibujado en una región propia (`Client`) en vez de en botones: así un
2// clic sobre el dibujo llega como evento del puntero sin que el engine invierta
3// las celdas, como hace con un Button bajo el mouse. Cada clic se manda al
4// módulo de hooks (`ui.message`), que decide la reacción.
5
6import type { ClientModule, JsonValue } from 'claude-code'
7
8/** Un tramo ya con sus colores resueltos: texto, color y fondo. */
9export type ClawdSeg = { t: string; c?: string; b?: string }
10export type ClawdProps = { rows: ClawdSeg[][] }
11
12const ClawdClient: ClientModule<JsonValue> = (props, surface) => {
13  const { Box, Text } = surface.elements
14  const { rows } = props as unknown as ClawdProps
15  surface.onPointer(e => {
16    if (e.type === 'down' && e.button === 'left') surface.post({ tap: true })
17  })
18  return Box({
19    flexDirection: 'column',
20    children: rows.map((row, i) =>
21      Box({
22        key: `r${i}`,
23        flexDirection: 'row',
24        children: row.map(s =>
25          Text({ ...(s.c === undefined ? {} : { color: s.c }), ...(s.b === undefined ? {} : { backgroundColor: s.b }), children: s.t }),
26        ),
27      }),
28    ),
29  })
30}
31
32export default ClawdClient
33
hooks/repo.ts 384 lines
1// Datos del repo de la sesión: git local y GitHub/GitLab. Puro como format.ts:
2// register.tsx corre git/gh/glab con $.process.run y acá solo se parsea.
3
4export type Forge = 'github' | 'gitlab'
5export type Remote = { forge: Forge | null; host: string; path: string }
6
7export type GitState = {
8  /** null: HEAD separado. */
9  branch: string | null
10  /** null: repo sin commits. */
11  sha: string | null
12  upstream: string | null
13  ahead: number
14  behind: number
15  staged: number
16  unstaged: number
17  untracked: number
18  conflicts: number
19  subject: string | null
20}
21
22export type CiState = 'ok' | 'falla' | 'corriendo'
23export type Ci = { state: CiState; ok: number; failing: number; running: number; failed: string[] }
24
25export type ChangeState = 'abierto' | 'borrador' | 'mergeado' | 'cerrado'
26/** El PR (GitHub) o MR (GitLab) de la rama. */
27export type Change = { number: number; title: string; state: ChangeState; review: string | null; url: string }
28
29export type ForgeData = {
30  change: Change | null
31  /** null: el commit no está en el remoto o no tiene checks. */
32  ci: Ci | null
33  openIssues: number | null
34  openChanges: number | null
35  assigned: number | null
36  reviewRequested: number | null
37  /** GitLab cuenta MRs listando hasta 100: el número es "100+". */
38  isCapped: boolean
39}
40
41export type RepoInfo = {
42  root: string
43  remote: Remote | null
44  git: GitState | null
45  forge: ForgeData | null
46  forgeError: string | null
47  /** Última consulta a GitHub/GitLab (ms). */
48  updatedAt: number | null
49}
50
51const forgeOf = (host: string): Forge | null => {
52  const h = host.toLowerCase()
53  if (h.includes('github')) return 'github'
54  if (h.includes('gitlab')) return 'gitlab'
55  return null
56}
57
58/**
59 * git@github.com:o/r.git, https://github.com/o/r, ssh://git@host:2222/g/sub/r.git
60 * → host y path "o/r"; null si no tiene forma de remoto.
61 */
62export const parseRemote = (url: string): Remote | null => {
63  const u = url.trim()
64  const m =
65    u.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/i) ??
66    u.match(/^(?:[^@/]+@)?([^/:]+):(?!\/)(.+?)(?:\.git)?\/?$/)
67  if (!m?.[1] || !m[2] || !m[2].includes('/')) return null
68  return { forge: forgeOf(m[1]), host: m[1], path: m[2] }
69}
70
71/** Salida de `git status --porcelain=v2 --branch`. */
72export const parseStatus = (out: string): Omit<GitState, 'subject'> => {
73  const git: Omit<GitState, 'subject'> = {
74    branch: null,
75    sha: null,
76    upstream: null,
77    ahead: 0,
78    behind: 0,
79    staged: 0,
80    unstaged: 0,
81    untracked: 0,
82    conflicts: 0,
83  }
84  for (const line of out.split('\n')) {
85    if (line.startsWith('# branch.oid ')) {
86      const v = line.slice('# branch.oid '.length)
87      git.sha = v === '(initial)' ? null : v
88    } else if (line.startsWith('# branch.head ')) {
89      const v = line.slice('# branch.head '.length)
90      git.branch = v === '(detached)' ? null : v
91    } else if (line.startsWith('# branch.upstream ')) {
92      git.upstream = line.slice('# branch.upstream '.length)
93    } else if (line.startsWith('# branch.ab ')) {
94      const m = line.match(/\+(\d+) -(\d+)/)
95      git.ahead = Number(m?.[1] ?? 0)
96      git.behind = Number(m?.[2] ?? 0)
97    } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
98      if (line[2] !== '.') git.staged += 1
99      if (line[3] !== '.') git.unstaged += 1
100    } else if (line.startsWith('u ')) {
101      git.conflicts += 1
102    } else if (line.startsWith('? ')) {
103      git.untracked += 1
104    }
105  }
106  return git
107}
108
109/** Salida de `git log -1 --format=%s` (vacía en un repo sin commits). */
110export const parseSubject = (out: string): string | null => out.trim() || null
111
112const asObj = (v: unknown): Record<string, unknown> =>
113  typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {}
114const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : [])
115const asNum = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
116const asStr = (v: unknown): string => (typeof v === 'string' ? v : '')
117
118/** Junta checks como ok/fallando/corriendo; null si no hay ninguno. */
119const summarizeCi = (checks: { name: string; result: CiState }[]): Ci | null => {
120  if (checks.length === 0) return null
121  const count = (r: CiState) => checks.filter(c => c.result === r).length
122  const ok = count('ok')
123  const failing = count('falla')
124  const running = count('corriendo')
125  return {
126    state: failing > 0 ? 'falla' : running > 0 ? 'corriendo' : 'ok',
127    ok,
128    failing,
129    running,
130    failed: checks.filter(c => c.result === 'falla').map(c => c.name),
131  }
132}
133
134const GITHUB_OK = ['SUCCESS', 'NEUTRAL', 'SKIPPED']
135
136/** Un nodo de `statusCheckRollup.contexts`: CheckRun (Actions, apps) o StatusContext. */
137const githubCheck = (node: unknown): { name: string; result: CiState } => {
138  const n = asObj(node)
139  if (n.__typename === 'StatusContext') {
140    const state = asStr(n.state)
141    return {
142      name: asStr(n.context),
143      result: state === 'SUCCESS' ? 'ok' : state === 'PENDING' || state === 'EXPECTED' ? 'corriendo' : 'falla',
144    }
145  }
146  return {
147    name: asStr(n.name),
148    result: n.status !== 'COMPLETED' ? 'corriendo' : GITHUB_OK.includes(asStr(n.conclusion)) ? 'ok' : 'falla',
149  }
150}
151
152const GITHUB_REVIEW: Record<string, string> = {
153  APPROVED: 'aprobado',
154  CHANGES_REQUESTED: 'cambios pedidos',
155  REVIEW_REQUIRED: 'review pendiente',
156}
157
158/** La consulta que corre `gh api graphql` (ver Doc/funcionalidades/repo.md). */
159export const GITHUB_QUERY = `query($owner: String!, $name: String!, $branch: String!, $sha: GitObjectID!, $mine: String!, $review: String!) {
160  repository(owner: $owner, name: $name) {
161    issues(states: OPEN) { totalCount }
162    pullRequests(states: OPEN) { totalCount }
163    object(oid: $sha) {
164      ... on Commit {
165        statusCheckRollup {
166          contexts(first: 100) {
167            nodes {
168              __typename
169              ... on CheckRun { name status conclusion }
170              ... on StatusContext { context state }
171            }
172          }
173        }
174      }
175    }
176    branchPrs: pullRequests(headRefName: $branch, first: 5, orderBy: { field: CREATED_AT, direction: DESC }) {
177      nodes { number title state isDraft reviewDecision url }
178    }
179  }
180  mine: search(query: $mine, type: ISSUE) { issueCount }
181  review: search(query: $review, type: ISSUE) { issueCount }
182}`
183
184/** Las variables de GITHUB_QUERY para un repo "owner/name". */
185export const githubVariables = (path: string, git: GitState): Record<string, string> => {
186  const [owner = '', name = ''] = path.split('/')
187  return {
188    owner,
189    name,
190    branch: git.branch ?? '',
191    // Un repo sin commits pregunta por un oid que no existe: object null.
192    sha: git.sha ?? '0'.repeat(40),
193    mine: `repo:${path} is:issue is:open assignee:@me`,
194    review: `repo:${path} is:pr is:open review-requested:@me`,
195  }
196}
197
198/** Respuesta de GITHUB_QUERY; con HEAD separado no hay PR de rama. */
199export const parseGithub = (json: unknown, git: GitState): ForgeData => {
200  const data = asObj(asObj(json).data)
201  const repo = asObj(data.repository)
202  const prs = asArr(asObj(repo.branchPrs).nodes).map(asObj)
203  const pr = git.branch === null ? undefined : (prs.find(p => p.state === 'OPEN') ?? prs[0])
204  const state = asStr(pr?.state)
205  const contexts = asArr(asObj(asObj(asObj(repo.object).statusCheckRollup).contexts).nodes)
206
207  return {
208    change:
209      pr === undefined
210        ? null
211        : {
212            number: asNum(pr.number) ?? 0,
213            title: asStr(pr.title),
214            state: state === 'MERGED' ? 'mergeado' : state === 'CLOSED' ? 'cerrado' : pr.isDraft === true ? 'borrador' : 'abierto',
215            review: state === 'OPEN' ? (GITHUB_REVIEW[asStr(pr.reviewDecision)] ?? null) : null,
216            url: asStr(pr.url),
217          },
218    ci: summarizeCi(contexts.map(githubCheck)),
219    openIssues: asNum(asObj(repo.issues).totalCount),
220    openChanges: asNum(asObj(repo.pullRequests).totalCount),
221    assigned: asNum(asObj(data.mine).issueCount),
222    reviewRequested: asNum(asObj(data.review).issueCount),
223    isCapped: false,
224  }
225}
226
227/** Las rutas de la API REST de GitLab (v4) que se piden con `glab api`. */
228export const gitlabPaths = (path: string, git: GitState) => {
229  const p = `projects/${encodeURIComponent(path)}`
230  return {
231    user: 'user',
232    mrs: `${p}/merge_requests?source_branch=${encodeURIComponent(git.branch ?? '')}&order_by=updated_at&per_page=5`,
233    pipelines: `${p}/pipelines?sha=${git.sha ?? ''}&per_page=1`,
234    issues: `${p}/issues_statistics`,
235    assigned: `${p}/issues_statistics?scope=assigned_to_me`,
236    openMrs: `${p}/merge_requests?state=opened&view=simple&per_page=100`,
237    reviewMrs: (username: string) =>
238      `${p}/merge_requests?state=opened&view=simple&per_page=100&reviewer_username=${encodeURIComponent(username)}`,
239  }
240}
241
242const GITLAB_CI: Record<string, CiState> = {
243  success: 'ok',
244  skipped: 'ok',
245  failed: 'falla',
246  canceled: 'falla',
247  running: 'corriendo',
248  pending: 'corriendo',
249  created: 'corriendo',
250  preparing: 'corriendo',
251  waiting_for_resource: 'corriendo',
252  scheduled: 'corriendo',
253}
254
255const GITLAB_REVIEW: Record<string, string> = {
256  not_approved: 'review pendiente',
257  mergeable: 'listo para merge',
258}
259
260/** Las respuestas de gitlabPaths, ya parseadas como JSON. */
261export const parseGitlab = (
262  r: { mrs: unknown; pipelines: unknown; issues: unknown; assigned: unknown; openMrs: unknown; reviewMrs: unknown },
263  git: GitState,
264): ForgeData => {
265  const mrs = asArr(r.mrs).map(asObj)
266  const mr = git.branch === null ? undefined : (mrs.find(m => m.state === 'opened') ?? mrs[0])
267  const state = asStr(mr?.state)
268  const pipeline = asObj(asArr(r.pipelines)[0])
269  const ciState = GITLAB_CI[asStr(pipeline.status)]
270  const openMrs = asArr(r.openMrs).length
271
272  return {
273    change:
274      mr === undefined
275        ? null
276        : {
277            number: asNum(mr.iid) ?? 0,
278            title: asStr(mr.title),
279            state: state === 'merged' ? 'mergeado' : state === 'opened' ? (mr.draft === true ? 'borrador' : 'abierto') : 'cerrado',
280            review: state === 'opened' ? (GITLAB_REVIEW[asStr(mr.detailed_merge_status)] ?? null) : null,
281            url: asStr(mr.web_url),
282          },
283    ci:
284      ciState === undefined
285        ? null
286        : summarizeCi([{ name: `pipeline ${asNum(pipeline.id) ?? ''}`.trim(), result: ciState }]),
287    openIssues: asNum(asObj(asObj(asObj(r.issues).statistics).counts).opened),
288    openChanges: Array.isArray(r.openMrs) ? openMrs : null,
289    assigned: asNum(asObj(asObj(asObj(r.assigned).statistics).counts).opened),
290    reviewRequested: Array.isArray(r.reviewMrs) ? r.reviewMrs.length : null,
291    isCapped: openMrs >= 100,
292  }
293}
294
295/** Lo que identifica un estado de la rama: si cambia, se vuelve a preguntar al remoto. */
296export const gitKey = (git: GitState | null): string =>
297  git === null ? '' : `${git.branch ?? ''}@${git.sha ?? ''}+${git.ahead}-${git.behind}`
298
299export const changeLabel = (forge: Forge | null): string => (forge === 'gitlab' ? 'MR' : 'PR')
300export const changeRef = (forge: Forge | null, n: number): string => `${forge === 'gitlab' ? '!' : '#'}${n}`
301
302export const CI_GLYPH: Record<CiState, string> = { ok: '✓', falla: '✗', corriendo: '○' }
303
304export const dirtyCount = (git: GitState): number => git.staged + git.unstaged + git.untracked + git.conflicts
305
306/** "una-rama-muy-larga" → "una-rama-mu…" si pasa de `max`. */
307export const clip = (s: string, max: number): string => (s.length <= max ? s : `${s.slice(0, max - 1)}…`)
308
309/** "/home/yo/workdir/app" → "~/workdir/app"; si pasa de `max`, conserva el final: "…/workdir/app". */
310export const formatPath = (path: string, home: string | null | undefined, max: number): string => {
311  const short = home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path
312  return short.length <= max ? short : `…${short.slice(short.length - max + 1)}`
313}
314
315export type RepoShow = { branch?: boolean; sync?: boolean; changes?: boolean; change?: boolean; ci?: boolean }
316
317/**
318 * El tramo del repo en la línea de estado:
319 * compacta  "main ✓"
320 * media/completa  "main ⇡2⇣1 ±3 #12 ✓" (⇡⇣ para no confundir con ↑↓ de tokens)
321 * Con `show`, solo las partes que valen true (sin `show`, todas).
322 */
323export const repoSummary = (repo: RepoInfo | null | undefined, compact: boolean, show: RepoShow = {}): string => {
324  const git = repo?.git
325  if (!repo || !git) return ''
326  const on = (k: keyof RepoShow) => show[k] !== false
327  const name = on('branch') ? clip(git.branch ?? git.sha?.slice(0, 7) ?? '?', compact ? 12 : 24) : ''
328  const ci = on('ci') && repo.forge?.ci ? CI_GLYPH[repo.forge.ci.state] : ''
329  if (compact) return [name, ci].filter(Boolean).join(' ')
330
331  const sync = on('sync') ? `${git.ahead > 0 ? `⇡${git.ahead}` : ''}${git.behind > 0 ? `⇣${git.behind}` : ''}` : ''
332  const dirty = on('changes') && dirtyCount(git) > 0 ? `±${dirtyCount(git)}` : ''
333  const change = on('change') && repo.forge?.change ? changeRef(repo.remote?.forge ?? null, repo.forge.change.number) : ''
334  return [name, sync, dirty, change, ci].filter(Boolean).join(' ')
335}
336
337/** "hace 40 s", "hace 3 min", "hace 2 h". */
338export const formatAgo = (thenMs: number, nowMs: number): string => {
339  const s = Math.max(0, Math.round((nowMs - thenMs) / 1000))
340  if (s < 60) return `hace ${s} s`
341  if (s < 3600) return `hace ${Math.floor(s / 60)} min`
342  return `hace ${Math.floor(s / 3600)} h`
343}
344
345/** "1 en conflicto · 3 preparados · 1 modificado · 2 sin seguir", o "limpio". */
346export const formatChanges = (git: GitState): string => {
347  const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
348  const parts = [
349    git.conflicts > 0 ? `${git.conflicts} en conflicto` : '',
350    git.staged > 0 ? plural(git.staged, 'preparado', 'preparados') : '',
351    git.unstaged > 0 ? plural(git.unstaged, 'modificado', 'modificados') : '',
352    git.untracked > 0 ? `${git.untracked} sin seguir` : '',
353  ].filter(Boolean)
354  return parts.length === 0 ? 'limpio' : parts.join(' · ')
355}
356
357/** "main → origin/main · ⇡2 ⇣1", "main (sin upstream)", "HEAD separado en abc1234". */
358export const formatBranch = (git: GitState): string => {
359  if (git.branch === null) return `HEAD separado en ${git.sha?.slice(0, 7) ?? '?'}`
360  if (git.upstream === null) return `${git.branch} (sin upstream)`
361  const sync =
362    git.ahead === 0 && git.behind === 0
363      ? 'al día'
364      : [git.ahead > 0 ? `⇡${git.ahead}` : '', git.behind > 0 ? `⇣${git.behind}` : ''].filter(Boolean).join(' ')
365  return `${git.branch} → ${git.upstream} · ${sync}`
366}
367
368/** "✗ 2 fallando (build, lint) · 1 corriendo · 5 ok". */
369export const formatCi = (ci: Ci): string => {
370  const shown = ci.failed.slice(0, 3).join(', ') + (ci.failed.length > 3 ? ', …' : '')
371  const parts = [
372    ci.failing > 0 ? `${ci.failing} fallando (${shown})` : '',
373    ci.running > 0 ? `${ci.running} corriendo` : '',
374    ci.ok > 0 ? `${ci.ok} ok` : '',
375  ].filter(Boolean)
376  return `${CI_GLYPH[ci.state]} ${parts.join(' · ')}`
377}
378
379export const formatCount = (n: number | null, isCapped = false): string =>
380  n === null ? '--' : isCapped ? `${n}+` : String(n)
381
382/** El primer renglón de un error de gh/glab, recortado. */
383export const firstLine = (s: string): string => clip(s.trim().split('\n')[0] ?? '', 120)
384