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

There is no magic here. The bash script just creates symbolic links from ~/dotfiles.
# >>> start
cd ~
git clone git@github.com:tatthien/dotfiles.git
cd dotfiles
./install.sh
# >>> donehooks/register.mjs 82 lines1const HISTORY = 12;
2const BARS = "▁▂▃▄▅▆▇█";
3const FORECAST = [
4 { upTo: 25, icon: "☀", word: "Clear", color: "yellow" },
5 { upTo: 50, icon: "☁", word: "Cloudy", color: "cyan" },
6 { upTo: 75, icon: "☂", word: "Showers", color: "blue" },
7 { upTo: 90, icon: "☇", word: "Storm", color: "magenta" },
8 { upTo: Infinity, icon: "↯", word: "Compact soon", color: "red" },
9];
10
11// Held by the host, so the history survives a hot reload of this file.
12const readings = { plugin: "token-weather", key: "readings" };
13
14export function register(on) {
15 on("session.start", async ($, e, next) => {
16 const result = await next(e);
17 await takeReading($);
18 return result;
19 });
20
21 on("turn.complete", async ($, e, next) => {
22 const result = await next(e);
23 if (!e.agentId) {
24 await takeReading($); // main-loop turns only, not subagents
25 }
26 return result;
27 });
28
29 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
30 const { value: history = [] } = await $.state.get(readings);
31 if (e.surface !== "terminal" || e.props.hasSurvey || history.length === 0) {
32 return next(e);
33 }
34 const { Box, Text } = $.ui.resolve(e);
35 return band(Box, Text, history, e.props.bodyColumns);
36 });
37}
38
39async function takeReading($) {
40 const { context } = await $.session.usage();
41 if (!context?.window) return;
42 const tokens = context.tokens ?? 0;
43 const percent = context.percent ?? Math.round((tokens / context.window) * 100);
44 const { value: history = [] } = await $.state.get(readings);
45 await $.state.set(readings, [...history, { tokens, window: context.window, percent }].slice(-HISTORY));
46}
47
48function band(Box, Text, history, columns) {
49 const now = history[history.length - 1];
50 const f = FORECAST.find((b) => now.percent < b.upTo);
51 const parts = [
52 Text({ color: f.color, bold: true, children: `${f.icon} ${f.word}` }),
53 Text({ children: ` ${now.percent}% of context` }),
54 Text({ dimColor: true, children: ` ${short(now.tokens)} / ${short(now.window)}` }),
55 ];
56 if (columns >= 60) {
57 parts.push(Text({ dimColor: true, children: " last turns " }));
58 parts.push(Text({ color: f.color, children: sparkline(history) }));
59 if (history.length > 1) {
60 parts.push(Text({ dimColor: true, children: trend(history) }));
61 }
62 }
63 return Box({ flexDirection: "row", paddingX: 1, children: parts });
64}
65
66function sparkline(history) {
67 const top = Math.max(...history.map((r) => r.tokens), 1);
68 return history.map((r) => BARS[Math.floor((r.tokens / top) * (BARS.length - 1))]).join("");
69}
70
71function trend(history) {
72 const delta = history[history.length - 1].tokens - history[history.length - 2].tokens;
73 if (delta === 0) return " steady";
74 return delta > 0 ? ` ▲ +${short(delta)} last turn` : ` ▼ ${short(-delta)} last turn`;
75}
76
77function short(n) {
78 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
79 if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`;
80 return String(n);
81}
82types/index.d.ts 8 lines1export type TokenWeatherReading = { tokens: number; window: number; percent: number };
2
3declare module "claude-code" {
4 interface PluginState {
5 "token-weather": { readings: TokenWeatherReading[] };
6 }
7}
8