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

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

<sub>Previews are renders drawn from the mod's real output format (sample numbers), not screenshots.</sub>
| Row | Content |
|---|---|
| 1 | Context 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). |
| 2 | 5-hour session limit: bar, percentage, time left and reset time. |
| 3 | Weekly limit: bar, percentage, reset day and time. |
→100% in 2h 10m when the window would fill before it resets, pace ok otherwise./compact) and 90% (compact now); each limit at 80% and 90%./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).
/usage-weatherEverything 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).
| Code | Language | Code | Language |
|---|---|---|---|
es | Español | zh | 中文 |
en | English | ja | 日本語 |
pt | Português | ko | 한국어 |
fr | Français | de | Deutsch |

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.
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 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.
Any of these works:
America/Bogota, Europe/Madrid, Asia/Tokyo, America/New_York;bogota, tokyo, new_york;UTC-5, +5:30, utc./usage-weather zones prints a ready list.
⏱API is an approximation (turn time minus time spent running tools and waiting for permission), not the exact figure from /usage.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.
hooks/register.tsx 919 lines1// 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}
919types/index.d.ts 34 lines1export 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