A live forecast of the context window, drawn above the prompt.

hooks/token-weather.ts 73 lines1// Token Weather: a live forecast of the context window, above the prompt.
2
3import type { On } from "claude-code";
4import type { TokenWeatherReading as Reading } from "../types";
5
6const HISTORY = 12;
7const BARS = "▁▂▃▄▅▆▇█";
8const FORECAST = [
9 { upTo: 25, icon: "☀", word: "Clear", color: "yellow" },
10 { upTo: 50, icon: "☁", word: "Cloudy", color: "cyan" },
11 { upTo: 75, icon: "☂", word: "Showers", color: "blue" },
12 { upTo: 90, icon: "☇", word: "Storm", color: "magenta" },
13 { upTo: Infinity, icon: "↯", word: "Compact soon", color: "red" },
14] as const;
15
16// Held by the host, so the history survives a hot reload of this file.
17const readings = { plugin: "token-weather", key: "readings" } as const;
18
19export function register(on: On) {
20 // Fires after each main-thread turn (never a subagent's) with the usage figures.
21 on("session.measure", async ($, e, next) => {
22 const { tokens, window, percent } = e.context;
23 if (e.changed.includes("context") && tokens !== undefined) {
24 const reading = { tokens, window, percent: percent ?? Math.round((tokens / window) * 100) };
25 const { value: history = [] } = await $.state.get(readings);
26 await $.state.set(readings, [...history, reading].slice(-HISTORY));
27 }
28 return next(e);
29 });
30
31 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
32 const { value: history = [] } = await $.state.get(readings);
33 const now = history.at(-1);
34 if (e.props.hasSurvey || !now) {
35 return next(e);
36 }
37 const { Text, Box } = $.ui.resolve(e);
38 // The last tier's upTo is Infinity, so find() always matches.
39 const f = FORECAST.find((b) => now.percent < b.upTo)!;
40 const parts = [
41 Text({ color: f.color, bold: true, children: `${f.icon} ${f.word}` }),
42 Text({ children: ` ${now.percent}% of context` }),
43 Text({ dimColor: true, children: ` ${short(now.tokens)} / ${short(now.window)}` }),
44 ];
45 if (e.props.bodyColumns >= 60) {
46 parts.push(Text({ dimColor: true, children: " last turns " }));
47 parts.push(Text({ color: f.color, children: sparkline(history) }));
48 const prev = history.at(-2);
49 if (prev) {
50 parts.push(Text({ dimColor: true, children: trend(prev, now) }));
51 }
52 }
53 return Box({ flexDirection: "row", paddingX: 1, children: parts });
54 });
55}
56
57function sparkline(history: Reading[]) {
58 const top = Math.max(...history.map((r) => r.tokens), 1);
59 return history.map((r) => BARS[Math.floor((r.tokens / top) * (BARS.length - 1))]).join("");
60}
61
62function trend(prev: Reading, now: Reading) {
63 const delta = now.tokens - prev.tokens;
64 if (delta === 0) return " steady";
65 return delta > 0 ? ` ▲ +${short(delta)} last turn` : ` ▼ ${short(-delta)} last turn`;
66}
67
68function short(n: number) {
69 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
70 if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`;
71 return String(n);
72}
73types/index.d.ts 7 lines1export type TokenWeatherReading = { tokens: number; window: number; percent: number };
2
3declare module "claude-code" {
4 interface PluginState {
5 "token-weather": { readings: TokenWeatherReading[] };
6 }
7}