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.

<img src="assets/clawd.svg" width="260" alt="Clawd, el compañero de token-bar, saludando y poniéndose los lentes">
Tus límites, tus tokens, tu repo y un compañero que te hace el aguante, todo sobre el prompt de Claude Code.
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.
| Límites del plan en vivo | Sesió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 avisan | Verde < 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. |
| Contexto | ctx 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ás | La 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). |
| Clawd | Un 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 ancho | En 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 portable | Exportá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.
Necesitás Claude Code con soporte de mods (probado desde la 2.1.303).
/plugin install token-bar --marketplace guille-nat/token-bar
guille-nat/token-bar.minima, normal o completa). Si ya tenés una config exportada, poné su ruta en Archivo de configuración./reload-plugins o abrí una sesión nueva. La banda aparece arriba del prompt.claude plugin marketplace add guille-nat/token-bar
claude plugin install token-bar@guille-nat --scope user
claude plugin update token-bar@guille-nat
Después, /reload-plugins en las sesiones abiertas.
claude plugin uninstall token-bar@guille-nat
claude plugin marketplace remove guille-nat
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 vive a la izquierda de la banda. No es decorado: sabe lo que está pasando y reacciona.
| Cuando Claude… | Clawd… |
|---|---|
| piensa | se rasca la cabeza como el meme, mira de reojo y le titila un ? ("pensando…", "mmm…", "a ver, a ver…") |
| lee un archivo | mueve los ojos de lado a lado: "leyendo register.tsx" |
| busca en el código | "buscando «useState»" |
| edita o escribe | teclea 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 subagente | saluda al ayudante: "ayudante: revisar tests" |
| lleva más de 90 s | transpira ° y cada tanto lo comenta ("esto viene largo…") |
Todo el tiempo el destello de Claude ✻ orbita a su alrededor.
| Evento | Reacción |
|---|---|
| Pasan los tests | brazos arriba, saltos y confeti de colores: "¡tests en verde!" |
| Fallan los tests | triste, sudando: "tests en rojo…" |
git commit | "¡commit hecho!" |
git push | **se pone los lentes de *deal with it*** y suben flechas ⇡: "¡pusheado!" |
| Termina la respuesta | festeja con confeti: "¡listo!", "¡hecho!" |
| Termina un turno de más de 1 minuto | se calza los lentes: "todo bajo control" |
| Una herramienta da error | se sacude con los ojos bien abiertos: "uy…", "mmm, falló" |
| Lo frenás con Esc | "ok, freno" |
| Vos… | Clawd… |
|---|---|
| abrís una sesión | te saluda según la hora: "¡Hola! ¡buen día!", "¿noche de código?" |
| escribís en el prompt | te sigue con los ojos mientras tipeás: "te leo…" |
| mandás el prompt | se agacha y salta: "¡a darle!" |
| le hacés clic | se 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 minutos | se 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
men la pestaña Info, sus comentarios consy todas las animaciones conn. Sin animaciones queda quieto, pero igual te avisa de los límites.
/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.
| Tecla | Pestaña | Qué muestra |
|---|---|---|
1 | Límites | Modelo y cada límite del plan con barra, porcentaje, hora de reinicio y contexto. |
2 | Tokens | Entrada, salida y caché de la sesión, el último turno y lo que gastaron los subagentes. |
3 | Ajustes | Banda, línea de texto, densidad, puntos de color, repo y reinicio de contadores. |
4 | Repo | Rama, sync, cambios, último commit, PR/MR con review, CI, issues y PRs abiertos, asignados a vos y con review pedida. |
5 | Leyenda | Qué significa cada color y cada símbolo. |
6 | Info | Qué se muestra, presets, cortes de color, Clawd y export/import de la config. |
u refresca todo al instante, desde cualquier pestaña.
| Símbolo | Significado |
|---|---|
━━━╸──── | Barra de uso (media celda de precisión) |
↻ 1:50 p. m. | Cuándo se reinicia el límite |
↑12,3k ↓4,5k | Tokens de entrada y salida de la sesión |
⎇ main | Rama actual |
⇡2 ⇣1 | Commits por pushear y por traer |
±3 | Archivos con cambios sin commitear |
#12 / !12 | PR (GitHub) o MR (GitLab) de la rama |
✓ ✗ ○ | CI en verde, fallando o corriendo |
Todo se elige desde la pestaña Info del panel (/token-bar → 6) y se guarda solo.
| Tecla | Preset | Qué muestra |
|---|---|---|
z | mínima | Modelo, sesión, semana, contexto y rama. Sin Clawd. |
x | normal | Lo de siempre, con Clawd. Es el preset por defecto. |
c | completa | Todo: costo, barras largas, cada dato del repo. |
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.
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.
/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] }
Si preferís la línea clásica de texto, prendela con b en Ajustes. Viene apagada porque Claude Code no le permite colores.
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.
/reload-plugins./token-bar-debug: muestra el uso crudo, el modelo, el repo, el ancho de la terminal y tus ajustes.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
.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/.
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
hooks/register.tsx 1252 lines1import 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 lines1// 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'
223hooks/format.ts 373 lines1// 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}
373hooks/mascot.ts 591 lines1// 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}
591hooks/clawd-client.ts 33 lines1// 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
33hooks/repo.ts 384 lines1// 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