Live context window forecast and MiniMax M Plan / Token Plan balance, drawn above the prompt.

A one-line dashboard in Claude Code's band above the prompt:
GET /v1/token_plan/remains.☂ Showers 67% of context 134.4k / 200k last turns ▁▂█ ▲ +98.3k last turn │ M Plan 5h 93% left (resets 06:40) • 7d 99% left (resets Wed)
The forecast is real (read from $.session.usage(), the same figures the status line shows). The M Plan balance is real (called every 60s with your Subscription Key). Both are free — $.session.usage() is free, and the M Plan endpoint does not count against usage.
For install / marketplace / hot-reload setup, see the parent marketplace README.
The M Plan side reads GET /v1/token_plan/remains, which MiniMax only accepts with a Subscription Key. A pay-as-you-go API Key returns login fail on this endpoint.
Since MiniMax exposes an Anthropic-compatible API, the key you already use for Claude Code (ANTHROPIC_API_KEY) is the same Subscription Key — and the mod reads it first:
export ANTHROPIC_API_KEY="eyJhbGciOi..."
Or, if you'd rather keep it under a dedicated name:
export MINIMAX_SUBSCRIPTION_KEY="eyJhbGciOi..."
The mod falls back to MINIMAX_SUBSCRIPTION_KEY if ANTHROPIC_API_KEY is unset.
Put either in your shell profile (~/.zshrc, ~/.bashrc) so it survives restarts, or in ~/.claude/settings.json under env:
{
"env": {
"ANTHROPIC_API_KEY": "eyJhbGciOi..."
}
}
⚠️ Put secrets in ~/.claude/settings.json (user-level), not .claude/settings.json (project-level) — the latter can end up in git.
Without either key, the band reads M Plan: set $ANTHROPIC_API_KEY (or $MINIMAX_SUBSCRIPTION_KEY), in dim grey, so it's obvious what to do.
The mod defaults to https://api.minimaxi.com (the China host). International accounts override via the baseUrl user field, set either through the config menu or directly in ~/.claude/settings.json:
{
"pluginConfigs": {
"cc-context-mod": { "baseUrl": "https://api.minimax.io" }
}
}
| Used | Forecast |
|---|---|
| under 25% | ☀ Clear |
| 25–49% | ☁ Cloudy |
| 50–74% | ☂ Showers |
| 75–89% | ☇ Storm |
| 90% up | ↯ Compact soon |
The chart (▁▂▃▄▅▆▇█) shows the last 12 turns, scaled to the busiest one. ▲ +98.3k last turn is how much the last turn added.
Two windows, each as percent remaining:
M Plan 5h 93% left (resets 06:40) • 7d 99% left (resets Wed)
The mod picks the general bucket from the API's model_remains[] array — that's where text models (including MiniMax-M3) live; the video bucket is separate. Reset times are local within 24 hours, weekday when longer.
Error states, all visible in the band:
| Band shows | Means |
|---|---|
☀ Clear —% of context — / 1M | No context reading yet (right after session.start, before the first turn completes) — same shape as a real reading, dimmed |
M Plan: HTTP 401 | Bad / missing Subscription Key |
M Plan: ECONNRESET | Wrong host — switch baseUrl (international vs. China) |
M Plan: login fail… | Subscription Key wrong, or pay-as-you-go key on this endpoint |
M Plan: empty response | API returned nothing parseable |
M Plan — | No fetch yet (right after session.start) |
Each "section" is its own Box; the outer Box has flexWrap: 'wrap' so a section that doesn't fit moves cleanly to the next line. The chart + trend show as soon as a real reading exists, regardless of width; M Plan's reset times are the only width-gated piece. Thresholds:
| Width | What's shown |
|---|---|
| ≥ 90 | forecast + chart + trend + M Plan with reset times |
| ≥ 55 | forecast + chart + trend + M Plan (no reset times) |
| < 55 | forecast + chart + trend |
| Hook | What it does | |
|---|---|---|
session.start | First context reading, first balance fetch, starts a 60s timer. | |
session.measure (primary) | Engine-pushed context figure — e.context carries { tokens, window, percent } directly, gated on e.changed.includes('context') so we only react when context actually moved. | |
session.compact | After /compact, autocompact, or a plugin's $.session.compact() — next(e)'s SessionCompacted carries the engine's own tokensAfter (the freshest context figure; session.measure does not necessarily remeasure after a compaction). Merged with the session's window and recorded as a fresh reading, so the chart shows the drop. Falls back to readUsage($) for precompute runs that don't record tokensAfter. The !e.agentId filter skips subagent compactions, which don't move the main window. | |
| `session.end { reason: 'clear' \ | 'resume' }` | Covers /clear (and aliases /reset, /new), /resume, --resume, --continue, /branch. The engine closes the current conversation and starts another in the same process without firing session.start for the new one, so module-level state (history, latest reading, last balance fetch) would otherwise stick. We mirror session.start's reset (clear history, balance, lastFetchAt) and seed a pending placeholder — we deliberately skip readUsage($) here because right after next(e) the engine is mid-handoff and $.session.usage() can still answer with the old session's figures, which would get recorded as the new reading. The first session.measure / session.append / turn.complete on the new session drops the placeholder and records the real value. Other reasons (prompt_input_exit / logout / other) mean the process itself is leaving — module state goes with it, no reset needed. |
session.append { door: 'tool-result' } (main loop only) | Fires when a tool_result lands in the transcript, including subagent returns. Catches intra-turn context jumps that session.measure may not push for. The !e.agentId filter skips subagent-internal tool_results, which don't move the parent's $.session.usage(). | |
session.append { door: 'response' } (main loop only) | Fires after each model response — the new input_tokens for that response is the freshest context figure available. | |
turn.complete | Safety net: covers subagent turns (session.measure is documented as main-thread only) and any case session.measure / session.compact / session.append / session.end missed. | |
ui.render with {component: "AbovePrompt"} | Draws the band. | |
$.clock.every(60000, …) | Re-fetches the balance so the windows stay current between turns. |
Multiple hooks can report the same value (e.g. session.measure and turn.complete both firing at turn end). readUsage dedupes against the last real reading — same tokens+window means no movement, so the 12-bar chart doesn't fill with duplicate bars. After /compact, the chart shows the drop naturally (a tall bar followed by a short one), and ▲ +X last turn becomes ▼ -X last turn for that delta — accurate, not reset.
The context reading is $.session.usage() — free, no breakdown. The balance reading is $.http.fetch('${baseUrl}/v1/token_plan/remains', { headers: { Authorization: Bearer ${key} } }). Rate-limited by a 30s minimum gap inside the module, on top of the 60s timer, so a burst of turns won't hammer the API.
Module state (history, latest reading) lives in module-level vars, exactly like token-weather. Both reset on session start and on hot reload — token-weather's README explains why this is fine.
AbovePrompt competes for the same row.$.http.fetch, which CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC may block. With that variable set the band shows an HTTP error.general bucket only. Per-model breakdown (model_remains[] has general + video) is parsed and dropped — open an issue if you want it.anthropics/claude-code-playground/mods/token-weather — context forecast patterns, single-row mod layout, state-reset-on-reload rationale.platform.minimax.io/docs/m-plan/faq, minimax-token-plan-api.md.code.claude.com/docs/en/settings and the in-process declaration at .claude-plugin/types/claude-code/index.d.ts (after the first load).hooks/register.mjs 560 lines1// Copyright 2026
2// SPDX-License-Identifier: Apache-2.0
3//
4// cc-context-mod: a live forecast of the context window on the left,
5// and MiniMax M Plan / Token Plan balance on the right.
6//
7// Context window: session.measure's e.context (free) on the main trigger,
8// $.session.usage().context (also free) as fallback for the other hooks.
9// M Plan balance: GET /v1/token_plan/remains on the host, every 60s,
10// with the Subscription Key from $MINIMAX_SUBSCRIPTION_KEY.
11//
12// session.measure (primary): the engine pushes usage figures here whenever
13// they move — after each main-thread turn and when rate-limit windows
14// move. e.context carries { tokens, window, percent } directly, so we
15// don't need to poll $.session.usage() on every fire. Gate on
16// e.changed.includes('context') so we don't churn when only rateLimits
17// moved.
18// session.compact: catches /compact and autocompact (and /rewind's
19// "Summarize from here/up to here", which is the same engine path),
20// where session.measure does not necessarily push a new measurement
21// (the boundary notice lands as a session.append, but the conversation
22// is just shorter now — no guarantee the engine remeasures). The
23// result of next(e) is SessionCompacted with the engine's own
24// tokensAfter, used as the post-compact reading; precompute runs omit
25// tokensAfter, so we fall back to readUsage($) for those. The
26// !e.agentId filter skips subagent compactions — they don't move the
27// main window.
28// session.end { reason: 'clear' | 'resume' }: catches the cases that
29// end one conversation and start another in the same process without
30// firing session.start for it: /clear (and aliases /reset, /new),
31// /resume, --resume, --continue, /branch. Without this hook the
32// chart, balance and lastFetchAt keep their old-session values until
33// the next turn — the band shows the prior percentage and stale bars
34// until session.measure / session.append finally push. We mirror the
35// session.start reset (history → empty, balance → null, fetch gap →
36// 0, then a pending placeholder — no readUsage here, see the hook)
37// so the band reads as fresh the instant the engine hands control
38// back. Other reasons (prompt_input_exit / logout / other) mean the
39// process itself is leaving — module state goes with it, no reset
40// needed. The 60s balance timer is already running from
41// session.start and stays running across these.
42// session.append { door: 'tool-result' | 'response' } (main loop only):
43// catches intra-turn context growth that session.measure might not push
44// for. A tool_result that lands in the parent's transcript (including a
45// subagent's return) bumps the parent's context before the next model
46// call; each new model response brings back input_tokens for what it
47// just answered over. The !e.agentId filter drops subagent-internal
48// rows — they don't move the parent's $.session.usage().
49// turn.complete: safety net for subagent turns and any case
50// session.measure / session.compact / session.append / session.end miss.
51// session.start: first reading, first balance fetch, starts a 60s timer.
52// ui.render (AbovePrompt): one band — context weather on the left, the
53// 5h and 7d M Plan windows on the right, separated by a divider.
54//
55// readUsage dedupes against the last real reading: when multiple hooks
56// report the same tokens+window (e.g. session.measure and turn.complete
57// both firing at turn end), only one bar is appended to the chart. The
58// seeded { pending: true } placeholder from session.start (and
59// session.end on /clear or /resume) is dropped inside readUsage once
60// any hook pushes the first real reading.
61
62const HISTORY = 12;
63const REFRESH_MS = 60_000; // poll the balance API every 60s
64const REFRESH_MIN_GAP_MS = 30_000; // but no more than once per 30s
65const BARS = "▁▂▃▄▅▆▇█";
66
67// Forecast bands by percent of context window used.
68const FORECAST = [
69 { upTo: 25, icon: "☀", word: "Clear", color: "yellow" },
70 { upTo: 50, icon: "☁", word: "Cloudy", color: "cyan" },
71 { upTo: 75, icon: "☂", word: "Showers", color: "blue" },
72 { upTo: 90, icon: "☇", word: "Storm", color: "magenta" },
73 { upTo: Infinity, icon: "↯", word: "Compact soon", color: "red" },
74];
75
76const DEFAULT_BASE_URL = "https://api.minimaxi.com";
77
78// State — resets when the module reloads.
79let readings = [];
80// { status: 'ok' | 'error' | 'no-key', interval, weekly, message?, fetchedAt }
81let balance = null;
82let lastFetchAt = 0;
83
84export function register(on, options) {
85 const rawBase = typeof options?.baseUrl === "string" ? options.baseUrl.trim() : "";
86 const baseUrl = rawBase ? rawBase.replace(/\/+$/, "") : DEFAULT_BASE_URL;
87
88 on("session.start", async ($, e, next) => {
89 const result = await next(e);
90 readings = [];
91 balance = null;
92 lastFetchAt = 0;
93 await readUsage($);
94 // If readUsage had no real numbers to record (common on a fresh
95 // session — $.session.usage() returns 0 before the first response),
96 // seed a "pending" reading so the band can render immediately with a
97 // "— / 1M" placeholder. Real data replaces it as soon as
98 // turn.complete records a real reading.
99 if (readings.length === 0) {
100 readings.push({ pending: true });
101 }
102 $.ui.invalidate("ui.render");
103 await refreshBalance($, baseUrl);
104 startRefresh($, baseUrl);
105 return result;
106 });
107
108 on("session.measure", async ($, e, next) => {
109 // Engine pushes figures here whenever a unit moved. Use the context
110 // it carried directly — no need to re-poll $.session.usage().
111 if (e.changed.includes("context")) {
112 await readUsage($, e.context);
113 }
114 return next(e);
115 });
116
117 on("session.compact", async ($, e, next) => {
118 // Runs around /compact, autocompact, or a plugin's $.session.compact()
119 // call. After next(e) the engine has installed the summary and the
120 // kept messages; the result is SessionCompacted with the engine's own
121 // tokensAfter (the freshest context figure we have here — session.measure
122 // does not necessarily push a new measurement after a compaction).
123 // Subagent compactions (e.agentId set) don't move the main window,
124 // so they get the same skip as session.append's main-loop filter.
125 const result = await next(e);
126 if (!e.agentId && result && !result.skip) {
127 if (Number.isFinite(result.tokensAfter) && result.tokensAfter > 0) {
128 // Merge tokensAfter with the session's window — $.session.usage()
129 // already reflects the post-compact state here. The combined
130 // record clears the dedupe in readUsage so the drop shows up in
131 // the chart instead of being silently skipped.
132 try {
133 const usage = await $.session.usage();
134 if (usage?.context?.window > 0) {
135 await readUsage($, {
136 tokens: result.tokensAfter,
137 window: usage.context.window,
138 percent: (result.tokensAfter / usage.context.window) * 100,
139 });
140 return result;
141 }
142 } catch {
143 // Fall through to readUsage($).
144 }
145 }
146 // Precompute runs and any case where the engine didn't record
147 // tokensAfter: readUsage($) reads from $.session.usage(), which the
148 // engine keeps up to date.
149 await readUsage($);
150 }
151 return result;
152 });
153
154 on("session.end", async ($, e, next) => {
155 // /clear, /reset, /new → reason 'clear': the conversation ends, the
156 // process goes on under a fresh session id, and (per the engine docs)
157 // no session.start fires for it. /resume, --resume, --continue,
158 // /branch → reason 'resume': another session takes its place in the
159 // same process. In both cases the plugin stays loaded but its
160 // module-level state (history, latest reading, last balance fetch)
161 // is from the old session and would otherwise stick in the band —
162 // session.measure on the new session's first turn eventually pushes
163 // the right number, but until then the chart shows stale bars and
164 // the balance reflects the old session's fetch gap. Mirror the
165 // session.start reset so the band reads as fresh the moment the
166 // engine hands control back. prompt_input_exit / logout / other all
167 // mean the process itself is leaving — module state goes with it,
168 // no reset needed.
169 const result = await next(e);
170 if (e.reason === "clear" || e.reason === "resume") {
171 readings = [];
172 balance = null;
173 lastFetchAt = 0;
174 // Don't call readUsage($) here. Right after next(e) the engine is
175 // mid-handoff between sessions: $.session.usage() may still
176 // answer with the *old* session's figures, which we'd then record
177 // as the new reading and the band would keep showing the prior
178 // percentage. Skipping readUsage and seeding the pending
179 // placeholder is safe — the first session.measure / session.append
180 // / turn.complete on the new session drops the placeholder and
181 // records the real value, the same shape session.start falls back
182 // to before its first response.
183 readings.push({ pending: true });
184 $.ui.invalidate("ui.render");
185 await refreshBalance($, baseUrl);
186 }
187 return result;
188 });
189
190 on("session.append", { door: "tool-result" }, async ($, e, next) => {
191 // A tool_result just landed in the transcript. Main loop only:
192 // subagent-internal tool_results carry agentId and don't move
193 // $.session.usage() (which always returns the main session's window).
194 // This is where subagent returns and big tool outputs show up between
195 // model calls — before session.measure has a chance to push.
196 if (!e.agentId) {
197 await readUsage($);
198 }
199 return next(e);
200 });
201
202 on("session.append", { door: "response" }, async ($, e, next) => {
203 // A model response just landed. The new input_tokens for that
204 // response is the freshest context figure available; $.session.usage()
205 // should reflect it. Main loop only.
206 if (!e.agentId) {
207 await readUsage($);
208 }
209 return next(e);
210 });
211
212 on("turn.complete", async ($, e, next) => {
213 const result = await next(e);
214 // Safety net: covers subagent turns (where session.measure is
215 // documented as main-thread only) and any case session.measure /
216 // session.compact / session.append / session.end missed. Dedup in
217 // readUsage keeps this from bloating the chart when the same value
218 // arrives from multiple hooks.
219 await readUsage($);
220 return result;
221 });
222
223 on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
224 if (e.hasSurvey) return next(e);
225 const { Box, Text } = $.ui.resolve(e);
226 return band(Box, Text, e.bodyColumns ?? 80);
227 });
228}
229
230async function readUsage($, ctx = null) {
231 let tokens, window, percent;
232 if (ctx) {
233 // session.measure hands us the figure directly — no $.session.usage() call.
234 ({ tokens, window, percent } = ctx);
235 } else {
236 try {
237 const usage = await $.session.usage();
238 if (!usage || !usage.context || !usage.context.window) return false;
239 tokens = usage.context.tokens;
240 window = usage.context.window;
241 percent = usage.context.percent;
242 } catch {
243 // Keep the previous reading.
244 return false;
245 }
246 }
247 // Skip zero / placeholder readings: a fresh session reads 0 before the
248 // first response, and seeding a 0 here would leave the band stuck at
249 // "0% of context" until the first real hook fires. The band shows a
250 // "— / 1M" placeholder via the `pending` reading seeded in
251 // session.start, until a real reading arrives.
252 if (
253 !Number.isFinite(tokens) ||
254 tokens <= 0 ||
255 !Number.isFinite(window) ||
256 window <= 0
257 ) {
258 return false;
259 }
260 // Dedupe against the last real reading: same tokens+window means no
261 // movement, so multiple hooks reporting the same state (e.g. session.measure
262 // and turn.complete both firing at turn end) don't bloat the chart.
263 const last = readings[readings.length - 1];
264 if (
265 last &&
266 !last.pending &&
267 last.tokens === tokens &&
268 last.window === window
269 ) {
270 return false;
271 }
272 const finalPercent = Math.round(percent ?? (tokens / window) * 100);
273 readings.push({ tokens, window, percent: finalPercent });
274 // Drop the seeded "pending" placeholder once a real reading arrives,
275 // regardless of which hook supplied it — so the chart and trend are
276 // computed from real data only.
277 if (readings[0]?.pending && readings.length > 1) {
278 readings.shift();
279 }
280 if (readings.length > HISTORY) readings = readings.slice(-HISTORY);
281 $.ui.invalidate("ui.render");
282 return true;
283}
284
285let timer = null;
286function startRefresh($, baseUrl) {
287 if (timer) return;
288 // The engine drops timers when the module reloads.
289 timer = $.clock.every(REFRESH_MS, async () => {
290 await refreshBalance($, baseUrl);
291 });
292}
293
294async function refreshBalance($, baseUrl) {
295 const now = Date.now();
296 if (now - lastFetchAt < REFRESH_MIN_GAP_MS) return;
297 lastFetchAt = now;
298
299 // $.env.get requires a string literal, so the env var names are spelled here.
300 // MiniMax M Plan is Anthropic-compatible, so the same key Claude Code uses
301 // for chat (ANTHROPIC_API_KEY, set to the Subscription Key) also works for
302 // /v1/token_plan/remains. The dedicated name is the explicit override.
303 let subscriptionKey = await $.env.get("ANTHROPIC_API_KEY");
304 if (!subscriptionKey) {
305 subscriptionKey = await $.env.get("MINIMAX_SUBSCRIPTION_KEY");
306 }
307 if (!subscriptionKey) {
308 balance = {
309 status: "no-key",
310 message: "set $ANTHROPIC_API_KEY (or $MINIMAX_SUBSCRIPTION_KEY)",
311 fetchedAt: now,
312 };
313 $.ui.invalidate("ui.render");
314 return;
315 }
316
317 try {
318 const { ok, status, text } = await $.http.fetch(
319 `${baseUrl}/v1/token_plan/remains`,
320 {
321 method: "GET",
322 headers: {
323 Authorization: `Bearer ${subscriptionKey}`,
324 "Content-Type": "application/json",
325 },
326 }
327 );
328 if (!ok) {
329 balance = { status: "error", message: `HTTP ${status}`, fetchedAt: now };
330 $.ui.invalidate("ui.render");
331 return;
332 }
333 let body;
334 try {
335 body = JSON.parse(text);
336 } catch {
337 throw new Error("invalid JSON");
338 }
339 balance = { ...parseBalance(body), fetchedAt: now };
340 } catch (err) {
341 balance = {
342 status: "error",
343 message: String(err?.message ?? err),
344 fetchedAt: now,
345 };
346 }
347 $.ui.invalidate("ui.render");
348}
349
350function parseBalance(body) {
351 if (!body || typeof body !== "object") {
352 return { status: "error", message: "empty response" };
353 }
354 // MiniMax error responses wrap in base_resp; success wraps in model_remains[].
355 if (body.base_resp && typeof body.base_resp === "object") {
356 const code = body.base_resp.status_code;
357 if (code && code !== 0) {
358 return {
359 status: "error",
360 message: body.base_resp.status_msg ?? `code ${code}`,
361 };
362 }
363 }
364 const items = Array.isArray(body.model_remains) ? body.model_remains : [];
365 if (items.length === 0) {
366 return { status: "error", message: "no model_remains" };
367 }
368 // Pick the text bucket; fall back to the first entry.
369 const general =
370 items.find((m) => m && m.model_name === "general") ?? items[0];
371
372 return {
373 status: "ok",
374 model: general.model_name,
375 interval: {
376 percent: general.current_interval_remaining_percent ?? null,
377 reset: msToIso(general.end_time),
378 },
379 weekly: {
380 percent: general.current_weekly_remaining_percent ?? null,
381 reset: msToIso(general.weekly_end_time),
382 },
383 };
384}
385
386function msToIso(ms) {
387 if (typeof ms !== "number" || !isFinite(ms) || ms <= 0) return null;
388 return new Date(ms).toISOString();
389}
390
391// --- drawing ---------------------------------------------------------------
392
393function band(Box, Text, columns) {
394 // The latest reading is "usable" only when we have real numbers. Before
395 // the first turn completes, session.start seeded a `{ pending: true }`
396 // reading so the band can render immediately with a placeholder instead
397 // of being absent.
398 const now = readings[readings.length - 1];
399 const hasData = !!now && !now.pending && Number.isFinite(now.tokens) && now.tokens > 0;
400 const ctx = hasData ? forecastFor(now.percent) : null;
401 const trend = hasData ? trendWord() : "";
402
403 // Width-based layout decisions. The chart is always shown once we have
404 // real data — the user asked for it visible regardless of terminal width.
405 const showChart = hasData;
406 const showResets = columns >= 90;
407 const showMPlan = columns >= 55;
408
409 // Each section is its own row-Box; the outer Box wraps whole sections,
410 // so a section that doesn't fit moves cleanly to the next line.
411 const sections = [];
412
413 // Section 1: forecast (always shown).
414 // No-data placeholder mirrors the real data shape (icon + word +
415 // "X% of context" + "X / Y") so the band stays the same width when the
416 // first reading arrives — just switches from dim placeholders to real
417 // values. Default forecast is Clear (the band a 0-token reading lands in).
418 const placeholder = forecastFor(0);
419 sections.push(
420 Box({
421 flexDirection: "row",
422 children: hasData
423 ? [
424 Text({ color: ctx.color, bold: true, children: `${ctx.icon} ${ctx.word}` }),
425 Text({ children: ` ${now.percent}% of context` }),
426 Text({
427 dimColor: true,
428 children: ` ${short(now.tokens)} / ${short(now.window)}`,
429 }),
430 ]
431 : [
432 Text({ dimColor: true, children: `${placeholder.icon} ${placeholder.word}` }),
433 Text({ dimColor: true, children: " —% of context" }),
434 Text({ dimColor: true, children: " — / 1M" }),
435 ],
436 })
437 );
438
439 // Section 2: chart of recent turns + trend (only on wide terminals, and
440 // only once we have at least one real reading — chart/trend are nonsense
441 // off a single placeholder).
442 if (showChart && hasData) {
443 const chartChildren = [
444 Text({ dimColor: true, children: " last turns " }),
445 Text({ color: ctx.color, children: chart() }),
446 ];
447 if (trend) {
448 chartChildren.push(Text({ dimColor: true, children: ` ${trend}` }));
449 }
450 sections.push(Box({ flexDirection: "row", children: chartChildren }));
451 }
452
453 // Section 3: M Plan balance, with reset times if there's room.
454 if (showMPlan) {
455 const planChildren = planTexts(Text, showResets);
456 planChildren.unshift(Text({ dimColor: true, children: " │ " }));
457 sections.push(Box({ flexDirection: "row", children: planChildren }));
458 }
459
460 return Box({
461 flexDirection: "row",
462 flexWrap: "wrap",
463 paddingX: 1,
464 children: sections,
465 });
466}
467
468function planTexts(Text, showResets = true) {
469 if (!balance) {
470 return [Text({ dimColor: true, children: "M Plan —" })];
471 }
472 if (balance.status === "error") {
473 return [Text({ color: "red", children: `M Plan: ${balance.message}` })];
474 }
475 if (balance.status === "no-key") {
476 return [Text({ dimColor: true, children: `M Plan: ${balance.message}` })];
477 }
478
479 const parts = [Text({ color: "magenta", bold: true, children: "M Plan" })];
480 const haveInterval = balance.interval.percent != null;
481 const haveWeekly = balance.weekly.percent != null;
482
483 if (haveInterval) {
484 parts.push(
485 Text({ children: ` 5h ${balance.interval.percent}% left` })
486 );
487 if (showResets && balance.interval.reset) {
488 parts.push(
489 Text({ dimColor: true, children: ` (resets ${formatReset(balance.interval.reset)})` })
490 );
491 }
492 }
493
494 if (haveInterval && haveWeekly) {
495 parts.push(Text({ dimColor: true, children: " •" }));
496 }
497
498 if (haveWeekly) {
499 parts.push(
500 Text({ children: ` 7d ${balance.weekly.percent}% left` })
501 );
502 if (showResets && balance.weekly.reset) {
503 parts.push(
504 Text({ dimColor: true, children: ` (resets ${formatReset(balance.weekly.reset)})` })
505 );
506 }
507 }
508
509 if (!haveInterval && !haveWeekly) {
510 parts.push(Text({ dimColor: true, children: " —" }));
511 }
512
513 return parts;
514}
515
516function formatReset(iso) {
517 const t = new Date(iso).getTime();
518 if (!isFinite(t)) return "";
519 const deltaH = (t - Date.now()) / 3_600_000;
520 const d = new Date(t);
521 if (deltaH > 24) {
522 return d.toLocaleDateString(undefined, { weekday: "short" });
523 }
524 return d.toLocaleTimeString(undefined, {
525 hour: "2-digit",
526 minute: "2-digit",
527 hour12: false,
528 });
529}
530
531function forecastFor(percent) {
532 return FORECAST.find((b) => percent < b.upTo) ?? FORECAST[FORECAST.length - 1];
533}
534
535// Bars scale to the busiest reading shown, so growth shows at any fill level.
536function chart() {
537 const real = readings.filter((r) => !r.pending);
538 if (real.length === 0) return "";
539 const top = Math.max(...real.map((r) => r.tokens), 1);
540 return real
541 .map((r) =>
542 BARS[Math.min(BARS.length - 1, Math.floor((r.tokens / top) * (BARS.length - 1)))]
543 )
544 .join("");
545}
546
547function trendWord() {
548 const real = readings.filter((r) => !r.pending);
549 if (real.length < 2) return "";
550 const delta = real[real.length - 1].tokens - real[real.length - 2].tokens;
551 if (delta > 0) return `▲ +${short(delta)} last turn`;
552 if (delta < 0) return `▼ ${short(-delta)} last turn`;
553 return "steady";
554}
555
556function short(n) {
557 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
558 if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}k`;
559 return String(n);
560}