A live forecast of the context window above the prompt, with the 5-hour and 7-day usage windows, the session cost, today's spend and the subagent tally on the…

A Claude Code mod: a one-line forecast of the context window above the prompt (or under it, by option), with the account's usage windows, the session cost, today's spend and the subagent tally on the same line, buttons that act on it, a /weather command, a pane, and a set of optional actions: compact at a threshold, hold a prompt when a window or a cost cap is spent, offer a cheaper model, notify, beep.
☀ Clear 23% of context 234.3k / 1M last turns ▇█ ▲ +281 · 5h 28% ↻43m · 7d 35% ↻1d5h · $12.55 · today $18.40 · wk $31.00 · agents 1.2M [ compact ] [ context ] [ cost ] [ clear ]
Every action is off until you turn it on in /config; the hints (the /compact suggestion, the buttons, today's spend, the agents tally) are on.
234.3k): bold, coloured by size: green under warnTokens, yellow from there, red from dangerTokens. The window (/ 1M) stays dim.warnTokens up: the prompt is above the long-context line, where 1M-context models bill input at a higher rate./context, /compact, /clear change the context without a turn) and on every measure, so it reads as the status line does. The chart, the trend and the crossings still take one reading per completed turn.↻ time until reset. Green below 50% used, yellow below 80%, red from there./cost totals it.showDailyCost): spend summed over every session in the plugin's store, today and this ISO week. Each session writes only its own row, so sessions running side by side add up without overwriting each other; a session's spend counts on the day it started; rows older than eight weeks are dropped at the next start.showAgents): the tokens the session's subagents, forks and teammates have used, from their turn.complete usage. Their cost is already inside $.warnTokens, once more at dangerTokens, and once per window when the 5h or 7d window reaches rateWarnPercent. Re-armed by /clear, /weather reset, and whenever the figure falls back under the line.With buttons on and the band at least 51 columns wide, four buttons end the line (or take the row under it when the line is full): [ compact ], [ context ], [ cost ], [ clear ]. Click one, or press ctrl+x tab to focus the band and then its letter: c, x, d, n. compact asks first when compactConfirm is on and passes compactFocus to the summarizer; clear always asks; context and cost run the slash commands. The buttons hide while a turn runs (commands wait for an idle session) and never draw on the hint row (placement: below).
/weather the forecast, the last turns, the windows, cost, spend, agents, the context
categories as /context counts them, and every threshold in force
/weather reset forget the readings and re-arm the toasts and actions
/weather pane open or close the pane
/weather compact compact now, with the compactFocus instructions, no question asked
/weather ledger spend by day over the stored sessions, and this week's total
The command's output is a transcript row like any typed command's; nothing of it is sent to the model on its own.
/weather pane opens Token weather beside the transcript: the forecast, a chart of up to 200 readings bucketed to the pane's width, each usage window as a bar with its reset countdown, the cost lines and the agents tally, and a close button. pane: true opens it at session start, which the terminal only places from 144 columns; a narrower terminal gets a toast instead. Opened by the command it is placed at any width.
Each one is an option in /config under token-weather, or in pluginConfigs in ~/.claude/settings.json (the id is token-weather@skills-dir for a skills-dir install, or token-weather@nasrallah-mods from the marketplace).
Compact at a threshold (compactAt, tokens, 0 = off). After the turn whose reading crossed the line, the mod asks Context at 352k. Compact now? (compactConfirm, default on) and runs the same compaction /compact does, with compactFocus as the instructions. Later leaves the line crossed; it fires again once the context has dropped below the line and climbs back over it. A subagent's turn never triggers it. Compaction cannot run while a turn is in flight, so every action runs just after the hook that saw the reading returns; should the direct call still be refused, the mod queues /compact for the idle session.
Compaction focus (compactFocus, text). Besides the compactions this mod triggers, the text is appended to the instructions of every /compact you type and every auto-compaction of the main conversation. This is the one option whose text the model reads: the summarizer sees it.
Suggest /compact (suggestCompact, default on). From dangerTokens up, after each turn the prompt box shows /compact as its dim suggestion; Tab takes it, Enter sends it. Nothing is sent unless you do.
Hold prompts on the 5-hour window (guardRateLimit, percent, 0 = off). When the 5h window is at or past the line, each prompt you send first asks 5h window at 96%. Send anyway?. Later drops the prompt with the reason shown and puts your text back in the prompt box. Prompts a plugin, a schedule or a peer submits are never held. A failing guard passes the prompt, never drops it.
Session cost cap (costLimitUsd, dollars, 0 = off). A toast at 80% of the cap, and at the cap each prompt asks Session cost $12.00 is past the $10.00 cap. Send anyway? the same way.
Model downshift (downshiftModel, an alias such as sonnet, empty = off; downshiftAt, percent, default 90). Once per session, when the weekly window (or the 5-hour one, where there is no weekly window) reaches the line, the mod asks 7d window at 91%. Switch to sonnet?; Switch runs /model sonnet. The alias is passed as typed; /model reports an unknown one.
Notifications (notify, default off). The warn and danger crossings, a window reaching rateWarnPercent and the cost cap's 80% mark also raise a native notification through your preferredNotifChannel, headed token-weather.
Sound (sound: off, beep, speak). On the same crossings, beep plays a short tone (sounds/warn.wav, sounds/danger.wav, through afplay; a Linux or Windows terminal plays nothing) and speak reads the alert with the system voice.
| key | type | default | effect |
|---|---|---|---|
placement | above / below | above | the band above the prompt, stacked with other mods' bands and with the buttons; or the hint row under it |
warnTokens | number | 200000 | yellow count, ⚠2x tag, first toast |
dangerTokens | number | 300000 | red count, second toast, /compact suggestion from here |
suggestCompact | boolean | true | dim /compact suggestion after each turn in the red zone |
buttons | boolean | true | compact, context, cost, clear buttons beside or under the line, from 51 columns |
showDailyCost | boolean | true | today $X · wk $Y on the line |
showAgents | boolean | true | agents 1.2M on the line |
compactAt | number | 0 | tokens; compact after the turn that crossed it; 0 off |
compactConfirm | boolean | true | ask Compact / Later first (compactAt and the button) |
compactFocus | string | "" | instructions for every compaction, this mod's and yours |
notify | boolean | false | native notification on alerts |
rateWarnPercent | number | 90 | window percent that raises an alert; 0 off |
sound | off / beep / speak | off | tone or speech on alerts |
guardRateLimit | number | 0 | 5h window percent from which prompts ask Send / Later; 0 off |
costLimitUsd | number | 0 | session cost cap: toast at 80%, prompts ask at 100%; 0 off |
downshiftModel | string | "" | alias offered once when the window runs low; empty off |
downshiftAt | number | 90 | 7d (else 5h) window percent for the offer |
pane | boolean | false | open the pane at session start |
Numbers that are not finite or are negative fall back to their default; dangerTokens is never below warnTokens; percents are 0 (off) or 1 to 100.
The band, the toasts, the notifications, the sounds, the guards' questions and /weather's output are drawn for you alone. Three things do reach the model, each by your hand or your setting: compactFocus, read by the summarizer when a compaction runs; a /compact suggestion you take with Tab and send; and /model, /compact, /context, /cost or /clear run from a button, an offer or /weather, whose rows enter the transcript as a typed command's would. The figures come from $.session.usage(), the same ones the status line reads; no model call is made for them.
In a Claude Code terminal session (2.1.287 or later, where mods load by default):
/plugin install token-weather --marketplace Nasrallah-Adel/claude-token-weather
Answer y to add the marketplace, then pick the user scope (Enter) so it loads in every project. It is active at once in that session and in each session started after.
To try it from a clone instead, for one session with hot reload:
git clone https://github.com/Nasrallah-Adel/claude-token-weather.git
claude --plugin-dir ./claude-token-weather
placement: "above", the band above the prompt is one slot shared by every mod. This mod awaits the mods beneath it and stacks its line above theirs, so it coexists with other band-drawing mods such as prompt-cache-control. A mod that draws without passing the band on will hide it./config change reloads the module: the readings start over and the toasts are armed again, as after /clear. The spend ledger lives in the plugin's store and survives.claude plugin validate . # what it hooks and calls
claude plugin test . # the tests in tests/: pure helpers and the module through the engine's kit
hooks/token-weather.mjs is the one module that touches $ (the host follows $ only inside the file it is handed to); the rest are pure and tested on their own: options, readings, band, guards, spend, weather-command, pane, usage-status.
Hooks: session.start, session.end, session.measure, session.compact, turn.step, turn.complete, prompt.submit, command.run (/weather, and every command for the live count), ui.render on AbovePrompt, PromptHint and the weather pane.
Apache-2.0. The context-window forecast started from the token-weather example in anthropics/claude-code-playground; the usage windows, cost, colors, band stacking, actions, command and pane are this repo's. NOTICE carries the attribution Apache-2.0 requires.
hooks/token-weather.mjs 472 lines1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Token Weather: a live forecast of the context window above the prompt, and
5// the actions it can take from there.
6//
7// This is the one module that touches $: the host follows $ only inside the
8// file it is handed to, so every on(...) and $.noun.method(...) is spelled
9// here, and helpers that take $ are top-level functions. The pure parts live
10// beside it: options, readings, band, guards, spend, weather-command, pane.
11//
12// Hooks: session.start, session.end, session.measure, session.compact,
13// turn.step, turn.complete, prompt.submit, command.run, ui.render on
14// PromptHint, AbovePrompt and the weather Pane.
15//
16// Two readings: `live` follows every model request, every command and every
17// measure, so the count on the band is the status line's; `readings` takes
18// one entry per completed turn, for the chart, the trend and the crossings.
19
20import { parseOptions } from "./options.mjs";
21import { pushReading, readingFrom, crossings, short, addAgentUsage, EMPTY_TALLY } from "./readings.mjs";
22import { bandParts, buttonSpecs, fitParts, layoutBand } from "./band.mjs";
23import { guardVerdict, rateCrossings, downshiftDue, costWarningDue, windowLabel } from "./guards.mjs";
24import { spendKey, spendEntry, sumSpend, staleKeys, SPEND_PREFIX, usd } from "./spend.mjs";
25import { parseWeatherArgs, weatherReport, ledgerReport, HELP } from "./weather-command.mjs";
26import { paneRows } from "./pane.mjs";
27
28const USAGE_TICK_MS = 60_000;
29// Actions wait for the hook that saw the reading to return: a turn's hooks are waited on.
30const ACT_DELAY_MS = 150;
31const PANE_ID = "weather";
32const PANE_COLUMNS = 60;
33const SOUND_ASSET = { warn: "sounds/warn.wav", danger: "sounds/danger.wav" };
34
35// The options, parsed once per load.
36let opts = parseOptions(undefined);
37// Everything the drawings read; replaced, never mutated.
38let state = fresh();
39let usageTick;
40let sessionId;
41// This conversation's own spend row, the session cost it last saw, and what the other rows add up to.
42let ownSpend;
43let lastSeenUsd = 0;
44let othersSpend = { today: 0, week: 0 };
45
46function fresh() {
47 return {
48 readings: [],
49 live: undefined,
50 usage: undefined,
51 spend: { today: 0, week: 0 },
52 agents: EMPTY_TALLY,
53 lastTokens: 0,
54 rateFired: {},
55 cost80: false,
56 downshifted: false,
57 compactFailed: false,
58 };
59}
60
61function set(patch) {
62 state = { ...state, ...patch };
63}
64
65export function register(on, options) {
66 opts = parseOptions(options);
67
68 on("session.start", async ($, e, next) => {
69 const result = await next(e);
70 // This mod pins nothing under the prompt; clear an entry an earlier build left.
71 $.ui.status(undefined);
72 state = fresh();
73 await registerCommand($);
74 await takeReading($);
75 await loadSpend($);
76 if (usageTick) usageTick.cancel();
77 usageTick = $.clock.every(USAGE_TICK_MS, () => refreshSpend($));
78 if (opts.pane) await openPane($, false);
79 return result;
80 });
81
82 // /clear starts a new conversation in the same process: fresh readings, every line armed again.
83 on("session.end", ($, e, next) => {
84 if (e.reason === "clear") {
85 state = { ...fresh(), usage: state.usage, spend: state.spend };
86 // The new conversation has an id of its own: its spend starts a row of its own.
87 sessionId = undefined;
88 ownSpend = undefined;
89 $.ui.invalidate("ui.render");
90 }
91 return next(e);
92 });
93
94 on("session.measure", async ($, e, next) => {
95 set({ usage: { rateLimits: e.rateLimits, cost: e.cost }, live: readingFrom(e.context) ?? state.live });
96 if (e.changed.includes("cost") && e.cost) await recordSpend($, e.cost.usd);
97 onWindowsMoved($, e.rateLimits);
98 onCostMoved($, e.cost?.usd);
99 $.ui.invalidate("ui.render");
100 return next(e);
101 });
102
103 // The focus text rides along with /compact and auto-compaction too, when set.
104 on("session.compact", ($, e, next) => {
105 if (!opts.compactFocus || e.agentId || !Array.isArray(e.messages) || (e.trigger !== "manual" && e.trigger !== "auto")) return next(e);
106 const instructions = [e.instructions, opts.compactFocus].filter(Boolean).join("\n");
107 return next({ ...e, instructions });
108 }).catch(($, e, next) => next(e));
109
110 // Each model request moves the live count: the band follows the status line mid-turn.
111 on("turn.step", async function* ($, e, next) {
112 const result = yield* next(e);
113 if (!e.agentId) await liveReading($);
114 return result;
115 });
116
117 on("turn.complete", async ($, e, next) => {
118 const result = await next(e);
119 if (e.agentId) {
120 set({ agents: addAgentUsage(state.agents, e.agentId, e.usage) });
121 $.ui.invalidate("ui.render");
122 return result;
123 }
124 const previous = state.lastTokens;
125 await takeReading($);
126 if (e.reason === "answer") afterTurn($, previous, state.lastTokens);
127 return result;
128 });
129
130 // The guards: a prompt waits for a yes when a window or the cost cap says so.
131 // A failing guard passes the prompt, never drops it: the .catch answers next(e).
132 on("prompt.submit", async ($, e, next) => {
133 const verdict = guardVerdict(state.usage, opts, e.origin?.kind);
134 if (!verdict) return next(e);
135 let choice;
136 try {
137 choice = await $.ui.ask(verdict.question, ["Send", "Later"]);
138 } catch {
139 choice = "Later";
140 }
141 if (choice === "Send") return next(e);
142 // The text goes back in the box, so Later costs nothing but the send.
143 defer($, () => $.prompt.fill({ text: e.text }));
144 return { drop: `token-weather: ${verdict.reason}` };
145 }).catch(($, e, next) => next(e));
146
147 on("command.run", { command: "weather" }, async ($, e) => ({ text: await runWeather($, e) }))
148 .catch(($, e, next) => (next.called ? next(e) : { text: `token weather: ${message(next.error?.cause ?? next.error)}` }));
149
150 // A command's rows (/context, /compact, /clear) change the context without a turn: read it again after.
151 on("command.run", async ($, e, next) => {
152 const result = await next(typeof e.args === "string" ? e : { ...e, args: "" });
153 if (e.command !== "weather") defer($, () => liveReading($));
154 return result;
155 }).catch(($, e, next) => next(e));
156
157 // Below the prompt: the dim hint row. The engine's own hint ("auto mode on…") follows the line, dim.
158 on("ui.render", { component: "PromptHint" }, ($, e, next) => {
159 if (opts.placement !== "below" || state.readings.length === 0) return next(e);
160 const { Box, Text } = $.ui.resolve(e);
161 const columns = e.viewport?.columns ?? e.props?.bodyColumns ?? 80;
162 // Two cells of padding, and the engine's hint keeps its own room at the end.
163 const reserve = 2 + (e.props?.hint ? [...e.props.hint].length + 4 : 0);
164 const line = fitParts(bandParts(state, opts, columns, Date.now()), columns, reserve).map((p) => Text(textProps(p)));
165 if (e.props?.hint) line.push(Text({ dimColor: true, wrap: "truncate-end", children: ` · ${e.props.hint}` }));
166 return Box({ flexDirection: "row", paddingX: 1, children: line });
167 });
168
169 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
170 if (opts.placement !== "above" || e.props?.hasSurvey || state.readings.length === 0) return next(e);
171 const own = drawBand($, e);
172 // The band is one instance: keep whatever the mods beneath draw, under this line.
173 const below = await next(e);
174 const { Box } = $.ui.resolve(e);
175 return below ? Box({ flexDirection: "column", children: [own, below] }) : own;
176 });
177
178 on("ui.render", { component: "Pane", requestId: PANE_ID }, ($, e) => drawPane($, e));
179}
180
181// ---- readings and what follows a turn ----------------------------------------------------
182
183async function takeReading($) {
184 try {
185 const { context, rateLimits, cost } = await $.session.usage();
186 const reading = readingFrom(context);
187 if (!reading) {
188 set({ usage: { rateLimits, cost } });
189 return;
190 }
191 set({
192 usage: { rateLimits, cost },
193 readings: pushReading(state.readings, reading),
194 live: reading,
195 lastTokens: reading.tokens,
196 });
197 $.ui.invalidate("ui.render");
198 } catch {
199 // No reading this turn; the band keeps the last one.
200 }
201}
202
203// The count as it stands now, without a bar on the chart.
204async function liveReading($) {
205 try {
206 const { context, rateLimits, cost } = await $.session.usage();
207 const reading = readingFrom(context);
208 if (!reading) return;
209 set({ usage: { rateLimits, cost }, live: reading });
210 $.ui.invalidate("ui.render");
211 } catch {
212 // The band keeps the last reading.
213 }
214}
215
216function afterTurn($, previous, tokens) {
217 const crossed = crossings(previous, tokens, { warn: opts.warnTokens, danger: opts.dangerTokens, compact: opts.compactAt });
218 if (crossed.includes("danger")) {
219 alert($, `context passed ${short(opts.dangerTokens)}: red zone, consider /compact`, "danger");
220 } else if (crossed.includes("warn")) {
221 alert($, `context passed ${short(opts.warnTokens)}: long-context rate from here`, "warn");
222 }
223 if (crossed.includes("compact")) {
224 defer($, () => runCompact($, { confirm: opts.compactConfirm, tokens }));
225 } else if (opts.suggestCompact && tokens >= opts.dangerTokens) {
226 defer($, () => $.prompt.suggest({ text: "/compact" }));
227 }
228}
229
230function onWindowsMoved($, rateLimits) {
231 const { fired, state: rateFired } = rateCrossings(state.rateFired, rateLimits, opts.rateWarnPercent);
232 set({ rateFired });
233 for (const kind of fired) {
234 const w = rateLimits.find((x) => x.kind === kind);
235 alert($, `${windowLabel(kind)} window at ${Math.round(w.percentUsed)}%`, "warn");
236 }
237 const due = downshiftDue(rateLimits, opts);
238 if (due && !state.downshifted) {
239 set({ downshifted: true });
240 defer($, () => runDownshift($, due));
241 }
242}
243
244function onCostMoved($, cost) {
245 if (!state.cost80 && costWarningDue(cost, opts)) {
246 set({ cost80: true });
247 alert($, `session cost ${usd(cost)}: 80% of the ${usd(opts.costLimitUsd)} cap`, "warn");
248 }
249}
250
251// ---- the actions ----------------------------------------------------------------------------
252
253// Runs `fn` once the hook that scheduled it has returned: a turn's hooks are waited on, and
254// compaction, commands, questions and suggestions all refuse to run inside one.
255function defer($, fn) {
256 $.clock.after(ACT_DELAY_MS, async () => {
257 try {
258 await fn();
259 } catch (error) {
260 $.ui.toast(`token-weather: ${message(error)}`);
261 }
262 });
263}
264
265function alert($, text, kind) {
266 $.ui.toast(text);
267 if (opts.notify) {
268 $.ui.notify(text, { title: "token-weather" }).catch(() => {});
269 }
270 if (opts.sound === "beep") {
271 $.audio.play({ asset: SOUND_ASSET[kind] ?? SOUND_ASSET.warn }).catch(() => {});
272 } else if (opts.sound === "speak") {
273 $.audio.speak(text).catch(() => {});
274 }
275}
276
277async function runCompact($, { confirm, tokens }) {
278 if (confirm) {
279 let choice;
280 try {
281 choice = await $.ui.ask(`Context at ${short(tokens ?? state.lastTokens)}. Compact now?`, ["Compact", "Later"]);
282 } catch {
283 choice = "Later";
284 }
285 if (choice !== "Compact") return "later";
286 }
287 const args = opts.compactFocus ? { instructions: opts.compactFocus } : undefined;
288 try {
289 const result = await $.session.compact(args);
290 if (result && "skip" in result) {
291 $.ui.toast(`compaction skipped: ${result.skip}`);
292 return "skipped";
293 }
294 return "compacted";
295 } catch (error) {
296 // Between turns the direct call works; mid-turn the command queues until the session is idle.
297 await $.command.run({ command: "compact", args: opts.compactFocus });
298 return `queued (${message(error)})`;
299 }
300}
301
302async function runDownshift($, due) {
303 let choice;
304 try {
305 choice = await $.ui.ask(`${due.window} window at ${Math.round(due.percent)}%. Switch to ${opts.downshiftModel}?`, ["Switch", "Keep"]);
306 } catch {
307 choice = "Keep";
308 }
309 if (choice !== "Switch") return;
310 await $.command.run({ command: "model", args: opts.downshiftModel });
311}
312
313async function pressButton($, key) {
314 if (key === "compact") return runCompact($, { confirm: opts.compactConfirm });
315 if (key === "clear") {
316 let choice;
317 try {
318 choice = await $.ui.ask("Clear the conversation?", ["Clear", "Keep"]);
319 } catch {
320 choice = "Keep";
321 }
322 if (choice !== "Clear") return;
323 }
324 await $.command.run({ command: key });
325}
326
327// ---- spend ledger over $.store ----------------------------------------------------------------
328
329async function loadSpend($) {
330 try {
331 sessionId = await $.session.id();
332 const keys = (await $.store.keys()).filter((k) => k.startsWith(SPEND_PREFIX));
333 const rows = Object.fromEntries(await Promise.all(keys.map(async (k) => [k, await $.store.get(k)])));
334 const now = Date.now();
335 for (const key of staleKeys(rows, now)) await $.store.delete(key);
336 ownSpend = rows[spendKey(sessionId)];
337 othersSpend = sumSpend(Object.entries(rows).filter(([k]) => k !== spendKey(sessionId)).map(([, row]) => row), now);
338 const own = sumSpend([ownSpend], now);
339 set({ spend: { today: othersSpend.today + own.today, week: othersSpend.week + own.week } });
340 } catch {
341 // No ledger: the band shows the session cost alone.
342 }
343}
344
345// Other sessions write their own rows: read them again on the clock, so the total keeps up.
346async function refreshSpend($) {
347 await loadSpend($);
348 $.ui.invalidate("ui.render");
349}
350
351// The session cost is a running total; the row takes what it grew by since the last
352// measure, so a /clear (a new id, a cost that may start over) never loses or doubles a cent.
353async function recordSpend($, cost) {
354 if (typeof cost !== "number" || !Number.isFinite(cost)) return;
355 try {
356 if (!sessionId) {
357 sessionId = await $.session.id();
358 const existing = await $.store.get(spendKey(sessionId));
359 ownSpend = existing && typeof existing.usd === "number" ? existing : undefined;
360 }
361 const delta = cost < lastSeenUsd ? cost : cost - lastSeenUsd;
362 lastSeenUsd = cost;
363 const now = Date.now();
364 // The row keeps the day the conversation started counting; only the amount grows.
365 ownSpend = ownSpend ? { ...ownSpend, usd: ownSpend.usd + delta, at: now } : spendEntry(delta, now);
366 await $.store.set(spendKey(sessionId), ownSpend);
367 const own = sumSpend([ownSpend], now);
368 set({ spend: { today: othersSpend.today + own.today, week: othersSpend.week + own.week } });
369 } catch {
370 // The ledger is a convenience; a failed write costs nothing else.
371 }
372}
373
374// ---- /weather ---------------------------------------------------------------------------------
375
376async function registerCommand($) {
377 try {
378 await $.command.register({
379 name: "weather",
380 description: "Token weather: the forecast, usage windows, spend and agents; reset | pane | compact | ledger",
381 argumentHint: "[reset|pane|compact|ledger]",
382 });
383 } catch {
384 // A refused registration leaves the band working.
385 }
386}
387
388async function runWeather($, e) {
389 const form = parseWeatherArgs(e.args);
390 if (form === "help") return HELP;
391 if (form === "reset") {
392 state = { ...fresh(), usage: state.usage, spend: state.spend };
393 $.ui.invalidate("ui.render");
394 return "token weather: readings forgotten, toasts and actions re-armed";
395 }
396 if (form === "pane") return togglePane($);
397 if (form === "compact") return `token weather: ${await runCompact($, { confirm: false })}`;
398 if (form === "ledger") return ledgerReport(await spendRows($), Date.now());
399 return weatherReport(await reportView($, e.presentation?.columns), opts, Date.now());
400}
401
402async function reportView($, columns) {
403 const view = { readings: state.readings, live: state.live, usage: state.usage, spend: state.spend, agents: state.agents };
404 try {
405 const usage = await $.session.usage({ breakdown: "summary", columns });
406 return { ...view, categories: usage.context?.breakdown?.categories, model: usage.context?.breakdown?.model ?? (await $.session.model()) };
407 } catch {
408 return view;
409 }
410}
411
412async function spendRows($) {
413 const keys = (await $.store.keys()).filter((k) => k.startsWith(SPEND_PREFIX));
414 return Promise.all(keys.map((k) => $.store.get(k)));
415}
416
417// ---- the pane ---------------------------------------------------------------------------------
418
419async function openPane($, asked) {
420 const opened = await $.ui.open({ id: PANE_ID, title: "Token weather", columns: PANE_COLUMNS });
421 if (!opened.isPlaced && asked) return `token weather: the pane is not placed (${opened.reason})`;
422 if (!opened.isPlaced) $.ui.toast(`token-weather: pane waits for a wider terminal (${opened.reason})`);
423 return opened.isPlaced ? "token weather: pane opened" : "";
424}
425
426async function togglePane($) {
427 const open = (await $.ui.panes()).some((p) => p.id === PANE_ID);
428 if (open) {
429 await $.ui.close({ id: PANE_ID });
430 return "token weather: pane closed";
431 }
432 return openPane($, true);
433}
434
435// ---- drawing ----------------------------------------------------------------------------------
436
437function textProps(part) {
438 return { color: part.color, bold: part.bold, dimColor: part.dim, children: part.text };
439}
440
441function drawBand($, e) {
442 const { Box, Text, Button } = $.ui.resolve(e);
443 const columns = e.props?.bodyColumns ?? e.viewport?.columns ?? 80;
444 const specs = buttonSpecs(opts, columns, Boolean(e.props?.isWorking));
445 const laid = layoutBand(bandParts(state, opts, columns, Date.now()), columns, specs);
446 const line = laid.parts.map((p) => Text(textProps(p)));
447 const buttons = laid.buttons.flatMap((b) => [
448 Text({ children: " " }),
449 Button({ key: b.key, label: b.label, hotkey: b.hotkey, onPress: () => pressButton($, b.key) }),
450 ]);
451 if (laid.row === "below") {
452 // The line keeps its row; the buttons take the next one, indented under the forecast word.
453 return Box({ flexDirection: "column", paddingX: 1, children: [
454 Box({ flexDirection: "row", children: line }),
455 Box({ flexDirection: "row", children: [Text({ children: " " }), ...buttons] }),
456 ] });
457 }
458 return Box({ flexDirection: "row", paddingX: 1, children: [...line, ...buttons] });
459}
460
461function drawPane($, e) {
462 const { Box, Text, Button } = $.ui.resolve(e);
463 const size = { columns: e.props?.bodyColumns ?? e.viewport?.columns ?? 60, rows: e.viewport?.rows ?? 24 };
464 const rows = paneRows(state, opts, size, Date.now()).map((r) => Text(textProps(r)));
465 const close = Button({ key: "close", label: "close", role: "dismiss", onPress: () => $.ui.close({ id: PANE_ID }) });
466 return Box({ flexDirection: "column", paddingX: 1, children: [...rows, Text({ children: "" }), close] });
467}
468
469function message(error) {
470 return error instanceof Error ? error.message : String(error);
471}
472hooks/options.mjs 95 lines1// Pure: the mod's options, parsed once in register() into one frozen config.
2// Every key is a userConfig field in .claude-plugin/plugin.json; the defaults
3// here match the manifest's. Actions are off by default, hints on.
4
5import { CONTEXT_WARN_TOKENS, CONTEXT_DANGER_TOKENS } from "./usage-status.mjs";
6
7export const DEFAULTS = Object.freeze({
8 placement: "above",
9 warnTokens: CONTEXT_WARN_TOKENS,
10 dangerTokens: CONTEXT_DANGER_TOKENS,
11 suggestCompact: true,
12 buttons: true,
13 showDailyCost: true,
14 showAgents: true,
15 compactAt: 0,
16 compactConfirm: true,
17 compactFocus: "",
18 notify: false,
19 rateWarnPercent: 90,
20 sound: "off",
21 guardRateLimit: 0,
22 costLimitUsd: 0,
23 downshiftModel: "",
24 downshiftAt: 90,
25 pane: false,
26});
27
28const PLACEMENTS = ["above", "below"];
29const SOUNDS = ["off", "beep", "speak"];
30
31/** The options as the engine hands them, made whole: defaults filled, bad values replaced. */
32export function parseOptions(options) {
33 const o = options ?? {};
34 const warn = positive(o.warnTokens, DEFAULTS.warnTokens);
35 const danger = Math.max(warn, positive(o.dangerTokens, DEFAULTS.dangerTokens));
36 return Object.freeze({
37 placement: pick(o.placement, PLACEMENTS, DEFAULTS.placement),
38 warnTokens: warn,
39 dangerTokens: danger,
40 suggestCompact: bool(o.suggestCompact, DEFAULTS.suggestCompact),
41 buttons: bool(o.buttons, DEFAULTS.buttons),
42 showDailyCost: bool(o.showDailyCost, DEFAULTS.showDailyCost),
43 showAgents: bool(o.showAgents, DEFAULTS.showAgents),
44 compactAt: nonNegative(o.compactAt, DEFAULTS.compactAt),
45 compactConfirm: bool(o.compactConfirm, DEFAULTS.compactConfirm),
46 compactFocus: text(o.compactFocus, DEFAULTS.compactFocus),
47 notify: bool(o.notify, DEFAULTS.notify),
48 rateWarnPercent: percent(o.rateWarnPercent, DEFAULTS.rateWarnPercent),
49 sound: pick(o.sound, SOUNDS, DEFAULTS.sound),
50 guardRateLimit: percent(o.guardRateLimit, DEFAULTS.guardRateLimit),
51 costLimitUsd: nonNegative(o.costLimitUsd, DEFAULTS.costLimitUsd),
52 downshiftModel: text(o.downshiftModel, DEFAULTS.downshiftModel),
53 downshiftAt: percent(o.downshiftAt, DEFAULTS.downshiftAt),
54 pane: bool(o.pane, DEFAULTS.pane),
55 });
56}
57
58function number(value) {
59 const n = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
60 return typeof n === "number" && Number.isFinite(n) ? n : undefined;
61}
62
63function positive(value, fallback) {
64 const n = number(value);
65 return n !== undefined && n > 0 ? n : fallback;
66}
67
68function nonNegative(value, fallback) {
69 const n = number(value);
70 return n !== undefined && n >= 0 ? n : fallback;
71}
72
73// 0 means off; otherwise a percentage clamped to 1..100.
74function percent(value, fallback) {
75 const n = number(value);
76 if (n === undefined || n < 0) return fallback;
77 if (n === 0) return 0;
78 return Math.min(100, Math.max(1, n));
79}
80
81function bool(value, fallback) {
82 if (typeof value === "boolean") return value;
83 if (value === "true") return true;
84 if (value === "false") return false;
85 return fallback;
86}
87
88function text(value, fallback) {
89 return typeof value === "string" ? value.trim() : fallback;
90}
91
92function pick(value, allowed, fallback) {
93 return allowed.includes(value) ? value : fallback;
94}
95hooks/readings.mjs 78 lines1// Pure: the context readings the band and the pane draw from, and the lines
2// a reading crosses. Nothing here touches $.
3
4export const HISTORY_BAND = 12;
5export const HISTORY_PANE = 200;
6const BARS = "▁▂▃▄▅▆▇█";
7
8// Forecast bands, by percent of the window used.
9// Single-width text symbols, not emoji: they line up in every terminal font.
10export const FORECAST = Object.freeze([
11 { upTo: 25, icon: "☀", word: "Clear", color: "yellow" },
12 { upTo: 50, icon: "☁", word: "Cloudy", color: "cyan" },
13 { upTo: 75, icon: "☂", word: "Showers", color: "blue" },
14 { upTo: 90, icon: "☇", word: "Storm", color: "magenta" },
15 { upTo: Infinity, icon: "↯", word: "Compact soon", color: "red" },
16]);
17
18export function forecastFor(percent) {
19 return FORECAST.find((band) => percent < band.upTo) ?? FORECAST[FORECAST.length - 1];
20}
21
22/** A new readings list with `reading` appended, zero readings dropped, the oldest trimmed past `keep`. */
23export function pushReading(readings, reading, keep = HISTORY_PANE) {
24 const kept = readings.filter((r) => r.tokens > 0);
25 const next = [...kept, reading];
26 return next.length > keep ? next.slice(-keep) : next;
27}
28
29/** One reading from a $.session.usage() context, or undefined when the window is unknown. */
30export function readingFrom(context) {
31 if (!context || !context.window) return undefined;
32 const tokens = context.tokens ?? 0;
33 const percent = Math.round(context.percent ?? (tokens / context.window) * 100);
34 return { tokens, window: context.window, percent };
35}
36
37/** The lines (by name) that `tokens` crossed upward since `previous`; `0` lines never fire. */
38export function crossings(previous, tokens, lines) {
39 return Object.entries(lines)
40 .filter(([, at]) => at > 0 && previous < at && tokens >= at)
41 .map(([name]) => name);
42}
43
44/** Bars scale to the busiest reading shown, so growth shows at any fill level. */
45export function chart(readings, width = readings.length) {
46 const shown = readings.slice(-width);
47 const top = Math.max(...shown.map((r) => r.tokens), 1);
48 return shown.map((r) => BARS[Math.min(BARS.length - 1, Math.floor((r.tokens / top) * (BARS.length - 1)))]).join("");
49}
50
51export function trendWord(readings) {
52 if (readings.length < 2) return "";
53 const delta = readings[readings.length - 1].tokens - readings[readings.length - 2].tokens;
54 if (delta > 0) return `▲ +${short(delta)}`;
55 if (delta < 0) return `▼ ${short(-delta)}`;
56 return "steady";
57}
58
59export function short(n) {
60 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
61 if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}k`;
62 return String(n);
63}
64
65/** Subagent usage summed into the tally: a new tally, never the old one changed. */
66export function addAgentUsage(tally, agentId, usage) {
67 const tokens = (usage?.input_tokens ?? 0) + (usage?.output_tokens ?? 0)
68 + (usage?.cache_read_input_tokens ?? 0) + (usage?.cache_creation_input_tokens ?? 0);
69 const was = tally.agents[agentId] ?? { tokens: 0, turns: 0, model: undefined };
70 return {
71 tokens: tally.tokens + tokens,
72 turns: tally.turns + 1,
73 agents: { ...tally.agents, [agentId]: { tokens: was.tokens + tokens, turns: was.turns + 1, model: usage?.model ?? was.model } },
74 };
75}
76
77export const EMPTY_TALLY = Object.freeze({ tokens: 0, turns: 0, agents: Object.freeze({}) });
78hooks/band.mjs 98 lines1// Pure: what the band line holds, as parts { text, color?, bold?, dim? } the
2// entry module turns into Text elements, and the Buttons it adds.
3
4import { usageParts, contextColor, rateTag } from "./usage-status.mjs";
5import { forecastFor, chart, trendWord, short, HISTORY_BAND } from "./readings.mjs";
6import { usd } from "./spend.mjs";
7
8export const CHART_MIN_COLUMNS = 60;
9const SEP = " · ";
10// Buttons as drawn, "[ compact ]" and so on, two spaces before each.
11export const BUTTON_CELLS = 4 * 2 + "[ compact ]".length + "[ context ]".length + "[ cost ]".length + "[ clear ]".length;
12// A band narrower than its own button row has no buttons.
13export const BUTTONS_MIN_COLUMNS = BUTTON_CELLS + 4;
14
15export const BUTTONS = Object.freeze([
16 { key: "compact", label: "compact", hotkey: "c" },
17 { key: "context", label: "context", hotkey: "x" },
18 { key: "cost", label: "cost", hotkey: "d" },
19 { key: "clear", label: "clear", hotkey: "n" },
20]);
21
22/** The band's text parts, in order, for a state with at least one reading. */
23export function bandParts(state, opts, columns, nowMs) {
24 const readings = state.readings;
25 // The live reading moves with every model request and command; the chart keeps one bar per turn.
26 const now = state.live ?? readings[readings.length - 1];
27 const f = forecastFor(now.percent);
28 const size = contextColor(now.tokens, opts.warnTokens, opts.dangerTokens);
29 const tag = rateTag(now.tokens, opts.warnTokens);
30 // `drop`: the order a part gives way when the band is too narrow (lowest first); none: never.
31 const head = [
32 { text: `${f.icon} ${f.word}`, color: f.color, bold: true },
33 { text: ` ${now.percent}% of context`, drop: 9, instead: ` ${now.percent}%` },
34 { text: " " },
35 { text: short(now.tokens), color: size, bold: true },
36 { text: ` / ${short(now.window)}`, dim: true, drop: 8 },
37 ...(tag ? [{ text: ` ${tag}`, color: size, bold: true, drop: 7 }] : []),
38 ];
39 const trend = trendWord(readings);
40 const graph = columns >= CHART_MIN_COLUMNS
41 ? [
42 { text: " last turns ", dim: true, drop: 5 },
43 { text: chart(readings, HISTORY_BAND), color: f.color, drop: 5 },
44 ...(trend ? [{ text: ` ${trend}`, dim: true, drop: 4 }] : []),
45 ]
46 : [];
47 const tail = [
48 ...usageParts(state.usage, nowMs).map((p) => ({ text: p.text, color: p.color, bold: Boolean(p.color), drop: 6 })),
49 ...spendPart(state.spend, opts),
50 ...agentsPart(state.agents, opts),
51 ].flatMap((p) => [{ text: SEP, dim: true, drop: p.drop }, p]);
52 return [...head, ...graph, ...tail];
53}
54
55/** The parts that fit in `columns` cells, `reserve` of them kept for the buttons: parts give way by their `drop` rank. */
56export function fitParts(parts, columns, reserve) {
57 const room = Math.max(0, columns - reserve);
58 const width = (list) => list.reduce((n, p) => n + [...p.text].length, 0);
59 const ranks = [...new Set(parts.filter((p) => p.drop).map((p) => p.drop))].sort((a, b) => a - b);
60 return ranks.reduce((kept, rank) => {
61 if (width(kept) <= room) return kept;
62 return kept.flatMap((p) => (p.drop !== rank ? [p] : p.instead ? [{ ...p, text: p.instead, drop: undefined, instead: undefined }] : []));
63 }, parts);
64}
65
66function spendPart(spend, opts) {
67 if (!opts.showDailyCost || !spend || (spend.today <= 0 && spend.week <= 0)) return [];
68 return [{ text: `today ${usd(spend.today)} · wk ${usd(spend.week)}`, dim: true, drop: 2 }];
69}
70
71function agentsPart(tally, opts) {
72 if (!opts.showAgents || !tally || tally.tokens <= 0) return [];
73 return [{ text: `agents ${short(tally.tokens)}`, dim: true, drop: 1 }];
74}
75
76/** The Buttons to draw after the line: none when off, narrow, or while a turn runs. */
77export function buttonSpecs(opts, columns, isWorking) {
78 if (!opts.buttons || isWorking || columns < BUTTONS_MIN_COLUMNS) return [];
79 return BUTTONS.map((b) => ({ ...b }));
80}
81
82// Parts of this rank or above (the windows, the cost, the count) outrank the buttons.
83const KEEP_OVER_BUTTONS = 6;
84
85/**
86 * The line and the buttons laid out in `columns`: beside each other when they fit with the
87 * windows and the cost, else the buttons on a row of their own (`row: "below"`). A band
88 * shorter than its tree scrolls, so the second row is never dropped for want of rows.
89 */
90export function layoutBand(parts, columns, specs, padding = 2) {
91 if (specs.length === 0) return { parts: fitParts(parts, columns, padding), buttons: [], row: "same" };
92 const withButtons = fitParts(parts, columns, padding + BUTTON_CELLS);
93 const kept = withButtons.filter((p) => p.drop >= KEEP_OVER_BUTTONS).length;
94 const wanted = parts.filter((p) => p.drop >= KEEP_OVER_BUTTONS).length;
95 if (kept >= wanted) return { parts: withButtons, buttons: specs, row: "same" };
96 return { parts: fitParts(parts, columns, padding), buttons: specs, row: "below" };
97}
98hooks/guards.mjs 55 lines1// Pure: the verdicts behind the actions. The entry module asks, drops,
2// toasts and runs commands on what these return.
3
4import { formatPercent } from "./usage-status.mjs";
5import { usd } from "./spend.mjs";
6
7const PERSON_ORIGINS = ["composer", "bridge", "sdk"];
8const LABEL = { five_hour: "5h", seven_day: "7d", spend_limit: "spend" };
9
10function window(rateLimits, kind) {
11 return (rateLimits ?? []).find((w) => w && w.kind === kind && typeof w.percentUsed === "number");
12}
13
14/** Why a prompt should wait, as a question and a drop reason; undefined lets it through. */
15export function guardVerdict(usage, opts, originKind) {
16 if (!PERSON_ORIGINS.includes(originKind)) return undefined;
17 const five = window(usage?.rateLimits, "five_hour");
18 if (opts.guardRateLimit > 0 && five && five.percentUsed >= opts.guardRateLimit) {
19 const pct = `${formatPercent(five.percentUsed)}%`;
20 return { question: `5h window at ${pct}. Send anyway?`, reason: `held back: 5h window at ${pct}` };
21 }
22 const cost = usage?.cost?.usd;
23 if (opts.costLimitUsd > 0 && typeof cost === "number" && cost >= opts.costLimitUsd) {
24 return {
25 question: `Session cost ${usd(cost)} is past the ${usd(opts.costLimitUsd)} cap. Send anyway?`,
26 reason: `held back: session cost ${usd(cost)} past the ${usd(opts.costLimitUsd)} cap`,
27 };
28 }
29 return undefined;
30}
31
32/** Windows that just reached `percent`: { fired: kinds[], state: { kind: isFired } }; 0 never fires. */
33export function rateCrossings(fired, rateLimits, percent) {
34 const windows = (rateLimits ?? []).filter((w) => w && typeof w.percentUsed === "number");
35 const state = windows.reduce((acc, w) => ({ ...acc, [w.kind]: percent > 0 && w.percentUsed >= percent }), { ...fired });
36 const now = windows.filter((w) => state[w.kind] && !fired[w.kind]).map((w) => w.kind);
37 return { fired: now, state };
38}
39
40export function windowLabel(kind) {
41 return LABEL[kind] ?? kind;
42}
43
44/** The window that calls for the downshift, or undefined. */
45export function downshiftDue(rateLimits, opts) {
46 if (!opts.downshiftModel) return undefined;
47 const w = window(rateLimits, "seven_day") ?? window(rateLimits, "five_hour");
48 if (!w || w.percentUsed < opts.downshiftAt) return undefined;
49 return { window: windowLabel(w.kind), percent: w.percentUsed };
50}
51
52export function costWarningDue(cost, opts) {
53 return opts.costLimitUsd > 0 && typeof cost === "number" && cost >= 0.8 * opts.costLimitUsd;
54}
55hooks/spend.mjs 54 lines1// Pure: the spend ledger's keys and sums. The entry module reads and writes
2// $.store; each session writes only its own key, so concurrent sessions
3// never overwrite each other. Rows: { day, week, usd, at }.
4
5export const SPEND_PREFIX = "spend:";
6const DAY_MS = 86_400_000;
7const KEEP_MS = 8 * 7 * DAY_MS;
8
9export function spendKey(sessionId) {
10 return `${SPEND_PREFIX}${sessionId}`;
11}
12
13/** The row a session stores: its cost so far, stamped with the day and week it started counting. */
14export function spendEntry(usd, nowMs) {
15 return { day: dayKey(nowMs), week: isoWeek(nowMs), usd, at: nowMs };
16}
17
18export function dayKey(ms) {
19 return new Date(ms).toISOString().slice(0, 10);
20}
21
22/** ISO 8601 week, `2026-W41`, in UTC. */
23export function isoWeek(ms) {
24 const d = new Date(ms);
25 const day = d.getUTCDay() || 7;
26 const thursday = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + 4 - day));
27 const yearStart = Date.UTC(thursday.getUTCFullYear(), 0, 1);
28 const week = Math.ceil(((thursday.getTime() - yearStart) / DAY_MS + 1) / 7);
29 return `${thursday.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
30}
31
32/** Today's and this week's total over every session's row. */
33export function sumSpend(entries, nowMs) {
34 const today = dayKey(nowMs);
35 const week = isoWeek(nowMs);
36 return entries
37 .filter((e) => e && typeof e.usd === "number" && Number.isFinite(e.usd))
38 .reduce((acc, e) => ({
39 today: acc.today + (e.day === today ? e.usd : 0),
40 week: acc.week + (e.week === week ? e.usd : 0),
41 }), { today: 0, week: 0 });
42}
43
44/** The spend keys whose row is older than eight weeks: safe to delete. */
45export function staleKeys(rows, nowMs) {
46 return Object.entries(rows)
47 .filter(([key, row]) => key.startsWith(SPEND_PREFIX) && (!row || typeof row.at !== "number" || nowMs - row.at > KEEP_MS))
48 .map(([key]) => key);
49}
50
51export function usd(n) {
52 return `$${n.toFixed(2)}`;
53}
54hooks/weather-command.mjs 106 lines1// Pure: the text /weather prints. The entry module gathers the view and
2// dispatches the subcommands.
3
4import { usageParts } from "./usage-status.mjs";
5import { forecastFor, short, trendWord, HISTORY_BAND } from "./readings.mjs";
6import { usd, isoWeek } from "./spend.mjs";
7
8export const SUBCOMMANDS = Object.freeze(["report", "reset", "pane", "compact", "ledger", "help"]);
9
10export const HELP = [
11 "/weather the forecast, windows, spend, agents, context categories",
12 "/weather reset forget the readings and re-arm the toasts and actions",
13 "/weather pane open or close the weather pane",
14 "/weather compact compact now, with the compactFocus instructions",
15 "/weather ledger spend by day over the stored sessions",
16].join("\n");
17
18export function parseWeatherArgs(args) {
19 const word = (args ?? "").trim().toLowerCase();
20 if (word === "") return "report";
21 return SUBCOMMANDS.includes(word) ? word : "help";
22}
23
24/** The report for a view { readings, usage, spend, agents, categories?, model? }. */
25export function weatherReport(view, opts, nowMs) {
26 return [
27 ...headline(view),
28 ...recentTurns(view.readings),
29 ...windows(view.usage, nowMs),
30 ...spend(view, opts),
31 ...agents(view.agents),
32 ...categories(view.categories),
33 ...thresholds(opts),
34 ].join("\n");
35}
36
37function headline(view) {
38 const now = view.live ?? view.readings[view.readings.length - 1];
39 if (!now) return ["token weather: no reading yet (the first API response brings one)"];
40 const f = forecastFor(now.percent);
41 const model = view.model ? ` ${view.model}` : "";
42 return [`${f.icon} ${f.word} ${now.percent}% of context ${short(now.tokens)} / ${short(now.window)}${model}`];
43}
44
45function recentTurns(readings) {
46 if (readings.length < 2) return [];
47 const shown = readings.slice(-HISTORY_BAND);
48 const rows = shown.map((r, i) => {
49 const prior = shown.slice(0, i + 1);
50 const trend = i === 0 ? "" : ` ${trendWord(prior)}`;
51 return ` ${String(short(r.tokens)).padStart(7)}${trend}`;
52 });
53 return ["", `last turns (${shown.length})`, ...rows];
54}
55
56function windows(usage, nowMs) {
57 const parts = usageParts({ rateLimits: usage?.rateLimits ?? [] }, nowMs);
58 return parts.length ? ["", "windows", ...parts.map((p) => ` ${p.text}`)] : [];
59}
60
61function spend(view, opts) {
62 const session = view.usage?.cost?.usd;
63 const rows = [
64 ...(typeof session === "number" ? [` session ${usd(session)}`] : []),
65 ...(opts.showDailyCost && view.spend ? [` today ${usd(view.spend.today)}`, ` week ${usd(view.spend.week)}`] : []),
66 ];
67 return rows.length ? ["", "cost", ...rows] : [];
68}
69
70function agents(tally) {
71 if (!tally || tally.tokens <= 0) return [];
72 const rows = Object.entries(tally.agents).slice(-20).map(([id, a]) => ` ${id} ${short(a.tokens)} ${a.turns} turns ${a.model ?? ""}`.trimEnd());
73 return ["", `agents ${short(tally.tokens)} over ${tally.turns} turns`, ...rows];
74}
75
76function categories(list) {
77 const used = (list ?? []).filter((c) => c.kind === "used" && c.tokens > 0);
78 return used.length ? ["", "context", ...used.map((c) => ` ${c.name} ${short(c.tokens)}`)] : [];
79}
80
81function thresholds(opts) {
82 const off = (on, text) => (on ? text : "off");
83 return [
84 "",
85 "thresholds",
86 ` warn ${short(opts.warnTokens)} danger ${short(opts.dangerTokens)}`,
87 ` compact at ${off(opts.compactAt > 0, short(opts.compactAt))}${opts.compactAt > 0 && opts.compactConfirm ? " (asks first)" : ""}`,
88 ` rate guard ${off(opts.guardRateLimit > 0, `${opts.guardRateLimit}%`)}`,
89 ` cost cap ${off(opts.costLimitUsd > 0, usd(opts.costLimitUsd))}`,
90 ` downshift ${off(opts.downshiftModel !== "", `to ${opts.downshiftModel} at ${opts.downshiftAt}%`)}`,
91 ` notify ${opts.notify ? "on" : "off"} sound ${opts.sound} suggest /compact ${opts.suggestCompact ? "on" : "off"}`,
92 ];
93}
94
95/** Spend rows by day, newest first, then the week's total. */
96export function ledgerReport(entries, nowMs) {
97 const rows = entries.filter((e) => e && typeof e.usd === "number");
98 if (rows.length === 0) return "no spend recorded yet";
99 const byDay = rows.reduce((acc, e) => ({ ...acc, [e.day]: { usd: (acc[e.day]?.usd ?? 0) + e.usd, sessions: (acc[e.day]?.sessions ?? 0) + 1 } }), {});
100 const week = isoWeek(nowMs);
101 const weekTotal = rows.filter((e) => e.week === week).reduce((sum, e) => sum + e.usd, 0);
102 const days = Object.entries(byDay).sort(([a], [b]) => (a < b ? 1 : -1))
103 .map(([day, d]) => `${day} ${usd(d.usd)} ${d.sessions} session${d.sessions === 1 ? "" : "s"}`);
104 return [...days, "", `week ${week} ${usd(weekTotal)}`].join("\n");
105}
106hooks/pane.mjs 74 lines1// Pure: the rows the weather pane draws, { text, color?, dim?, kind? }.
2
3import { usageParts, colorFor, formatPercent, resetIn } from "./usage-status.mjs";
4import { forecastFor, short } from "./readings.mjs";
5import { usd } from "./spend.mjs";
6
7const BARS = "▁▂▃▄▅▆▇█";
8const LABEL = { five_hour: "5h", seven_day: "7d", spend_limit: "spend" };
9
10/** Readings bucketed to `width` cells, each cell the max of its bucket, scaled to the busiest. */
11export function sparkline(readings, width) {
12 if (readings.length === 0 || width <= 0) return "";
13 const per = Math.ceil(readings.length / width);
14 const buckets = Array.from({ length: Math.ceil(readings.length / per) }, (_, i) =>
15 Math.max(...readings.slice(i * per, (i + 1) * per).map((r) => r.tokens)));
16 const top = Math.max(...buckets, 1);
17 return buckets.map((t) => BARS[Math.min(BARS.length - 1, Math.floor((t / top) * (BARS.length - 1)))]).join("");
18}
19
20export function bar(percent, width) {
21 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width);
22 return "█".repeat(filled) + "░".repeat(Math.max(0, width - filled));
23}
24
25export function paneRows(state, opts, size, nowMs) {
26 const width = Math.max(10, size.columns);
27 return [
28 ...headRows(state, opts),
29 ...chartRows(state.readings, width),
30 ...windowRows(state.usage, width, nowMs),
31 ...costRows(state, opts),
32 ];
33}
34
35function headRows(state, opts) {
36 const now = state.live ?? state.readings[state.readings.length - 1];
37 if (!now) return [{ text: "no reading yet", dim: true }];
38 const f = forecastFor(now.percent);
39 const over = now.tokens >= opts.warnTokens ? " ⚠2x" : "";
40 return [{ text: `${f.icon} ${f.word} ${now.percent}% ${short(now.tokens)} / ${short(now.window)}${over}`, color: f.color, bold: true }];
41}
42
43function chartRows(readings, width) {
44 if (readings.length < 2) return [];
45 const line = sparkline(readings, width);
46 return [
47 { text: `turns ${readings.length} peak ${short(Math.max(...readings.map((r) => r.tokens)))}`, dim: true },
48 { text: line, kind: "chart" },
49 ];
50}
51
52function windowRows(usage, width, nowMs) {
53 const limits = (usage?.rateLimits ?? []).filter((w) => w && typeof w.percentUsed === "number");
54 if (limits.length === 0) return [];
55 const barWidth = Math.max(4, Math.min(20, width - 22));
56 return limits.map((w) => {
57 const reset = resetIn(w.resetsAt, nowMs);
58 const label = (LABEL[w.kind] ?? w.kind).padEnd(5);
59 return { text: `${label}${bar(w.percentUsed, barWidth)} ${formatPercent(w.percentUsed)}%${reset ? ` ↻${reset}` : ""}`, color: colorFor(w.percentUsed) };
60 });
61}
62
63function costRows(state, opts) {
64 const cost = usageParts({ cost: state.usage?.cost }, 0)[0];
65 const spend = opts.showDailyCost && state.spend && (state.spend.today > 0 || state.spend.week > 0)
66 ? ` today ${usd(state.spend.today)} week ${usd(state.spend.week)}`
67 : "";
68 const rows = cost ? [{ text: `session ${cost.text}${spend}`, dim: true }] : [];
69 const tally = state.agents;
70 return opts.showAgents && tally && tally.tokens > 0
71 ? [...rows, { text: `agents ${short(tally.tokens)} over ${tally.turns} turns`, dim: true }]
72 : rows;
73}
74hooks/usage-status.mjs 98 lines1// Pure helpers for the usage part of the band: no $ calls, so they test on their own.
2//
3// usageParts({ rateLimits, cost }, nowMs) ->
4// [{ text: "5h 23% ↻2h10m", color: "green" }, { text: "7d 41% ↻3d4h", color: "green" }, { text: "$1.23" }]
5// Empty when there is nothing to show (no window reading, no cost).
6// A window is green below WARN_PERCENT, yellow below DANGER_PERCENT, red from there.
7
8const WINDOW_LABEL = {
9 five_hour: "5h",
10 seven_day: "7d",
11 spend_limit: "spend",
12};
13
14const WINDOW_ORDER = ["five_hour", "seven_day", "spend_limit"];
15const WARN_PERCENT = 50;
16const DANGER_PERCENT = 80;
17// Context size, in tokens: green below WARN, yellow below DANGER, red from there.
18export const CONTEXT_WARN_TOKENS = 200_000;
19export const CONTEXT_DANGER_TOKENS = 300_000;
20
21export function contextColor(tokens, warn = CONTEXT_WARN_TOKENS, danger = CONTEXT_DANGER_TOKENS) {
22 if (tokens >= danger) return "red";
23 if (tokens >= warn) return "yellow";
24 return "green";
25}
26
27/** "⚠2x" once the prompt is above the long-context line (1M-context models bill input higher there). */
28export function rateTag(tokens, warn = CONTEXT_WARN_TOKENS) {
29 return tokens >= warn ? "⚠2x" : undefined;
30}
31
32/** The two limits from the mod's options: positive numbers, else the defaults; danger never below warn. */
33export function thresholds(options) {
34 const warn = positive(options?.warnTokens, CONTEXT_WARN_TOKENS);
35 const danger = Math.max(warn, positive(options?.dangerTokens, CONTEXT_DANGER_TOKENS));
36 return { warn, danger };
37}
38
39function positive(value, fallback) {
40 const n = typeof value === "string" ? Number(value) : value;
41 return typeof n === "number" && Number.isFinite(n) && n > 0 ? n : fallback;
42}
43
44export function usageParts(usage, nowMs) {
45 const windows = windowParts(usage?.rateLimits ?? [], nowMs);
46 const cost = costPart(usage?.cost);
47 return cost ? [...windows, cost] : windows;
48}
49
50function windowParts(rateLimits, nowMs) {
51 return [...rateLimits]
52 .filter((w) => w && typeof w.percentUsed === "number")
53 .sort((a, b) => rank(a.kind) - rank(b.kind))
54 .map((w) => windowPart(w, nowMs));
55}
56
57function rank(kind) {
58 const i = WINDOW_ORDER.indexOf(kind);
59 return i === -1 ? WINDOW_ORDER.length : i;
60}
61
62function windowPart(w, nowMs) {
63 const label = WINDOW_LABEL[w.kind] ?? w.kind;
64 const pct = `${formatPercent(w.percentUsed)}%`;
65 const reset = resetIn(w.resetsAt, nowMs);
66 const text = reset ? `${label} ${pct} ↻${reset}` : `${label} ${pct}`;
67 return { text, color: colorFor(w.percentUsed) };
68}
69
70export function colorFor(percentUsed) {
71 if (percentUsed >= DANGER_PERCENT) return "red";
72 if (percentUsed >= WARN_PERCENT) return "yellow";
73 return "green";
74}
75
76export function formatPercent(p) {
77 const n = Math.max(0, p);
78 return Number.isInteger(n) ? String(n) : n.toFixed(1);
79}
80
81export function resetIn(resetsAt, nowMs) {
82 if (!resetsAt) return undefined;
83 const at = Date.parse(resetsAt);
84 if (Number.isNaN(at)) return undefined;
85 const left = Math.max(0, Math.round((at - nowMs) / 60_000));
86 const days = Math.floor(left / 1440);
87 const hours = Math.floor((left % 1440) / 60);
88 const mins = left % 60;
89 if (days > 0) return hours > 0 ? `${days}d${hours}h` : `${days}d`;
90 if (hours > 0) return `${hours}h${String(mins).padStart(2, "0")}m`;
91 return `${mins}m`;
92}
93
94function costPart(cost) {
95 if (!cost || typeof cost.usd !== "number") return undefined;
96 return { text: `$${cost.usd.toFixed(2)}` };
97}
98