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)…

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