A clean side pane for Claude Code: model, context, rate limits, cost, activity and project at a glance. Themes, light/dark backgrounds and any language.

<h1 align="center">Cabina</h1>
<img src="https://img.shields.io/badge/version-1.0.8-d97757?style=for-the-badge" alt="Version 1.0.8"> <img src="https://img.shields.io/badge/Claude%20Code-2.1.304%2B-d97757?style=for-the-badge" alt="Claude Code 2.1.304 or later"> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3fb950?style=for-the-badge" alt="MIT License"></a>
🇪🇸 <a href="README-es.md">Español</a>
<a href="#install"><strong>Install</strong></a> · <a href="#commands">Commands</a> · <a href="#settings">Settings</a> · <a href="#themes">Themes</a> · <a href="#languages">Languages</a>
<img src="docs/images/panel-en.png" alt="The Cabina pane: usage, cost, activity, files and project" width="380">
<table align="center"> <tr> <td align="center"><img src="docs/images/settings-language.png" alt="Language setting" width="200"><br><sub>Language</sub></td> <td align="center"><img src="docs/images/settings-theme.png" alt="Theme setting" width="200"><br><sub>Theme</sub></td> <td align="center"><img src="docs/images/settings-background.png" alt="Background setting" width="200"><br><sub>Background</sub></td> <td align="center"><img src="docs/images/settings-sections.png" alt="Sections setting" width="200"><br><sub>Sections</sub></td> </tr> </table>
Cabina docks a pane beside the conversation that keeps the numbers you usually go looking for: how full the context window is, how close you are to your 5‑hour and 7‑day limits and when they reset, what the session has cost and how fast it is spending, what Claude is doing right now and which files it touched. It updates by itself and stays out of your way. Anything you don't need can be hidden.
↕ in the heading and the mouse wheel scrolls the list.+120 −34 · 5 uncommitted).Requires Claude Code 2.1.304 or later. Inside Claude Code:
/plugin marketplace add SrPirson/cabina
/plugin install cabina@cabina
/reload-plugins
/cabina
From a terminal:
claude plugin marketplace add SrPirson/cabina
claude plugin install cabina@cabina
For a single session from a clone:
git clone https://github.com/SrPirson/cabina
claude --plugin-dir ./cabina
Update
claude plugin marketplace update cabina && claude plugin update cabina@cabina
Uninstall
claude plugin uninstall cabina@cabina
| Command | Does | ||
|---|---|---|---|
/cabina | Opens the pane | ||
/cabina close | Closes the pane (/cabina opens it again) | ||
/cabina config | Opens the settings view | ||
/cabina theme <name> | Switches theme right away, e.g. /cabina theme dracula | ||
/cabina language <name> | Sets the language: a code (es, fr) or any name (swahili, català) | ||
| `/cabina background <auto\ | dark\ | light>` | Keeps the terminal background or paints the pane |
/cabina help | Lists the commands |
Subcommands also work in other languages: tema, idioma, fondo, ajustes…
| Key | Does |
|---|---|
Tab | Moves between the pane's controls (⚙ Settings, compact) |
Enter | Presses the focused control |
↑ ↓ | In settings, moves through a list |
Enter | In settings, saves the highlighted choice, or shows/hides a section |
Esc | Gives the keyboard back to the prompt |
Open ⚙ Settings at the bottom of the pane, or run /cabina config. Every change applies at once.
| Setting | Values | Default |
|---|---|---|
| Language | auto, es, en, pt, fr, de, it, custom | auto |
| Other language | any language name or code, used with custom | — |
| Theme | see Themes | classic |
| Background | auto (terminal), dark, light | auto |
| Sections | Usage, Cost, Activity, Agents, Hooks, Trend, Files, Project: on/off each | all on |
| Live hooks | lights up settings.json hooks as they run; turns itself off when an organization plugin skips Cabina's hook events, leaving only blocks and added context | on |
The same options appear in /config, or in ~/.claude/settings.json:
{
"pluginConfigs": {
"cabina@cabina": {
"options": {
"language": "en",
"theme": "tokyo-night",
"background": "dark",
"show_trend": false
}
}
}
}
| Theme | |
|---|---|
classic | Claude Code's own colors; follows your terminal theme |
catppuccin | Mocha on dark, Latte on light |
dracula | Dracula, with an Alucard-style light variant |
nord | Polar Night on dark, Snow Storm on light |
tokyo-night | Night on dark, Day on light |
gruvbox | Gruvbox dark and light |
github | GitHub dark and light |
solarized | Solarized dark and light |
colorblind | Okabe‑Ito palette, safe for protanopia, deuteranopia and tritanopia |
contrast | High contrast |
mono | Monochrome |
Every theme has one palette for dark backgrounds and one for light. With the auto background, Cabina picks the one that matches your Claude Code theme. Bars and accents reach at least 3:1 contrast against their background, secondary text 4.5:1 and body text 7:1, and a test checks all of them.
English, Español, Português, Français, Deutsch and Italiano are built in. For any other language, set Other language (or /cabina language swahili): Cabina asks Claude Haiku once to translate its labels, checks the answer and keeps it for later sessions. If the translation fails, it falls back to English.
auto follows Claude Code's own language setting.
git status and git diff --shortstat locally in the session's folder.~/.claude/settings.json under pluginConfigs["cabina@cabina"].Author: SrPirson · MIT License
hooks/register.tsx 848 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentStat, Snapshot, Strings } from '../types'
5import { BUILT_IN, ENDONYM, fill, normalize, parseStrings, translatePrompt } from './i18n'
6import { BACKGROUNDS, isThemeId, surfaceOf, THEME_IDS, themeName, themeOf, toneOf } from './themes'
7
8const PANE = 'cabina'
9const EMPTY: Snapshot = {
10 model: '', ctxWindow: 0, limits: [], compactions: 0, agents: [], crew: [], hooks: {}, cwd: '', now: 0,
11 files: [], tools: {}, prompts: 0, ctxTrend: [], costTrend: [],
12}
13const snap = atom({ plugin: 'cabina', key: 'snap' } as const, EMPTY)
14const text = atom({ plugin: 'cabina', key: 'text' } as const, BUILT_IN.en!)
15const view = atom({ plugin: 'cabina', key: 'view' } as const, 'main' as 'main' | 'settings')
16// Filas que la lista de agentes está subida desde el final; 0 sigue a los más recientes.
17const crewBack = atom({ plugin: 'cabina', key: 'crewBack' } as const, 0)
18const CREW_ROWS = 6
19
20// "claude-opus-5-5[1m]" → "Opus 5.5 1M"; lo que no encaje se muestra tal cual.
21export const prettyModel = (id: string) => {
22 const m = /^claude-([a-z]+)-(\d{1,2})(?:-(\d{1,2})(?!\d))?/.exec(id)
23 if (!m) return id
24 const name = m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1)
25 return `${name} ${m[2]}${m[3] ? '.' + m[3] : ''}${/\[1m\]/i.test(id) ? ' 1M' : ''}`
26}
27
28export const tokens = (n: number) =>
29 n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${n}`
30
31export const span = (ms: number) => {
32 if (ms < 60_000) return `${Math.max(0, Math.floor(ms / 1000))}s`
33 const m = Math.round(ms / 60_000)
34 const d = Math.floor(m / 1440), h = Math.floor((m % 1440) / 60)
35 return d ? `${d}d ${h}h` : h ? `${h}h ${m % 60}m` : `${m}m`
36}
37
38// [3, 9, 1] → "▃█▁"; con `max`, escala absoluta (el contexto va de 0 a 100).
39export const spark = (values: number[], max = Math.max(...values, 1e-9)) =>
40 values.map(v => '▁▂▃▄▅▆▇█'[Math.min(7, Math.max(0, Math.round((v / max) * 7)))]).join('')
41
42// Ruta relativa al directorio de la sesión, con barras normales.
43export const relPath = (file: string, cwd: string) => {
44 const f = file.replace(/\\/g, '/'), c = cwd.replace(/\\/g, '/').replace(/\/$/, '')
45 return f.toLowerCase().startsWith(c.toLowerCase() + '/') ? f.slice(c.length + 1) : f
46}
47
48// "3 files changed, 120 insertions(+), 34 deletions(-)" → { plus: 120, minus: 34 }
49export const shortstat = (out: string) => ({
50 plus: Number(/(\d+) insertion/.exec(out)?.[1] ?? 0),
51 minus: Number(/(\d+) deletion/.exec(out)?.[1] ?? 0),
52})
53
54// Cambia un subagente de la lista por id; si no está, nada.
55const touchAgent = ($: EngineInterface, id: string, fn: (a: AgentStat) => AgentStat) =>
56 bump($, s => ({ ...s, crew: s.crew.map(a => a.id === id ? fn(a) : a) }))
57
58// Contexto de su última petición · salida sumada · tiempo (hasta ahora si sigue en curso).
59export const agentUsage = (a: AgentStat, now: number) =>
60 `${tokens(a.tokens)} · ↓ ${tokens(a.out)} · ${span((a.endedAt ?? now) - a.startedAt)}`
61
62const pushTrend = (list: number[], v: number) => [...list, v].slice(-30)
63
64export const shortPath = (p: string) => {
65 const parts = p.split(/[\\/]+/).filter(Boolean)
66 return parts.length > 2 ? `…/${parts.slice(-2).join('/')}` : p
67}
68
69// Idioma: tabla propia si la hay; si no, una traducción del modelo guardada en $.store; si falla, inglés.
70const resolveStrings = async ($: EngineInterface, setting: string): Promise<Strings> => {
71 let lang = setting.trim()
72 if (!lang || lang.toLowerCase() === 'auto') {
73 const v = await $.settings.read({ key: 'language' }).catch(() => undefined)
74 lang = typeof v === 'string' && v.trim() ? v : 'en'
75 }
76 const code = normalize(lang)
77 const built = BUILT_IN[code]
78 if (built) return built
79 const key = `i18n:v4:${code}`
80 const cached = parseStrings(JSON.stringify((await $.store.get(key)) ?? null))
81 if (cached) return cached
82 const r = await $.model.complete({ model: 'haiku', prompt: translatePrompt(lang), maxTokens: 800 })
83 const got = r.isAnswered ? parseStrings(r.text) : undefined
84 if (!got) return BUILT_IN.en!
85 await $.store.set(key, got)
86 return got
87}
88
89// El panel es decorativo: si una escritura falla, la sesión sigue igual.
90// Un estado guardado por una versión anterior puede no traer los campos nuevos: se rellenan con EMPTY.
91const readSnap = async ($: EngineInterface): Promise<Snapshot> => ({ ...EMPTY, ...(await read($, snap)) })
92const bump = ($: EngineInterface, fn: (s: Snapshot) => Snapshot) =>
93 update($, snap, s => fn({ ...EMPTY, ...s })).catch(() => undefined)
94const patch = ($: EngineInterface, p: Partial<Snapshot>) => bump($, s => ({ ...s, ...p }))
95
96const refreshUsage = async ($: EngineInterface) => {
97 const u = await $.session.usage()
98 await patch($, {
99 ctxTokens: u.context.tokens,
100 ctxWindow: u.context.window,
101 ctxPercent: u.context.percent,
102 limits: u.rateLimits,
103 costUsd: u.cost?.usd,
104 startedAt: u.startedAt,
105 })
106 return u
107}
108
109const openSettings = async ($: EngineInterface) => {
110 await update($, view, () => 'settings')
111 await $.ui.open({ id: PANE, title: 'Cabina', focus: true })
112}
113
114// Guarda un campo de userConfig como lo haría /config; el motor recarga el mod con el valor nuevo.
115// Devuelve el motivo si no se pudo guardar.
116const save = async ($: EngineInterface, field: string, value: string | boolean): Promise<string | undefined> => {
117 const row = (await $.config.list()).find(r => new RegExp(`^cabina(@[^.]*)?\\.${field}$`).test(r.key))
118 if (!row) return `cabina.${field}: not in /config`
119 const r = await $.config.set({ key: row.key, value })
120 return 'deny' in r && r.deny ? r.deny : undefined
121}
122const saveOrToast = async ($: EngineInterface, field: string, value: string | boolean) => {
123 const deny = await save($, field, value)
124 if (deny) void $.ui.toast(deny)
125}
126
127// "es", "English", "swahili" → los dos campos de userConfig.
128const saveLanguage = async ($: EngineInterface, input: string) => {
129 const code = normalize(input)
130 if (code === 'auto' || BUILT_IN[code]) return save($, 'language', code)
131 return (await save($, 'customLanguage', input.trim())) ?? save($, 'language', 'custom')
132}
133
134const git = async ($: EngineInterface, cwd: string, args: string[]) => {
135 try {
136 const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 3000 })
137 return r.exitCode === 0 ? r.stdout : undefined
138 } catch {
139 return undefined
140 }
141}
142
143// Rama y cambios sin commitear; fuera de un repositorio, nada.
144const gitInfo = async ($: EngineInterface, cwd: string): Promise<Partial<Snapshot>> => {
145 const status = await git($, cwd, ['status', '--porcelain'])
146 if (status === undefined) return { branch: undefined, git: undefined }
147 const branch = (await git($, cwd, ['branch', '--show-current']))?.trim() || undefined
148 const changed = status.split('\n').filter(Boolean).length
149 return { branch, git: { changed, ...shortstat((await git($, cwd, ['diff', 'HEAD', '--shortstat'])) ?? '') } }
150}
151
152// "1.0.10" > "1.0.9": compara número a número; lo que no sea número cuenta como 0.
153export const isNewer = (latest: string, current: string) => {
154 const a = latest.split('.').map(Number), b = current.split('.').map(Number)
155 for (let i = 0; i < Math.max(a.length, b.length); i++) {
156 const d = (a[i] || 0) - (b[i] || 0)
157 if (d) return d > 0
158 }
159 return false
160}
161
162const MANIFEST_URL = 'https://raw.githubusercontent.com/SrPirson/cabina/main/.claude-plugin/plugin.json'
163const versionOf = (json: string) => {
164 const v = (JSON.parse(json) as { version?: unknown }).version
165 return typeof v === 'string' ? v : undefined
166}
167
168// Versión instalada y la publicada en GitHub; sin red, solo la instalada.
169const checkVersion = async ($: EngineInterface) => {
170 const version = await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).then(versionOf).catch(() => undefined)
171 const r = await $.http.fetch(MANIFEST_URL).catch(() => undefined)
172 const latest = r?.ok ? (() => { try { return versionOf(r.text) } catch { return undefined } })() : undefined
173 await patch($, { version, latest, updating: undefined })
174}
175
176// "cabina@<marketplace>" si se instaló desde un marketplace; con --plugin-dir (@inline) no hay nada que actualizar.
177const installedId = async ($: EngineInterface) => {
178 const key = (await $.config.list()).find(r => /^cabina@[^.]+\.theme$/.test(r.key))?.key
179 const id = key?.slice(0, -'.theme'.length)
180 return id && !id.endsWith('@inline') ? id : undefined
181}
182
183// Lo mismo que "Update" del README; el motor aplica la versión nueva al reiniciar.
184const runUpdate = async ($: EngineInterface) => {
185 const s = await readSnap($)
186 const id = await installedId($)
187 if (!id || s.updating) return
188 await patch($, { updating: 'running' })
189 const run = (args: string[]) => $.process.run(['claude', 'plugin', ...args], { timeoutMs: 120_000 })
190 try {
191 const m = await run(['marketplace', 'update', id.split('@')[1]!])
192 const r = m.exitCode === 0 ? await run(['update', id]) : m
193 if (r.exitCode !== 0) throw new Error((r.stderr || r.stdout).trim().split('\n').pop())
194 await patch($, { updating: 'done' })
195 void $.ui.toast(fill((await read($, text)).updated, s.latest ?? ''))
196 } catch (err) {
197 await patch($, { updating: undefined })
198 void $.ui.toast(`Cabina: ${err instanceof Error ? err.message : String(err)}`)
199 }
200}
201
202// Un hook se ve en verde mientras corre y unos segundos después: los de settings suelen durar milisegundos.
203export const HOOK_GLOW = 5000
204
205export const register: Register = (on, options) => {
206 // Eventos con hooks en settings.json (todas las fuentes, la del proyecto incluida); se relee al dibujar.
207 let configured = new Set<string>()
208 // Con un plugin gestionado que se salta los classic.* de Cabina, el seguimiento en vivo se apaga solo
209 // (track_hooks en /config): queda lo que los hooks escriben en la conversación, al bloquear o añadir contexto.
210 const trackHooks = options.track_hooks !== false
211 const seenPre = new Set<string>()
212 let misses = 0
213 const langOption = String(options.language ?? 'auto')
214 const customLanguage = String(options.customLanguage ?? '')
215 const setting = langOption === 'custom' ? customLanguage || 'auto' : langOption
216 const themeId = String(options.theme ?? 'classic')
217 const background = String(options.background ?? 'auto')
218 // Apartados que el usuario quiere ver (userConfig show_<id>, todos por defecto).
219 const SECTION_IDS = ['usage', 'cost', 'activity', 'agents', 'hooks', 'trend', 'files', 'project'] as const
220 const visible = Object.fromEntries(SECTION_IDS.map(id => [id, options[`show_${id}`] !== false])) as
221 Record<(typeof SECTION_IDS)[number], boolean>
222 const surface = surfaceOf(themeId, background)
223
224 on('session.start', async ($, e, next) => {
225 void checkVersion($).catch(() => undefined)
226 const known = BUILT_IN[normalize(setting)]
227 await $.command.register({ name: 'cabina', description: (known ?? BUILT_IN.en!).describe, argumentHint: '[close | config | theme <…> | language <…> | background <…> | help]' })
228 void resolveStrings($, setting)
229 .then(t => update($, text, () => t))
230 .catch(() => undefined)
231 const cwd = await $.session.cwd()
232 configured = new Set(Object.keys((await $.settings.read({ key: 'hooks' }).catch(() => undefined)) ?? {}))
233 const claudeTheme = await $.settings.read({ key: 'theme' }).catch(() => undefined)
234 await patch($, { model: await $.session.model(), cwd, ...(await gitInfo($, cwd)), prompts: await $.session.turns(), termLight: typeof claudeTheme === 'string' && claudeTheme.startsWith('light'), now: await $.clock.now() })
235 await refreshUsage($)
236 if ((await readSnap($)).schema !== 1) {
237 await patch($, { schema: 1, ctxTrend: [], costTrend: [], lastTurn: undefined, turnCostStart: undefined })
238 }
239
240 // Reloj: cada segundo mientras trabaja (cronómetro), cada 30 s en reposo (cuentas atrás).
241 let tick = 0
242 $.clock.every(1000, async () => {
243 tick++
244 const s = await readSnap($)
245 const live = s.busySince !== undefined || s.crew.some(a => a.endedAt === undefined)
246 || Object.values(s.hooks).some(h => h.busy || s.now - h.at < 60_000)
247 if (live || tick % 30 === 0) await patch($, { now: await $.clock.now() })
248 })
249 void $.ui.open({ id: PANE, title: 'Cabina' })
250
251 return next(e)
252 })
253
254 on('command.run', { command: 'cabina' }, async ($, e) => {
255 const t = await read($, text)
256 const [sub = '', ...rest] = e.args.trim().split(/\s+/)
257 const arg = rest.join(' ')
258 if (/^(config|settings|setup|ajustes|configurar)$/i.test(sub)) {
259 await openSettings($)
260 return { text: `Cabina · ${t.settings}` }
261 }
262 if (/^(close|closed|hide|cerrar|ocultar|fechar|fermer|schließen|chiudi)$/i.test(sub)) {
263 await $.ui.close({ id: PANE })
264 return { text: t.closed }
265 }
266 if (/^(theme|tema|thème|thema)$/i.test(sub) && arg) {
267 const id = arg.toLowerCase().replace(/-light$/, '')
268 if (!isThemeId(id)) return { text: `${t.theme}: ${THEME_IDS.join(', ')}` }
269 return { text: (await save($, 'theme', id)) ?? `${t.theme}: ${themeName(t, id)}` }
270 }
271 if (/^(background|bg|fondo|fond|fundo|hintergrund|sfondo)$/i.test(sub) && arg) {
272 const word = arg.toLowerCase()
273 const mode = /^(dark|oscuro|escuro|sombre|dunkel|scuro)$/.test(word) ? 'dark'
274 : /^(light|claro|clair|hell|chiaro)$/.test(word) ? 'light' : 'auto'
275 return { text: (await save($, 'background', mode)) ?? `${t.background}: ${mode}` }
276 }
277 if (/^(language|lang|idioma|langue|sprache|lingua)$/i.test(sub) && arg) {
278 return { text: (await saveLanguage($, arg)) ?? `${t.language}: ${arg}` }
279 }
280 if (sub) {
281 return {
282 text: [
283 '/cabina Cabina',
284 `/cabina close ✕ ${t.close}`,
285 `/cabina config ⚙ ${t.settings}`,
286 `/cabina theme <…> ${t.theme}: ${THEME_IDS.join(', ')}`,
287 `/cabina language <…> ${t.language}: auto, ${Object.keys(ENDONYM).join(', ')}, swahili…`,
288 `/cabina background <…> ${t.background}: ${BACKGROUNDS.join(', ')}`,
289 ].join('\n'),
290 }
291 }
292 await $.ui.open({ id: PANE, title: 'Cabina' })
293 return { text: (await read($, text)).opened }
294 })
295
296 on('session.measure', async ($, e, next) => {
297 await patch($, {
298 ctxTokens: e.context.tokens,
299 ctxWindow: e.context.window,
300 ctxPercent: e.context.percent,
301 limits: e.rateLimits,
302 costUsd: e.cost?.usd,
303 })
304 return next(e)
305 })
306
307 on('turn.start', async ($, e, next) => {
308 if (e.agentId !== undefined) {
309 const id = e.agentId
310 const now = await $.clock.now()
311 await bump($, s => ({ ...s, agents: s.agents.includes(id) ? s.agents : [...s.agents, id] }))
312 if ((await readSnap($)).crew.some(a => a.id === id)) {
313 await touchAgent($, id, a => ({ ...a, endedAt: undefined }))
314 } else {
315 const info = (await $.agent.list().catch(() => [])).find(a => a.id === id)
316 const stat: AgentStat = {
317 id, type: info?.type ?? 'agent', desc: info?.description ?? '', parentId: info?.parentId,
318 tokens: 0, out: 0, tools: 0, startedAt: now,
319 }
320 await bump($, s => ({ ...s, crew: [...s.crew.filter(a => a.id !== id), stat].slice(-50) }))
321 }
322 } else {
323 // En el estado y no en una variable: una recarga del mod a mitad de turno no lo pierde.
324 const turnCostStart = (await readSnap($)).costUsd ?? 0
325 await patch($, { turnCostStart, busySince: await $.clock.now(), now: await $.clock.now() })
326 }
327 return next(e)
328 })
329
330 on('turn.step', async function* ($, e, next) {
331 if (e.agentId === undefined) {
332 await patch($, { model: e.model, effort: e.effort === undefined ? undefined : String(e.effort) })
333 return yield* next(e)
334 }
335 const r = yield* next(e)
336 // Tokens como los cuenta Claude Code: el contexto de la última petición; la salida, sumada.
337 const u = r.usage
338 if (u) {
339 const ctx = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
340 await touchAgent($, e.agentId, a => ({ ...a, tokens: ctx, out: a.out + u.output_tokens }))
341 }
342 return r
343 })
344
345 on('tool.call', async ($, e, next) => {
346 const tool = 'tool' in e ? e.tool : undefined
347 const isMain = e.agentId === undefined
348 if (isMain) await patch($, { tool })
349 try {
350 const ran = await next(e)
351 // Toda llamada que llega a ejecutarse pasa antes por classic.PreToolUse; si nunca nos llega, nos lo saltan.
352 // Tres seguidas, por si alguna llamada no lo dispara (las de $.tool.call de otro plugin).
353 if (trackHooks && misses < 3 && !ran.isError && !('deny' in ran && ran.deny)) {
354 misses = seenPre.delete(e.tool_use_id) ? 0 : misses + 1
355 if (misses === 3) void save($, 'track_hooks', false)
356 }
357 // Herramientas usadas (también por subagentes) y archivos que se escribieron bien.
358 const file = 'file_path' in e && typeof e.file_path === 'string' ? e.file_path
359 : 'notebook_path' in e && typeof e.notebook_path === 'string' ? e.notebook_path : undefined
360 const isWrite = tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit'
361 const wrote = isWrite && file !== undefined && !('deny' in ran && ran.deny) && !ran.isError
362 if (tool && e.agentId !== undefined) await touchAgent($, e.agentId, a => ({ ...a, tools: a.tools + 1 }))
363 if (tool) {
364 await bump($, s => ({
365 ...s,
366 tools: { ...s.tools, [tool]: (s.tools[tool] ?? 0) + 1 },
367 files: wrote ? [file, ...s.files.filter(f => f !== file)].slice(0, 20) : s.files,
368 }))
369 }
370 return ran
371 } finally {
372 if (isMain) await patch($, { tool: undefined })
373 }
374 })
375
376 on('turn.complete', async ($, e, next) => {
377 const ran = await next(e)
378 if (e.agentId !== undefined) {
379 const id = e.agentId
380 const now = await $.clock.now()
381 await bump($, s => ({ ...s, agents: s.agents.filter(a => a !== id) }))
382 await touchAgent($, id, a => ({ ...a, endedAt: now }))
383 return ran
384 }
385 const u = await refreshUsage($)
386 const s = await readSnap($)
387 // Sin el comienzo del turno (no se vio su inicio) no hay coste que atribuirle: no se apunta.
388 const turnCost = s.turnCostStart === undefined ? undefined : (u.cost?.usd ?? 0) - s.turnCostStart
389 const more = { ...(await gitInfo($, s.cwd)), prompts: await $.session.turns() }
390 await bump($, x => ({
391 ...x,
392 ...more,
393 busySince: undefined,
394 tool: undefined,
395 turnCostStart: undefined,
396 ...(turnCost === undefined ? {} : {
397 lastTurn: { durationMs: e.durationMs, costUsd: turnCost },
398 ctxTrend: pushTrend(x.ctxTrend, u.context.percent ?? 0),
399 costTrend: pushTrend(x.costTrend, turnCost),
400 }),
401 }))
402 return ran
403 })
404
405 on('session.compact', async ($, e, next) => {
406 const ran = await next(e)
407 if (e.agentId === undefined) await bump($, s => ({ ...s, compactions: s.compactions + 1 }))
408 return ran
409 })
410
411 // Los hooks de settings van debajo de los módulos en la cadena: lo que tarda next es lo que tardan ellos.
412 if (trackHooks) on('classic.*', async ($, e, next) => {
413 const event = next.event.slice('classic.'.length)
414 if (event === 'PreToolUse' && 'tool_use_id' in e) seenPre.add(e.tool_use_id)
415 if (!configured.has(event)) return next(e)
416 const detail = 'tool' in e ? e.tool : 'tool_name' in e ? e.tool_name : undefined
417 const mark = async (busy: boolean, blocked = false) => {
418 const at = await $.clock.now()
419 await bump($, s => ({ ...s, now: at, hooks: { ...s.hooks, [event]: { at, busy, blocked, detail } } }))
420 }
421 await mark(true)
422 try {
423 const ran = await next(e)
424 const r = ran as { block?: string; deny?: string } | undefined
425 await mark(false, Boolean(r?.block || r?.deny))
426 return ran
427 } catch (err) {
428 await mark(false)
429 throw err
430 }
431 })
432
433 // Lo que un hook de settings devuelve (un bloqueo, contexto) entra en la conversación con su evento.
434 on('session.append', async ($, e, next) => {
435 const ran = await next(e)
436 if (e.origin.kind === 'hook') {
437 const event = e.origin.event
438 const at = await $.clock.now()
439 const blocked = /block|stop|error/i.test(e.message.name ?? '')
440 await bump($, s => ({ ...s, now: at, hooks: { ...s.hooks, [event]: { at, busy: false, blocked, detail: s.hooks[event]?.detail } } }))
441 }
442 return ran
443 })
444
445 // La vista principal cabe siempre en el panel: la rueda mueve la lista de agentes cuando no caben.
446 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
447 const max = Math.max(0, (await readSnap($)).crew.length - CREW_ROWS)
448 if (max === 0 || (await read($, view)) === 'settings') return next(e)
449 await update($, crewBack, b => Math.min(max, Math.max(0, b - e.by))).catch(() => undefined)
450 return {}
451 })
452
453 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
454 // Ajustes: otra vista del mismo panel. Un segundo panel se abría como pestaña oculta.
455 if ((await read($, view)) === 'settings') {
456 const ui = $.ui.resolve(e)
457 const { Box, Text, Button } = ui
458 const t = await read($, text)
459 const languages = [
460 { value: 'auto', label: t.auto },
461 ...Object.entries(ENDONYM).map(([value, label]) => ({ value, label })),
462 { value: 'custom', label: `${t.other}…` },
463 ]
464 const themes = THEME_IDS.map(id => ({ value: id, label: themeName(t, id) }))
465 const snapMode = await readSnap($)
466 const sectionName = { usage: t.secUsage, cost: t.secCost, activity: t.secActivity, agents: t.secAgents, hooks: t.secHooks, trend: t.secTrend, files: t.files, project: t.secProject }
467 // Elegir un apartado lo muestra u oculta; la marca dice cómo está ahora.
468 const sections = SECTION_IDS.map(id => ({ value: id, label: `${visible[id] ? '✓' : '○'} ${sectionName[id]}` }))
469 const toggle = (id: string) => {
470 if (id in visible) void saveOrToast($, `show_${id}`, !visible[id as keyof typeof visible])
471 }
472 const backgrounds = [
473 { value: 'auto', label: t.bgAuto },
474 { value: 'dark', label: t.bgDark },
475 { value: 'light', label: t.bgLight },
476 ]
477 const swatch = (id: string) => {
478 const th = themeOf(id, surface?.mode ?? (snapMode.termLight ? 'light' : 'dark'), surface !== undefined)
479 return (
480 <Text>
481 <Text color={th.ok}>━━━</Text>
482 <Text color={th.warn}>━━━</Text>
483 <Text color={th.danger}>━━━</Text>
484 <Text color={th.accent}>━━━</Text>
485 </Text>
486 )
487 }
488
489 // Terminal, escritorio y VS Code: listas que se recorren con ↑↓ y se eligen con Enter.
490 // Móvil no tiene Select ni Input: una lista de botones, uno por fila.
491 const body =
492 'Select' in ui && e.surface !== 'mobile' ? (
493 <Box flexDirection="column" gap={1}>
494 <Text dimColor>{t.hint}</Text>
495 <ui.Select key="language" label={t.language} options={languages} value={langOption} autoFocus
496 onSelect={value => void saveOrToast($, 'language', value)} />
497 {langOption === 'custom' && 'Input' in ui && (
498 <ui.Input key="custom" label={t.other} placeholder={t.otherHint} value={customLanguage}
499 autoFocus={customLanguage ? undefined : true}
500 onSubmit={async value => {
501 const deny = await saveLanguage($, value.trim() || 'auto')
502 if (deny) void $.ui.toast(deny)
503 }} />
504 )}
505 <ui.Select key="theme" label={t.theme} options={themes} value={themeId}
506 onSelect={value => void saveOrToast($, 'theme', value)} />
507 <ui.Select key="background" label={t.background} options={backgrounds} value={background}
508 onSelect={value => void saveOrToast($, 'background', value)} />
509 <ui.Select key="sections" label={t.sections} options={sections} onSelect={toggle} />
510 {swatch(themeId)}
511 </Box>
512 ) : (
513 <Box flexDirection="column" gap={1}>
514 <Box flexDirection="column">
515 <Text bold>{t.language}</Text>
516 {languages.filter(l => l.value !== 'custom').map(l => (
517 <Button key={`lang-${l.value}`} plain onPress={() => void saveOrToast($, 'language', l.value)}>
518 {`${l.value === langOption ? '●' : '○'} ${l.label}`}
519 </Button>
520 ))}
521 </Box>
522 <Box flexDirection="column">
523 <Text bold>{t.theme}</Text>
524 {themes.map(th => (
525 <Button key={`theme-${th.value}`} plain onPress={() => void saveOrToast($, 'theme', th.value)}>
526 {`${th.value === themeId ? '●' : '○'} ${th.label}`}
527 </Button>
528 ))}
529 </Box>
530 <Box flexDirection="column">
531 <Text bold>{t.background}</Text>
532 {backgrounds.map(b => (
533 <Button key={`bg-${b.value}`} plain onPress={() => void saveOrToast($, 'background', b.value)}>
534 {`${b.value === background ? '●' : '○'} ${b.label}`}
535 </Button>
536 ))}
537 </Box>
538 <Box flexDirection="column">
539 <Text bold>{t.sections}</Text>
540 {sections.map(sec => (
541 <Button key={`sec-${sec.value}`} plain onPress={() => toggle(sec.value)}>{sec.label}</Button>
542 ))}
543 </Box>
544 </Box>
545 )
546
547 return (
548 <Box flexDirection="column" gap={1}>
549 {body}
550 <Button key="close" label={t.close} onPress={() => void update($, view, () => 'main')} />
551 </Box>
552 )
553 }
554
555 const { Box, Text, Button } = $.ui.resolve(e)
556 const s = await readSnap($)
557 // /model no abre turno, y classic.PostModelSwitch no llega aquí en cuentas
558 // con ajustes gestionados (cc-plugin-sec-default se lo queda): se lee en vivo.
559 const liveModel = await $.session.model().catch(() => s.model)
560 // /effort tampoco abre turno; lo guarda por modelo en modelSettings.<id>.effortLevel.
561 const settings = await $.settings.read({}).catch(() => undefined)
562 if (settings) configured = new Set(Object.keys(settings.hooks ?? {}))
563 const perModel = (settings?.modelSettings as Record<string, { effortLevel?: unknown }> | undefined)?.[liveModel.replace(/\[.*\]$/, '')]
564 const liveEffort = perModel?.effortLevel !== undefined ? String(perModel.effortLevel)
565 : liveModel === s.model ? s.effort : undefined
566 const fg = surface?.fg
567 const muted = surface?.muted ?? 'inactive'
568 // Texto secundario: gris con contraste medido sobre un fondo pintado; atenuado sobre el de la terminal.
569 const dim = surface ? { color: surface.sub } : { dimColor: true as const }
570 const theme = themeOf(themeId, surface?.mode ?? (s.termLight ? 'light' : 'dark'), surface !== undefined)
571 const t = await read($, text)
572 const limitLabel: Record<string, string> = { five_hour: t.h5, seven_day: t.d7, spend_limit: t.spend }
573 const lastLabel = t.last.charAt(0).toUpperCase() + t.last.slice(1)
574 const labels = [t.context, t.cost, t.agents, t.total, lastLabel, t.duration, t.tools, ...Object.values(limitLabel)]
575 const labelWidth = Math.min(12, 1 + Math.max(...labels.map(x => x.length)))
576 const cols = e.props.bodyColumns - (surface ? 2 : 0)
577 const barWidth = Math.max(6, Math.min(20, cols - 18 - labelWidth))
578
579 // Cabecera de cada apartado: nombre en el color de acento y una regla hasta el borde.
580 const section = (title: string, right = '') => {
581 const name = title.toUpperCase()
582 const rule = Math.max(2, cols - name.length - (right ? right.length + 2 : 1))
583 return (
584 <Text color={fg} wrap="truncate">
585 <Text color={theme.accent} bold>{name}</Text>
586 <Text color={muted}>{` ${'─'.repeat(rule)}`}</Text>
587 {right && <Text {...dim}>{` ${right}`}</Text>}
588 </Text>
589 )
590 }
591
592 const gauge = (label: string, pct: number, right: string) => {
593 const full = Math.round((Math.min(100, Math.max(0, pct)) / 100) * barWidth)
594 return (
595 <Box flexDirection="row" gap={1}>
596 <Box width={labelWidth}><Text {...dim} wrap="truncate">{label}</Text></Box>
597 <Text color={fg}>
598 <Text color={toneOf(theme, pct)}>{'━'.repeat(full)}</Text>
599 <Text color={muted}>{'━'.repeat(barWidth - full)}</Text>
600 </Text>
601 <Box width={4} justifyContent="flex-end"><Text color={fg} bold>{`${Math.round(pct)}%`}</Text></Box>
602 <Text {...dim} wrap="truncate">{right}</Text>
603 </Box>
604 )
605 }
606
607 const busy = s.busySince !== undefined
608 const ctx = s.ctxPercent ?? 0
609 const status = busy
610 ? [s.tool, span(s.now - (s.busySince ?? s.now))].filter(Boolean).join(' · ')
611 : t.idle
612 const row = (label: string, children: JSX.Element) => (
613 <Box flexDirection="row" gap={1}>
614 <Box width={labelWidth}><Text {...dim} wrap="truncate">{label}</Text></Box>
615 {children}
616 </Box>
617 )
618
619 // Apartados fijos: estado, consumo, coste y proyecto. Los demás, por prioridad, solo si caben
620 // (cada uno cuenta su cabecera y el hueco que lo separa).
621 const showCompact = ctx >= 70 || s.compactions > 0
622 const hasChanges = s.git !== undefined && s.git.changed > 0
623 const baseRows = 2
624 + (visible.usage ? 2 + s.limits.length + (showCompact ? 1 : 0) : 0)
625 + (visible.cost ? 3 + (s.lastTurn ? 1 : 0) : 0)
626 + (visible.project ? 3 + (hasChanges ? 1 : 0) : 2)
627 const files = s.files.slice(0, 4)
628 const topTools = Object.entries(s.tools).sort((a, b) => b[1] - a[1]).slice(0, 3)
629 const trendLen = Math.min(s.ctxTrend.length, barWidth + 8)
630 // Una ventana de CREW_ROWS que la rueda mueve (ui.scroll); los que no caben se cuentan en la cabecera.
631 const back = Math.min(await read($, crewBack), Math.max(0, s.crew.length - CREW_ROWS))
632 const crew = s.crew.slice(Math.max(0, s.crew.length - CREW_ROWS - back), s.crew.length - back)
633 // Sin seguimiento en vivo, solo los eventos de los que ya llegó algo: los demás se quedarían en gris.
634 const hookEvents = trackHooks ? [...configured] : Object.keys(s.hooks)
635 const wanted = {
636 agents: crew.length ? 1 + crew.length : 0,
637 hooks: hookEvents.length ? 1 + hookEvents.length : 0,
638 files: files.length ? 2 + files.length : 0,
639 activity: s.startedAt !== undefined ? 3 + (topTools.length ? 1 : 0) + (s.agents.length ? 1 : 0) : 0,
640 trend: trendLen >= 3 ? 4 : 0,
641 }
642 let room = e.props.scroll.bodyRows - baseRows
643 const fits = (rows: number) => {
644 if (rows <= 0 || rows > room) return false
645 room -= rows
646 return true
647 }
648 const show = {
649 files: visible.files && fits(wanted.files),
650 activity: visible.activity && fits(wanted.activity),
651 agents: visible.agents && fits(wanted.agents),
652 hooks: visible.hooks && fits(wanted.hooks),
653 trend: visible.trend && fits(wanted.trend),
654 }
655
656 const elapsed = s.startedAt !== undefined ? s.now - s.startedAt : 0
657 const perHour = s.costUsd !== undefined && elapsed > 5 * 60_000 ? s.costUsd / (elapsed / 3_600_000) : undefined
658 const costMax = Math.max(...s.costTrend.slice(-trendLen), 0)
659
660 // Esquina de ajustes: la versión, discreta; si hay una más nueva, un botón que la instala.
661 const hasUpdate = s.version !== undefined && s.latest !== undefined && isNewer(s.latest, s.version)
662 const corner = (
663 <Box flexDirection="row" gap={1} flexShrink={0}>
664 {hasUpdate && !s.updating ? (
665 <Button key="update" plain onPress={() => void runUpdate($)}>
666 <Text color={theme.accent} bold>{`↑ v${s.latest}`}</Text>
667 </Button>
668 ) : s.version && (
669 <Text {...dim}>{hasUpdate ? `${s.updating === 'done' ? '✓' : '↻'} v${s.latest}` : `v${s.version}`}</Text>
670 )}
671 <Button key="settings" plain onPress={() => void openSettings($)}>
672 <Text {...dim}>{`⚙ ${t.settings}`}</Text>
673 </Button>
674 </Box>
675 )
676
677 return (
678 <Box flexDirection="column" gap={1} backgroundColor={surface?.bg} paddingX={surface ? 1 : 0}
679 minHeight={surface ? e.props.scroll.bodyRows : undefined}>
680 <Box flexDirection="row" justifyContent="space-between">
681 <Text color={fg} wrap="truncate">
682 <Text color={busy ? theme.accent : muted}>● </Text>
683 <Text color={fg} bold>{prettyModel(liveModel) || '—'}</Text>
684 {liveEffort && <Text {...dim}>{` · ${liveEffort}`}</Text>}
685 </Text>
686 <Text color={busy ? theme.accent : muted}>{status}</Text>
687 </Box>
688
689 {visible.usage && <Box flexDirection="column">
690 {section(t.secUsage)}
691 {gauge(
692 t.context,
693 ctx,
694 s.ctxWindow ? `${tokens(s.ctxTokens ?? 0)}/${tokens(s.ctxWindow)}` : '',
695 )}
696 {s.limits.map(l =>
697 gauge(
698 limitLabel[l.kind] ?? l.kind,
699 l.percentUsed,
700 l.resetsAt ? `↻ ${span(Date.parse(l.resetsAt) - s.now)}` : '',
701 ),
702 )}
703 {showCompact && (
704 <Box flexDirection="row" gap={1}>
705 {ctx >= 70 && (
706 <Button key="compact" label={t.compact} variant={ctx >= 85 ? 'primary' : undefined}
707 onPress={() => void $.session.compact()} />
708 )}
709 {s.compactions > 0 && <Text {...dim}>{fill(t.compacted, s.compactions)}</Text>}
710 </Box>
711 )}
712 </Box>}
713
714 {visible.cost && <Box flexDirection="column">
715 {section(t.secCost)}
716 {row(t.total, (
717 <Text color={fg} wrap="truncate">
718 <Text color={fg} bold>{s.costUsd === undefined ? '—' : `$${s.costUsd.toFixed(2)}`}</Text>
719 {perHour !== undefined && <Text {...dim}>{` · $${perHour.toFixed(2)}/h`}</Text>}
720 </Text>
721 ))}
722 {s.lastTurn && row(lastLabel, (
723 <Text {...dim} wrap="truncate">
724 {`+$${s.lastTurn.costUsd.toFixed(2)} · ${span(s.lastTurn.durationMs)}`}
725 </Text>
726 ))}
727 </Box>}
728
729 {show.activity && (
730 <Box flexDirection="column">
731 {section(t.secActivity)}
732 {row(t.duration, (
733 <Text color={fg} wrap="truncate">
734 {span(elapsed)}
735 <Text {...dim}>{` · ${fill(t.prompts, s.prompts)}`}</Text>
736 </Text>
737 ))}
738 {topTools.length > 0 && row(t.tools, (
739 <Text color={fg} wrap="truncate">
740 {topTools.map(([name, n], i) => `${i > 0 ? ' · ' : ''}${name} ${n}`).join('')}
741 </Text>
742 ))}
743 {s.agents.length > 0 && row(t.agents, (
744 <Text color={theme.accent}>{fill(t.running, s.agents.length)}</Text>
745 ))}
746 </Box>
747 )}
748
749 {show.agents && (
750 <Box flexDirection="column">
751 {section(t.secAgents, `${s.crew.length > CREW_ROWS ? '↕ ' : ''}${s.crew.length} · ↓ ${tokens(s.crew.reduce((n, a) => n + a.out, 0))}`)}
752 {crew.map(a => {
753 const running = a.endedAt === undefined
754 const nested = a.parentId !== undefined && s.crew.some(p => p.id === a.parentId)
755 return (
756 <Box key={a.id} flexDirection="row" justifyContent="space-between" gap={1}>
757 <Box flexShrink={1}>
758 <Text color={fg} wrap="truncate">
759 <Text color={running ? theme.ok : theme.danger}>{nested ? '└ ' : '● '}</Text>
760 <Text color={fg}>{a.desc || a.type}</Text>
761 </Text>
762 </Box>
763 <Box flexShrink={0}>
764 <Text {...dim}>{agentUsage(a, s.now)}</Text>
765 </Box>
766 </Box>
767 )
768 })}
769 </Box>
770 )}
771
772 {show.hooks && (
773 <Box flexDirection="column">
774 {section(t.secHooks, `${hookEvents.length}`)}
775 {hookEvents.map(name => {
776 const hook = s.hooks[name]
777 const lit = hook !== undefined && (hook.busy || s.now - hook.at < HOOK_GLOW)
778 const state = !hook ? '—' : hook.busy ? '…' : `${hook.blocked ? `${t.blocked} · ` : ''}${fill(t.ago, span(s.now - hook.at))}`
779 return (
780 <Box key={name} flexDirection="row" justifyContent="space-between" gap={1}>
781 <Box flexShrink={1}>
782 <Text color={fg} wrap="truncate">
783 <Text color={hook?.blocked ? theme.danger : lit ? theme.ok : muted}>● </Text>
784 <Text color={fg}>{name}</Text>
785 {hook?.detail && <Text {...dim}>{` · ${hook.detail}`}</Text>}
786 </Text>
787 </Box>
788 <Box flexShrink={0}>
789 <Text {...dim}>{state}</Text>
790 </Box>
791 </Box>
792 )
793 })}
794 </Box>
795 )}
796
797 {show.trend && (
798 <Box flexDirection="column">
799 {section(fill(t.trend, trendLen))}
800 {row(t.context, <Text color={toneOf(theme, ctx)}>{spark(s.ctxTrend.slice(-trendLen), 100)}</Text>)}
801 {row(t.cost, (
802 <Text color={fg}>
803 <Text color={theme.accent}>{spark(s.costTrend.slice(-trendLen))}</Text>
804 <Text {...dim}>{` ≤ $${costMax.toFixed(2)}`}</Text>
805 </Text>
806 ))}
807 </Box>
808 )}
809
810 {show.files && (
811 <Box flexDirection="column">
812 {section(t.files, fill(t.edited, s.files.length))}
813 {files.map(f => (
814 // Los de fuera del proyecto (relPath no los acorta) van atenuados.
815 relPath(f, s.cwd) === f.replace(/\\/g, '/')
816 ? <Text {...dim} wrap="truncate-start">{f.replace(/\\/g, '/')}</Text>
817 : <Text color={fg} wrap="truncate-start">{relPath(f, s.cwd)}</Text>
818 ))}
819 </Box>
820 )}
821
822 {visible.project ? <Box flexDirection="column">
823 {section(t.secProject)}
824 <Box flexDirection="row" justifyContent="space-between" gap={1}>
825 <Box flexShrink={1}>
826 <Text color={fg} wrap="truncate-start">
827 {shortPath(s.cwd)}
828 {s.branch && <Text color={theme.branch}>{` ⎇ ${s.branch}`}</Text>}
829 </Text>
830 </Box>
831 {corner}
832 </Box>
833 {hasChanges && s.git && (
834 <Text color={fg} wrap="truncate">
835 <Text color={theme.ok}>{`+${s.git.plus}`}</Text>
836 <Text color={theme.danger}>{` −${s.git.minus}`}</Text>
837 <Text {...dim}>{` · ${fill(t.uncommitted, s.git.changed)}`}</Text>
838 </Text>
839 )}
840 </Box> : (
841 // Sin «Proyecto», el botón de ajustes se queda solo: siempre hay forma de volver.
842 <Box flexDirection="row" justifyContent="flex-end">{corner}</Box>
843 )}
844 </Box>
845 )
846 })
847}
848hooks/i18n.ts 127 lines1import type { Strings } from '../types'
2
3const en: Strings = {
4 context: 'Context', h5: '5 hours', d7: '7 days', spend: 'Spend', cost: 'Cost', last: 'last',
5 agents: 'Agents', running: '{n} running', idle: 'idle', compact: 'compact', compacted: '{n}× compacted',
6 opened: 'Cabina pane opened.', closed: 'Cabina pane closed.', describe: 'Open the Cabina pane (config: settings)',
7 settings: 'Settings', language: 'Language', theme: 'Theme', auto: 'Automatic', other: 'Other language',
8 otherHint: 'name or code, e.g. swahili', close: 'close', hint: '↑↓ choose · Enter save · Tab next · Esc close',
9 session: 'Session', prompts: '{n} prompts', tools: 'Tools', trend: 'Trend · {n} turns', files: 'Files', uncommitted: '{n} uncommitted', clean: 'clean',
10 background: 'Background', bgAuto: 'Terminal', bgDark: 'Dark', bgLight: 'Light',
11 secUsage: 'Usage', secCost: 'Cost', secActivity: 'Activity', secProject: 'Project', total: 'Total', duration: 'Duration', edited: '{n} edited',
12 sections: 'Sections', secTrend: 'Trend', secAgents: 'Agents', secHooks: 'Hooks', blocked: 'blocked', ago: '{n} ago',
13 tClassic: 'Classic', tColorblind: 'Colour-blind safe', tContrast: 'High contrast', tMono: 'Monochrome',
14 updated: 'Updated to v{n} · restart Claude Code to apply',
15}
16
17export const BUILT_IN: Record<string, Strings> = {
18 en,
19 es: {
20 context: 'Contexto', h5: '5 horas', d7: '7 días', spend: 'Gasto', cost: 'Coste', last: 'último',
21 agents: 'Agentes', running: '{n} en curso', idle: 'en espera', compact: 'compactar', compacted: '{n}× compactado',
22 opened: 'Panel Cabina abierto.', closed: 'Panel Cabina cerrado.', describe: 'Abre el panel Cabina (config: ajustes)',
23 settings: 'Ajustes', language: 'Idioma', theme: 'Tema', auto: 'Automático', other: 'Otro idioma',
24 otherHint: 'nombre o código, p. ej. swahili', close: 'cerrar', hint: '↑↓ elegir · Enter guardar · Tab siguiente · Esc cerrar',
25 session: 'Sesión', prompts: '{n} prompts', tools: 'Herram.', trend: 'Tendencia · {n} turnos', files: 'Archivos', uncommitted: '{n} sin commitear', clean: 'sin cambios',
26 background: 'Fondo', bgAuto: 'Terminal', bgDark: 'Oscuro', bgLight: 'Claro',
27 secUsage: 'Consumo', secCost: 'Coste', secActivity: 'Actividad', secProject: 'Proyecto', total: 'Total', duration: 'Duración', edited: '{n} editados',
28 sections: 'Apartados', secTrend: 'Tendencia', secAgents: 'Agentes', secHooks: 'Hooks', blocked: 'bloqueó', ago: 'hace {n}',
29 tClassic: 'Clásico', tColorblind: 'Apto para daltonismo', tContrast: 'Alto contraste', tMono: 'Monocromo',
30 updated: 'Actualizado a v{n} · reinicia Claude Code para aplicarlo',
31 },
32 pt: {
33 context: 'Contexto', h5: '5 horas', d7: '7 dias', spend: 'Gasto', cost: 'Custo', last: 'último',
34 agents: 'Agentes', running: '{n} em curso', idle: 'em espera', compact: 'compactar', compacted: '{n}× compactado',
35 opened: 'Painel Cabina aberto.', closed: 'Painel Cabina fechado.', describe: 'Abre o painel Cabina (config: definições)',
36 settings: 'Definições', language: 'Idioma', theme: 'Tema', auto: 'Automático', other: 'Outro idioma',
37 otherHint: 'nome ou código, p. ex. swahili', close: 'fechar', hint: '↑↓ escolher · Enter guardar · Tab seguinte · Esc fechar',
38 session: 'Sessão', prompts: '{n} prompts', tools: 'Ferram.', trend: 'Tendência · {n} turnos', files: 'Ficheiros', uncommitted: '{n} por commitar', clean: 'sem alterações',
39 background: 'Fundo', bgAuto: 'Terminal', bgDark: 'Escuro', bgLight: 'Claro',
40 secUsage: 'Consumo', secCost: 'Custo', secActivity: 'Atividade', secProject: 'Projeto', total: 'Total', duration: 'Duração', edited: '{n} editados',
41 sections: 'Secções', secTrend: 'Tendência', secAgents: 'Agentes', secHooks: 'Hooks', blocked: 'bloqueou', ago: 'há {n}',
42 tClassic: 'Clássico', tColorblind: 'Para daltonismo', tContrast: 'Alto contraste', tMono: 'Monocromático',
43 updated: 'Atualizado para v{n} · reinicie o Claude Code para aplicar',
44 },
45 fr: {
46 context: 'Contexte', h5: '5 heures', d7: '7 jours', spend: 'Dépense', cost: 'Coût', last: 'dernier',
47 agents: 'Agents', running: '{n} en cours', idle: 'en attente', compact: 'compacter', compacted: '{n}× compacté',
48 opened: 'Panneau Cabina ouvert.', closed: 'Panneau Cabina fermé.', describe: 'Ouvre le panneau Cabina (config : réglages)',
49 settings: 'Réglages', language: 'Langue', theme: 'Thème', auto: 'Automatique', other: 'Autre langue',
50 otherHint: 'nom ou code, ex. swahili', close: 'fermer', hint: '↑↓ choisir · Entrée valider · Tab suivant · Échap fermer',
51 session: 'Session', prompts: '{n} invites', tools: 'Outils', trend: 'Tendance · {n} tours', files: 'Fichiers', uncommitted: '{n} non commités', clean: 'propre',
52 background: 'Fond', bgAuto: 'Terminal', bgDark: 'Sombre', bgLight: 'Clair',
53 secUsage: 'Consommation', secCost: 'Coût', secActivity: 'Activité', secProject: 'Projet', total: 'Total', duration: 'Durée', edited: '{n} modifiés',
54 sections: 'Sections', secTrend: 'Tendance', secAgents: 'Agents', secHooks: 'Hooks', blocked: 'bloqué', ago: 'il y a {n}',
55 tClassic: 'Classique', tColorblind: 'Adapté au daltonisme', tContrast: 'Contraste élevé', tMono: 'Monochrome',
56 updated: "Mis à jour en v{n} · redémarrez Claude Code pour l'appliquer",
57 },
58 de: {
59 context: 'Kontext', h5: '5 Std.', d7: '7 Tage', spend: 'Ausgaben', cost: 'Kosten', last: 'zuletzt',
60 agents: 'Agenten', running: '{n} aktiv', idle: 'bereit', compact: 'kompaktieren', compacted: '{n}× kompaktiert',
61 opened: 'Cabina-Bereich geöffnet.', closed: 'Cabina-Bereich geschlossen.', describe: 'Öffnet den Cabina-Bereich (config: Einstellungen)',
62 settings: 'Einstellungen', language: 'Sprache', theme: 'Design', auto: 'Automatisch', other: 'Andere Sprache',
63 otherHint: 'Name oder Code, z. B. swahili', close: 'schließen', hint: '↑↓ wählen · Enter speichern · Tab weiter · Esc schließen',
64 session: 'Sitzung', prompts: '{n} Prompts', tools: 'Tools', trend: 'Verlauf · {n} Runden', files: 'Dateien', uncommitted: '{n} ungesichert', clean: 'sauber',
65 background: 'Hintergrund', bgAuto: 'Terminal', bgDark: 'Dunkel', bgLight: 'Hell',
66 secUsage: 'Verbrauch', secCost: 'Kosten', secActivity: 'Aktivität', secProject: 'Projekt', total: 'Gesamt', duration: 'Dauer', edited: '{n} bearbeitet',
67 sections: 'Bereiche', secTrend: 'Verlauf', secAgents: 'Agenten', secHooks: 'Hooks', blocked: 'blockiert', ago: 'vor {n}',
68 tClassic: 'Klassisch', tColorblind: 'Farbenblind-freundlich', tContrast: 'Hoher Kontrast', tMono: 'Monochrom',
69 updated: 'Auf v{n} aktualisiert · Claude Code neu starten',
70 },
71 it: {
72 context: 'Contesto', h5: '5 ore', d7: '7 giorni', spend: 'Spesa', cost: 'Costo', last: 'ultimo',
73 agents: 'Agenti', running: '{n} in corso', idle: 'in attesa', compact: 'compatta', compacted: '{n}× compattato',
74 opened: 'Pannello Cabina aperto.', closed: 'Pannello Cabina chiuso.', describe: 'Apre il pannello Cabina (config: impostazioni)',
75 settings: 'Impostazioni', language: 'Lingua', theme: 'Tema', auto: 'Automatico', other: 'Altra lingua',
76 otherHint: 'nome o codice, es. swahili', close: 'chiudi', hint: '↑↓ scegli · Invio salva · Tab avanti · Esc chiudi',
77 session: 'Sessione', prompts: '{n} prompt', tools: 'Strum.', trend: 'Andamento · {n} turni', files: 'File', uncommitted: '{n} da committare', clean: 'pulito',
78 background: 'Sfondo', bgAuto: 'Terminale', bgDark: 'Scuro', bgLight: 'Chiaro',
79 secUsage: 'Consumo', secCost: 'Costo', secActivity: 'Attività', secProject: 'Progetto', total: 'Totale', duration: 'Durata', edited: '{n} modificati',
80 sections: 'Sezioni', secTrend: 'Andamento', secAgents: 'Agenti', secHooks: 'Hook', blocked: 'bloccato', ago: '{n} fa',
81 tClassic: 'Classico', tColorblind: 'Adatto al daltonismo', tContrast: 'Alto contrasto', tMono: 'Monocromatico',
82 updated: 'Aggiornato a v{n} · riavvia Claude Code per applicarlo',
83 },
84}
85
86// Cada idioma con su propio nombre, para reconocerlo aunque el panel esté en otro.
87export const ENDONYM: Record<string, string> = {
88 es: 'Español', en: 'English', pt: 'Português', fr: 'Français', de: 'Deutsch', it: 'Italiano',
89}
90
91const ALIASES: Record<string, string> = {
92 english: 'en', inglés: 'en', ingles: 'en',
93 spanish: 'es', español: 'es', espanol: 'es', castellano: 'es',
94 portuguese: 'pt', português: 'pt', portugues: 'pt',
95 french: 'fr', français: 'fr', francais: 'fr', francés: 'fr',
96 german: 'de', deutsch: 'de', alemán: 'de',
97 italian: 'it', italiano: 'it',
98}
99
100// "es-ES", "Español", "spanish" → "es"; lo que no reconoce se devuelve en minúsculas ("swahili").
101export const normalize = (lang: string) => {
102 const l = lang.trim().toLowerCase()
103 const code = l.split(/[-_]/)[0]!
104 return ALIASES[l] ?? (BUILT_IN[code] ? code : l)
105}
106
107export const fill = (template: string, n: number | string) => template.replace('{n}', String(n))
108
109// Respuesta del modelo → Strings, o undefined si le falta alguna clave o pierde el {n}.
110export const parseStrings = (text: string): Strings | undefined => {
111 try {
112 const raw = JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1)) as Record<string, unknown>
113 for (const [key, value] of Object.entries(en)) {
114 const got = raw[key]
115 if (typeof got !== 'string' || !got.trim() || (value.includes('{n}') && !got.includes('{n}'))) return undefined
116 }
117 return raw as Strings
118 } catch {
119 return undefined
120 }
121}
122
123export const translatePrompt = (lang: string) =>
124 `Translate the values of this JSON (labels of a compact developer status pane) into the language "${lang}". ` +
125 `Keep every key, keep "{n}" as is, keep each value as short as the original. Reply with the JSON only.\n` +
126 JSON.stringify(en)
127hooks/themes.ts 121 lines1import type { Strings } from '../types'
2
3// ok/warn/danger: tramos de las barras (<60 %, <85 %, resto); accent: trabajando y agentes; branch: rama git.
4export type Theme = { ok: string; warn: string; danger: string; accent: string; branch: string }
5export type Mode = 'dark' | 'light'
6
7// Cada tema con una paleta por modo: la de fondo oscuro no se lee sobre claro, ni al revés.
8// tests/contrast.test.ts comprueba que cada color llega a 3:1 sobre su fondo (WCAG, gráficos).
9type ThemeDef = { dark: Theme; light: Theme; bg?: Partial<Record<Mode, string>> }
10
11const DEFS = {
12 // Sin fondo propio usa los colores del tema de Claude Code (CLASSIC_NATIVE); estos, sobre un fondo pintado.
13 classic: {
14 dark: { ok: '#3fb950', warn: '#d29922', danger: '#f85149', accent: '#d97757', branch: '#58a6ff' },
15 light: { ok: '#1a7f37', warn: '#9a6700', danger: '#cf222e', accent: '#b6532f', branch: '#0969da' },
16 },
17 catppuccin: {
18 dark: { ok: '#a6e3a1', warn: '#f9e2af', danger: '#f38ba8', accent: '#cba6f7', branch: '#89b4fa' },
19 light: { ok: '#3a8f27', warn: '#c27a10', danger: '#d20f39', accent: '#8839ef', branch: '#1e66f5' },
20 bg: { dark: '#1e1e2e', light: '#eff1f5' },
21 },
22 dracula: {
23 dark: { ok: '#50fa7b', warn: '#f1fa8c', danger: '#ff5555', accent: '#bd93f9', branch: '#8be9fd' },
24 light: { ok: '#14710a', warn: '#846e15', danger: '#cb3a2a', accent: '#644ac9', branch: '#036a96' },
25 bg: { dark: '#282a36', light: '#fffbeb' },
26 },
27 nord: {
28 dark: { ok: '#a3be8c', warn: '#ebcb8b', danger: '#d08770', accent: '#88c0d0', branch: '#81a1c1' },
29 light: { ok: '#4f7a34', warn: '#946200', danger: '#a3333d', accent: '#2e6d85', branch: '#4c6a92' },
30 bg: { dark: '#2e3440', light: '#eceff4' },
31 },
32 'tokyo-night': {
33 dark: { ok: '#9ece6a', warn: '#e0af68', danger: '#f7768e', accent: '#7aa2f7', branch: '#bb9af7' },
34 light: { ok: '#4f6b2f', warn: '#8c6c3e', danger: '#c4204f', accent: '#2e6bd1', branch: '#7847bd' },
35 bg: { dark: '#1a1b26', light: '#e1e2e7' },
36 },
37 gruvbox: {
38 dark: { ok: '#b8bb26', warn: '#fabd2f', danger: '#fb4934', accent: '#fe8019', branch: '#83a598' },
39 light: { ok: '#79740e', warn: '#9a6312', danger: '#9d0006', accent: '#af3a03', branch: '#076678' },
40 bg: { dark: '#282828', light: '#fbf1c7' },
41 },
42 github: {
43 dark: { ok: '#3fb950', warn: '#d29922', danger: '#f85149', accent: '#a371f7', branch: '#58a6ff' },
44 light: { ok: '#1a7f37', warn: '#9a6700', danger: '#cf222e', accent: '#8250df', branch: '#0969da' },
45 bg: { dark: '#0d1117', light: '#ffffff' },
46 },
47 solarized: {
48 dark: { ok: '#859900', warn: '#b58900', danger: '#e0524f', accent: '#268bd2', branch: '#2aa198' },
49 light: { ok: '#5f6e00', warn: '#8a6800', danger: '#c4271f', accent: '#1d6fae', branch: '#1b7a73' },
50 bg: { dark: '#002b36', light: '#fdf6e3' },
51 },
52 // Okabe-Ito: distinguible con protanopía, deuteranopía y tritanopía.
53 colorblind: {
54 dark: { ok: '#56b4e9', warn: '#e69f00', danger: '#d55e00', accent: '#cc79a7', branch: '#009e73' },
55 light: { ok: '#0072b2', warn: '#9c6500', danger: '#b34f00', accent: '#a8507f', branch: '#00795a' },
56 },
57 contrast: {
58 dark: { ok: '#00ff00', warn: '#ffff00', danger: '#ff5050', accent: '#00ffff', branch: '#ffffff' },
59 light: { ok: '#006400', warn: '#6b5200', danger: '#b00000', accent: '#0000cc', branch: '#000000' },
60 bg: { dark: '#000000', light: '#ffffff' },
61 },
62 mono: {
63 dark: { ok: '#e6e6e6', warn: '#e6e6e6', danger: '#ffffff', accent: '#e6e6e6', branch: '#b8b8b8' },
64 light: { ok: '#24292f', warn: '#24292f', danger: '#000000', accent: '#24292f', branch: '#4b535c' },
65 },
66} satisfies Record<string, ThemeDef>
67
68// Clásico sin fondo propio: los colores del tema de Claude Code, que ya se adaptan al de la terminal.
69const CLASSIC_NATIVE: Theme = { ok: 'success', warn: 'warning', danger: 'error', accent: 'claude', branch: 'suggestion' }
70
71export type ThemeId = keyof typeof DEFS
72
73export const THEME_IDS = Object.keys(DEFS) as ThemeId[]
74
75export const isThemeId = (id: string): id is ThemeId => id in DEFS
76
77const defOf = (id: string): ThemeDef => (isThemeId(id) ? DEFS[id] : DEFS.classic)
78
79export const themeOf = (id: string, mode: Mode, isPainted: boolean): Theme =>
80 !isPainted && (id === 'classic' || !isThemeId(id)) ? CLASSIC_NATIVE : defOf(id)[mode]
81
82// Los nombres propios no se traducen; los descriptivos sí.
83export const themeName = (t: Strings, id: ThemeId) =>
84 ({
85 classic: t.tClassic, colorblind: t.tColorblind, contrast: t.tContrast, mono: t.tMono,
86 catppuccin: 'Catppuccin', dracula: 'Dracula', nord: 'Nord', 'tokyo-night': 'Tokyo Night',
87 gruvbox: 'Gruvbox', github: 'GitHub', solarized: 'Solarized',
88 })[id]
89
90export const toneOf = (theme: Theme, pct: number) => (pct >= 85 ? theme.danger : pct >= 60 ? theme.warn : theme.ok)
91
92// Fondo del panel: 'auto' deja el de la terminal; oscuro y claro lo pintan, con texto a juego.
93export const BACKGROUNDS = ['auto', 'dark', 'light'] as const
94export type Background = (typeof BACKGROUNDS)[number]
95// fg: texto; sub: texto secundario (en vez de dimColor, que sobre claro se queda sin contraste); muted: raíl de las barras.
96export type Surface = { mode: Mode; bg: string; fg: string; sub: string; muted: string }
97
98// Fondos con los que se mide el contraste cuando no se pinta (el de la terminal es desconocido).
99export const NEUTRAL_BG: Record<Mode, string> = { dark: '#1e1e1e', light: '#ffffff' }
100
101export const surfaceOf = (themeId: string, background: string): Surface | undefined => {
102 if (background !== 'dark' && background !== 'light') return undefined
103 const bg = defOf(themeId).bg?.[background] ?? (background === 'dark' ? '#1e1e1e' : '#fafafa')
104 return background === 'dark'
105 ? { mode: 'dark', bg, fg: '#e6e6e6', sub: '#a8a8a8', muted: '#4a4a4a' }
106 : { mode: 'light', bg, fg: '#1f2328', sub: '#545d68', muted: '#c4c9d0' }
107}
108
109// Contraste WCAG 2.x entre dos colores #rrggbb, de 1 a 21.
110export const contrast = (a: string, b: string) => {
111 const lum = (hex: string) => {
112 const [r, g, b] = [1, 3, 5].map(i => {
113 const c = parseInt(hex.slice(i, i + 2), 16) / 255
114 return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
115 })
116 return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!
117 }
118 const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x)
119 return (hi! + 0.05) / (lo! + 0.05)
120}
121types/index.d.ts 70 lines1export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
2
3// Un subagente (o compañero de equipo) de la sesión y lo que lleva gastado.
4export type AgentStat = {
5 id: string
6 type: string
7 desc: string
8 parentId?: string
9 tokens: number
10 out: number
11 tools: number
12 startedAt: number
13 endedAt?: number
14}
15
16// Último disparo de un evento con hooks en settings.json (PreToolUse, Stop…).
17export type HookStat = { at: number; busy: boolean; blocked: boolean; detail?: string }
18
19export type Snapshot = {
20 model: string
21 effort?: string
22 ctxTokens?: number
23 ctxWindow: number
24 ctxPercent?: number
25 limits: RateLimit[]
26 costUsd?: number
27 compactions: number
28 busySince?: number
29 tool?: string
30 agents: string[]
31 crew: AgentStat[]
32 hooks: Record<string, HookStat>
33 lastTurn?: { durationMs: number; costUsd: number }
34 cwd: string
35 branch?: string
36 git?: { changed: number; plus: number; minus: number }
37 files: string[]
38 tools: Record<string, number>
39 prompts: number
40 startedAt?: number
41 ctxTrend: number[]
42 costTrend: number[]
43 termLight?: boolean
44 turnCostStart?: number
45 schema?: number
46 version?: string
47 latest?: string
48 updating?: 'running' | 'done'
49 now: number
50}
51
52export type Strings = {
53 context: string; h5: string; d7: string; spend: string; cost: string; last: string
54 agents: string; running: string; idle: string; compact: string; compacted: string
55 opened: string; closed: string; describe: string
56 settings: string; language: string; theme: string; auto: string; other: string; otherHint: string; close: string; hint: string
57 session: string; prompts: string; tools: string; trend: string; files: string; uncommitted: string; clean: string
58 background: string; bgAuto: string; bgDark: string; bgLight: string
59 secUsage: string; secCost: string; secActivity: string; secProject: string; total: string; duration: string; edited: string
60 sections: string; secTrend: string; secAgents: string; secHooks: string; blocked: string; ago: string
61 tClassic: string; tColorblind: string; tContrast: string; tMono: string
62 updated: string
63}
64
65declare module 'claude-code' {
66 interface PluginState {
67 cabina: { snap: Snapshot; text: Strings; view: 'main' | 'settings'; crewBack: number }
68 }
69}
70