SLOPSHOPPER

cc-context-mod

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

newbandnetworktimer
v0.2.1Apache-2.0updated 2026-10-06kukaka/cc-mods/cc-context-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cc-context-mod
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ☁ Cloudy 49% of context 97.4k / 200k last turns █ │ M Plan: set $ANTHROPIC_API_KEY (or $MINIMAX_SUBSCRIPTION ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☁ Cloudy 49% of context 97.4k / 200k last turns █ │ M Plan: set $ANTHROPIC_API_KEY (or $MINI
README

cc-context-mod

A one-line dashboard in Claude Code's band above the prompt:

  1. Context window weather — a forecast of how full the conversation's context is, with a chart of the last 12 turns.
  2. MiniMax M Plan / Token Plan balance — the 5-hour and 7-day usage windows, fetched live from 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.


Configure

Subscription Key (required for M Plan)

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.

API host (optional, defaults to China)

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" }
  }
}

What you see

Context forecast (left)

UsedForecast
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.

M Plan / Token Plan (right)

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 showsMeans
☀ Clear —% of context — / 1MNo context reading yet (right after session.start, before the first turn completes) — same shape as a real reading, dimmed
M Plan: HTTP 401Bad / missing Subscription Key
M Plan: ECONNRESETWrong 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 responseAPI returned nothing parseable
M Plan —No fetch yet (right after session.start)

Layout at narrow terminals

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:

WidthWhat's shown
≥ 90forecast + chart + trend + M Plan with reset times
≥ 55forecast + chart + trend + M Plan (no reset times)
< 55forecast + chart + trend

How it works

HookWhat it does
session.startFirst 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.compactAfter /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.completeSafety 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.


Limitations

  • The 60s balance timer covers any drift between context events.
  • The chart's bars are relative to the busiest reading shown. M Plan percentages are absolute.
  • One band per session — another plugin that draws AbovePrompt competes for the same row.
  • The M Plan side calls $.http.fetch, which CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC may block. With that variable set the band shows an HTTP error.
  • Picks the general bucket only. Per-model breakdown (model_remains[] has general + video) is parsed and dropped — open an issue if you want it.

Inspiration

Source 1 files
hooks/register.mjs 560 lines
1// 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}