SLOPSHOPPER

token-weather

A live forecast of the context window, drawn above the prompt: a weather icon, the fill percentage, a chart of the last turns, and (on wide terminals)…

newband
v0.1.6no licenseupdated 2026-10-14carollin-issdl/claude-code-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 ☁ Cloudy 49% of context 97.4k / 200k │ Session 31% (now) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☁ Cloudy 49% of context 97.4k / 200k │ Session 31% (now)
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.

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 is free, because the mod doesn't ask for a breakdown, so it sends no token-count request.

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.

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.

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 1 files
hooks/token-weather.mjs 191 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// Locally extended (carol-local-mods fork) to also show plan rate limits
6// (e.g. the 5-hour session limit, weekly limit) and their reset countdown,
7// in the same band, on wide enough terminals.
8//
9// turn.complete: after each main-loop turn, read the context window's fill
10// from $.session.usage() (the same figures the status line shows) and keep
11// the last HISTORY readings.
12// session.measure: fires after each turn and whenever a plan limit's percent
13// used changes; used to refresh the rate-limit figures promptly.
14// session.start: take a first reading, so the band shows before any turn.
15// ui.render (AbovePrompt): one line: icon, forecast word, percent, tokens
16// used of the window, a block-character chart of the recent turns, and
17// (space permitting) the plan's rate limits with reset countdowns.
18//
19// The host reads on(...) and $.noun.method(...) from source, so they are
20// spelled literally, and helpers that take $ are top-level functions.
21
22const HISTORY = 12;
23const BARS = "▁▂▃▄▅▆▇█";
24
25// Forecast bands, by percent of the window used.
26const FORECAST = [
27// Single-width text symbols, not emoji: they line up in every terminal font.
28  { upTo: 25, icon: "☀", word: "Clear", color: "yellow" },
29  { upTo: 50, icon: "☁", word: "Cloudy", color: "cyan" },
30  { upTo: 75, icon: "☂", word: "Showers", color: "blue" },
31  { upTo: 90, icon: "☇", word: "Storm", color: "magenta" },
32  { upTo: Infinity, icon: "↯", word: "Compact soon", color: "red" },
33];
34
35// Readings: { tokens, window, percent }, oldest first.
36let readings = [];
37// Latest plan rate limits: list of { kind, percentUsed, resetsAt }.
38let rateLimits = [];
39
40export function register(on) {
41  on("session.start", async ($, e, next) => {
42    const result = await next(e);
43    readings = [];
44    await takeReading($);
45    return result;
46  });
47
48  on("turn.complete", async ($, e, next) => {
49    const result = await next(e);
50    if (e.agentId) {
51      return result;
52    }
53    await takeReading($);
54    return result;
55  });
56
57  on("session.measure", async ($, e, next) => {
58    const result = await next(e);
59    await takeReading($);
60    return result;
61  });
62
63  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
64    if (e.hasSurvey || readings.length === 0) {
65      return next(e);
66    }
67    const { Box, Text } = $.ui.resolve(e);
68    return band(Box, Text, e.bodyColumns ?? 80);
69  });
70}
71
72async function takeReading($) {
73  try {
74    const { context, rateLimits: limits } = await $.session.usage();
75    if (Array.isArray(limits)) {
76      rateLimits = limits;
77    }
78    if (!context || !context.window) {
79      return;
80    }
81    const tokens = context.tokens ?? 0;
82    const percent = Math.round(context.percent ?? (tokens / context.window) * 100);
83    // The session.start reading is 0 before any response; drop it once real readings arrive.
84    readings = readings.filter((r) => r.tokens > 0);
85    readings.push({ tokens, window: context.window, percent });
86    if (readings.length > HISTORY) {
87      readings = readings.slice(-HISTORY);
88    }
89    $.ui.invalidate("ui.render");
90  } catch {
91    // No reading this turn; the band keeps the last one.
92  }
93}
94
95// SessionRateLimit.kind is one of exactly three strings per Claude Code's
96// own mods type declarations: "five_hour", "seven_day", or a gateway's
97// "spend_limit". There is no per-model variant exposed to mods (the
98// Desktop app's own "Weekly · Fable" row is drawn from data mods can't see).
99function labelFor(kind) {
100  const k = String(kind || "");
101  if (k === "five_hour") return "Session";
102  if (k === "seven_day") return "Week";
103  if (k === "spend_limit") return "Spend";
104  return k || "Limit";
105}
106
107function fmtReset(resetsAt) {
108  if (!resetsAt) return "";
109  // resetsAt is an ISO 8601 string per SessionRateLimit, but accept a raw
110  // epoch value too in case a future host sends one.
111  let ms = typeof resetsAt === "number" ? (resetsAt < 1e12 ? resetsAt * 1000 : resetsAt) : Date.parse(resetsAt);
112  if (!Number.isFinite(ms)) return "";
113  const diff = ms - Date.now();
114  if (diff <= 0) return "now";
115  const mins = Math.round(diff / 60000);
116  if (mins < 60) return `${mins}m`;
117  const hours = Math.floor(mins / 60);
118  if (hours < 24) return `${hours}h${mins % 60}m`;
119  const days = Math.floor(hours / 24);
120  return `${days}d${hours % 24}h`;
121}
122
123function rateLimitParts(Text) {
124  if (!rateLimits.length) return [];
125  return rateLimits.slice(0, 2).map((rl) => {
126    const used = Math.round(rl.percentUsed ?? 0);
127    const reset = fmtReset(rl.resetsAt);
128    const label = labelFor(rl.kind);
129    const urgent = used >= 90;
130    return Text({
131      dimColor: !urgent,
132      color: urgent ? "red" : undefined,
133      children: `  ${label} ${used}%${reset ? ` (${reset})` : ""}`,
134    });
135  });
136}
137
138function band(Box, Text, columns) {
139  const now = readings[readings.length - 1];
140  const f = forecastFor(now.percent);
141  const trend = trendWord();
142  // Plan limits are the figure asked for most, so they go right after the
143  // headline numbers -- before the chart/trend, which are nice-to-haves and
144  // are the first thing dropped on a narrower band.
145  const parts = [
146    Text({ color: f.color, bold: true, children: `${f.icon}  ${f.word}` }),
147    Text({ children: `  ${now.percent}% of context` }),
148    Text({ dimColor: true, children: `  ${short(now.tokens)} / ${short(now.window)}` }),
149  ];
150  const limitParts = rateLimitParts(Text);
151  if (limitParts.length) {
152    parts.push(Text({ dimColor: true, children: "  │" }));
153    parts.push(...limitParts);
154  }
155  if (columns >= 90) {
156    parts.push(Text({ dimColor: true, children: "   last turns " }));
157    parts.push(Text({ color: f.color, children: chart() }));
158    if (trend) {
159      parts.push(Text({ dimColor: true, children: `  ${trend}` }));
160    }
161  }
162  return Box({ flexDirection: "row", paddingX: 1, children: parts });
163}
164
165function forecastFor(percent) {
166  return FORECAST.find((band) => percent < band.upTo) ?? FORECAST[FORECAST.length - 1];
167}
168
169// Bars scale to the busiest reading shown, so growth shows at any fill level.
170function chart() {
171  const top = Math.max(...readings.map((r) => r.tokens), 1);
172  const bars = readings.map((r) => BARS[Math.min(BARS.length - 1, Math.floor((r.tokens / top) * (BARS.length - 1)))]);
173  return bars.join("");
174}
175
176function trendWord() {
177  if (readings.length < 2) {
178    return "";
179  }
180  const delta = readings[readings.length - 1].tokens - readings[readings.length - 2].tokens;
181  if (delta > 0) return `▲ +${short(delta)} last turn`;
182  if (delta < 0) return `▼ ${short(-delta)} last turn`;
183  return "steady";
184}
185
186function short(n) {
187  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
188  if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}k`;
189  return String(n);
190}
191