SLOPSHOPPER

usage-weather

A weather forecast for your Claude Code usage: context, cost, cache and plan limits above the prompt. 8 languages, any time zone.

newbandguardcommandtoasttimer
v0.3.0Apache-2.0updated 2026-10-01AndersonDavi/claude-mods/usage-weather
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-weather
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ usage-weather │ ⏺ Read(src/auth.ts) │ usage-weather: English, │ ⎿ Read 6 lines │ America/Los_Angeles. Settings: │ ⏺ Update(src/auth.ts) │ /usage-weather help │ ⎿ Added 2 lines, removed 1 line ╰────────────────────────────────────────────╯ ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /usage-weather ⎿ usage-weather: Language: English · Time zone: America/Los_Angeles (UTC-7, 1:53 AM) · View: full · Theme: default ⎿ usage-weather: Usage: /usage-weather [timezone] [language] [full|mini|off] [theme] · zones · languages ⎿ usage-weather: /usage-weather bogota es · /usage-weather ja Asia/Tokyo · /usage-weather mini · /usage-weather synthwave ☁ Cloudy 49% 97.4k/200.0k █████░░░░░ · $0.42 · ⏱API 42s · cache 93% Session 5h ███░░░░░░░ 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☁ Cloudy 49% 97.4k/200.0k █████░░░░░ · $0.42 · ⏱API 42s · cache 93% Session 5h ███░░░░░░░ 31%
README

usage-weather

A weather forecast for your Claude Code usage, drawn above the prompt: context window, session cost, cache hit rate and your Pro/Max plan limits, with the time they reset in your own time zone and language.

Español: README.es.md

The usage-weather bar in English with New York time: context, cost, cache and plan limits

<sub>Previews are renders drawn from the mod's real output format (sample numbers), not screenshots.</sub>

What it shows

RowContent
1Context forecast (Clear < 25%, Cloudy < 50%, Showers < 75%, Storm < 90%, Compact soon ≥ 90%), tokens used / window and a real progress bar; equivalent API cost and the last turn's cost; approximate API time; prompt-cache hit rate of the last turn, with the whole-session rate after Σ when it differs (it drops after /compact or a pause, which is when it costs money; green ≥ 80%, yellow ≥ 50%, red below).
25-hour session limit: bar, percentage, time left and reset time.
3Weekly limit: bar, percentage, reset day and time.
  • Limit bars are green below 50%, yellow below 80%, red from 80%.
  • Pace: with at least 5 minutes of data, →100% in 2h 10m when the window would fill before it resets, pace ok otherwise.
  • Alerts (toasts), once each: context at 75% (consider /compact) and 90% (compact now); each limit at 80% and 90%.
  • Countdowns tick every minute, even if you are not typing.
  • Rows 2 and 3 only appear on a Pro/Max subscription, after the first model response. On a plain API key you get row 1.
  • "Cost" is the equivalent API cost. On a subscription it is what the session would have cost, not money spent.

Install

/plugin marketplace add AndersonDavi/claude-mods
/plugin install usage-weather@andersondavi-mods
/reload-plugins

If it does not show up, restart Claude Code. It draws in the terminal UI and in the Code tab of the desktop app.

Requirements: a Claude Code with mods support (tested on 2.1.287; check yours with claude --version). A Pro/Max subscription is needed for the two limit rows.

A mod runs code inside Claude Code with the same access Claude Code has. Read the source before you install it (hooks/register.tsx, about 800 lines; no network calls, no file access and no environment access: it only keeps its settings in Claude Code's own per-plugin store).

Settings: /usage-weather

Everything is one command. Words can come in any order; one unknown word changes nothing.

/usage-weather                       show the current settings
/usage-weather ja                    language
/usage-weather Asia/Tokyo            time zone
/usage-weather America/Bogota es     time zone + language
/usage-weather synthwave             color theme
/usage-weather mini                  one-row view (full | mini | off)
/usage-weather themes                list color themes
/usage-weather test                  preview the theme: fake readings from calm to nearly full
/usage-weather zones                 list time zones
/usage-weather languages             list languages

Settings are saved and remembered across sessions. The default language is English; the time zone is detected from your system. On the first run a short message tells you how to change them (and suggests your system language if it is one of the eight).

Languages

CodeLanguageCodeLanguage
esEspañolzh中文
enEnglishja日本語
ptPortuguêsko한국어
frFrançaisdeDeutsch

The same bar in Japanese with Tokyo time

Chinese, Japanese and Korean labels are aligned by display width. English and Spanish show times as AM/PM, the rest as 24 h.

Missing yours? A language is one table entry: see CONTRIBUTING.

Themes

13 color themes, saved like the other settings: default (your terminal's own colors), mono (no color: weight and inversion), contrast (color-blind safe), synthwave, neon, violet, ocean, sunset, forest, candy, claude (Anthropic's orange, blue and green), catppuccin, dracula. Themes use 24-bit colors; on a terminal without them the nearest colors are used.

/usage-weather neon
/usage-weather synthwave es

The 13 themes, each with the bar at three levels: Showers 62%, 5h limit at 35% and weekly limit at 82%

The test word (also demo or preview) steps the bar through five fake readings, 1 s each, from calm to nearly full, so you can see every color of the current theme; then it returns to your real numbers. Try /usage-weather neon test.

Time zones

Any of these works:

  • an IANA name, with daylight saving handled: America/Bogota, Europe/Madrid, Asia/Tokyo, America/New_York;
  • just the city: bogota, tokyo, new_york;
  • a fixed offset: UTC-5, +5:30, utc.

/usage-weather zones prints a ready list.

Limits you should know about

  • The weekly and 5-hour numbers are your whole account's, but the mod only knows the value reported with the last response of this session. If another session spends in between, the desktop app may be one point ahead.
  • Context and cost are per session.
  • ⏱API is an approximation (turn time minus time spent running tools and waiting for permission), not the exact figure from /usage.
  • The cache rate counts from when the mod loaded in the session.
  • The remaining API credit balance is not available to mods: it is not in any response and has no public endpoint.

Credits and license

Inspired by token-weather from the Claude Code mods examples (Claude Code DevRel, © 2026 Anthropic PBC, Apache-2.0). This mod extends the idea with cost, cache, plan limits, alerts, languages and time zones. See NOTICE.

Licensed under the Apache License 2.0.

Source 2 files
hooks/register.tsx 919 lines
1// usage-weather: a weather forecast for your Claude Code usage, above the prompt.
2//
3// Inspired by "token-weather" from the Claude Code mods examples
4// (Copyright 2026 Anthropic PBC, Apache-2.0). See NOTICE.
5//
6// session.start: loads settings (language, time zone, view; English by default) and this session's
7//   totals from $.store, registers /usage-weather and starts a 1-minute clock so
8//   the reset countdowns keep moving.
9// tool.call: times how long tools run in the main thread, to approximate API time.
10// turn.complete: accumulates API time and cache use, takes a reading, raises alerts.
11// session.measure: refreshes the plan limits live.
12// ui.render (AbovePrompt): 3 rows (full), 1 row (mini) or nothing (off).
13// command.run: /usage-weather [time zone] [language] [full|mini|off] | zones | languages
14
15import { atom, read, update } from 'claude-code'
16import type { EngineInterface, Register } from 'claude-code'
17
18import type { Acumulado, Ajustes, Idioma, Lectura, Limite, Modo, Muestra, Tema } from '../types'
19
20const HISTORIAL = 12
21const ANCHO_BARRA = 10
22const SESIONES_GUARDADAS = 30
23// Minimum data (ms) in a limit window before the pace is estimated.
24const RITMO_MIN_MS = 5 * 60_000
25
26const CERO: Acumulado = { duracionApiMs: 0, entrada: 0, cacheLeida: 0, cacheEscrita: 0, turnos: 0 }
27const AJUSTES_INICIALES: Ajustes = { modo: 'completo', idioma: 'en', zona: 'UTC', tema: 'default' }
28
29const lecturas = atom({ plugin: 'usage-weather', key: 'lecturas' } as const, [] as Lectura[])
30const limites = atom({ plugin: 'usage-weather', key: 'limites' } as const, [] as Limite[])
31const acumulado = atom({ plugin: 'usage-weather', key: 'acumulado' } as const, CERO)
32const avisados = atom({ plugin: 'usage-weather', key: 'avisados' } as const, [] as string[])
33// Preview of the theme: -1 = off, otherwise the step being shown.
34const demo = atom({ plugin: 'usage-weather', key: 'demo' } as const, -1)
35const ritmo = atom({ plugin: 'usage-weather', key: 'ritmo' } as const, {} as Record<string, Muestra>)
36const ajustes = atom({ plugin: 'usage-weather', key: 'ajustes' } as const, AJUSTES_INICIALES)
37
38// ───────────────────────────── Languages ─────────────────────────────
39
40interface Textos {
41  nombre: string
42  clima: readonly [string, string, string, string, string]
43  cache: string
44  sesion: string
45  semana: string
46  semanaCorta: string
47  dias: readonly string[]
48  ampm: readonly [string, string] | null
49  cuenta: (dur: string, hora: string) => string
50  reinicia: string
51  ritmoOk: string
52  llena: (dur: string) => string
53  ctx75: (n: number) => string
54  ctx90: (n: number) => string
55  lblIdioma: string
56  lblZona: string
57  lblVista: string
58  lblTema: string
59  vistas: Record<Modo, string>
60  zonasNota: (iana: boolean) => string
61  invalido: (t: string) => string
62  uso: string
63  sugerir: string
64}
65
66const IDIOMAS: Record<Idioma, Textos> = {
67  es: {
68    nombre: 'Español',
69    clima: ['Despejado', 'Nublado', 'Lluvia', 'Tormenta', 'Compacta ya'],
70    cache: 'caché',
71    sesion: 'Sesión 5h',
72    semana: 'Semana',
73    semanaCorta: 'sem',
74    dias: ['dom', 'lun', 'mar', 'mié', 'jue', 'vie', 'sáb'],
75    ampm: ['am', 'pm'],
76    cuenta: (d, h) => `en ${d} (${h})`,
77    reinicia: 'reinicia',
78    ritmoOk: 'ritmo ok',
79    llena: d => `→100% en ${d}`,
80    ctx75: n => `Contexto al ${n}%: considera /compact`,
81    ctx90: n => `Contexto al ${n}%: compacta ya (/compact)`,
82    lblIdioma: 'Idioma',
83    lblZona: 'Zona horaria',
84    lblVista: 'Vista',
85    lblTema: 'Tema',
86    vistas: { completo: 'completo', mini: 'mini', oculto: 'oculto' },
87    zonasNota: iana => `También: UTC-5, +5:30${iana ? ' o cualquier nombre IANA (ej. Europe/Zurich)' : ''}`,
88    invalido: t => `No reconozco "${t}". Prueba /usage-weather zonas o /usage-weather idiomas.`,
89    uso: 'Uso: /usage-weather [zona] [idioma] [completo|mini|oculto] [theme] · zonas · idiomas',
90    sugerir: 'Para español:',
91  },
92  en: {
93    nombre: 'English',
94    clima: ['Clear', 'Cloudy', 'Showers', 'Storm', 'Compact soon'],
95    cache: 'cache',
96    sesion: 'Session 5h',
97    semana: 'Week',
98    semanaCorta: 'wk',
99    dias: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
100    ampm: ['AM', 'PM'],
101    cuenta: (d, h) => `in ${d} (${h})`,
102    reinicia: 'resets',
103    ritmoOk: 'pace ok',
104    llena: d => `→100% in ${d}`,
105    ctx75: n => `Context at ${n}%: consider /compact`,
106    ctx90: n => `Context at ${n}%: compact now (/compact)`,
107    lblIdioma: 'Language',
108    lblZona: 'Time zone',
109    lblVista: 'View',
110    lblTema: 'Theme',
111    vistas: { completo: 'full', mini: 'mini', oculto: 'off' },
112    zonasNota: iana => `Also: UTC-5, +5:30${iana ? ', or any IANA name (e.g. Europe/Zurich)' : ''}`,
113    invalido: t => `Unrecognized "${t}". Try /usage-weather zones or /usage-weather languages.`,
114    uso: 'Usage: /usage-weather [timezone] [language] [full|mini|off] [theme] · zones · languages',
115    sugerir: 'For English:',
116  },
117  pt: {
118    nombre: 'Português',
119    clima: ['Limpo', 'Nublado', 'Chuva', 'Tempestade', 'Compacte já'],
120    cache: 'cache',
121    sesion: 'Sessão 5h',
122    semana: 'Semana',
123    semanaCorta: 'sem',
124    dias: ['dom', 'seg', 'ter', 'qua', 'qui', 'sex', 'sáb'],
125    ampm: null,
126    cuenta: (d, h) => `em ${d} (${h})`,
127    reinicia: 'reinicia',
128    ritmoOk: 'ritmo ok',
129    llena: d => `→100% em ${d}`,
130    ctx75: n => `Contexto em ${n}%: considere /compact`,
131    ctx90: n => `Contexto em ${n}%: compacte agora (/compact)`,
132    lblIdioma: 'Idioma',
133    lblZona: 'Fuso horário',
134    lblVista: 'Vista',
135    lblTema: 'Tema',
136    vistas: { completo: 'completo', mini: 'mini', oculto: 'oculto' },
137    zonasNota: iana => `Também: UTC-5, +5:30${iana ? ' ou qualquer nome IANA (ex.: Europe/Zurich)' : ''}`,
138    invalido: t => `Não reconheço "${t}". Tente /usage-weather zonas ou /usage-weather idiomas.`,
139    uso: 'Uso: /usage-weather [fuso] [idioma] [completo|mini|oculto] [theme] · zonas · idiomas',
140    sugerir: 'Para português:',
141  },
142  fr: {
143    nombre: 'Français',
144    clima: ['Dégagé', 'Nuageux', 'Averses', 'Orage', 'Compactez'],
145    cache: 'cache',
146    sesion: 'Session 5h',
147    semana: 'Semaine',
148    semanaCorta: 'sem',
149    dias: ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam'],
150    ampm: null,
151    cuenta: (d, h) => `dans ${d} (${h})`,
152    reinicia: 'réinit.',
153    ritmoOk: 'rythme ok',
154    llena: d => `→100% dans ${d}`,
155    ctx75: n => `Contexte à ${n}% : pensez à /compact`,
156    ctx90: n => `Contexte à ${n}% : compactez maintenant (/compact)`,
157    lblIdioma: 'Langue',
158    lblZona: 'Fuseau horaire',
159    lblVista: 'Affichage',
160    lblTema: 'Thème',
161    vistas: { completo: 'complet', mini: 'mini', oculto: 'masqué' },
162    zonasNota: iana => `Aussi : UTC-5, +5:30${iana ? ', ou tout nom IANA (ex. Europe/Zurich)' : ''}`,
163    invalido: t => `"${t}" non reconnu. Essayez /usage-weather zones ou /usage-weather languages.`,
164    uso: 'Usage : /usage-weather [fuseau] [langue] [complet|mini|masqué] [theme] · zones · languages',
165    sugerir: 'En français :',
166  },
167  de: {
168    nombre: 'Deutsch',
169    clima: ['Klar', 'Wolkig', 'Schauer', 'Sturm', 'Bald voll'],
170    cache: 'Cache',
171    sesion: 'Sitzung 5h',
172    semana: 'Woche',
173    semanaCorta: 'Wo',
174    dias: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'],
175    ampm: null,
176    cuenta: (d, h) => `in ${d} (${h})`,
177    reinicia: 'Reset',
178    ritmoOk: 'Tempo ok',
179    llena: d => `→100% in ${d}`,
180    ctx75: n => `Kontext bei ${n}%: /compact erwägen`,
181    ctx90: n => `Kontext bei ${n}%: jetzt /compact`,
182    lblIdioma: 'Sprache',
183    lblZona: 'Zeitzone',
184    lblVista: 'Ansicht',
185    lblTema: 'Design',
186    vistas: { completo: 'voll', mini: 'mini', oculto: 'aus' },
187    zonasNota: iana => `Auch: UTC-5, +5:30${iana ? ' oder jeder IANA-Name (z. B. Europe/Zurich)' : ''}`,
188    invalido: t => `"${t}" nicht erkannt. Versuche /usage-weather zones oder /usage-weather languages.`,
189    uso: 'Nutzung: /usage-weather [zeitzone] [sprache] [voll|mini|aus] [theme] · zones · languages',
190    sugerir: 'Auf Deutsch:',
191  },
192  zh: {
193    nombre: '中文',
194    clima: ['晴', '多云', '阵雨', '暴风雨', '该压缩了'],
195    cache: '缓存',
196    sesion: '会话 5h',
197    semana: '本周',
198    semanaCorta: '周',
199    dias: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'],
200    ampm: null,
201    cuenta: (d, h) => `${d}后 (${h})`,
202    reinicia: '重置',
203    ritmoOk: '节奏正常',
204    llena: d => `→100% 还需 ${d}`,
205    ctx75: n => `上下文已达 ${n}%:建议 /compact`,
206    ctx90: n => `上下文已达 ${n}%:请立即 /compact`,
207    lblIdioma: '语言',
208    lblZona: '时区',
209    lblVista: '视图',
210    lblTema: '主题',
211    vistas: { completo: '完整', mini: '迷你', oculto: '隐藏' },
212    zonasNota: iana => `也可用:UTC-5、+5:30${iana ? ',或任意 IANA 名称(如 Europe/Zurich)' : ''}`,
213    invalido: t => `无法识别 "${t}"。试试 /usage-weather zones 或 /usage-weather languages。`,
214    uso: '用法:/usage-weather [时区] [语言] [完整|mini|隐藏] [theme] · zones · languages',
215    sugerir: '中文:',
216  },
217  ja: {
218    nombre: '日本語',
219    clima: ['快晴', '曇り', '雨', '嵐', '要圧縮'],
220    cache: 'キャッシュ',
221    sesion: 'セッション 5h',
222    semana: '週間',
223    semanaCorta: '週',
224    dias: ['日', '月', '火', '水', '木', '金', '土'],
225    ampm: null,
226    cuenta: (d, h) => `あと${d} (${h})`,
227    reinicia: 'リセット',
228    ritmoOk: 'ペース良好',
229    llena: d => `→100%まで ${d}`,
230    ctx75: n => `コンテキスト ${n}%:/compact を検討`,
231    ctx90: n => `コンテキスト ${n}%:今すぐ /compact`,
232    lblIdioma: '言語',
233    lblZona: 'タイムゾーン',
234    lblVista: '表示',
235    lblTema: 'テーマ',
236    vistas: { completo: 'フル', mini: 'ミニ', oculto: '非表示' },
237    zonasNota: iana => `他に:UTC-5、+5:30${iana ? '、または任意の IANA 名 (例 Europe/Zurich)' : ''}`,
238    invalido: t => `"${t}" を認識できません。/usage-weather zones か /usage-weather languages を試してください。`,
239    uso: '使い方:/usage-weather [タイムゾーン] [言語] [フル|mini|非表示] [theme] · zones · languages',
240    sugerir: '日本語は:',
241  },
242  ko: {
243    nombre: '한국어',
244    clima: ['맑음', '흐림', '소나기', '폭풍', '압축 필요'],
245    cache: '캐시',
246    sesion: '세션 5h',
247    semana: '주간',
248    semanaCorta: '주',
249    dias: ['일', '월', '화', '수', '목', '금', '토'],
250    ampm: null,
251    cuenta: (d, h) => `${d} 후 (${h})`,
252    reinicia: '초기화',
253    ritmoOk: '페이스 양호',
254    llena: d => `→100%까지 ${d}`,
255    ctx75: n => `컨텍스트 ${n}%: /compact 권장`,
256    ctx90: n => `컨텍스트 ${n}%: 지금 /compact`,
257    lblIdioma: '언어',
258    lblZona: '시간대',
259    lblVista: '보기',
260    lblTema: '테마',
261    vistas: { completo: '전체', mini: '미니', oculto: '숨김' },
262    zonasNota: iana => `또는: UTC-5, +5:30${iana ? ', 또는 모든 IANA 이름 (예: Europe/Zurich)' : ''}`,
263    invalido: t => `"${t}"을(를) 인식하지 못했습니다. /usage-weather zones 또는 /usage-weather languages 를 시도해 보세요.`,
264    uso: '사용법: /usage-weather [시간대] [언어] [전체|mini|숨김] [theme] · zones · languages',
265    sugerir: '한국어:',
266  },
267}
268
269const CODIGOS = Object.keys(IDIOMAS) as Idioma[]
270const ALIAS_IDIOMA: Record<string, Idioma> = {
271  spanish: 'es', espanol: 'es', english: 'en', portuguese: 'pt', portugues: 'pt', french: 'fr',
272  francais: 'fr', german: 'de', chinese: 'zh', japanese: 'ja', korean: 'ko',
273  jp: 'ja', cn: 'zh', kr: 'ko', br: 'pt',
274}
275
276// Words that pick a view; the localized names of every language are added below.
277const MODOS: Record<string, Modo> = {
278  full: 'completo', completo: 'completo', on: 'completo', show: 'completo',
279  mini: 'mini',
280  off: 'oculto', oculto: 'oculto', hidden: 'oculto', hide: 'oculto',
281}
282for (const idioma of CODIGOS) {
283  for (const [modo, nombre] of Object.entries(IDIOMAS[idioma].vistas)) {
284    MODOS[nombre.toLowerCase()] = modo as Modo
285  }
286}
287const PALABRAS_TEMAS = ['themes', 'theme', 'temas', 'tema', 'colors', 'colours', 'colores']
288const PALABRAS_DEMO = ['test', 'demo', 'preview', 'probar', 'prueba']
289const PALABRAS_AYUDA = ['help', 'ayuda', 'ajuda', 'aide', 'hilfe', '?', '-h', '--help']
290const PALABRAS_ZONAS = ['zones', 'zone', 'zonas', 'zona', 'tz', 'timezones', 'timezone']
291const PALABRAS_IDIOMAS = ['languages', 'language', 'idiomas', 'idioma', 'lang', 'langs']
292
293function resolverIdioma(texto: string): Idioma | undefined {
294  const p = texto.toLowerCase()
295  if ((CODIGOS as string[]).includes(p)) return p as Idioma
296  const alias = ALIAS_IDIOMA[p]
297  if (alias) return alias
298  for (const c of CODIGOS) {
299    if (IDIOMAS[c].nombre.toLowerCase() === p) return c
300  }
301  const base = p.split(/[-_]/)[0] ?? ''
302  if (/^[a-z]{2}[-_][a-z]{2}$/.test(p) && (CODIGOS as string[]).includes(base)) return base as Idioma
303  return undefined
304}
305
306// ───────────────────────────── Time zones ─────────────────────────────
307
308// Standard (non-DST) offsets in minutes: the picker list, and the fallback when
309// the runtime has no Intl.
310const ZONAS: readonly { id: string; min: number }[] = [
311  { id: 'America/Bogota', min: -300 }, { id: 'America/Lima', min: -300 }, { id: 'America/Panama', min: -300 },
312  { id: 'America/Mexico_City', min: -360 }, { id: 'America/Caracas', min: -240 }, { id: 'America/La_Paz', min: -240 },
313  { id: 'America/Santiago', min: -240 }, { id: 'America/Argentina/Buenos_Aires', min: -180 },
314  { id: 'America/Sao_Paulo', min: -180 }, { id: 'America/Montevideo', min: -180 },
315  { id: 'America/New_York', min: -300 }, { id: 'America/Toronto', min: -300 }, { id: 'America/Chicago', min: -360 },
316  { id: 'America/Denver', min: -420 }, { id: 'America/Los_Angeles', min: -480 },
317  { id: 'Europe/London', min: 0 }, { id: 'Europe/Lisbon', min: 0 }, { id: 'Europe/Madrid', min: 60 },
318  { id: 'Europe/Paris', min: 60 }, { id: 'Europe/Berlin', min: 60 }, { id: 'Europe/Rome', min: 60 },
319  { id: 'Europe/Athens', min: 120 }, { id: 'Europe/Istanbul', min: 180 }, { id: 'Europe/Moscow', min: 180 },
320  { id: 'Africa/Lagos', min: 60 }, { id: 'Africa/Cairo', min: 120 }, { id: 'Africa/Johannesburg', min: 120 },
321  { id: 'Africa/Nairobi', min: 180 },
322  { id: 'Asia/Dubai', min: 240 }, { id: 'Asia/Karachi', min: 300 }, { id: 'Asia/Kolkata', min: 330 },
323  { id: 'Asia/Dhaka', min: 360 }, { id: 'Asia/Bangkok', min: 420 }, { id: 'Asia/Jakarta', min: 420 },
324  { id: 'Asia/Shanghai', min: 480 }, { id: 'Asia/Hong_Kong', min: 480 }, { id: 'Asia/Singapore', min: 480 },
325  { id: 'Asia/Manila', min: 480 }, { id: 'Asia/Taipei', min: 480 }, { id: 'Asia/Seoul', min: 540 },
326  { id: 'Asia/Tokyo', min: 540 },
327  { id: 'Australia/Perth', min: 480 }, { id: 'Australia/Sydney', min: 600 }, { id: 'Pacific/Auckland', min: 720 },
328  { id: 'Pacific/Honolulu', min: -600 },
329]
330
331// "UTC-5", "-5", "+5:30", "gmt+0530" → milliseconds; undefined if it is not an offset.
332function parseOffset(texto: string): number | undefined {
333  const m = /^(?:utc|gmt)?\s*([+-])\s*(\d{1,2})(?::?(\d{2}))?$/i.exec(texto.trim())
334  if (!m) return undefined
335  const horas = Number(m[2])
336  const minutos = Number(m[3] ?? 0)
337  if (horas > 14 || minutos > 59) return undefined
338  return (m[1] === '-' ? -1 : 1) * (horas * 60 + minutos) * 60_000
339}
340
341function etiquetaOffset(ms: number) {
342  const min = Math.abs(ms) / 60_000
343  const horas = Math.floor(min / 60)
344  const resto = min % 60
345  return `UTC${ms < 0 ? '-' : '+'}${horas}${resto ? `:${String(resto).padStart(2, '0')}` : ''}`
346}
347
348// The IANA name as the runtime spells it, or undefined (no such zone, or no Intl).
349function canonica(id: string): string | undefined {
350  try {
351    return new Intl.DateTimeFormat('en-US', { timeZone: id }).resolvedOptions().timeZone
352  } catch {
353    return undefined
354  }
355}
356
357function offsetIana(zona: string, tMs: number): number | undefined {
358  try {
359    const partes = new Intl.DateTimeFormat('en-US', {
360      timeZone: zona, hourCycle: 'h23', year: 'numeric', month: 'numeric', day: 'numeric',
361      hour: 'numeric', minute: 'numeric', second: 'numeric',
362    }).formatToParts(new Date(tMs))
363    const v: Record<string, number> = {}
364    for (const p of partes) {
365      if (p.type !== 'literal') v[p.type] = Number(p.value)
366    }
367    const local = Date.UTC(v.year ?? 0, (v.month ?? 1) - 1, v.day ?? 1, v.hour ?? 0, v.minute ?? 0, v.second ?? 0)
368    return local - Math.floor(tMs / 1000) * 1000
369  } catch {
370    return undefined
371  }
372}
373
374// Offset of `zona` from UTC at the instant `tMs`, in ms (DST included when Intl is there).
375function offsetMs(zona: string, tMs: number): number {
376  const fijo = parseOffset(zona)
377  if (fijo !== undefined) return fijo
378  if (zona === 'UTC') return 0
379  return offsetIana(zona, tMs) ?? (ZONAS.find(z => z.id === zona)?.min ?? 0) * 60_000
380}
381
382// What the person typed → the zone as stored: 'UTC', 'UTC-5', an IANA name, or undefined.
383function resolverZona(texto: string): string | undefined {
384  const crudo = texto.trim()
385  if (/^(utc|gmt|z)$/i.test(crudo)) return 'UTC'
386  const fijo = parseOffset(crudo)
387  if (fijo !== undefined) return etiquetaOffset(fijo)
388  const canon = canonica(crudo)
389  if (canon) return canon
390  const ciudad = crudo.toLowerCase().replace(/_/g, ' ')
391  return ZONAS.find(z => z.id.toLowerCase().replace(/_/g, ' ').endsWith(`/${ciudad}`))?.id
392}
393
394function zonaSistema(): string {
395  try {
396    return new Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
397  } catch {
398    return 'UTC'
399  }
400}
401
402// The system language, only to suggest it on a first run; never used to pick the display language.
403function idiomaDelSistema(): Idioma | undefined {
404  try {
405    const base = (new Intl.DateTimeFormat().resolvedOptions().locale || '').toLowerCase().split(/[-_.]/)[0] ?? ''
406    return (CODIGOS as string[]).includes(base) ? (base as Idioma) : undefined
407  } catch {
408    return undefined
409  }
410}
411
412function textoZonas(t: Textos) {
413  const grupos = new Map<string, string[]>()
414  for (const z of ZONAS) {
415    const i = z.id.indexOf('/')
416    const region = z.id.slice(0, i)
417    const lista = grupos.get(region) ?? []
418    lista.push(z.id.slice(i + 1))
419    grupos.set(region, lista)
420  }
421  const filas = [...grupos].map(([region, lugares]) => `${region}: ${lugares.join(', ')}`)
422  return `${t.lblZona}:\n${filas.join('\n')}\nUTC\n${t.zonasNota(canonica('America/Bogota') !== undefined)}`
423}
424
425function textoIdiomas() {
426  return CODIGOS.map(c => `${c}  ${IDIOMAS[c].nombre}`).join('\n')
427}
428
429// Fake readings the preview steps through, from calm to nearly full.
430const PASOS_DEMO = [
431  { pct: 8, usd: 0.4, cinco: 5, semana: 12, cache: 95 },
432  { pct: 30, usd: 1.73, cinco: 35, semana: 34, cache: 92 },
433  { pct: 55, usd: 3.9, cinco: 60, semana: 58, cache: 70 },
434  { pct: 78, usd: 7.8, cinco: 82, semana: 79, cache: 45 },
435  { pct: 94, usd: 12.9, cinco: 96, semana: 93, cache: 8 },
436]
437const DEMO_PASO_MS = 1000
438
439// ───────────────────────────── Themes ─────────────────────────────
440
441// How one role is drawn. `color` is a raw color (hex) or an ANSI name; mono
442// themes use weight and inversion instead of color.
443type Estilo = { color?: string; bold?: boolean; inverse?: boolean }
444type Paleta = { ok: Estilo; warn: Estilo; bad: Estilo; costo: Estilo; api: Estilo; neutro: Estilo }
445
446const paleta = (ok: string, warn: string, bad: string, costo: string, api: string, neutro = 'gray'): Paleta => ({
447  ok: { color: ok }, warn: { color: warn }, bad: { color: bad }, costo: { color: costo }, api: { color: api }, neutro: { color: neutro },
448})
449
450// The first theme uses the terminal's own ANSI colors, so it follows its palette.
451const TEMAS: Record<Tema, Paleta> = {
452  default: paleta('green', 'yellow', 'red', 'cyan', 'magenta'),
453  mono: {
454    ok: {}, warn: { bold: true }, bad: { bold: true, inverse: true }, costo: { bold: true }, api: {}, neutro: {},
455  },
456  // Okabe-Ito colors: told apart with every common kind of color blindness.
457  contrast: paleta('#56b4e9', '#f0e442', '#d55e00', '#ffffff', '#cc79a7', '#aaaaaa'),
458  synthwave: paleta('#72f1b8', '#fede5d', '#ff3a8c', '#36f9f6', '#ff7edb', '#8f7fb8'),
459  neon: paleta('#39ff14', '#fff200', '#ff073a', '#00e5ff', '#ff00ff', '#7a7a9a'),
460  violet: paleta('#b794f6', '#f0abfc', '#ff5c8a', '#a78bfa', '#e879f9', '#8b7fb0'),
461  ocean: paleta('#5eead4', '#fde68a', '#fb7185', '#38bdf8', '#818cf8', '#7b8fa6'),
462  sunset: paleta('#ffd166', '#ff9a3c', '#ef476f', '#ffb4a2', '#c77dff', '#a8878a'),
463  forest: paleta('#8bd450', '#e6c94a', '#e5603f', '#5fc9a0', '#b6a06a', '#7e8f7a'),
464  candy: paleta('#a8e6cf', '#ffd3b6', '#ff8b94', '#a0d8ef', '#d4a5ff', '#b0a8b9'),
465  // Anthropic's palette: orange #d97757, blue #6a9bcc, green #788c5d.
466  claude: paleta('#8aa66b', '#e3b25c', '#d6504a', '#d97757', '#6a9bcc', '#87867f'),
467  catppuccin: paleta('#a6e3a1', '#f9e2af', '#f38ba8', '#89dceb', '#cba6f7', '#6c7086'),
468  dracula: paleta('#50fa7b', '#f1fa8c', '#ff5555', '#8be9fd', '#bd93f9', '#6272a4'),
469}
470const NOMBRES_TEMAS = Object.keys(TEMAS) as Tema[]
471
472function resolverTema(texto: string): Tema | undefined {
473  const p = texto.toLowerCase()
474  return (NOMBRES_TEMAS as string[]).includes(p) ? (p as Tema) : undefined
475}
476
477function textoTemas() {
478  return NOMBRES_TEMAS.join(', ')
479}
480
481function ajustesValidos(x: unknown): Ajustes | undefined {
482  if (!x || typeof x !== 'object') return undefined
483  const a = x as Partial<Ajustes>
484  const zona = typeof a.zona === 'string' ? resolverZona(a.zona) : undefined
485  if (!a.idioma || !(a.idioma in IDIOMAS) || !zona) return undefined
486  const modo: Modo = a.modo === 'mini' || a.modo === 'oculto' ? a.modo : 'completo'
487  const tema = typeof a.tema === 'string' ? resolverTema(a.tema) ?? 'default' : 'default'
488  return { modo, idioma: a.idioma, zona, tema }
489}
490
491// ───────────────────────────── Hooks ─────────────────────────────
492
493const BANDAS = [
494  { hasta: 25, icono: '☀', nivel: 'ok' as const },
495  { hasta: 50, icono: '☁', nivel: 'ok' as const },
496  { hasta: 75, icono: '☂', nivel: 'warn' as const },
497  { hasta: 90, icono: '⚡', nivel: 'bad' as const },
498  { hasta: Infinity, icono: '🔥', nivel: 'bad' as const },
499]
500
501export const register: Register = on => {
502  // Time with tools running in the main thread during the current turn.
503  let herramientasMs = 0
504  let activas = 0
505  let desde = 0
506  let reloj: { cancel: () => void } | undefined
507
508  on('session.start', async ($, e, next) => {
509    const result = await next(e)
510    await $.command.register({
511      name: 'usage-weather',
512      description: 'Usage weather bar: /usage-weather [timezone] [language] [theme] [full|mini|off]',
513    })
514    const guardado = ajustesValidos(await $.store.get('ajustes'))
515    const inicial: Ajustes = guardado ?? {
516      modo: 'completo',
517      idioma: 'en',
518      zona: resolverZona(zonaSistema()) ?? 'UTC',
519      tema: 'default',
520    }
521    await update($, ajustes, () => inicial)
522    if (!guardado) {
523      await $.store.set('ajustes', inicial)
524      const sugerido = idiomaDelSistema()
525      const pista = sugerido && sugerido !== inicial.idioma ? ` · ${IDIOMAS[sugerido].sugerir} /usage-weather ${sugerido}` : ''
526      $.ui.toast(
527        `usage-weather: ${IDIOMAS[inicial.idioma].nombre}, ${inicial.zona}. Settings: /usage-weather help${pista}`,
528        { timeoutMs: 20_000 },
529      )
530    }
531    const sesion = await $.store.get(`sesion:${await $.session.id()}`)
532    if (sesion && typeof sesion === 'object') {
533      await update($, acumulado, a => (a.turnos > 0 ? a : { ...CERO, ...(sesion as Acumulado) }))
534    }
535    reloj?.cancel()
536    reloj = $.clock.every(60_000, () => void $.ui.invalidate('ui.render'))
537    await tomarLectura($)
538    return result
539  })
540
541  on('command.run', { command: 'usage-weather' }, async ($, e) => {
542    const actual = await read($, ajustes)
543    const t = IDIOMAS[actual.idioma]
544    const todos = e.args.trim().split(/\s+/).filter(Boolean)
545    const pideDemo = todos.some(p => PALABRAS_DEMO.includes(p.toLowerCase()))
546    const tokens = todos.filter(p => !PALABRAS_DEMO.includes(p.toLowerCase()))
547    const palabras = tokens.map(p => p.toLowerCase())
548
549    if (pideDemo && tokens.length === 0) {
550      await empezarDemo($)
551      return { text: `${t.lblTema}: ${actual.tema} · demo ${PASOS_DEMO.length} × ${DEMO_PASO_MS / 1000}s` }
552    }
553
554    if (tokens.length === 0 || palabras.some(p => PALABRAS_AYUDA.includes(p))) {
555      const ejemplos = '/usage-weather bogota es  ·  /usage-weather ja Asia/Tokyo  ·  /usage-weather mini  ·  /usage-weather synthwave'
556      return { text: `${await resumen($, actual)}\n${t.uso}\n${ejemplos}` }
557    }
558    if (palabras.some(p => PALABRAS_ZONAS.includes(p))) {
559      return { text: textoZonas(t) }
560    }
561    if (palabras.some(p => PALABRAS_TEMAS.includes(p))) {
562      return { text: textoTemas() }
563    }
564    if (palabras.some(p => PALABRAS_IDIOMAS.includes(p))) {
565      return { text: textoIdiomas() }
566    }
567
568    // Each word is a view, a language or a time zone, in any order; one bad word
569    // changes nothing.
570    const siguiente: Ajustes = { ...actual }
571    for (const token of tokens) {
572      const modo = MODOS[token.toLowerCase()]
573      const tema = modo ? undefined : resolverTema(token)
574      const idioma = modo || tema ? undefined : resolverIdioma(token)
575      const zona = modo || tema || idioma ? undefined : resolverZona(token)
576      if (modo) siguiente.modo = modo
577      else if (tema) siguiente.tema = tema
578      else if (idioma) siguiente.idioma = idioma
579      else if (zona) siguiente.zona = zona
580      else return { text: t.invalido(token) }
581    }
582    await update($, ajustes, () => siguiente)
583    await $.store.set('ajustes', siguiente)
584    if (pideDemo) await empezarDemo($)
585    return { text: `✔ ${await resumen($, siguiente)}` }
586  })
587
588  on('tool.call', async ($, e, next) => {
589    if (e.agentId) {
590      return next(e)
591    }
592    if (activas === 0) {
593      desde = await $.clock.now()
594    }
595    activas += 1
596    try {
597      return await next(e)
598    } finally {
599      activas -= 1
600      if (activas === 0) {
601        herramientasMs += (await $.clock.now()) - desde
602      }
603    }
604  })
605
606  on('turn.complete', async ($, e, next) => {
607    const result = await next(e)
608    const u = e.usage
609    const apiMs = e.agentId ? 0 : Math.max(0, e.durationMs - herramientasMs)
610    if (!e.agentId) {
611      herramientasMs = 0
612    }
613    const nuevo = await update($, acumulado, a => ({
614      duracionApiMs: a.duracionApiMs + apiMs,
615      entrada: a.entrada + (u?.input_tokens ?? 0),
616      cacheLeida: a.cacheLeida + (u?.cache_read_input_tokens ?? 0),
617      cacheEscrita: a.cacheEscrita + (u?.cache_creation_input_tokens ?? 0),
618      turnos: a.turnos + (e.agentId ? 0 : 1),
619      ultimoCache: e.agentId ? a.ultimoCache : porcentajeCache(u),
620    }))
621    await guardarSesion($, nuevo)
622    if (!e.agentId) {
623      await tomarLectura($)
624    }
625    return result
626  })
627
628  on('session.measure', async ($, e, next) => {
629    if (e.changed.includes('rateLimits')) {
630      await registrarLimites($, e.rateLimits)
631    }
632    return next(e)
633  })
634
635  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
636    const todas = await read($, lecturas)
637    const aj = await read($, ajustes)
638    const paso = await read($, demo)
639    const enDemo = paso >= 0 && paso < PASOS_DEMO.length
640    if (e.props.hasSurvey || (todas.length === 0 && !enDemo) || (aj.modo === 'oculto' && !enDemo)) {
641      return next(e)
642    }
643    const t = IDIOMAS[aj.idioma]
644    const { Box, Text } = $.ui.resolve(e)
645    const ahoraMs = await $.clock.now()
646    const columnas = e.props.bodyColumns ?? 80
647    const falso = enDemo ? PASOS_DEMO[paso]! : undefined
648    const lims: readonly Limite[] = falso
649      ? [
650          { kind: 'five_hour', percentUsed: falso.cinco, resetsAt: new Date(ahoraMs + 200 * 60_000).toISOString() },
651          { kind: 'seven_day', percentUsed: falso.semana, resetsAt: new Date(ahoraMs + 2 * 86_400_000).toISOString() },
652        ]
653      : await read($, limites)
654    const acc = falso ? { ...(await read($, acumulado)), ultimoCache: falso.cache } : await read($, acumulado)
655    const muestras = falso ? {} : await read($, ritmo)
656
657    const ahora: Lectura = falso
658      ? { tokens: falso.pct * 10_000, ventana: 1_000_000, porcentaje: falso.pct, usd: falso.usd }
659      : todas[todas.length - 1]!
660    const antes = !falso && todas.length > 1 ? todas[todas.length - 2] : undefined
661    const pal = TEMAS[aj.tema] ?? TEMAS.default
662    const nivel = (n: 'ok' | 'warn' | 'bad') => pal[n]
663    const colorUso = (pct: number) => nivel(pct < 50 ? 'ok' : pct < 80 ? 'warn' : 'bad')
664    const banda = bandaPara(ahora.porcentaje)
665    const estiloBanda = nivel(banda.nivel)
666    const totalEntrada = acc.entrada + acc.cacheLeida + acc.cacheEscrita
667    const cachePct = falso ? 99 : totalEntrada > 0 ? Math.round((acc.cacheLeida / totalEntrada) * 100) : null
668    // Main number = last turn (drops after /compact or a long pause); Σ = whole session.
669    const ultimo = acc.ultimoCache ?? null
670    const cachePrincipal = ultimo ?? cachePct
671    const estiloCache = cachePrincipal === null ? pal.neutro : nivel(cachePrincipal >= 80 ? 'ok' : cachePrincipal >= 50 ? 'warn' : 'bad')
672    const delta = antes ? ahora.usd - antes.usd : 0
673    const cinco = lims.find(l => l.kind === 'five_hour')
674    const semana = lims.find(l => l.kind === 'seven_day')
675
676    if (aj.modo === 'mini') {
677      return (
678        <Box flexDirection="row" paddingX={1}>
679          <Text {...estiloBanda} bold>{`${banda.icono} ${ahora.porcentaje}%`}</Text>
680          <Text dimColor>{' · '}</Text>
681          <Text {...pal.costo}>{`$${ahora.usd.toFixed(2)}`}</Text>
682          {cinco ? <Text dimColor>{' · 5h '}</Text> : null}
683          {cinco ? <Text {...colorUso(cinco.percentUsed)}>{`${Math.round(cinco.percentUsed)}%`}</Text> : null}
684          {semana ? <Text dimColor>{` · ${t.semanaCorta} `}</Text> : null}
685          {semana ? <Text {...colorUso(semana.percentUsed)}>{`${Math.round(semana.percentUsed)}%`}</Text> : null}
686        </Box>
687      )
688    }
689
690    const llenosCtx = llenos(ahora.porcentaje)
691    const anchoEtiqueta = Math.max(ancho(t.sesion), ancho(t.semana))
692    const filaLimite = (l: Limite) => {
693      const color = colorUso(l.percentUsed)
694      const n = llenos(l.percentUsed)
695      const r = textoRitmo(l, muestras[l.kind], ahoraMs, t)
696      const cuando = l.resetsAt ? textoReinicio(l, ahoraMs, t, aj.zona) : ''
697      return (
698        <Box flexDirection="row">
699          <Text bold>{etiqueta(nombreLimite(l.kind, t), anchoEtiqueta)}</Text>
700          <Text {...color}>{'█'.repeat(n)}</Text>
701          <Text dimColor>{'░'.repeat(ANCHO_BARRA - n)}</Text>
702          <Text {...color} bold>{` ${Math.round(l.percentUsed)}%`}</Text>
703          <Text dimColor>{cuando ? `  ↻ ${cuando}` : ''}</Text>
704          {r && columnas >= 70 ? <Text {...nivel(r.nivel)}>{`  ${r.texto}`}</Text> : null}
705        </Box>
706      )
707    }
708
709    return (
710      <Box flexDirection="column" paddingX={1}>
711        <Box flexDirection="row">
712          <Text {...estiloBanda} bold>{`${banda.icono} ${t.clima[banda.indice]} ${ahora.porcentaje}%`}</Text>
713          <Text dimColor>{` ${corto(ahora.tokens)}/${corto(ahora.ventana)}`}</Text>
714          {columnas >= 72 ? <Text {...estiloBanda}>{` ${'█'.repeat(llenosCtx)}`}</Text> : null}
715          {columnas >= 72 ? <Text dimColor>{'░'.repeat(ANCHO_BARRA - llenosCtx)}</Text> : null}
716          <Text dimColor>{' · '}</Text>
717          <Text {...pal.costo} bold>{`$${ahora.usd.toFixed(2)}`}</Text>
718          {delta >= 0.005 ? <Text {...pal.costo}>{` (+$${delta.toFixed(2)})`}</Text> : null}
719          <Text dimColor>{' · ⏱API '}</Text>
720          <Text {...pal.api}>{duracion(acc.duracionApiMs)}</Text>
721          <Text dimColor>{` · ${t.cache} `}</Text>
722          <Text {...estiloCache}>{cachePrincipal === null ? '—' : `${cachePrincipal}%`}</Text>
723          {ultimo !== null && cachePct !== null && ultimo !== cachePct ? <Text dimColor>{` Σ${cachePct}%`}</Text> : null}
724        </Box>
725        {cinco ? filaLimite(cinco) : null}
726        {semana ? filaLimite(semana) : null}
727      </Box>
728    )
729  })
730}
731
732// ───────────────────────────── Readings and alerts ─────────────────────────────
733
734async function resumen($: EngineInterface, aj: Ajustes) {
735  const t = IDIOMAS[aj.idioma]
736  const ahora = await $.clock.now()
737  const off = offsetMs(aj.zona, ahora)
738  const local = new Date(ahora + off)
739  const hora = formatoHora(local.getUTCHours(), local.getUTCMinutes(), t)
740  return `${t.lblIdioma}: ${t.nombre} · ${t.lblZona}: ${aj.zona} (${etiquetaOffset(off)}, ${hora}) · ${t.lblVista}: ${t.vistas[aj.modo]} · ${t.lblTema}: ${aj.tema}`
741}
742
743function porcentajeCache(u: { input_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number } | undefined): number | null {
744  const total = (u?.input_tokens ?? 0) + (u?.cache_read_input_tokens ?? 0) + (u?.cache_creation_input_tokens ?? 0)
745  return total > 0 ? Math.round(((u?.cache_read_input_tokens ?? 0) / total) * 100) : null
746}
747
748// Steps the preview every few seconds, then goes back to the real readings.
749let relojDemo: { cancel: () => void } | undefined
750
751async function empezarDemo($: EngineInterface) {
752  relojDemo?.cancel()
753  await update($, demo, () => 0)
754  void $.ui.invalidate('ui.render')
755  relojDemo = $.clock.every(DEMO_PASO_MS, () => {
756    void (async () => {
757      const paso = (await read($, demo)) + 1
758      await update($, demo, () => (paso >= PASOS_DEMO.length ? -1 : paso))
759      if (paso >= PASOS_DEMO.length) relojDemo?.cancel()
760      void $.ui.invalidate('ui.render')
761    })()
762  })
763}
764
765async function tomarLectura($: EngineInterface) {
766  try {
767    const { context, cost, rateLimits } = await $.session.usage()
768    await registrarLimites($, rateLimits)
769    if (!context || !context.window) {
770      return
771    }
772    const tokens = context.tokens ?? 0
773    const lectura: Lectura = {
774      tokens,
775      ventana: context.window,
776      porcentaje: Math.round(context.percent ?? (tokens / context.window) * 100),
777      usd: cost?.usd ?? 0,
778    }
779    await update($, lecturas, prev => [...prev.filter(r => r.tokens > 0), lectura].slice(-HISTORIAL))
780    if (tokens > 0) {
781      const t = IDIOMAS[(await read($, ajustes)).idioma]
782      await avisar($, 'ctx:75', lectura.porcentaje >= 75, lectura.porcentaje < 65, t.ctx75(lectura.porcentaje))
783      await avisar($, 'ctx:90', lectura.porcentaje >= 90, lectura.porcentaje < 80, t.ctx90(lectura.porcentaje))
784    }
785  } catch {
786    // No reading this turn; the band keeps the last one.
787  }
788}
789
790async function registrarLimites($: EngineInterface, rateLimits: readonly Limite[]) {
791  const lims = rateLimits.map(r => ({ ...r }))
792  await update($, limites, () => lims)
793  const ahoraMs = await $.clock.now()
794  const aj = await read($, ajustes)
795  const t = IDIOMAS[aj.idioma]
796  for (const l of lims) {
797    if (l.resetsAt) {
798      const resetsAt = l.resetsAt
799      await update($, ritmo, m => {
800        const previa = m[l.kind]
801        if (previa && previa.resetsAt === resetsAt && previa.pct <= l.percentUsed) return m
802        return { ...m, [l.kind]: { resetsAt, t: ahoraMs, pct: l.percentUsed } }
803      })
804    }
805    const cuando = l.resetsAt ? textoReinicio(l, ahoraMs, t, aj.zona) : ''
806    const texto = `${nombreLimite(l.kind, t)} ${Math.round(l.percentUsed)}%${cuando ? ` · ${t.reinicia} ${cuando}` : ''}`
807    const ventana = l.resetsAt ?? ''
808    await avisar($, `${l.kind}:80:${ventana}`, l.percentUsed >= 80, false, texto)
809    await avisar($, `${l.kind}:90:${ventana}`, l.percentUsed >= 90, false, texto)
810  }
811}
812
813// One alert per key: raised on crossing the threshold, re-armed when `rearmar`.
814async function avisar($: EngineInterface, clave: string, cruzo: boolean, rearmar: boolean, texto: string) {
815  const ya = (await read($, avisados)).includes(clave)
816  if (cruzo && !ya) {
817    $.ui.toast(texto)
818    await update($, avisados, a => [...a, clave].slice(-50))
819  } else if (rearmar && ya) {
820    await update($, avisados, a => a.filter(k => k !== clave))
821  }
822}
823
824async function guardarSesion($: EngineInterface, acc: Acumulado) {
825  try {
826    const id = await $.session.id()
827    await $.store.set(`sesion:${id}`, acc)
828    const claves = (await $.store.keys()).filter(k => k.startsWith('sesion:'))
829    for (const vieja of claves.slice(0, Math.max(0, claves.length - SESIONES_GUARDADAS))) {
830      await $.store.delete(vieja)
831    }
832  } catch {
833    // Not saved this time; retried next turn.
834  }
835}
836
837// ───────────────────────────── Text helpers ─────────────────────────────
838
839function nombreLimite(kind: string, t: Textos) {
840  return kind === 'five_hour' ? t.sesion : kind === 'seven_day' ? t.semana : kind
841}
842
843// "in 4h 40m (8:10 PM)" for the 5-hour window; "Mon 10:00 AM" for the weekly one.
844function textoReinicio(l: Limite, ahoraMs: number, t: Textos, zona: string) {
845  const ms = Date.parse(l.resetsAt!)
846  if (Number.isNaN(ms)) return ''
847  const local = new Date(ms + offsetMs(zona, ms))
848  const hora = formatoHora(local.getUTCHours(), local.getUTCMinutes(), t)
849  if (l.kind === 'five_hour') {
850    return t.cuenta(cuentaRegresiva(Math.max(0, ms - ahoraMs)), hora)
851  }
852  return `${t.dias[local.getUTCDay()] ?? ''} ${hora}`
853}
854
855function formatoHora(h: number, m: number, t: Textos) {
856  const mm = String(m).padStart(2, '0')
857  if (!t.ampm) return `${String(h).padStart(2, '0')}:${mm}`
858  return `${h % 12 === 0 ? 12 : h % 12}:${mm} ${h < 12 ? t.ampm[0] : t.ampm[1]}`
859}
860
861// "→100% in 2h 10m" if at this pace the window fills before it resets; "pace ok" if not.
862function textoRitmo(l: Limite, m: Muestra | undefined, ahoraMs: number, t: Textos) {
863  if (!m || !l.resetsAt || m.resetsAt !== l.resetsAt) return null
864  const transcurrido = ahoraMs - m.t
865  const subio = l.percentUsed - m.pct
866  if (transcurrido < RITMO_MIN_MS || subio <= 0) return null
867  const faltaReinicio = Date.parse(l.resetsAt) - ahoraMs
868  const llenaEn = ((100 - l.percentUsed) / subio) * transcurrido
869  if (llenaEn >= faltaReinicio) return { texto: t.ritmoOk, nivel: 'ok' as const }
870  return { texto: t.llena(duracion(llenaEn)), nivel: llenaEn < faltaReinicio / 2 ? ('bad' as const) : ('warn' as const) }
871}
872
873// Terminal cells a string takes: CJK and fullwidth characters take two.
874function ancho(s: string) {
875  let w = 0
876  for (const ch of s) {
877    const c = ch.codePointAt(0) ?? 0
878    const ancha =
879      (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
880      (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe6f) || (c >= 0xff00 && c <= 0xff60) ||
881      (c >= 0xffe0 && c <= 0xffe6) || c >= 0x20000
882    w += ancha ? 2 : 1
883  }
884  return w
885}
886
887function etiqueta(s: string, anchoTotal: number) {
888  return `${s}${' '.repeat(Math.max(0, anchoTotal - ancho(s)))} `
889}
890
891function bandaPara(porcentaje: number) {
892  const indice = Math.max(0, BANDAS.findIndex(b => porcentaje < b.hasta))
893  const banda = BANDAS[indice === -1 ? BANDAS.length - 1 : indice]!
894  return { ...banda, indice: porcentaje >= 90 ? 4 : indice }
895}
896
897function llenos(pct: number) {
898  return Math.round((Math.min(Math.max(pct, 0), 100) / 100) * ANCHO_BARRA)
899}
900
901// Countdown: minutes rounded up, as the desktop app shows them.
902function cuentaRegresiva(ms: number) {
903  const min = Math.ceil(ms / 60_000)
904  return min >= 60 ? `${Math.floor(min / 60)}h ${min % 60}m` : `${min}m`
905}
906
907function duracion(ms: number) {
908  const min = Math.floor(ms / 60_000)
909  if (min >= 60) return `${Math.floor(min / 60)}h ${min % 60}m`
910  if (min >= 1) return `${min}m ${Math.floor((ms % 60_000) / 1000)}s`
911  return `${Math.round(ms / 1000)}s`
912}
913
914function corto(n: number) {
915  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
916  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
917  return String(n)
918}
919
types/index.d.ts 34 lines
1export type Lectura = { tokens: number; ventana: number; porcentaje: number; usd: number }
2export type Limite = { kind: string; percentUsed: number; resetsAt?: string }
3export type Acumulado = {
4  duracionApiMs: number
5  entrada: number
6  cacheLeida: number
7  cacheEscrita: number
8  /** Cache hit % of the last main turn (null if unknown). */
9  ultimoCache?: number | null
10  turnos: number
11}
12// First reading of a limit window, to work out the pace of use.
13export type Muestra = { resetsAt: string; t: number; pct: number }
14export type Modo = 'completo' | 'mini' | 'oculto'
15export type Idioma = 'es' | 'en' | 'pt' | 'fr' | 'de' | 'zh' | 'ja' | 'ko'
16export type Tema =
17  | 'default' | 'mono' | 'contrast' | 'synthwave' | 'neon'
18  | 'violet' | 'ocean' | 'sunset' | 'forest' | 'candy' | 'claude' | 'catppuccin' | 'dracula'
19export type Ajustes = { modo: Modo; idioma: Idioma; zona: string; tema: Tema }
20
21declare module 'claude-code' {
22  interface PluginState {
23    'usage-weather': {
24      lecturas: Lectura[]
25      limites: Limite[]
26      acumulado: Acumulado
27      avisados: string[]
28      ritmo: Record<string, Muestra>
29      ajustes: Ajustes
30      demo: number
31    }
32  }
33}
34