SLOPSHOPPER

cabina

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.

newpaneguardcommandtoastmodel
v1.0.8MITupdated 2026-10-23SrPirson/cabina
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cabina
│ ┃ Cabina ✕ › fix the failing auth test and add an audit log call │ ┃ ● Opus 5.5 idle │ ┃ ⏺ Read(src/auth.ts) │ ┃ USAGE ─────────────────────────────────────… ⎿ Read 6 lines │ ┃ Context ━━━━━━━━━━━━━━━━━━━━ 49% 97k/200k ⏺ Update(src/auth.ts) │ ┃ 5 hours ━━━━━━━━━━━━━━━━━━━━ 31% ↻ NaNm ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ COST ──────────────────────────────────────… ⎿ 3 pass, 1 fail │ ┃ Total $0.42 · $0.84/h │ ┃ Last +$0.00 · 42s ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ACTIVITY ──────────────────────────────────… ✻ Worked for 42s · done 4:20 PM │ ┃ Duration 30m · 1 prompts │ ┃ Tools Bash 4 · Write 2 · Read 1 › /cabina │ ┃ ⎿ cabina: Cabina pane opened. │ ┃ FILES ─────────────────────────────────────… │ ┃ src/cache.ts │ ┃ src/audit.ts │ ┃ src/auth.ts │ ┃ │ ┃ PROJECT ───────────────────────────────────… │ ┃ /work/app ⎇ feat/auth-refresh settings │ ┃ +14 −3 · 2 uncommitted │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Cabina
● Opus 5.5 idle USAGE ────────────────────────────────────────────────── Context ━━━━━━━━━━━━━━━━━━━━ 49% 97k/200k 5 hours ━━━━━━━━━━━━━━━━━━━━ 31% ↻ NaNm COST ─────────────────────────────────────────────────── Total $0.42 · $0.84/h Last +$0.00 · 42s ACTIVITY ─────────────────────────────────────────────── Duration 30m · 1 prompts Tools Bash 4 · Write 2 · Read 1 FILES ───────────────────────────────────────── 3 edited src/cache.ts src/audit.ts src/auth.ts PROJECT ──────────────────────────────────────────────── /work/app ⎇ feat/auth-refresh settings +14 −3 · 2 uncommitted
README

<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>


📸 Screenshots

<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>

What it does

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.

Features

  • Usage: context window, 5‑hour and 7‑day rate limits with reset countdowns, colored green → amber → red as they fill. A compact button shows up past 70 % context.
  • Cost: session total, spend per hour and what the last turn cost and took.
  • Activity: session length, prompts sent, most used tools and running subagents.
  • Agents: each subagent with its tokens and time, green while running and red when done. Past 6, a ↕ in the heading and the mouse wheel scrolls the list.
  • Trend: sparklines of context and cost per turn.
  • Files: files edited this session, newest first. Files outside the project are dimmed.
  • Project: folder, git branch and uncommitted changes (+120 −34 · 5 uncommitted).
  • Live status: model, effort and the tool running now, with a timer.
  • 11 themes, each with a dark and a light palette, every color checked for WCAG contrast.
  • Background: keep the terminal's, or paint the pane dark or light with matching text.
  • Any language: 6 built in, any other translated once and cached.
  • Show only what you want: every section can be turned on or off.
  • Adapts to height: when the pane is short, optional sections step aside instead of being cut.

Install

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

Commands

CommandDoes
/cabinaOpens the pane
/cabina closeCloses the pane (/cabina opens it again)
/cabina configOpens 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 helpLists the commands

Subcommands also work in other languages: tema, idioma, fondo, ajustes…

Keyboard

KeyDoes
TabMoves between the pane's controls (⚙ Settings, compact)
EnterPresses the focused control
↑ ↓In settings, moves through a list
EnterIn settings, saves the highlighted choice, or shows/hides a section
EscGives the keyboard back to the prompt

Settings

Open ⚙ Settings at the bottom of the pane, or run /cabina config. Every change applies at once.

SettingValuesDefault
Languageauto, es, en, pt, fr, de, it, customauto
Other languageany language name or code, used with custom—
Themesee Themesclassic
Backgroundauto (terminal), dark, lightauto
SectionsUsage, Cost, Activity, Agents, Hooks, Trend, Files, Project: on/off eachall on
Live hookslights up settings.json hooks as they run; turns itself off when an organization plugin skips Cabina's hook events, leaving only blocks and added contexton

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
      }
    }
  }
}

Themes

Theme
classicClaude Code's own colors; follows your terminal theme
catppuccinMocha on dark, Latte on light
draculaDracula, with an Alucard-style light variant
nordPolar Night on dark, Snow Storm on light
tokyo-nightNight on dark, Day on light
gruvboxGruvbox dark and light
githubGitHub dark and light
solarizedSolarized dark and light
colorblindOkabe‑Ito palette, safe for protanopia, deuteranopia and tritanopia
contrastHigh contrast
monoMonochrome

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.

Languages

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.

Notes

  • Cabina reads what Claude Code already knows about the session. It makes no network requests of its own; the only model call is the one-off translation for a language that isn't built in.
  • Git data comes from running git status and git diff --shortstat locally in the session's folder.
  • Settings are saved in ~/.claude/settings.json under pluginConfigs["cabina@cabina"].

Author: SrPirson · MIT License

Source 4 files
hooks/register.tsx 848 lines
1import { 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}
848
hooks/i18n.ts 127 lines
1import 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)
127
hooks/themes.ts 121 lines
1import 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}
121
types/index.d.ts 70 lines
1export 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