SLOPSHOPPER

Token Weather Usage

One line above the prompt: context tokens with their weather, a bar per recent prompt, your 5-hour and 7-day limits against the time elapsed, the prompt cache…

newbandtimer
★ 9v3.12.0Apache-2.0updated 2026-10-09augiefra/claude-mods/plugins/token-weather-usage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-weather-usage
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ 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 ☁ 97.4k │ 5h ━━──── 31% │ cache — │ ≈ $0.42 ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☁ 97.4k │ 5h ━━──── 31% │ cache — │ ≈ $0.42 ⟨Claude Code's own drawing⟩
README

Token Weather Usage

One band above the Claude Code prompt: the context in tokens, your 5-hour and 7-day limits against the clock, whether the prompt cache is still warm, what the session and the last prompt cost, and which agents are running.

One session, step by step

All clear

Agents at work

Cache about to lapse

Slow down

  • Context: tokens in the context with a weather icon (hover the pill for the weather and the share of the window), one bar per recent prompt, the last prompt's change.
  • 5h / 7d: the share of your account's limits already used, in green, yellow or red by pace. The gap with the time elapsed is hatched: grey while you have margin, in the bar's color when you use faster than time passes. Then the time left; in the app, hover the 5-hour pill for the reset time (machine's time zone).
  • Cache: the time before the prompt cache lapses (1 hour on a subscription, 5 minutes on an API key, inferred), behind a bolt in the app. The share of the last message read from the cache shows only under 90%. Yellow under 10 minutes, with what is at stake ("$2.32 at stake"); "missed" with its cause and what it cost above a hit ("+$2.10"); red "expired" with the cost of writing the context again ("289k to rewrite ≈ $2.32"), and the way out from 100k tokens: /compact, or a new thread from 300k (on the line in the terminal, in the hover card in the app). In the app, hover the cache pill for the expiry time, the share read, what the context costs a message from the cache against writing it again, and what the cache saved in this thread. The dollars come from Anthropic's list prices (dated 2026-10-08); on a subscription they are API-price equivalents, hence the "≈". A model missing from the price table shows tokens only. After a compaction the band updates at once: the context drops, and the cache reads "compacted" until the next message writes a new one.
  • Cost: the session cost as /cost totals it (whole dollars from $100), and what the last prompt added, in dollars and in points of the 5-hour limit (in the pill's hover card in the app). On a subscription, an API-price equivalent, not a bill.
  • Agents: how many subagents the thread ran ("2 running · 7" while some run). In the app the pill stays; hover it for the cost split by model and effort, the agents running and what delegating saved.

In the terminal:

☂ 634k ▃▄▂█▆ ▲ +6.3k │ 5h ━━━━╍─ 74% · 24 min │ 7d ━━━━── 65% · 2d20h │ cache 52 min │ ≈ $41.07 (+$0.58 · +1.5% 5h) │ 2 agents

Labels in English, French or Russian: auto follows LC_ALL, LC_MESSAGES or LANG, otherwise pick en, fr or ru in the Language option (/config).

Privacy

No personal data collected, sent or retained, no network requests. The mod reads the usage figures Claude Code provides (context, limits, session cost, each request's cache token counts), the list of the session's subagents, the locale variables and the prompt-cache switches, and keeps in the plugin's local storage the latest limits reading and, per session, recent context readings, the last request's cache figures, what the cache saved in the session, the last prompt's cost and each subagent's model, effort, token counts and description (deleted after 8 idle days).

Credits and license

Weather, context and turns chart after Anthropic's Token Weather example (claude-code-playground, Apache-2.0). Limit gauges written after HolyGrail's usage-meter (HolyGrail/claude-mods), and the cache block after Daniel San's prompt-cache-control (davila7/claude-code-templates, MIT), without copying their code. Apache-2.0 license: see LICENSE and NOTICE.

Source 1 files
hooks/token-weather-usage.mjs 1329 lines
1// Token Weather Usage: one line above the prompt.
2//   Terminal, blocks split by a thin rule:
3//   ☁ 440k ▃▄▂▇▆ ▲ +8.4k │ 5h ━━╍╍── 37% · 2h22 │ 7d ━━━╍── 60% · 2d23h │ cache 52 min │ ≈ $4.32 (+$0.84 · +2% 5h) │ 2 agents
4//   Desktop app: the same blocks as tinted, outlined pills; the cost pill keeps the total (the last
5//   prompt in its card) and the agents pill stays, its card splitting the cost by model and effort.
6//
7// Weather, context and recent turns: adapted from the Token Weather example,
8//   Copyright 2026 Anthropic PBC, SPDX-License-Identifier: Apache-2.0 (claude-code-playground).
9// 5-hour and 7-day limits: written for this mod after HolyGrail's usage-meter
10//   (https://github.com/HolyGrail/claude-mods/tree/main/plugins/usage-meter), without copying its code.
11// Prompt cache: written for this mod after Daniel San's prompt-cache-control
12//   (https://github.com/davila7/claude-code-templates, MIT), without copying its code.
13//
14// The engine reads on(...) and $.noun.method(...) from the source: they stay spelled out,
15// and the functions that take $ live at the top level.
16
17// ---------- Language ----------
18
19// Labels in English, French or Russian. "auto" follows LC_ALL, LC_MESSAGES or LANG, then the runtime's
20// locale; English unless one of them starts with "fr" or "ru". The desktop app often sets none of
21// them, so the language option (/config) is the sure way to pick.
22const TEXT = {
23  en: {
24    weather: { clear: "Clear", cloudy: "Cloudy", showers: "Showers", storm: "Storm", compact: "Compact soon" },
25    percent: (n) => `${n}%`,
26    labels: { five_hour: "5h", seven_day: "7d", spend_limit: "$" },
27    day: "d",
28    hour: "h",
29    minute: "min",
30    contextAlt: (word, percent, window) => `${word} · ${percent} of ${window}`,
31    turnsAlt: (n) => `Tokens added by the last ${n} prompts`,
32    gaugeAlt: (label, value) => `${label}: ${value} used`,
33    cache: "cache",
34    expired: "expired",
35    compacted: "compacted",
36    missed: "missed",
37    causes: { model: "model changed", lapsed: "lapsed", prefix: "start changed" },
38    underMinute: "< 1 min",
39    cost: (usd) => (usd >= 100 ? `≈ $${Math.round(usd)}` : `≈ $${usd.toFixed(2)}`),
40    resetsAt: (time) => `Resets at ${time}`,
41    lastPrompt: (usd) => `+$${usd.toFixed(2)}`,
42    lastPrompt5h: (points) => `+${decimal(points)}% 5h`,
43    toRewrite: (tokens) => `${tokens} to rewrite`,
44    newThread: "new thread",
45    // $2.32, $182 from 100 dollars, < $0.01 under a cent.
46    money: (usd) => (usd < 0.01 ? "< $0.01" : `$${amount(usd)}`),
47    atStake: (what) => `${what} at stake`,
48    // The cache's tooltip in the app, one line per item.
49    tips: {
50      warm: (time, oneHour, observed) => `Cache warm until ${time} (${oneHour ? "1-hour" : "5-minute"} lifetime, ${observed ? "observed" : "assumed"}).`,
51      lastRead: (share, tokens) => `Last message: ${share} read from the cache (${tokens}).`,
52      costs: (read, rewrite) => `Reading the context: ${read} a message. If it expires: ${rewrite} to write it again.`,
53      saved: (usd) => `This thread: ${usd} saved by the cache.`,
54      soon: (time, tokens, costs) =>
55        `The cache expires at ${time}. Send your next message before then, or it writes ${tokens} tokens again${costs ? ` (${costs.rewrite} instead of ${costs.read})` : ""}.`,
56      expired: (tokens, cost) => `The next message writes the whole context (${tokens}) again at full price${cost ? `, ${cost}` : ""}.`,
57      compact: "/compact before going on: the context written again will be smaller.",
58      newThread: "A new thread avoids this rewrite; a compaction would read it all again.",
59      missed: (share, cause, tokens, surcharge) =>
60        `This message read only ${share} from the cache (${cause}): it wrote ${tokens} tokens again${surcharge ? `, ${surcharge} more than a message served by the cache` : ""}.`,
61      compacted: "Compacted: the next message writes a new, smaller cache.",
62    },
63    agents: (n) => (n === 1 ? "1 agent" : `${n} agents`),
64    agentsRunning: (running, n) => `${running} running · ${n}`,
65    // The cost pill's card in the app.
66    costTips: {
67      total: (usd) => `Thread cost ${usd}`,
68      plan: "At API prices: a subscription is not billed per token, this counts toward its limits.",
69      api: "At API list prices.",
70      last: (what) => `Last prompt: ${what}`,
71    },
72    // The agents pill's card: who spent what, by model and effort.
73    team: {
74      title: (n, running) => `Agents in this thread: ${n}${running ? ` (${running} running)` : ""}`,
75      line: (label, usd, share) => `${label}: ${usd}${share ? ` · ${share}` : ""}`,
76      main: (model) => `${model} · main thread`,
77      group: (model, n) => `${model} · ${n === 1 ? "1 agent" : `${n} agents`}`,
78      detail: (efforts, tokens) => `  ${efforts ? `effort ${efforts} · ` : ""}${tokens} tokens`,
79      efforts: { low: "low", medium: "medium", high: "high", xhigh: "extra high", max: "max" },
80      none: "No subagent in this thread yet.",
81      running: (model, description, time) => `Running: ${model} · “${description}” · ${time}`,
82      saved: (usd, model) => `Delegating: ${usd} saved compared with ${model}.`,
83    },
84    icons: { five_hour: "5-hour limit", seven_day: "7-day limit", spend_limit: "Spend limit", reset: "Resets in", cache: "Prompt cache", cost: "Session cost", lastPrompt: "Last prompt", agents: "Agents" },
85  },
86  fr: {
87    weather: { clear: "Clair", cloudy: "Nuageux", showers: "Averses", storm: "Orage", compact: "Compacter bientôt" },
88    percent: (n) => `${n} %`,
89    labels: { five_hour: "5h", seven_day: "7j", spend_limit: "$" },
90    day: "j",
91    hour: "h",
92    minute: "min",
93    contextAlt: (word, percent, window) => `${word} · ${percent} de ${window}`,
94    turnsAlt: (n) => `Tokens ajoutés par les ${n} derniers prompts`,
95    gaugeAlt: (label, value) => `${label} : ${value} consommés`,
96    cache: "cache",
97    expired: "expiré",
98    compacted: "compacté",
99    missed: "raté",
100    causes: { model: "modèle changé", lapsed: "délai dépassé", prefix: "début modifié" },
101    underMinute: "< 1 min",
102    cost: (usd) => (usd >= 100 ? `≈ ${Math.round(usd)} $` : `≈ ${usd.toFixed(2).replace(".", ",")} $`),
103    resetsAt: (time) => `Remise à zéro à ${time}`,
104    lastPrompt: (usd) => `+${usd.toFixed(2).replace(".", ",")} $`,
105    lastPrompt5h: (points) => `+${decimal(points).replace(".", ",")} % 5h`,
106    toRewrite: (tokens) => `${tokens} à réécrire`,
107    newThread: "nouveau fil",
108    money: (usd) => (usd < 0.01 ? "< 0,01 $" : `${amount(usd).replace(".", ",")} $`),
109    atStake: (what) => `${what} en jeu`,
110    tips: {
111      warm: (time, oneHour, observed) => `Cache chaud jusqu'à ${time} (durée ${oneHour ? "1 h" : "5 min"} ${observed ? "constatée" : "supposée"}).`,
112      lastRead: (share, tokens) => `Dernier message : ${share} lu depuis le cache (${tokens}).`,
113      costs: (read, rewrite) => `Relire le contexte : ${read} par message. S'il expire : ${rewrite} pour le réécrire.`,
114      saved: (usd) => `Ce fil : ${usd} économisés grâce au cache.`,
115      soon: (time, tokens, costs) =>
116        `Le cache expire à ${time}. Envoie ton prochain message avant, sinon il réécrira ${tokens} tokens${costs ? ` (${costs.rewrite} au lieu de ${costs.read})` : ""}.`,
117      expired: (tokens, cost) => `Le prochain message réécrira tout le contexte (${tokens}) au prix fort${cost ? `, ${cost}` : ""}.`,
118      compact: "/compact avant de reprendre : le contexte réécrit sera plus petit.",
119      newThread: "Un nouveau fil évite cette réécriture ; une compaction relirait tout.",
120      missed: (share, cause, tokens, surcharge) =>
121        `Ce message n'a lu que ${share} depuis le cache (${cause}) : il a réécrit ${tokens} tokens${surcharge ? `, ${surcharge} de plus qu'un message servi par le cache` : ""}.`,
122      compacted: "Compacté : le prochain message écrira un cache neuf, plus petit.",
123    },
124    agents: (n) => (n === 1 ? "1 agent" : `${n} agents`),
125    agentsRunning: (running, n) => `${running} en cours · ${n}`,
126    costTips: {
127      total: (usd) => `Coût du fil ${usd}`,
128      plan: "Au prix de l'API : un abonnement n'est pas facturé au token, cela compte dans ses limites.",
129      api: "Au prix catalogue de l'API.",
130      last: (what) => `Dernier message : ${what}`,
131    },
132    team: {
133      title: (n, running) => `Agents du fil : ${n}${running ? ` (${running} en cours)` : ""}`,
134      line: (label, usd, share) => `${label} : ${usd}${share ? ` · ${share}` : ""}`,
135      main: (model) => `${model} · fil principal`,
136      group: (model, n) => `${model} · ${n === 1 ? "1 agent" : `${n} agents`}`,
137      detail: (efforts, tokens) => `  ${efforts ? `effort ${efforts} · ` : ""}${tokens} tokens`,
138      efforts: { low: "bas", medium: "moyen", high: "élevé", xhigh: "très élevé", max: "max" },
139      none: "Aucun sous-agent dans ce fil.",
140      running: (model, description, time) => `En cours : ${model} · « ${description} » · ${time}`,
141      saved: (usd, model) => `Délégation : ${usd} économisés par rapport à ${model}.`,
142    },
143    icons: { five_hour: "Limite 5 h", seven_day: "Limite 7 jours", spend_limit: "Plafond de dépense", reset: "Remise à zéro dans", cache: "Cache de prompt", cost: "Coût du fil", lastPrompt: "Dernier prompt", agents: "Agents" },
144  },
145  ru: {
146    weather: { clear: "Ясно", cloudy: "Облачно", showers: "Ливни", storm: "Гроза", compact: "Скоро сжатие" },
147    percent: (n) => `${n}%`,
148    labels: { five_hour: "5ч", seven_day: "7д", spend_limit: "$" },
149    day: "д",
150    hour: "ч",
151    minute: "мин",
152    contextAlt: (word, percent, window) => `${word} · ${percent} из ${window}`,
153    turnsAlt: (n) => `Токены, добавленные последними промптами: ${n}`,
154    gaugeAlt: (label, value) => `${label}: израсходовано ${value}`,
155    cache: "кэш",
156    expired: "истёк",
157    compacted: "сжат",
158    missed: "промах",
159    causes: { model: "смена модели", lapsed: "истёк срок", prefix: "изменилось начало" },
160    underMinute: "< 1 мин",
161    cost: (usd) => (usd >= 100 ? `≈ ${Math.round(usd)} $` : `≈ ${usd.toFixed(2).replace(".", ",")} $`),
162    resetsAt: (time) => `Сброс в ${time}`,
163    lastPrompt: (usd) => `+${usd.toFixed(2).replace(".", ",")} $`,
164    lastPrompt5h: (points) => `+${decimal(points).replace(".", ",")}% 5ч`,
165    toRewrite: (tokens) => `${tokens} к перезаписи`,
166    newThread: "новый тред",
167    money: (usd) => (usd < 0.01 ? "< 0,01 $" : `${amount(usd).replace(".", ",")} $`),
168    atStake: (what) => `под угрозой ${what}`,
169    tips: {
170      warm: (time, oneHour, observed) => `Кэш тёплый до ${time} (срок жизни ${oneHour ? "1 час" : "5 минут"}, ${observed ? "замерен" : "предполагается"}).`,
171      lastRead: (share, tokens) => `Последнее сообщение: ${share} прочитано из кэша (${tokens}).`,
172      costs: (read, rewrite) => `Чтение контекста: ${read} за сообщение. Если кэш истечёт: ${rewrite} на повторную запись.`,
173      saved: (usd) => `Этот тред: кэш сэкономил ${usd}.`,
174      soon: (time, tokens, costs) =>
175        `Кэш истекает в ${time}. Отправь следующее сообщение до этого, иначе ${tokens} токенов запишутся заново${costs ? ` (${costs.rewrite} вместо ${costs.read})` : ""}.`,
176      expired: (tokens, cost) => `Следующее сообщение заново запишет весь контекст (${tokens}) по полной цене${cost ? `, ${cost}` : ""}.`,
177      compact: "Сделай /compact перед продолжением: перезаписываемый контекст будет меньше.",
178      newThread: "Новый тред избавит от перезаписи; сжатие прочитает всё заново.",
179      missed: (share, cause, tokens, surcharge) =>
180        `Это сообщение прочитало из кэша только ${share} (${cause}): ${tokens} токенов записаны заново${surcharge ? `, на ${surcharge} дороже, чем сообщение из кэша` : ""}.`,
181      compacted: "Сжато: следующее сообщение запишет новый, меньший кэш.",
182    },
183    agents: (n) => `${n} ${plural(n, "агент", "агента", "агентов")}`,
184    agentsRunning: (running, n) => `${running} в работе · ${n}`,
185    costTips: {
186      total: (usd) => `Стоимость треда ${usd}`,
187      plan: "По ценам API: подписка не тарифицируется по токенам, это идёт в зачёт её лимитов.",
188      api: "По прайсовым ценам API.",
189      last: (what) => `Последний промпт: ${what}`,
190    },
191    team: {
192      title: (n, running) => `Агенты в треде: ${n}${running ? ` (в работе: ${running})` : ""}`,
193      line: (label, usd, share) => `${label}: ${usd}${share ? ` · ${share}` : ""}`,
194      main: (model) => `${model} · основной тред`,
195      group: (model, n) => `${model} · ${n} ${plural(n, "агент", "агента", "агентов")}`,
196      detail: (efforts, tokens) => `  ${efforts ? `усилие ${efforts} · ` : ""}${tokens} токенов`,
197      efforts: { low: "низкое", medium: "среднее", high: "высокое", xhigh: "очень высокое", max: "макс." },
198      none: "В этом треде пока нет субагентов.",
199      running: (model, description, time) => `В работе: ${model} · «${description}» · ${time}`,
200      saved: (usd, model) => `Делегирование: сэкономлено ${usd} по сравнению с ${model}.`,
201    },
202    icons: { five_hour: "Лимит на 5 часов", seven_day: "Лимит на 7 дней", spend_limit: "Лимит расходов", reset: "Сброс через", cache: "Кэш промпта", cost: "Стоимость сессии", lastPrompt: "Последний промпт", agents: "Агенты" },
203  },
204};
205let T = TEXT.en;
206
207// Russian plural: 1 агент, 2 агента, 5 агентов.
208function plural(n, one, few, many) {
209  const m10 = n % 10;
210  const m100 = n % 100;
211  if (m10 === 1 && m100 !== 11) return one;
212  if (m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14)) return few;
213  return many;
214}
215
216// 2.32, or 182 from 100 dollars (cents dropped).
217function amount(usd) {
218  return Math.round(usd * 100) >= 10_000 ? String(Math.round(usd)) : usd.toFixed(2);
219}
220
221// "≈ $2.32"; under a cent, "< $0.01" alone.
222function approx(usd) {
223  return usd < 0.01 ? T.money(usd) : `≈ ${T.money(usd)}`;
224}
225
226// ---------- Context weather (Token Weather) ----------
227
228const HISTORY = 12;
229const BARS = "▁▂▃▄▅▆▇█";
230const FORECAST = [
231  // Single-column symbols, no emoji: they line up in every font.
232  { upTo: 25, id: "clear", icon: "☀", color: "yellow" },
233  { upTo: 50, id: "cloudy", icon: "☁", color: "cyan" },
234  { upTo: 75, id: "showers", icon: "☂", color: "blue" },
235  { upTo: 90, id: "storm", icon: "☇", color: "magenta" },
236  { upTo: Infinity, id: "compact", icon: "↯", color: "red" },
237];
238
239// Turn bars: tokens added by each recent prompt; the current prompt takes the weather's tint
240// (colors readable on light and dark backgrounds), earlier ones stay grey.
241const TURN_BARS = 5;
242const SPARK = { height: 14, bar: 5.5, gap: 2 };
243const PAST_BAR = "rgba(127,127,127,0.45)";
244const SPARK_COLORS = { yellow: "#e0b000", cyan: "#1ba1c4", blue: "#2f68c0", magenta: "#b04fc0", red: "#d64545" };
245
246// Weather icons drawn in the app (the terminal keeps FORECAST's Unicode symbols): filled,
247// 15 px, each in its own tint. "Compact soon" redraws the ↯ zigzag with a thick stroke.
248const WEATHER_ICON_SIZE = 15;
249const WEATHER_ICONS = {
250  clear: (c) =>
251    `<circle cx="12" cy="12" r="4.5" fill="${c}"/><path fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round" d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>`,
252  cloudy: (c) =>
253    `<path fill="${c}" stroke="${c}" stroke-width="1.5" stroke-linejoin="round" d="M7 18.5a3.75 3.75 0 0 1-.4-7.48A5.6 5.6 0 0 1 17.2 9.6a4.45 4.45 0 0 1 .3 8.9z"/>`,
254  showers: (c) =>
255    `<path fill="${c}" stroke="${c}" stroke-width="1.5" stroke-linejoin="round" d="M7 14.5a3.25 3.25 0 0 1-.35-6.48A5 5 0 0 1 16.2 6.8a3.85 3.85 0 0 1 .3 7.7z"/><path fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round" d="M8.5 17.5l-1 2.5M12.5 17.5l-1 2.5M16.5 17.5l-1 2.5"/>`,
256  storm: (c) => `<path fill="${c}" stroke="${c}" stroke-width="1.5" stroke-linejoin="round" d="M13.5 2 5 13.5h6.5L10.5 22 19 10.5h-6.5z"/>`,
257  compact: (c) =>
258    `<path fill="none" stroke="${c}" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round" d="M14 2 7 12h8l-5 9M14.5 18.8 10 21l-.5-5"/>`,
259};
260const WEATHER_ICON_COLORS = { clear: "#e0b000", cloudy: "#8ea3b8", showers: "#2f68c0", storm: "#b04fc0", compact: "#d64545" };
261
262// Hover cards: the app shows no SVG <title> tooltip, so each pill carries a card of its own,
263// hidden until the pointer is over the pill, drawn above the band, in the app theme's own
264// background and outline colors (tested in the app against a fixed dark card: this one reads better).
265const CARD = { back: "background", line: "subtle" };
266function hoverCard(Box, Text, tip) {
267  const lines = String(tip).split("\n");
268  // No key: a keyed Box would scope its own hover, and a hidden one is never hovered.
269  return Box({
270    position: "absolute",
271    bottom: 1,
272    left: 0,
273    display: "none",
274    hover: { display: "flex" },
275    flexDirection: "column",
276    paddingX: 1,
277    paddingY: 0,
278    borderStyle: "round",
279    borderColor: CARD.line,
280    backgroundColor: CARD.back,
281    // An indented line is a detail of the one above it: dim.
282    children: lines.map((line, i) => Text(line.startsWith("  ") ? { key: "t" + i, dimColor: true, children: line } : { key: "t" + i, children: line })),
283  });
284}
285
286// The weather word goes to the pill's hover card: the pill keeps the tokens alone.
287function weatherSvg(id) {
288  const draw = WEATHER_ICONS[id];
289  if (!draw) return null;
290  return `<svg xmlns="http://www.w3.org/2000/svg" width="${WEATHER_ICON_SIZE}" height="${WEATHER_ICON_SIZE}" viewBox="0 0 24 24">${draw(WEATHER_ICON_COLORS[id])}</svg>`;
291}
292
293// Context readings: { tokens, window, percent }, oldest first.
294let readings = [];
295// Each session's readings are kept in $.store, so the bars come back after a restart.
296const TURNS_PREFIX = "turns:";
297const TURNS_KEEP_MS = 8 * 24 * 3_600_000;
298let turnsKey = null;
299
300// ---------- Account limits ----------
301
302const MINUTE = 60_000;
303const HOUR = 60 * MINUTE;
304const DAY = 24 * HOUR;
305// Length of each window; without one (spend cap), no elapsed-time marker.
306const SPANS = { five_hour: 5 * HOUR, seven_day: 7 * DAY };
307// Display order; an unknown window goes last.
308const ORDER = ["five_hour", "seven_day", "spend_limit"];
309// Pace = share used minus share of time elapsed, in points.
310// Above 0: using faster than time; beyond 15, or at 90% used: alert.
311const PACE_ALERT = 15;
312const USED_ALERT = 90;
313// Limits belong to the account: the latest reading, across sessions, lives in $.store.
314const SHARED_KEY = "limits";
315
316// Latest known reading: { at (ms), list: SessionRateLimit[] }.
317let limits = { at: 0, list: [] };
318let ticker = null;
319
320// ---------- Prompt cache ----------
321
322// The cache keeps the start of the conversation for 5 minutes, or 1 hour; each request that
323// reads it starts the time again, counted from the request's start. Once it lapses, the next
324// message writes the whole context again. Mods get the token counts, not the lifetime: it is
325// inferred (Claude Code's rules, then what the traffic shows).
326const TTL = { "5m": 5 * MINUTE, "1h": HOUR };
327// Yellow under 10 minutes left.
328const CACHE_SOON = 10 * MINUTE;
329// From this context size, an expired cache suggests /compact before going on.
330const COMPACT_AT = 100_000;
331// From this one, a new thread instead: after a pause the next message writes the whole context
332// again at full price; a new thread avoids that rewrite, a compaction would read it all again.
333const LARGE_CONTEXT = 300_000;
334// A request that read less than half its prompt from the cache, and wrote more than this, missed.
335const MISS_SHARE = 50;
336const MISS_WRITE = 1_000;
337// From this share read from the cache, the pill shows the time left alone.
338const GOOD_HIT = 90;
339// Last main-loop request: { at, model, read, write, fresh, cause }.
340let cache = null;
341// True from a compaction of the main conversation until its next request: the cache that
342// request writes is a new one, neither expired nor missed.
343let compacted = false;
344// Lifetime seen in the traffic ("5m" | "1h"), which beats the rules.
345let seenTtl = null;
346// What the cache saved this session, in dollars: each request's tokens read from the cache,
347// at the input price minus the cache-read price.
348let savedUsd = 0;
349
350// Anthropic first-party list prices, USD per million tokens, as of 2026-10-08: input and cache
351// read. Cache writes follow from input: 1.25× for the 5-minute lifetime, 2× for 1 hour; output
352// is 5× input for every model here (OUTPUT).
353// "over" holds the prices of prompts above "at" tokens, for a model priced by prompt size.
354// Update this table, and its date, when the prices change. A model missing here shows tokens only.
355const PRICES = {
356  "claude-fable-5-1": { input: 10, read: 0.25 },
357  "claude-mythos-5-1": { input: 10, read: 0.25 },
358  "claude-fable-5": { input: 10, read: 1 },
359  "claude-opus-5-5": { input: 4, read: 0.2 },
360  "claude-opus-5": { input: 5, read: 0.5 },
361  "claude-opus-4-8": { input: 5, read: 0.5 },
362  "claude-opus-4-7": { input: 5, read: 0.5 },
363  "claude-opus-4-6": { input: 5, read: 0.5 },
364  "claude-sonnet-5-5": { input: 2, read: 0.1 },
365  "claude-sonnet-5": { input: 2, read: 0.2 },
366  "claude-sonnet-4-6": { input: 3, read: 0.3 },
367  "claude-haiku-5-5": { input: 0.1, read: 0.01, over: { at: 100_000, input: 0.5, read: 0.05 } },
368  "claude-haiku-4-5": { input: 1, read: 0.1 },
369};
370const OUTPUT = 5;
371// Environment switches read at session start.
372let cacheEnv = {};
373let cacheTicker = null;
374let cacheKey = "";
375
376// Session cost in dollars, as /cost totals it; null where the host keeps no ledger.
377let cost = null;
378// What the last prompt added to it (its subagents included), and the total it started from.
379let lastPrompt = null;
380let promptBase = null;
381// The same in points of the 5-hour limit (an account figure: other sessions running at the
382// same time count in it), and the reading it started from.
383let lastPrompt5h = null;
384let promptBase5h = null;
385
386// Subagents running now: { id, description, type }.
387let agents = [];
388let agentsKey = "";
389// Every subagent this thread ran, by id: { model, effort, usd, priced, input, read, write, output,
390// at, description, type }. Its requests are counted at list prices as they come back.
391let team = {};
392
393// ---------- Layout ----------
394
395const SEP = "│";
396const TEXT_CELLS = 6;
397const GAUGE = { width: 54, height: 9 };
398const TONES = {
399  calm: { svg: "#3fa66b", text: "green" },
400  // The theme's "yellow" is bright yellow in the app, unreadable on the yellow pill: a deep amber.
401  fast: { svg: "#d9962b", text: "#a8690a" },
402  alert: { svg: "#d64545", text: "red" },
403};
404const TRACK = "rgba(127,127,127,0.2)";
405// Hatching of the gap when using slower than time: grey stripes on the gauge's track.
406const HATCH = { back: "rgba(127,127,127,0.16)", line: "rgba(127,127,127,0.6)" };
407// Desktop pills: a light tint and a slightly stronger outline per block.
408const TINTS = {
409  context: ["rgba(47,104,192,0.10)", "rgba(47,104,192,0.28)"],
410  five_hour: ["rgba(63,166,107,0.13)", "rgba(63,166,107,0.32)"],
411  seven_day: ["rgba(140,100,210,0.13)", "rgba(140,100,210,0.32)"],
412  spend_limit: ["rgba(184,140,40,0.13)", "rgba(184,140,40,0.34)"],
413  calm: ["rgba(27,161,196,0.11)", "rgba(27,161,196,0.30)"],
414  fast: ["rgba(217,150,43,0.14)", "rgba(217,150,43,0.36)"],
415  alert: ["rgba(214,69,69,0.12)", "rgba(214,69,69,0.36)"],
416  cost: ["rgba(184,140,40,0.13)", "rgba(184,140,40,0.34)"],
417  agents: ["rgba(196,80,127,0.11)", "rgba(196,80,127,0.32)"],
418};
419// Small outlined icons in the app, each in its pill's color (the alt text is required: a
420// drawing without one is dropped). The clock before a reset time takes the pill's color too.
421const ICON_SIZE = 16;
422const SMALL_ICON = 14;
423const ICONS = {
424  // Gauge and speech bubble are drawn around y=11.5: half a unit down centres them like the others.
425  gauge: (c) =>
426    `<g transform="translate(0 0.5)"><path d="M3.6 18.5a9.5 9.5 0 1 1 16.8 0" fill="none" stroke="${c}" stroke-width="2.2" stroke-linecap="round"/><path d="M12 14.5l4.3-4.6" fill="none" stroke="${c}" stroke-width="2.2" stroke-linecap="round"/><circle cx="12" cy="14.5" r="1.7" fill="${c}"/></g>`,
427  calendar: (c) =>
428    `<rect x="3" y="4.5" width="18" height="17" rx="3" fill="none" stroke="${c}" stroke-width="2"/><path d="M3 9.5h18M8 2.5v4M16 2.5v4" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round"/><text x="12" y="19.2" font-size="8.5" font-weight="700" font-family="-apple-system,Helvetica,Arial,sans-serif" text-anchor="middle" fill="${c}">7</text>`,
429  // A clock turning back: the time left before the window starts over.
430  clock: (c) =>
431    `<path d="M4.2 13A8 8 0 1 0 6.6 6.2" fill="none" stroke="${c}" stroke-width="2.1" stroke-linecap="round"/><path d="M3.4 3.6v4.2h4.2" fill="none" stroke="${c}" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 8v4.6l3 1.8" fill="none" stroke="${c}" stroke-width="2.1" stroke-linecap="round"/>`,
432  bolt: (c) => `<path d="M13.2 2 4 13.6h7.2L10.4 22l9.2-11.6h-7.2z" fill="${c}" fill-opacity="0.18" stroke="${c}" stroke-width="2" stroke-linejoin="round"/>`,
433  coin: (c) =>
434    `<circle cx="12" cy="12" r="9.5" fill="${c}" fill-opacity="0.16" stroke="${c}" stroke-width="2"/><path d="M15 8.8c-.5-1-1.6-1.6-3-1.6-1.7 0-3 .9-3 2.2s1.3 1.8 3 2.1 3 .9 3 2.2-1.3 2.3-3 2.3c-1.4 0-2.5-.6-3.1-1.6M12 5.6v1.6M12 16.8v1.6" fill="none" stroke="${c}" stroke-width="1.9" stroke-linecap="round"/>`,
435  // A speech bubble: what the last prompt cost.
436  prompt: (c) =>
437    `<g transform="translate(0 0.5)"><path d="M4 5.5A2.5 2.5 0 0 1 6.5 3h11A2.5 2.5 0 0 1 20 5.5v8a2.5 2.5 0 0 1-2.5 2.5H10l-4.5 4v-4H6.5A2.5 2.5 0 0 1 4 13.5z" fill="${c}" fill-opacity="0.14" stroke="${c}" stroke-width="2" stroke-linejoin="round"/><path d="M8.5 8.5h7M8.5 11.5h4.5" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round"/></g>`,
438  // A small robot: subagents at work.
439  agents: (c) =>
440    `<rect x="4" y="7.5" width="16" height="12.5" rx="3.5" fill="${c}" fill-opacity="0.14" stroke="${c}" stroke-width="2"/><path d="M12 7.5V4M2 12.5v3M22 12.5v3" fill="none" stroke="${c}" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="3.2" r="1.3" fill="${c}"/><circle cx="9" cy="13" r="1.5" fill="${c}"/><circle cx="15" cy="13" r="1.5" fill="${c}"/><path d="M9.5 16.8h5" fill="none" stroke="${c}" stroke-width="1.8" stroke-linecap="round"/>`,
441};
442// Icon color per block: deeper than the pill's tint, readable on light and dark backgrounds.
443const ICON_COLORS = { five_hour: "#3a9a62", seven_day: "#8a5fd0", spend_limit: "#b8892a", calm: "#1b9cbe", fast: "#d9962b", alert: "#d64545", cost: "#b8892a", agents: "#c4507f" };
444const LIMIT_ICONS = { five_hour: "gauge", seven_day: "calendar", spend_limit: "coin" };
445// Columns the terminal may cover at the end of the band.
446const RESERVED_COLUMNS = 2;
447
448export function register(on, options) {
449  const language = options?.language;
450
451  on("session.start", async ($, e, next) => {
452    ticker?.cancel();
453    cacheTicker?.cancel();
454    T = TEXT[await languageOf($, language)];
455    readings = [];
456    limits = { at: 0, list: [] };
457    cache = null;
458    compacted = false;
459    seenTtl = null;
460    savedUsd = 0;
461    team = {};
462    lastPrompt = null;
463    lastPrompt5h = null;
464    cacheKey = "";
465    cacheEnv = await cacheEnvOf($);
466    turnsKey = TURNS_PREFIX + (await $.session.id());
467    await restoreTurns($);
468    const usage = await $.session.usage();
469    pushReading(usage.context);
470    cost = usage.cost?.usd ?? null;
471    promptBase = cost;
472    promptBase5h = fiveHourOf(usage.rateLimits);
473    agents = [];
474    agentsKey = "";
475    await refreshAgents($);
476    nameTeam();
477    // On start or reload the local reading may be stale (an idle session): the shared reading
478    // wins, and the local one is published only when none exists yet.
479    await adoptShared($);
480    if (limits.list.length === 0 && usage.rateLimits.length > 0) await shareLimits($, usage.rateLimits);
481    // Every minute: elapsed time moves on, and another session may have measured something newer.
482    ticker = $.clock.every(MINUTE, async () => {
483      await adoptShared($);
484      $.ui.invalidate("ui.render");
485    });
486    // The cache countdown: a redraw only when its text changes.
487    // and the agents running, which start and end between turns.
488    cacheTicker = $.clock.every(10_000, async () => {
489      const key = cacheText(cacheState(await $.clock.now()));
490      const changed = await refreshAgents($);
491      if (key !== cacheKey || changed) {
492        cacheKey = key;
493        $.ui.invalidate("ui.render");
494      }
495    });
496    $.ui.invalidate("ui.render");
497    return next(e);
498  });
499
500  on("session.end", async ($, e, next) => {
501    // A real end (exit, or process stopped); /clear, /resume and disconnect keep the tickers.
502    if (e.reason === "prompt_input_exit" || e.reason === "other") {
503      ticker?.cancel();
504      cacheTicker?.cancel();
505    }
506    return next(e);
507  });
508
509  // Each main-loop request: how much of its prompt the cache served (subagents have their own).
510  // A subagent's request goes to its line in the agents card: model, effort, tokens, cost.
511  on("turn.step", async function* ($, e, next) {
512    if (e.agentId) {
513      const at = await $.clock.now();
514      const result = yield* next(e);
515      if (result?.usage) recordAgentStep(e.agentId, at, result.usage, e.model, e.effort);
516      return result;
517    }
518    const at = await $.clock.now();
519    const result = yield* next(e);
520    if (result?.usage) {
521      recordRequest(at, result.usage, e.model);
522      // The request may have started an agent.
523      await refreshAgents($);
524      $.ui.invalidate("ui.render");
525    }
526    return result;
527  });
528
529  // One context reading after each main turn (not subagents' turns).
530  on("turn.complete", async ($, e, next) => {
531    const result = await next(e);
532    // A subagent's turn: it may have just finished.
533    if (e.agentId) {
534      await refreshAgents($);
535      nameTeam();
536      await saveTurns($);
537      $.ui.invalidate("ui.render");
538      return result;
539    }
540    try {
541      const usage = await $.session.usage();
542      pushReading(usage.context);
543      if (usage.cost) {
544        cost = usage.cost.usd;
545        if (promptBase !== null && cost >= promptBase) lastPrompt = cost - promptBase;
546        promptBase = cost;
547      }
548      // A window that reset in between gives no share.
549      const now5h = fiveHourOf(usage.rateLimits);
550      if (now5h !== null) {
551        lastPrompt5h = promptBase5h !== null && now5h >= promptBase5h ? Math.round((now5h - promptBase5h) * 10) / 10 : null;
552        promptBase5h = now5h;
553      }
554      await saveTurns($);
555      $.ui.invalidate("ui.render");
556    } catch {
557      // No reading this turn: the line keeps the previous one.
558    }
559    return result;
560  });
561
562  // A compaction of the main conversation: the context drops now, not at the end of the next
563  // prompt. Its size comes from the compaction's result (or, missing, the live figures); the
564  // next request writes a new cache, which is neither expired nor a miss.
565  on("session.compact", async ($, e, next) => {
566    const result = await next(e);
567    if (e.agentId || e.trigger === "precompute" || !result || typeof result.skip === "string") return result;
568    try {
569      const last = readings[readings.length - 1];
570      if (Number.isFinite(result.tokensAfter) && result.tokensAfter > 0 && last?.window > 0) {
571        pushReading({ tokens: result.tokensAfter, window: last.window });
572      } else {
573        pushReading((await $.session.usage()).context);
574      }
575      compacted = true;
576      await saveTurns($);
577      $.ui.invalidate("ui.render");
578    } catch {
579      // No reading: the line catches up at the end of the next prompt.
580    }
581    return result;
582  });
583
584  on("session.measure", async ($, e, next) => {
585    if (e.changed.includes("rateLimits") && e.rateLimits.length > 0) await shareLimits($, e.rateLimits);
586    if (e.cost) cost = e.cost.usd;
587    $.ui.invalidate("ui.render");
588    return next(e);
589  });
590
591  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
592    const props = e.props ?? e;
593    if (props.hasSurvey || (readings.length === 0 && limits.list.length === 0)) return next(e);
594    const elements = $.ui.resolve(e);
595    const now = await $.clock.now();
596    const line = drawLine(elements, e.surface, props.bodyColumns ?? 80, now);
597    // Mods placed after us draw below our line; an empty drawing adds no blank line.
598    const below = await next(e);
599    return isBlank(below) ? line : elements.Box({ flexDirection: "column", children: [line, below] });
600  });
601}
602
603// "en", "fr" or "ru": the language option when it names one, otherwise the environment's locale.
604async function languageOf($, choice) {
605  if (choice === "en" || choice === "fr" || choice === "ru") return choice;
606  let locale = "";
607  try {
608    locale = (await $.env.get("LC_ALL")) || (await $.env.get("LC_MESSAGES")) || (await $.env.get("LANG")) || "";
609  } catch {
610    locale = "";
611  }
612  if (!locale || locale === "C" || locale === "POSIX") {
613    try {
614      locale = Intl.DateTimeFormat().resolvedOptions().locale;
615    } catch {
616      locale = "";
617    }
618  }
619  return /^fr/i.test(locale) ? "fr" : /^ru/i.test(locale) ? "ru" : "en";
620}
621
622// ---------- Turns: readings kept per session ----------
623
624// Restores this session's readings and cache, and deletes sessions idle for more than 8 days.
625async function restoreTurns($) {
626  const now = await $.clock.now();
627  try {
628    for (const key of await $.store.keys()) {
629      if (!key.startsWith(TURNS_PREFIX)) continue;
630      const saved = await $.store.get(key);
631      if (key === turnsKey && saved && Array.isArray(saved.readings)) {
632        readings = saved.readings.filter((r) => r && r.window > 0).slice(-HISTORY);
633        if (saved.cache && Number.isFinite(saved.cache.at)) cache = saved.cache;
634        compacted = saved.compacted === true;
635        if (saved.seenTtl === "5m" || saved.seenTtl === "1h") seenTtl = saved.seenTtl;
636        if (Number.isFinite(saved.saved) && saved.saved >= 0) savedUsd = saved.saved;
637        if (Number.isFinite(saved.lastPrompt)) lastPrompt = saved.lastPrompt;
638        if (Number.isFinite(saved.lastPrompt5h)) lastPrompt5h = saved.lastPrompt5h;
639        if (saved.team && typeof saved.team === "object") team = saved.team;
640      } else if (!saved || !(now - saved.at < TURNS_KEEP_MS)) await $.store.delete(key);
641    }
642  } catch {
643    // Unreadable store: the line starts from scratch.
644  }
645}
646
647async function saveTurns($) {
648  if (!turnsKey) return;
649  try {
650    await $.store.set(turnsKey, { at: await $.clock.now(), readings, cache, compacted, seenTtl, saved: savedUsd, lastPrompt, lastPrompt5h, team });
651  } catch {
652    // Not saved this turn: the bars come back on the next one.
653  }
654}
655
656// ---------- Limits: shared reading ----------
657
658// Keeps this session's reading and publishes it if it is the most recent known.
659async function shareLimits($, list) {
660  const at = await $.clock.now();
661  limits = { at, list: sortLimits(list) };
662  let stored = null;
663  try {
664    stored = await $.store.get(SHARED_KEY);
665  } catch {
666    stored = null;
667  }
668  if (!stored || !(stored.at > at)) await $.store.set(SHARED_KEY, limits);
669}
670
671// Takes another session's reading when it is newer than ours.
672async function adoptShared($) {
673  try {
674    const stored = await $.store.get(SHARED_KEY);
675    if (stored && Array.isArray(stored.list) && stored.at > limits.at) limits = { at: stored.at, list: sortLimits(stored.list) };
676  } catch {
677    // Unreadable store: keep the local reading.
678  }
679}
680
681function sortLimits(list) {
682  const rank = (kind) => (ORDER.includes(kind) ? ORDER.indexOf(kind) : ORDER.length);
683  return [...list].sort((a, b) => rank(a.kind) - rank(b.kind));
684}
685
686// ---------- Limits: reading one window ----------
687
688// What the line shows of a window: share used, time elapsed, tone, grey detail.
689function gaugeOf(limit, now) {
690  const used = Math.max(0, limit.percentUsed);
691  const resetMs = limit.resetsAt ? Date.parse(limit.resetsAt) : NaN;
692  const span = SPANS[limit.kind];
693  const left = Number.isFinite(resetMs) ? Math.max(0, resetMs - now) : null;
694  const elapsed = span && left !== null ? bound(((span - left) / span) * 100) : null;
695  const pace = elapsed === null ? 0 : used - elapsed;
696  const tone = used >= USED_ALERT || pace > PACE_ALERT ? "alert" : pace > 0 ? "fast" : "calm";
697  // The time left; the 5-hour reset time goes to the clock's tooltip.
698  const when = left !== null ? duration(left) : "";
699  const resetAt = left !== null && limit.kind === "five_hour" ? clockTime(resetMs) : "";
700  return { kind: limit.kind, label: T.labels[limit.kind] ?? limit.kind, used, elapsed, tone, value: T.percent(Math.round(used)), when, resetAt };
701}
702
703// 3h02, 42 min, 2d23h (2j23h in French, 2д23ч in Russian).
704function duration(ms) {
705  const minutes = Math.round(ms / MINUTE);
706  if (minutes < 60) return `${minutes} ${T.minute}`;
707  const days = Math.floor(minutes / 1440);
708  const hours = Math.floor((minutes % 1440) / 60);
709  if (days > 0) return `${days}${T.day}${String(hours).padStart(2, "0")}${T.hour}`;
710  return `${hours}${T.hour}${String(minutes % 60).padStart(2, "0")}`;
711}
712
713// 24-hour time in the machine's time zone; UTC when the runtime has no time zone data.
714function clockTime(ms) {
715  try {
716    return new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).format(ms);
717  } catch {
718    const d = new Date(ms);
719    return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")} UTC`;
720  }
721}
722
723// The 5-hour window's share used, or null without one.
724function fiveHourOf(list) {
725  const w = (list ?? []).find((l) => l.kind === "five_hour");
726  return w && Number.isFinite(w.percentUsed) ? w.percentUsed : null;
727}
728
729// 6.5, 12, 0.4: one decimal under 10.
730function decimal(x) {
731  return x >= 10 ? String(Math.round(x)) : String(Math.round(x * 10) / 10);
732}
733
734function bound(percent) {
735  return Math.min(100, Math.max(0, percent));
736}
737
738// ---------- Prompt cache: requests and lifetime ----------
739
740// Names stay literal: the engine lists the variables a module reads.
741async function cacheEnvOf($) {
742  const read = async (get) => {
743    try {
744      return (await get()) || "";
745    } catch {
746      return "";
747    }
748  };
749  return {
750    off: isOn(await read(() => $.env.get("DISABLE_PROMPT_CACHING"))),
751    force5m: isOn(await read(() => $.env.get("FORCE_PROMPT_CACHING_5M"))),
752    ttl: await read(() => $.env.get("CLAUDE_CODE_PROMPT_CACHE_TTL")),
753    enable1h: isOn(await read(() => $.env.get("ENABLE_PROMPT_CACHING_1H"))),
754  };
755}
756
757function isOn(value) {
758  return /^(1|true|yes|on)$/i.test(String(value).trim());
759}
760
761function promptOf(r) {
762  return (r.read ?? 0) + (r.write ?? 0) + (r.fresh ?? 0);
763}
764
765function hitOf(r) {
766  const total = promptOf(r);
767  return total > 0 ? Math.round(((r.read ?? 0) / total) * 100) : 0;
768}
769
770// Notes a main-loop request, names the cause when it missed the cache, learns the lifetime, and
771// adds what the tokens read from the cache saved. The model comes from the usage, else the request.
772function recordRequest(at, usage, model) {
773  const cur = {
774    at,
775    model: usage.model || model || "",
776    read: usage.cache_read_input_tokens ?? 0,
777    write: usage.cache_creation_input_tokens ?? 0,
778    fresh: usage.input_tokens ?? 0,
779    cause: null,
780  };
781  const prev = cache;
782  const afterCompact = compacted;
783  compacted = false;
784  // After a compaction the request writes a new cache: read nothing, yet nothing missed.
785  if (afterCompact) cur.rebuilt = true;
786  if (prev && !afterCompact) {
787    const gap = at - prev.at;
788    const missed = hitOf(cur) < MISS_SHARE && cur.write > MISS_WRITE;
789    // A hit more than 5 minutes after the previous request proves the 1-hour lifetime;
790    // a miss within the hour, same model, prompt not shrunk, says 5 minutes.
791    if (!missed && cur.read > 0 && gap > TTL["5m"]) seenTtl = "1h";
792    else if (missed && gap > TTL["5m"] && gap < TTL["1h"] && cur.model === prev.model && promptOf(cur) >= promptOf(prev)) seenTtl = "5m";
793    if (missed) cur.cause = cur.model !== prev.model ? "model" : gap >= ttlMs() ? "lapsed" : "prefix";
794  }
795  const price = priceOf(cur.model, promptOf(cur));
796  if (price) savedUsd += (cur.read * (price.input - price.read)) / 1e6;
797  cache = cur;
798}
799
800// List prices of a model id, for a prompt of that many tokens: lowercase, without a "[1m]"-style
801// suffix, a trailing date or a provider prefix ("anthropic.", "us.anthropic.", ".../"); null when
802// the table lacks it.
803function priceOf(model, tokens = 0) {
804  let id = String(model ?? "").trim().toLowerCase();
805  id = id.replace(/\[[^\]]*\]$/, "").replace(/-20\d{6}$/, "");
806  id = id.slice(Math.max(id.lastIndexOf("."), id.lastIndexOf("/")) + 1);
807  if (!Object.prototype.hasOwnProperty.call(PRICES, id)) return null;
808  const price = PRICES[id];
809  return price.over && tokens > price.over.at ? price.over : price;
810}
811
812// Price of a cache write, per million tokens, for the lifetime in use.
813function writePrice(price, ttl) {
814  return (ttl === TTL["1h"] ? 2 : 1.25) * price.input;
815}
816
817// Claude Code's rules for the main conversation, after what the traffic showed.
818function ttlMs() {
819  if (seenTtl) return TTL[seenTtl];
820  if (cacheEnv.force5m) return TTL["5m"];
821  if (cacheEnv.ttl === "5m" || cacheEnv.ttl === "1h") return TTL[cacheEnv.ttl];
822  if (cacheEnv.enable1h) return TTL["1h"];
823  // A Claude subscription within its plan usage gets 1 hour; usage credits or an API key, 5 minutes.
824  const plan = limits.list.filter((l) => l.kind === "five_hour" || l.kind === "seven_day");
825  return plan.length > 0 && plan.every((l) => l.percentUsed < 100) ? TTL["1h"] : TTL["5m"];
826}
827
828// What the cache block shows: { tone, value, detail, urgent, stake, advice, tip }; null when
829// caching is off. detail follows the value (in yellow when urgent), stake comes after it, dim;
830// advice is for the terminal only (the app puts it in tip, the bolt's tooltip).
831function cacheState(now) {
832  if (cacheEnv.off) return null;
833  if (compacted) return { tone: "none", value: T.compacted, detail: "", tip: T.tips.compacted };
834  if (!cache) return { tone: "none", value: "—", detail: "" };
835  const left = cache.at + ttlMs() - now;
836  const tokens = readings.length > 0 ? readings[readings.length - 1].tokens : promptOf(cache);
837  const ttl = ttlMs();
838  // In dollars, at list prices, for the context the next message reads: from the cache, and
839  // written again once it lapsed. Null for a model missing from PRICES: tokens only.
840  const price = priceOf(cache.model, tokens);
841  const costs = price ? { read: (tokens * price.read) / 1e6, rewrite: (tokens * writePrice(price, ttl)) / 1e6 } : null;
842  // Expired: say what the next message writes again, and the way out: from 300k a new thread
843  // (it avoids rewriting the whole context at full price), from 100k /compact. In the app the
844  // way out goes to the tooltip; the terminal, without one, keeps it on the line.
845  if (left <= 0) {
846    const rewrite = T.toRewrite(short(tokens));
847    const detail = tokens < COMPACT_AT ? "" : costs ? `${rewrite} ${approx(costs.rewrite)}` : rewrite;
848    const advice = tokens >= LARGE_CONTEXT ? T.newThread : tokens >= COMPACT_AT ? "/compact" : "";
849    const tip = [T.tips.expired(short(tokens), costs && approx(costs.rewrite))];
850    if (tokens >= LARGE_CONTEXT) tip.push(T.tips.newThread);
851    else if (tokens >= COMPACT_AT) tip.push(T.tips.compact);
852    return { tone: "alert", value: T.expired, detail, advice, tip: tip.join("\n") };
853  }
854  const share = hitOf(cache);
855  // A miss: what writing the cache again cost above a message served by it.
856  if (cache.cause) {
857    const cause = T.causes[cache.cause];
858    const surcharge = price ? ((cache.write ?? 0) * (writePrice(price, ttl) - price.read)) / 1e6 : null;
859    const extra = surcharge !== null && surcharge >= 0.01 ? ` · +${T.money(surcharge)}` : "";
860    const tip = T.tips.missed(T.percent(share), cause, short(cache.write ?? 0), surcharge !== null && approx(surcharge));
861    return { tone: "fast", value: T.percent(share), detail: `${T.missed} · ${cause}${extra}`, tip };
862  }
863  const time = left < MINUTE ? T.underMinute : duration(left);
864  const soon = left < CACHE_SOON;
865  const expiry = clockTime(cache.at + ttl);
866  // A cache that served the message (90% or more), or one just rebuilt after a compaction,
867  // shows its time alone; below, the share first.
868  const shown = share >= GOOD_HIT || cache.rebuilt ? { value: time, detail: "" } : { value: T.percent(share), detail: time };
869  // Under 10 minutes: what letting it lapse would cost.
870  if (soon) {
871    const stake = T.atStake(costs ? T.money(costs.rewrite) : short(tokens));
872    const tip = T.tips.soon(expiry, short(tokens), costs && { read: approx(costs.read), rewrite: approx(costs.rewrite) });
873    return { tone: "fast", ...shown, urgent: true, stake, tip };
874  }
875  const tip = [T.tips.warm(expiry, ttl === TTL["1h"], seenTtl !== null), T.tips.lastRead(T.percent(share), short(cache.read ?? 0))];
876  if (costs) {
877    tip.push(T.tips.costs(approx(costs.read), approx(costs.rewrite)));
878    if (savedUsd >= 0.01) tip.push(T.tips.saved(approx(savedUsd)));
879  }
880  return { tone: "calm", ...shown, urgent: false, tip: tip.join("\n") };
881}
882
883// ---------- Agents ----------
884
885// Every agent $.agent.list() returned last: it names the team's entries.
886let listed = [];
887
888// Reads the subagents running now; true when the list changed.
889async function refreshAgents($) {
890  let list = [];
891  try {
892    list = await $.agent.list();
893  } catch {
894    return false;
895  }
896  listed = (list ?? []).filter((a) => a && a.id);
897  const running = listed.filter((a) => a.status === "running").map((a) => ({ id: a.id, type: a.type ?? "", description: a.description ?? "" }));
898  const key = running.map((a) => a.id).join(",");
899  if (key === agentsKey) return false;
900  agentsKey = key;
901  agents = running;
902  return true;
903}
904
905// One subagent request: its tokens, and their cost at list prices (Haiku 5.5's by the request's
906// prompt size). Subagents write the 5-minute cache (assumed). A model missing from PRICES counts
907// tokens only.
908function recordAgentStep(id, at, usage, model, effort) {
909  const a = (team[id] ??= { model: "", effort: null, usd: 0, priced: true, input: 0, read: 0, write: 0, output: 0, at, description: "", type: "" });
910  const u = { input: usage.input_tokens ?? 0, read: usage.cache_read_input_tokens ?? 0, write: usage.cache_creation_input_tokens ?? 0, output: usage.output_tokens ?? 0 };
911  a.model = usage.model || model || a.model;
912  if (effort !== undefined && effort !== null) a.effort = effort;
913  for (const k of ["input", "read", "write", "output"]) a[k] += u[k];
914  const price = priceOf(a.model, u.input + u.read + u.write);
915  if (price) a.usd += requestCost(price, u, TTL["5m"]);
916  else a.priced = false;
917}
918
919// What one request cost: its fresh input, cache reads, cache writes and output.
920function requestCost(price, u, ttl) {
921  return (u.input * price.input + u.read * price.read + u.write * writePrice(price, ttl) + u.output * OUTPUT * price.input) / 1e6;
922}
923
924// The description and type of each agent the list still holds.
925function nameTeam() {
926  for (const a of listed) {
927    const t = team[a.id];
928    if (!t) continue;
929    if (a.description) t.description = a.description;
930    if (a.type) t.type = a.type;
931  }
932}
933
934// "claude-haiku-5-5" → "Haiku 5.5"; an id it cannot read stays as it is.
935function modelName(model) {
936  let id = String(model ?? "").trim().toLowerCase().replace(/\[[^\]]*\]$/, "").replace(/-20\d{6}$/, "");
937  id = id.slice(Math.max(id.lastIndexOf("."), id.lastIndexOf("/")) + 1);
938  const m = /^claude-([a-z]+)-(\d+(?:-\d+)*)$/.exec(id);
939  return m ? `${m[1][0].toUpperCase()}${m[1].slice(1)} ${m[2].replace(/-/g, ".")}` : String(model ?? "");
940}
941
942// A share of the thread's cost, "> 99%" and "< 1%" at the ends.
943function shareOf(usd, total) {
944  if (!(total > 0)) return "";
945  const pct = (usd / total) * 100;
946  return T.percent(pct < 1 ? "< 1" : pct > 99 ? "> 99" : Math.round(pct));
947}
948
949// Agents in this thread: those counted and those running that have not answered yet.
950function teamSize() {
951  const ids = new Set(Object.keys(team));
952  for (const a of agents) ids.add(a.id);
953  return ids.size;
954}
955
956// The agents pill's card: the main thread's share, then each model's agents with their efforts
957// and tokens, the agents still running, and what delegating saved against the main thread's model.
958function teamTip(now) {
959  const list = Object.values(team);
960  const lines = [T.team.title(teamSize(), agents.length)];
961  const agentsUsd = list.reduce((sum, a) => sum + (a.priced ? a.usd : 0), 0);
962  const mainModel = cache?.model ?? "";
963  // The main thread: the session's cost less its agents' (its own requests are not all seen here).
964  if (cost !== null) {
965    const mainUsd = Math.max(0, cost - agentsUsd);
966    lines.push(T.team.line(T.team.main(modelName(mainModel) || "Claude"), approx(mainUsd), shareOf(mainUsd, cost)));
967  }
968  const groups = new Map();
969  for (const a of list) {
970    const g = groups.get(a.model) ?? { n: 0, usd: 0, priced: true, tokens: 0, efforts: new Map() };
971    g.n++;
972    g.usd += a.usd;
973    g.priced &&= a.priced;
974    g.tokens += a.input + a.read + a.write + a.output;
975    if (a.effort !== null && a.effort !== undefined) {
976      const e = T.team.efforts[a.effort] ?? String(a.effort);
977      g.efforts.set(e, (g.efforts.get(e) ?? 0) + 1);
978    }
979    groups.set(a.model, g);
980  }
981  for (const [model, g] of [...groups].sort((x, y) => y[1].usd - x[1].usd)) {
982    const label = T.team.group(modelName(model), g.n);
983    lines.push(g.priced ? T.team.line(label, approx(g.usd), cost !== null ? shareOf(g.usd, cost) : "") : label);
984    lines.push(T.team.detail([...g.efforts].map(([e, n]) => `${e} ×${n}`).join(" · "), short(g.tokens)));
985  }
986  if (list.length === 0 && agents.length === 0) lines.push(T.team.none);
987  for (const r of agents) {
988    const a = team[r.id];
989    lines.push(T.team.running(modelName(a?.model) || r.type, r.description, a && now - a.at >= MINUTE ? duration(now - a.at) : T.underMinute));
990  }
991  // The same tokens at the main thread's prices, for the agents on a cheaper model.
992  const main = priceOf(mainModel, 0);
993  if (main) {
994    let saved = 0;
995    for (const a of list) {
996      if (!a.priced || a.model === mainModel) continue;
997      const ref = requestCost(main, a, TTL["5m"]);
998      if (ref > a.usd) saved += ref - a.usd;
999    }
1000    if (saved >= 0.01) lines.push(T.team.saved(approx(saved), modelName(mainModel)));
1001  }
1002  return lines.join("\n");
1003}
1004
1005// The cache block as the terminal writes it: "cache 8 min · $2.32 at stake".
1006function cacheText(state) {
1007  return state ? [`${T.cache} ${state.value}`, state.detail, state.stake, state.advice].filter(Boolean).join(" · ") : "";
1008}
1009
1010// ---------- Blocks ----------
1011
1012function icon(Svg, key, name, color, alt, size = ICON_SIZE) {
1013  const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">${ICONS[name](color)}</svg>`;
1014  return Svg({ key, source, alt, width: size, height: size });
1015}
1016
1017function divider(Text, key) {
1018  return Text({ key, dimColor: true, children: SEP });
1019}
1020
1021function gaugeBlock({ Box, Text, Svg }, mode, g) {
1022  // The bar carries the color; the text stays in the theme's color, readable everywhere.
1023  const color = ICON_COLORS[g.kind] ?? ICON_COLORS.spend_limit;
1024  const parts = [];
1025  if (mode === "svg") parts.push(icon(Svg, "k", LIMIT_ICONS[g.kind] ?? "coin", color, T.icons[g.kind] ?? g.label));
1026  parts.push(Text({ key: "l", children: g.label }));
1027  if (mode === "svg" && Svg) parts.push(Svg({ key: "g", source: svgGauge(g), alt: T.gaugeAlt(g.label, g.value), width: GAUGE.width, height: GAUGE.height }));
1028  if (mode === "text") parts.push(textGauge(Box, Text, g));
1029  parts.push(Text(g.tone === "alert" ? { key: "v", bold: true, color: TONES.alert.text, children: g.value } : { key: "v", bold: true, children: g.value }));
1030  // Terminal too narrow: the detail goes with the bar, leaving the label and the percentage.
1031  if (g.when && mode === "svg") {
1032    parts.push(divider(Text, "s"), icon(Svg, "i", "clock", color, T.icons.reset, SMALL_ICON), Text({ key: "d", dimColor: true, children: g.when }));
1033  }
1034  else if (g.when && mode === "text") parts.push(Text({ key: "d", dimColor: true, children: `· ${g.when}` }));
1035  // The 5-hour reset time goes to the hover card.
1036  return { key: "gauge-" + g.label, tint: TINTS[g.kind] ?? TINTS.spend_limit, parts, tip: g.resetAt ? T.resetsAt(g.resetAt) : "" };
1037}
1038
1039function cacheBlock({ Text, Svg }, mode, state) {
1040  const parts = [];
1041  if (mode === "svg") {
1042    const color = ICON_COLORS[state.tone] ?? ICON_COLORS.calm;
1043    parts.push(icon(Svg, "i", "bolt", color, T.icons.cache));
1044  }
1045  // In the app the bolt says "cache"; the terminal keeps the word.
1046  if (mode !== "svg") parts.push(Text({ key: "l", children: T.cache }));
1047  // A miss in yellow, an expired cache in red; while the time runs short, the time carries the color.
1048  const valueColor =
1049    state.tone === "alert" ? TONES.alert.text : state.tone === "fast" && (!state.urgent || !state.detail) ? TONES.fast.text : undefined;
1050  parts.push(Text(state.tone === "none" ? { key: "v", dimColor: true, children: state.value } : { key: "v", bold: true, ...(valueColor ? { color: valueColor } : {}), children: state.value }));
1051  // After the value: the urgent detail in yellow, then the rest dim (the stake, and in the
1052  // terminal the advice the app keeps for the tooltip).
1053  if (mode !== "none") {
1054    const lead = state.urgent ? state.detail : "";
1055    const rest = [state.urgent ? "" : state.detail, state.stake, mode === "text" ? state.advice : ""].filter(Boolean).join(" · ");
1056    if (mode === "svg" && (lead || rest)) parts.push(divider(Text, "s"));
1057    if (lead) parts.push(Text({ key: "d", bold: true, color: TONES.fast.text, children: mode === "svg" ? lead : `· ${lead}` }));
1058    if (rest) parts.push(Text({ key: "e", dimColor: true, children: mode === "svg" && !lead ? rest : `· ${rest}` }));
1059  }
1060  const tint = TINTS[state.tone] ?? TINTS.calm;
1061  // Hover the pill for the expiry time, the share read, the costs and the advice.
1062  return { key: "cache", tint, parts, tip: state.tip ?? "" };
1063}
1064
1065// ---------- Limits: gauges ----------
1066
1067// Character bar: solid up to the share used; the gap with elapsed time in thick dashes ╍,
1068// in the bar's color when using faster than time, grey otherwise.
1069function textGauge(Box, Text, g) {
1070  const used = Math.round((g.used / 100) * TEXT_CELLS);
1071  const time = g.elapsed === null ? used : Math.round((g.elapsed / 100) * TEXT_CELLS);
1072  const color = TONES[g.tone].text;
1073  const cell = (i) => {
1074    const key = "c" + i;
1075    if (i < Math.min(used, time)) return Text({ key, color, children: "━" });
1076    if (i < used) return Text({ key, color, children: "╍" });
1077    if (i < time) return Text({ key, dimColor: true, children: "╍" });
1078    return Text({ key, dimColor: true, children: "─" });
1079  };
1080  // Cells side by side, without the block's spacing between them.
1081  return Box({ key: "bar", flexDirection: "row", children: Array.from({ length: TEXT_CELLS }, (_, i) => cell(i)) });
1082}
1083
1084// Drawn gauge: solid bar up to the share used; the gap with elapsed time is hatched,
1085// grey after the bar (margin left) or in the bar's color (ahead of time).
1086function svgGauge(g) {
1087  const { width, height } = GAUGE;
1088  const radius = height / 2;
1089  const used = (bound(g.used) / 100) * width;
1090  const time = g.elapsed === null ? used : (g.elapsed / 100) * width;
1091  const color = TONES[g.tone].svg;
1092  const id = "tw" + String(g.label).replace(/[^a-z0-9]/gi, "");
1093  const stripes = (name, back, backOpacity, line) =>
1094    `<pattern id="${name}" width="4" height="4" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">` +
1095    `<rect width="4" height="4" fill="${back}" fill-opacity="${backOpacity}"/><rect width="1.6" height="4" fill="${line}"/></pattern>`;
1096  const defs =
1097    `<defs>${stripes(id + "m", HATCH.back, 1, HATCH.line)}${stripes(id + "a", color, 0.35, color)}` +
1098    `<clipPath id="${id}t"><rect width="${width}" height="${height}" rx="${radius}"/></clipPath>` +
1099    `<clipPath id="${id}b"><rect width="${used.toFixed(1)}" height="${height}" rx="${radius}"/></clipPath></defs>`;
1100  let body = `<rect width="${width}" height="${height}" fill="${TRACK}"/>`;
1101  if (time > used) body += `<rect width="${time.toFixed(1)}" height="${height}" fill="url(#${id}m)"/>`;
1102  body += `<g clip-path="url(#${id}b)"><rect width="${Math.min(used, time).toFixed(1)}" height="${height}" fill="${color}"/>`;
1103  if (used > time) body += `<rect x="${time.toFixed(1)}" width="${(used - time).toFixed(1)}" height="${height}" fill="url(#${id}a)"/>`;
1104  body += `</g>`;
1105  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${defs}<g clip-path="url(#${id}t)">${body}</g></svg>`;
1106}
1107
1108// ---------- Line ----------
1109
1110function drawLine(elements, surface, columns, now) {
1111  const { Box, Text, Svg } = elements;
1112  const desktop = surface === "desktop" && !!Svg;
1113  // A window that already reset has no valid reading: hidden until the next one.
1114  const gauges = limits.list.filter((limit) => !(Date.parse(limit.resetsAt ?? "") <= now)).map((limit) => gaugeOf(limit, now));
1115  const cacheNow = cacheState(now);
1116  // Drawn bars in the app; in the terminal, characters when the line fits, otherwise no bar or detail.
1117  let mode = "svg";
1118  if (!desktop) mode = textWidth(gauges, cacheNow) <= columns - RESERVED_COLUMNS ? "text" : "none";
1119
1120  const blocks = [];
1121  if (readings.length > 0) {
1122    const cur = readings[readings.length - 1];
1123    const f = forecastFor(cur.percent);
1124    const title = T.contextAlt(T.weather[f.id], T.percent(cur.percent), short(cur.window));
1125    const icon = desktop
1126      ? Svg({ key: "icon", source: weatherSvg(f.id), alt: title, width: WEATHER_ICON_SIZE, height: WEATHER_ICON_SIZE })
1127      : Text({ key: "icon", color: f.color, bold: true, children: f.icon });
1128    const parts = [icon, Text({ key: "tokens", bold: true, children: short(cur.tokens) })];
1129    // A single reading draws no trend: the bars wait for the second turn.
1130    if (readings.length >= 2) {
1131      if (desktop) {
1132        parts.push(divider(Text, "s"));
1133        parts.push(Svg({ key: "spark", source: barsSvg(SPARK_COLORS[f.color] ?? SPARK_COLORS.blue), alt: T.turnsAlt(turnDeltas().length), width: barsWidth(turnDeltas().length), height: SPARK.height }));
1134      } else {
1135        parts.push(Box({ key: "spark", flexDirection: "row", children: chartText(Text, f.color) }));
1136      }
1137      const trend = trendWord();
1138      if (trend) parts.push(Text({ key: "d", dimColor: true, children: trend }));
1139    }
1140    blocks.push({ key: "context", tint: TINTS.context, parts, tip: title });
1141  }
1142  for (const g of gauges) blocks.push(gaugeBlock(elements, mode, g));
1143  if (cacheNow) blocks.push(cacheBlock(elements, mode, cacheNow));
1144  // The cost goes first when the terminal is short of room.
1145  if (cost !== null && cost >= 0.005 && mode !== "none") {
1146    const parts = [Text({ key: "v", bold: true, children: T.cost(cost) })];
1147    const share = lastPromptText();
1148    if (desktop) {
1149      // The app keeps the total; the last prompt goes to the card.
1150      parts.unshift(icon(Svg, "i", "coin", ICON_COLORS.cost, T.icons.cost));
1151      const tip = [T.costTips.total(T.cost(cost)), limits.list.length > 0 ? T.costTips.plan : T.costTips.api];
1152      if (share) tip.push(T.costTips.last(share));
1153      blocks.push({ key: "cost", tint: TINTS.cost, parts, tip: tip.join("\n") });
1154    } else {
1155      if (share) parts.push(Text({ key: "d", dimColor: true, children: `(${share})` }));
1156      blocks.push({ key: "cost", tint: TINTS.cost, parts });
1157    }
1158  }
1159  // Agents last. In the app the pill stays, its card splitting the cost by model and effort; in
1160  // the terminal the count shows once the thread has run one.
1161  const crew = teamSize();
1162  if (desktop || crew > 0) {
1163    const parts = [];
1164    if (desktop) parts.push(icon(Svg, "i", "agents", ICON_COLORS.agents, T.icons.agents));
1165    parts.push(Text({ key: "v", bold: true, children: agentsText(crew, desktop) }));
1166    blocks.push({ key: "agents", tint: TINTS.agents, parts, tip: desktop ? teamTip(now) : "" });
1167  }
1168
1169  const row = (b) => ({ key: b.key, flexDirection: "row", columnGap: 1, alignItems: "center", children: b.parts });
1170  if (desktop) {
1171    // Pills: tinted, outlined, side by side. The app rounds a Box only through its border, and
1172    // a border brings a padding that made the band taller than the prompt box: paddingY, set
1173    // after it, takes the vertical part back.
1174    // A pill never shrinks: squeezed, the app broke "24 %" over two lines.
1175    // A keyed pill is a hover scope: its card shows while the pointer is over it.
1176    const pills = blocks.map((b) =>
1177      Box({
1178        ...row(b),
1179        children: b.tip ? [...b.parts, hoverCard(Box, Text, b.tip)] : b.parts,
1180        flexShrink: 0,
1181        paddingX: 1,
1182        paddingY: 0,
1183        borderStyle: "round",
1184        borderColor: b.tint[1],
1185        backgroundColor: b.tint[0],
1186      }),
1187    );
1188    return Box({ flexDirection: "row", alignItems: "center", columnGap: 1, paddingX: 1, children: pills });
1189  }
1190  const children = [];
1191  blocks.forEach((b, i) => {
1192    if (i > 0) children.push(Box({ key: "sep-" + i, paddingX: 1, children: [Text({ dimColor: true, children: SEP })] }));
1193    children.push(Box(row(b)));
1194  });
1195  return Box({ flexDirection: "row", alignItems: "center", paddingX: 1, children });
1196}
1197
1198// Width of the terminal line in characters, with the bars and details.
1199function textWidth(gauges, cacheNow) {
1200  let width = 0;