SLOPSHOPPER

token-weather

A live forecast of the context window, drawn above the prompt: a weather icon, the fill percentage, and a chart of the last turns.

newband
v0.1.11no licenseupdated 2026-10-20ZH-L1N/agent-window-mods/token-weather
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-weather
› 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 ◔ PLAN ☁︎ 49% 97.4k / 200k last turns ██ cost $0.42 today 98.9k tok Oct $0.42 · 99k tokens ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◔ PLAN ☁︎ 49% 97.4k / 200k last turns ██ cost $0.42 today 98.9k tok Oct $0.42 · 99k tokens
README

Token Weather

A Claude Code mod that draws a live forecast of your context window in the band above the prompt. We're sharing it as the smallest of the three mods here: one hooks module that reads real usage figures and draws one line of UI.

Local changes (zehui-mods)

This copy is extended locally (see PROVENANCE.md). The band now reads:

 ◔ PLAN  ☁ 25%  250k / 1M  last turns ▂▃▆  ▲ +1.9k last turn  cost $12.00  today 12.4M tok  Oct $412 · 38M tokens
  • Chip ◔ PLAN (bold #0b0b0f on #60a5fa), then the weather glyph (the weather word is gone).
  • Plain percent of the window after the glyph, in the default colour (option showPercent, default true).
  • Context moved to the HANDOFF row (local change 6). The 20-cell bar stacked by /context category, its traffic-light percent and the top three categories are drawn by the handoff mod's ⇄ HANDOFF row now. This mod still reads the breakdown ($.session.usage({ breakdown: "summary" }), estimated locally, no request) on session.start, turn.complete and after session.compact, never on render, and publishes each reading to $.state under token-weather.context (declared in types/index.d.ts): { categories: [{ name, tokens, color, kind }], totalTokens, maxTokens, autoCompactThreshold?, percent, readAt }, deferred rows left out and colours as hex. So the breakdown is read once per turn, whichever row draws it.
  • Month usage at the end of the row: every session on this machine writes its { usd, tokens } for the month to the mod's store (token-weather.month.<YYYY-MM>.<sessionId>) at session.start and after each main-loop turn; the band sums the current month. Keys older than two months are deleted at session.start.
  • Today's tokens (Local change 10 (zehui-mods), 2026-10-19): today 12.4M tok just before the month figure. The same per-turn sums go to token-weather.day.<YYYY-MM-DD>.<sessionId> (local date) as { tokens }, and the band sums today's keys over this machine's sessions. Past midnight the figure is hidden until the session's next turn, so it never shows yesterday's sum as today's. Day keys are pruned with the month keys. When columns are short it drops after tokens / window and before the month. The month figure's caveats below apply to it too.
  • Rate limits stay: a gateway's spend_limit window (and 5h / 7d where a subscription reports them) still shows when rateLimits carries one.
  • Composes with other bands (2026-10-19 local fix): the row draws its own line and nests whatever the other band mods draw beneath it (AGENTS, GOAL, CACHE, HANDOFF), and yields to a survey, so the upstream limitation "One band per session" below no longer holds.
  • Stack order (Local change 11 (zehui-mods), 2026-10-20): the PLAN row is keyed zm-20-plan and takes its slot among the zehui-mods rows (SYS 10, PLAN 20, HANDOFF 30, CACHE 40, AGENTS 50, GOAL 60, HERE 70, NEXT 80) whatever order the plugins load in; other bands follow.
  • Install from this marketplace (the upstream steps under "Run it" are kept as shipped): claude plugin install token-weather@zehui-mods --scope user.

Caveats of the month figure:

  • It starts with this change (2026-10-19): sessions before it wrote nothing.
  • It covers this machine only: the store is a local file; other machines, claude.ai and the desktop app are not counted.
  • The dollars are Claude Code's own API-rate estimate (/cost), not what the plan bills.
  • Tokens are every turn's input, output, cache-read and cache-write tokens summed, subagents included; cache reads dominate, so the figure runs large.
  • Tokens of a subagent that ends after the session's last main-loop turn are counted at the session's next turn, or not at all if none comes.
  • The published category colours come from the breakdown's theme keys, mapped to approximate dark-theme hex values; a key the table does not know is grey #9a9a96.

What this shows

 ☂  Showers  67% of context  134.4k / 200k   last turns ▁▂█  ▲ +98.3k last turn

After each turn, the band shows:

  • a weather icon and word for how full the window is,
  • the percentage used, and the tokens used out of the window,
  • a chart of the last 12 turns, drawn with block characters, and
  • how much the last turn added.
UsedForecast
under 25%☀ Clear
25–49%☁ Cloudy
50–74%☂ Showers
75–89%☇ Storm
90% up↯ Compact soon

The numbers are real, not estimated. After each main-loop turn, the mod calls $.session.usage() and reads context:

  • tokens: the input tokens the last response was answered over (uncached, cache-written and cache-read together),
  • window: the context window of the session's model, and
  • percent: tokens over window.

These are the same figures the status line shows (total_input_tokens, context_window_size, used_percentage). The call sends no token-count request: upstream asks for no breakdown, and the local change asks only for a summary one, which is estimated locally.

HookWhat it does
session.startTakes a first reading, so the band shows before the first turn.
turn.completeTakes a reading after each main-loop turn. Subagent turns are skipped.
ui.render with {component: "AbovePrompt"}Draws the band as one line.
session.compact (local change)Reads the breakdown again after the main conversation is compacted.

Demo

After the first turn, 18% full:

Token Weather reading Clear at 18% of context

After reading four large files, 67% full:

Token Weather reading Showers at 67% of context

After one more file, 81% full. Claude Code's own notice reads 90%, because it counts toward the auto-compact point (see the limitations):

Token Weather reading Storm at 81% of context

How it was built

  • Model: built with Claude in Claude Code. The test runs and screenshots used Claude Haiku 4.5, because its 200k window shows the forecast change within a few turns. The mod itself doesn't call a model.
  • Prompt(s): the mod started as one of ten ideas Claude wrote for mods. This is the idea as written:

Token Weather. A live forecast of your context window. A band above the prompt draws a small chart of context use for each turn, as weather icons. It's clear when the window is mostly empty, and stormy when it's nearly full. It uses ui.render on the AbovePrompt component, with the Svg element, and it updates on turn.complete. I haven't confirmed which event or call gives the token counts, so that needs a check. Developers would share it because it looks good in a screenshot, and it answers "why did Claude forget that?" at a glance. The status line only shows text. A weekend build.

The build prompt, which picked this idea and two others by number:

implement 1,2,7. give me zips for them. test them in claude code and get me screenshots of what they look like when used.

  • Transcript: not shared. The build ran in an internal workspace.
  • Iterations:
  • The idea left open where the token counts come from. The build found $.session.usage(), which returns the same figures as the status line, so the mod shows real numbers rather than an estimate.
  • The idea drew the chart with the Svg element. The terminal can't draw Svg, so the chart is a row of block characters.
  • The first icons were emoji, which weren't in the test terminal's font. The mod uses single-width text symbols instead, which line up in any terminal font.
  • Tested in Claude Code on a test repo of five 42 KB source files: three turns, each reading more files, moved the band from ☀ 18%, to ☂ 67%, to ☇ 81%.

Run it

Requirements:

  • Claude Code 2.1.287 or later, where mods load by default. The mod was built and tested on 2.1.280, and claude plugin validate passes on 2.1.285.
  • A terminal. AbovePrompt is drawn on the terminal surface only.

No environment variables or configuration. (Local change 6: one option, showPercent, default true.)

Steps:

  1. Clone this repository and go to this folder's parent:
   git clone https://github.com/anthropics/claude-code-playground.git
   cd claude-code-playground/claude-code/mods
  1. Check the plugin:
   claude plugin validate ./token-weather
  1. Try it for one session:
   claude --plugin-dir ./token-weather

Or install it, with the other mods here, from the local marketplace in this folder (see the mods README):

   claude plugin marketplace add ./
   claude plugin install token-weather@claude-code-playground-mods --scope user
  1. Work as normal. The band updates after each turn.

Notes / limitations

  • The percentage is of the full window. Claude Code's own "context used" notice counts against the auto-compact point, which is lower, so the two can differ. In testing, the band read 81% when the notice read 90%.
  • The band updates once per turn, not during a turn.
  • The chart's bars are relative to the fullest turn shown, so growth shows even at low fill. The percentage is the absolute figure.
  • The history resets when the session starts, or when the plugin reloads.
  • On a 1M-token window, ordinary work stays at ☀ for a long time. That's accurate.
  • One band per session. Another plugin that draws AbovePrompt competes for the same band.
  • Before the first response, the band reads 0%, because no response has reported usage yet.

Dependencies

NameVersionLicense (SPDX)Source
None

Third-party notices

None.


Shared as-is as part of claude-code-playground. Not an official Anthropic product; no support or maintenance is implied. See the root README and LICENSE.

Source 2 files
hooks/token-weather.mjs 559 lines
1// 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.
5//
6// turn.complete: after each main-loop turn, read the context window's fill
7// from $.session.usage() (the same figures the status line shows) and keep
8// the last HISTORY readings.
9// session.start: take a first reading, so the band shows before any turn.
10// ui.render (AbovePrompt): one line: icon, forecast word, percent, tokens
11// used of the window, a block-character chart of the recent turns, the
12// session cost in USD and the rate-limit windows when the API reports them.
13//
14// Local change (zehui-mods): the line now reads, left to right, a ` ◔ PLAN `
15// chip, the weather glyph, the plain fill percent (option showPercent),
16// tokens used of the window, the turn chart and delta, the session cost,
17// rate-limit windows, and this month's usage summed over every session on
18// this machine ($.store). The breakdown is read on turn.complete,
19// session.start and after session.compact, never on render.
20//
21// Local change 6 (zehui-mods): the stacked context bar, its percent colours
22// and the top-three categories moved to the handoff mod's ⇄ HANDOFF row.
23// This module still reads the breakdown, once per turn, and publishes the
24// latest reading to $.state under token-weather.context for that row.
25//
26// Local change 10 (zehui-mods): "today 12.4M tok" before the month figure:
27// the same per-turn usage sums the month keeps, per day, under
28// token-weather.day.<YYYY-MM-DD>.<sessionId>, pruned with the month keys.
29//
30// Local change 11 (zehui-mods): the PLAN row is a Box keyed "zm-20-plan" and
31// the hook returns stackRow(line, below): the zehui-mods band rows stack by
32// their number (SYS 10, PLAN 20, HANDOFF 30, CACHE 40, AGENTS 50, GOAL 60,
33// HERE 70, NEXT 80), not by the plugins' load order; other bands follow.
34//
35// The host reads on(...) and $.noun.method(...) from source, so they are
36// spelled literally, and helpers that take $ are top-level functions.
37
38const HISTORY = 12;
39const BARS = "▁▂▃▄▅▆▇█";
40
41// Forecast bands, by percent of the window used.
42const FORECAST = [
43// Single-width text symbols, not emoji: they line up in every terminal font.
44  // Local change 8 (zehui-mods): U+FE0E after each symbol that has an emoji twin, so
45  // the terminal draws the text glyph in the band colour, not the colour-emoji picture.
46  { upTo: 25, icon: "☀\uFE0E", word: "Clear", color: "yellow" },
47  { upTo: 50, icon: "☁\uFE0E", word: "Cloudy", color: "cyan" },
48  { upTo: 75, icon: "☂\uFE0E", word: "Showers", color: "blue" },
49  { upTo: 90, icon: "☇", word: "Storm", color: "magenta" },
50  { upTo: Infinity, icon: "↯", word: "Compact soon", color: "red" },
51];
52
53// Local change (zehui-mods): chip of the deck-style band.
54// Local change 9 (zehui-mods): padded to the widest zehui-mods chip (HANDOFF) so the colour bars line up.
55const CHIP = " ◔ PLAN    ";
56const CHIP_FG = "#0b0b0f";
57const CHIP_BG = "#60a5fa";
58// Local change 11 (zehui-mods): the PLAN row's key in the zehui-mods stack.
59const PLAN_KEY = "zm-20-plan";
60// The breakdown names each category's colour by its theme key. These are
61// the dark theme's values, approximately; a key not listed draws grey.
62const THEME_HEX = {
63  text: "#ffffff",
64  inactive: "#999999",
65  subtle: "#505050",
66  suggestion: "#b1b9f9",
67  remember: "#b1b9f9",
68  success: "#4eba65",
69  error: "#ff6b80",
70  warning: "#ffc107",
71  merged: "#af87ff",
72  claude: "#d77757",
73  permission: "#b1b9f9",
74  planMode: "#48968c",
75  autoAccept: "#af87ff",
76  promptBorder: "#888888",
77  bashBorder: "#fd5db1",
78  ide: "#4782c8",
79  red_FOR_SUBAGENTS_ONLY: "#dc2626",
80  blue_FOR_SUBAGENTS_ONLY: "#2563eb",
81  green_FOR_SUBAGENTS_ONLY: "#16a34a",
82  yellow_FOR_SUBAGENTS_ONLY: "#ca8a04",
83  purple_FOR_SUBAGENTS_ONLY: "#9333ea",
84  orange_FOR_SUBAGENTS_ONLY: "#ea580c",
85  pink_FOR_SUBAGENTS_ONLY: "#db2777",
86  cyan_FOR_SUBAGENTS_ONLY: "#0891b2",
87};
88const FALLBACK_HEX = "#9a9a96";
89const MONTH_PREFIX = "token-weather.month.";
90const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
91// Months kept besides the current one; older keys are deleted at session.start.
92const MONTHS_KEPT = 2;
93// Local change 10 (zehui-mods): today's tokens, one key per session per day;
94// a day key goes when its month's keys go.
95const DAY_PREFIX = "token-weather.day.";
96
97// Local change 6 (zehui-mods): the latest context reading, for the handoff
98// mod's row (declared in types/index.d.ts). Any plugin may read it; only this
99// one writes it.
100const CONTEXT_STATE = { plugin: "token-weather", key: "context" };
101
102// Readings: { tokens, window, percent, isEstimate? }, oldest first.
103let readings = [];
104// Local change (zehui-mods): session cost in USD and the rate-limit windows, from the latest reading.
105let costUsd = null;
106let rateLimits = [];
107// Local change (zehui-mods): tokens counted by turns since the last month write, and this month's totals.
108let pendingTokens = 0;
109let month = null;
110// Local change 10 (zehui-mods): today's tokens summed over this machine's sessions: { ymd, tokens }.
111// The row shows it only while ymd is still the clock's day, so an idle session
112// never shows yesterday's sum as today's past midnight.
113let today = null;
114
115export function register(on, options = {}) {
116  // Local change 6 (zehui-mods): the plain percent after the glyph; on unless the option is false.
117  const showPercent = options.showPercent !== false;
118
119  on("session.start", async ($, e, next) => {
120    const result = await next(e);
121    readings = [];
122    month = null;
123    // Local change 10 (zehui-mods): today's figure is read again with the month's.
124    today = null;
125    await takeReading($);
126    // Local change (zehui-mods): month usage.
127    await pruneMonths($);
128    await recordMonth($);
129    return result;
130  });
131
132  on("turn.complete", async ($, e, next) => {
133    const result = await next(e);
134    // Local change (zehui-mods): every loop's tokens count toward the month, a subagent's too.
135    pendingTokens += turnTokens(e.usage);
136    if (e.agentId) {
137      return result;
138    }
139    await takeReading($);
140    // Local change (zehui-mods): month usage.
141    await recordMonth($);
142    return result;
143  });
144
145  // Local change (zehui-mods): after a compaction of the main conversation, read the breakdown again.
146  on("session.compact", async ($, e, next) => {
147    const result = await next(e);
148    if (e.agentId || e.trigger === "precompute" || !result || typeof result.skip === "string") {
149      return result;
150    }
151    await takeReading($);
152    return result;
153  }).catch(($, e, next) => next(e)); // A failed reading never changes the compaction.
154
155  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
156    // Local change (zehui-mods): the props live under e.props (upstream read e.hasSurvey / e.bodyColumns).
157    const props = e.props ?? {};
158    // Local change 7 (zehui-mods): terminal surface only, as the other zehui-mods bands.
159    if (e.surface !== "terminal" || props.hasSurvey || readings.length === 0) {
160      return next(e);
161    }
162    const { Box, Text } = $.ui.resolve(e);
163    // Local change 10 (zehui-mods): the day now, to drop a today figure from an earlier day.
164    let nowYmd = null;
165    try {
166      nowYmd = dayOf(await $.clock.now());
167    } catch {
168      nowYmd = null;
169    }
170    const line = band(Box, Text, props.bodyColumns ?? 80, showPercent, nowYmd);
171    // Local change (zehui-mods): compose: whatever another band mod beneath
172    // draws goes under this line. The engine's own placeholder
173    // ({ type: "engine" }) is not nested.
174    let below = null;
175    try {
176      below = await next(e);
177    } catch {
178      below = null;
179    }
180    // Local change 11 (zehui-mods): this row takes PLAN 20 among the zehui-mods
181    // rows beneath; any other band follows them (stackRow skips the engine's placeholder).
182    return stackRow(line, below);
183  });
184}
185
186async function takeReading($) {
187  try {
188    // Local change (zehui-mods): a summary breakdown is estimated locally and sends no request.
189    const usage = await $.session.usage({ breakdown: "summary" });
190    const { context } = usage;
191    costUsd = usage.cost?.usd ?? costUsd;
192    rateLimits = usage.rateLimits ?? [];
193    if (!context || !context.window) {
194      return;
195    }
196    const breakdown = readBreakdown(context);
197    // Local change (zehui-mods): before a response of the live window (a fresh
198    // session, or one just compacted) the breakdown's estimate stands in.
199    const isEstimate = context.tokens == null && breakdown != null;
200    const tokens = context.tokens ?? (isEstimate ? breakdown.totalTokens : 0);
201    const percent = Math.round(context.percent ?? (tokens / context.window) * 100);
202    // The session.start reading is 0 before any response; drop it once real readings arrive.
203    readings = readings.filter((r) => r.tokens > 0 && !r.isEstimate);
204    readings.push({ tokens, window: context.window, percent, ...(isEstimate ? { isEstimate } : {}) });
205    if (readings.length > HISTORY) {
206      readings = readings.slice(-HISTORY);
207    }
208    $.ui.invalidate("ui.render");
209    // Local change 6 (zehui-mods): publish the reading for the handoff mod's row.
210    await publishContext($, { breakdown, tokens, window: context.window, percent });
211  } catch {
212    // No reading this turn; the band keeps the last one.
213  }
214}
215
216// Local change 6 (zehui-mods): the reading as token-weather.context:
217// { categories: [{ name, tokens, color, kind }], totalTokens, maxTokens,
218// autoCompactThreshold?, percent, readAt }. Categories are in /context's
219// order with the deferred rows left out and colours as hex; empty on a build
220// without a breakdown. totalTokens is the reading's tokens (the PLAN row's),
221// maxTokens the window; autoCompactThreshold is left out when auto-compact is
222// off or the build does not say.
223async function publishContext($, r) {
224  try {
225    const value = {
226      categories: r.breakdown ? r.breakdown.categories : [],
227      totalTokens: r.tokens,
228      maxTokens: r.window,
229      ...(r.breakdown && r.breakdown.autoCompactThreshold ? { autoCompactThreshold: r.breakdown.autoCompactThreshold } : {}),
230      percent: r.percent,
231      readAt: await $.clock.now(),
232    };
233    await $.state.set(CONTEXT_STATE, value);
234  } catch {
235    // The handoff row keeps the last reading it had.
236  }
237}
238
239// Local change (zehui-mods): the breakdown's rows in /context's order, the
240// deferred tool schemas left out as /context leaves them out.
241function readBreakdown(context) {
242  const bd = context.breakdown;
243  if (!bd || !Array.isArray(bd.categories)) {
244    return null;
245  }
246  const categories = bd.categories
247    .filter((c) => c && c.kind !== "deferred" && !c.isDeferred && Number(c.tokens) > 0)
248    .map((c) => ({ name: String(c.name), tokens: Number(c.tokens), color: hexFor(c.color), kind: String(c.kind) }));
249  if (categories.length === 0) {
250    return null;
251  }
252  return {
253    categories,
254    totalTokens: Number(bd.totalTokens) || 0,
255    // Local change 6 (zehui-mods): the compaction point, when auto-compact is on.
256    autoCompactThreshold: bd.isAutoCompactEnabled === false ? 0 : Number(bd.autoCompactThreshold) || 0,
257  };
258}
259
260function hexFor(color) {
261  if (typeof color !== "string") return FALLBACK_HEX;
262  if (/^#[0-9a-f]{6}$/i.test(color)) return color;
263  return Object.prototype.hasOwnProperty.call(THEME_HEX, color) ? THEME_HEX[color] : FALLBACK_HEX;
264}
265
266// Local change (zehui-mods): the line is built from groups, each with a drop
267// rank; while the line is wider than the columns, the lowest rank goes first
268// (rate limits, the trend, the chart, the cost, the tokens, the month).
269// Local change 6 (zehui-mods): the stacked bar, its percent colours and the
270// top three categories are gone (they are the handoff mod's now); a plain
271// percent follows the glyph while showPercent is on.
272function band(Box, Text, columns, showPercent, nowYmd) {
273  const now = readings[readings.length - 1];
274  const f = forecastFor(now.percent);
275  const groups = [];
276  const add = (rank, texts) => groups.push({ rank, texts });
277  const t = (children, style = {}) => ({ children, style });
278
279  add(Infinity, [t(CHIP, { color: CHIP_FG, backgroundColor: CHIP_BG, bold: true })]);
280  add(Infinity, [t(` ${f.icon} `, { color: f.color, bold: true })]);
281  if (showPercent) {
282    add(Infinity, [t(`${now.percent}%`)]);
283  }
284  add(6, [t(`  ${short(now.tokens)} / ${short(now.window)}`, { dimColor: true })]);
285  add(4, [t("  last turns ", { dimColor: true }), t(chart(), { color: f.color })]);
286  const trend = trendWord();
287  if (trend) {
288    add(3, [t(`  ${trend}`, { dimColor: true })]);
289  }
290  // Local change (zehui-mods): session cost.
291  if (costUsd != null) {
292    add(5, [t("  cost ", { dimColor: true }), t(`$${costUsd.toFixed(2)}`)]);
293  }
294  // Local change (zehui-mods): rate-limit windows (a gateway's spend_limit among them).
295  const limits = limitsText();
296  if (limits) {
297    add(2, [t(`  ${limits}`, { dimColor: true })]);
298  }
299  // Local change 10 (zehui-mods): today's tokens, just before the month
300  // figure; dropped after the tokens / window, before the month. Only while
301  // its day is the clock's day.
302  if (today && today.ymd === nowYmd) {
303    add(6.5, [t("  today ", { dimColor: true }), t(`${short(today.tokens)} tok`)]);
304  }
305  if (month) {
306    add(7, [t("  "), t(monthText(month), { bold: true })]);
307  }
308
309  const width = (g) => g.texts.reduce((sum, x) => sum + [...x.children].length, 0);
310  let total = groups.reduce((sum, g) => sum + width(g), 0);
311  while (total > columns) {
312    let drop = -1;
313    groups.forEach((g, i) => {
314      if (g.rank !== Infinity && (drop < 0 || g.rank < groups[drop].rank)) drop = i;
315    });
316    if (drop < 0) break;
317    total -= width(groups[drop]);
318    groups.splice(drop, 1);
319  }
320  const parts = groups.flatMap((g) => g.texts.map((x) => Text({ ...x.style, children: x.children })));
321  // Local change 11 (zehui-mods): keyed for the zehui-mods stack order.
322  return Box({ key: PLAN_KEY, flexDirection: "row", children: parts });
323}
324
325function forecastFor(percent) {
326  return FORECAST.find((band) => percent < band.upTo) ?? FORECAST[FORECAST.length - 1];
327}
328
329// Bars scale to the busiest reading shown, so growth shows at any fill level.
330function chart() {
331  const top = Math.max(...readings.map((r) => r.tokens), 1);
332  const bars = readings.map((r) => BARS[Math.min(BARS.length - 1, Math.floor((r.tokens / top) * (BARS.length - 1)))]);
333  return bars.join("");
334}
335
336function trendWord() {
337  if (readings.length < 2) {
338    return "";
339  }
340  const delta = readings[readings.length - 1].tokens - readings[readings.length - 2].tokens;
341  if (delta > 0) return `▲ +${short(delta)} last turn`;
342  if (delta < 0) return `▼ ${short(-delta)} last turn`;
343  return "steady";
344}
345
346// Local change (zehui-mods): rate-limit windows as "5h 23% · 7d 41%"; a window past 80% names its reset.
347function limitsText() {
348  const names = { five_hour: "5h", seven_day: "7d", spend_limit: "spend" };
349  return rateLimits
350    .filter((r) => typeof r.percentUsed === "number")
351    .map((r) => {
352      const label = `${names[r.kind] ?? r.kind} ${Math.round(r.percentUsed)}%`;
353      if (r.percentUsed >= 80 && r.resetsAt) {
354        const d = new Date(r.resetsAt);
355        const hh = String(d.getHours()).padStart(2, "0");
356        const mm = String(d.getMinutes()).padStart(2, "0");
357        return `${label} (resets ${hh}:${mm})`;
358      }
359      return label;
360    })
361    .join(" · ");
362}
363
364// Local change (zehui-mods): month usage. Each session keeps one record per
365// month under token-weather.month.<YYYY-MM>.<sessionId>: { usd, tokens }
366// spent in that month. A session that runs across a month boundary carries
367// its earlier months' usd forward as already counted, so each part counts once.
368
369function turnTokens(usage) {
370  if (!usage) return 0;
371  return (
372    (Number(usage.input_tokens) || 0) +
373    (Number(usage.output_tokens) || 0) +
374    (Number(usage.cache_read_input_tokens) || 0) +
375    (Number(usage.cache_creation_input_tokens) || 0)
376  );
377}
378
379function monthOf(ms) {
380  const d = new Date(ms);
381  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
382}
383
384// Local change 10 (zehui-mods): "YYYY-MM-DD", local time, as monthOf.
385function dayOf(ms) {
386  const d = new Date(ms);
387  return `${monthOf(ms)}-${String(d.getDate()).padStart(2, "0")}`;
388}
389
390// Local change 10 (zehui-mods): "<YYYY-MM-DD>.<sessionId>" of a day key, or null for any other key.
391function parseDayKey(key) {
392  if (!key.startsWith(DAY_PREFIX)) return null;
393  const m = /^(\d{4}-\d{2})-(\d{2})\.(.+)$/.exec(key.slice(DAY_PREFIX.length));
394  return m ? { ym: m[1], ymd: `${m[1]}-${m[2]}`, sessionId: m[3] } : null;
395}
396
397function monthIndex(ym) {
398  const [y, m] = ym.split("-").map(Number);
399  return y * 12 + (m - 1);
400}
401
402// "<YYYY-MM>.<sessionId>" of a month key, or null for any other key.
403function parseMonthKey(key) {
404  if (!key.startsWith(MONTH_PREFIX)) return null;
405  const rest = key.slice(MONTH_PREFIX.length);
406  const m = /^(\d{4}-\d{2})\.(.+)$/.exec(rest);
407  return m ? { ym: m[1], sessionId: m[2] } : null;
408}
409
410function record(value) {
411  const v = value && typeof value === "object" ? value : {};
412  return { usd: Number(v.usd) || 0, tokens: Number(v.tokens) || 0 };
413}
414
415async function pruneMonths($) {
416  try {
417    const current = monthIndex(monthOf(await $.clock.now()));
418    for (const key of await $.store.keys()) {
419      // Local change 10 (zehui-mods): a day key goes with its month.
420      const k = parseMonthKey(key) ?? parseDayKey(key);
421      if (k && monthIndex(k.ym) < current - MONTHS_KEPT) {
422        await $.store.delete(key);
423      }
424    }
425  } catch {
426    // Pruning waits for the next session.
427  }
428}
429
430async function recordMonth($) {
431  try {
432    const ym = monthOf(await $.clock.now());
433    const sessionId = await $.session.id();
434    const key = `${MONTH_PREFIX}${ym}.${sessionId}`;
435    const keys = await $.store.keys();
436    const mine = record(await $.store.get(key));
437    let earlierUsd = 0;
438    for (const k of keys) {
439      const p = parseMonthKey(k);
440      if (p && p.sessionId === sessionId && p.ym !== ym) {
441        earlierUsd += record(await $.store.get(k)).usd;
442      }
443    }
444    const usd = costUsd != null ? Math.max(0, costUsd - earlierUsd) : mine.usd;
445    // Local change 10 (zehui-mods): the turns' tokens go to the day's key too.
446    const added = pendingTokens;
447    const tokens = mine.tokens + added;
448    pendingTokens = 0;
449    await $.store.set(key, { usd, tokens });
450    await recordDay($, keys, sessionId, added);
451    let sumUsd = usd;
452    let sumTokens = tokens;
453    for (const k of keys) {
454      const p = parseMonthKey(k);
455      if (p && p.ym === ym && k !== key) {
456        const r = record(await $.store.get(k));
457        sumUsd += r.usd;
458        sumTokens += r.tokens;
459      }
460    }
461    month = { label: MONTH_NAMES[Number(ym.slice(5)) - 1], usd: sumUsd, tokens: sumTokens };
462    $.ui.invalidate("ui.render");
463  } catch {
464    // The month line keeps its last figures.
465  }
466}
467
468// Local change 10 (zehui-mods): adds this session's new tokens to its key for
469// today, { tokens }, and sums today's keys of every session on this machine
470// into today = { ymd, tokens }.
471// A failure here leaves the month's figures as they are.
472async function recordDay($, keys, sessionId, added) {
473  try {
474    const ymd = dayOf(await $.clock.now());
475    const key = `${DAY_PREFIX}${ymd}.${sessionId}`;
476    const tokens = record(await $.store.get(key)).tokens + added;
477    await $.store.set(key, { tokens });
478    let sum = tokens;
479    for (const k of keys) {
480      const p = parseDayKey(k);
481      if (p && p.ymd === ymd && k !== key) {
482        sum += record(await $.store.get(k)).tokens;
483      }
484    }
485    today = { ymd, tokens: sum };
486  } catch {
487    // The today figure keeps its last value.
488  }
489}
490
491function monthText(m) {
492  const usd = m.usd >= 100 ? String(Math.round(m.usd)) : m.usd.toFixed(2);
493  return `${m.label} $${usd} · ${compact(m.tokens)} tokens`;
494}
495
496// 38M, 4.2M, 950k: whole units from ten up.
497function compact(n) {
498  const units = [
499    [1e9, "B"],
500    [1e6, "M"],
501    [1e3, "k"],
502  ];
503  for (const [size, suffix] of units) {
504    if (n >= size) {
505      const v = n / size;
506      return `${v >= 10 ? Math.round(v) : v.toFixed(1)}${suffix}`;
507    }
508  }
509  return String(Math.round(n));
510}
511
512function short(n) {
513  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
514  if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}k`;
515  return String(n);
516}
517
518// Local change 11 (zehui-mods): the stack-order helper, a JavaScript port of
519// ~/.claude/skills/building-claude-code-mods/references/stack-row.tsx (keep the
520// two in step). A zehui-mods row is a Box keyed "zm-<order>-<name>".
521function stackOrderOf(node) {
522  const key = node && typeof node === "object" ? node.props?.key : undefined;
523  const m = typeof key === "string" ? /^zm-(\d+)-/.exec(key) : null;
524  return m ? Number(m[1]) : null;
525}
526
527// The column a band hook beneath built: a Box whose only prop is
528// flexDirection "column" and whose direct children hold a zm row.
529function isStack(node) {
530  return (
531    node.type === "Box" &&
532    node.props?.flexDirection === "column" &&
533    Object.keys(node.props).every((k) => k === "flexDirection") &&
534    Array.isArray(node.children) &&
535    node.children.some((c) => stackOrderOf(c) !== null)
536  );
537}
538
539// `row` put in its slot among the zm rows of `below` (what next(e) drew): zm
540// rows sorted by their number, then every other child in its original order.
541// Nothing beneath (null, a failed next, the engine placeholder): the row alone.
542function stackRow(row, below) {
543  const tree = below && typeof below === "object" && below.type !== "engine" ? below : null;
544  if (!tree) return row;
545  let items = [tree];
546  if (isStack(tree)) {
547    items = [...tree.children];
548    // The column's mark moves to the one row that carries none, its own; the
549    // engine lets a mark move, never multiply.
550    const bare = items.filter((c) => c && typeof c === "object" && !c.part);
551    if (tree.part && bare.length === 1) items = items.map((c) => (c === bare[0] ? { ...c, part: tree.part } : c));
552  }
553  const rows = items.filter((c) => stackOrderOf(c) !== null);
554  const others = items.filter((c) => stackOrderOf(c) === null);
555  // A stable sort: two rows of one number keep their order, this row after.
556  const sorted = [...rows, row].sort((a, b) => (stackOrderOf(a) ?? 0) - (stackOrderOf(b) ?? 0));
557  return { type: "Box", props: { flexDirection: "column" }, children: [...sorted, ...others] };
558}
559
types/index.d.ts 40 lines
1// Local change 6 (zehui-mods): token-weather's state contract. The latest
2// context reading, published after every main-loop turn, at session.start and
3// after a compaction, for the handoff mod's ⇄ HANDOFF row.
4
5/** One row of the context breakdown, in /context's order. */
6export type TokenWeatherCategory = {
7  /** The row's label as /context prints it (`System prompt`, `Messages`, `Free space`). */
8  name: string
9  /** The row's estimated tokens. */
10  tokens: number
11  /** The row's colour as hex, from the theme key the breakdown names (grey `#9a9a96` when unknown). */
12  color: string
13  /** `used`, `free` or `buffer`; deferred rows are left out. */
14  kind: string
15}
16
17/** The latest context reading of the session's main loop. */
18export type TokenWeatherContext = {
19  /** The breakdown's rows (deferred left out); empty on a build without a breakdown. */
20  categories: TokenWeatherCategory[]
21  /** Tokens in the window: the last response's input, or the breakdown's estimate before one. */
22  totalTokens: number
23  /** The context window, in tokens. */
24  maxTokens: number
25  /** Where auto-compact fires, in tokens; absent when it is off or not reported. */
26  autoCompactThreshold?: number
27  /** `totalTokens` over `maxTokens`, a whole percentage. */
28  percent: number
29  /** When the reading was taken, `$.clock.now()` milliseconds. */
30  readAt: number
31}
32
33declare module 'claude-code' {
34  interface PluginState {
35    'token-weather': {
36      context: TokenWeatherContext
37    }
38  }
39}
40