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

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.
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
◔ PLAN (bold #0b0b0f on #60a5fa), then the weather glyph (the weather word is gone).showPercent, default true)./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.{ 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 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.spend_limit window (and 5h / 7d where a subscription reports them) still shows when rateLimits carries one.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.claude plugin install token-weather@zehui-mods --scope user.Caveats of the month figure:
/cost), not what the plan bills.#9a9a96. ☂ Showers 67% of context 134.4k / 200k last turns ▁▂█ ▲ +98.3k last turn
After each turn, the band shows:
| Used | Forecast |
|---|---|
| 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, andpercent: 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.
| Hook | What it does |
|---|---|
session.start | Takes a first reading, so the band shows before the first turn. |
turn.complete | Takes 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. |
After the first turn, 18% full:

After reading four large files, 67% full:

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. 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.renderon theAbovePromptcomponent, with theSvgelement, and it updates onturn.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.
$.session.usage(), which returns the same figures as the status line, so the mod shows real numbers rather than an estimate.Svg element. The terminal can't draw Svg, so the chart is a row of block characters.Requirements:
claude plugin validate passes on 2.1.285.AbovePrompt is drawn on the terminal surface only.No environment variables or configuration. (Local change 6: one option, showPercent, default true.)
Steps:
git clone https://github.com/anthropics/claude-code-playground.git
cd claude-code-playground/claude-code/mods
claude plugin validate ./token-weather
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
AbovePrompt competes for the same band.| Name | Version | License (SPDX) | Source |
|---|---|---|---|
| None |
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.
hooks/token-weather.mjs 559 lines1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Token Weather: a live forecast of the context window, above the prompt.
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}
559types/index.d.ts 40 lines1// 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