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…

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.





/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 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.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).
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).
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.
hooks/token-weather-usage.mjs 1329 lines1// 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;