Context window and session cost meter, drawn above the prompt.

Shows conversation fullness, session cost and available usage-limit readings above the prompt. Think of it as a dashboard gauge.
See the complete guide and creation prompt.
Test from the kit root:
claude plugin validate plugins/context-meter
claude plugin test plugins/context-meterhooks/context-meter.mjs 122 lines1// Context Meter: context fill, session cost and rate limits, above the prompt.
2
3const HISTORY = 12;
4const BARS = "▁▂▃▄▅▆▇█";
5const LEVELS = [
6 { upTo: 50, icon: "◔", color: "green" },
7 { upTo: 75, icon: "◑", color: "yellow" },
8 { upTo: 90, icon: "◕", color: "magenta" },
9 { upTo: Infinity, icon: "●", color: "red" },
10];
11const LIMIT_NAMES = { five_hour: "5h", seven_day: "7d", spend_limit: "spend" };
12
13// Held by the host, so the history survives a hot reload of this file.
14const readings = { plugin: "context-meter", key: "readings" };
15const limits = { plugin: "context-meter", key: "limits" };
16
17export function register(on) {
18 // Seed on start (and on resume), so the band shows before the first turn ends.
19 on("session.start", async ($, e, next) => {
20 const result = await next(e);
21 const u = await $.session.usage();
22 const { value: history = [] } = await $.state.get(readings);
23 const reading = toReading(u);
24 if (reading && !sameAs(history[history.length - 1], reading)) {
25 await $.state.set(readings, [...history, reading].slice(-HISTORY));
26 }
27 await $.state.set(limits, toLimits(u.rateLimits));
28 return result;
29 });
30
31 // Pushed by the engine after each main-thread turn and when a limit moves.
32 on("session.measure", async ($, e, next) => {
33 const result = await next(e);
34 if (e.changed.includes("context") || e.changed.includes("cost")) {
35 const { value: history = [] } = await $.state.get(readings);
36 const reading = toReading(e);
37 if (reading && !sameAs(history[history.length - 1], reading)) {
38 await $.state.set(readings, [...history, reading].slice(-HISTORY));
39 }
40 }
41 if (e.changed.includes("rateLimits")) {
42 await $.state.set(limits, toLimits(e.rateLimits));
43 }
44 return result;
45 });
46
47 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
48 const original = await next(e);
49 const { value: history = [] } = await $.state.get(readings);
50 const { value: windows = [] } = await $.state.get(limits);
51 if (e.props.hasSurvey || history.length === 0) return original;
52 const { Box, Text } = $.ui.resolve(e);
53 const meter = band(Box, Text, history, windows, e.props.bodyColumns);
54 if (!original) return meter;
55 return Box({ flexDirection: "column", children: [original, meter] });
56 });
57}
58
59function toReading(u) {
60 const window = u.context?.window;
61 if (!window || u.context.tokens === undefined) return null;
62 const tokens = u.context.tokens;
63 const percent = u.context.percent ?? Math.round((tokens / window) * 100);
64 return { tokens, window, percent, usd: u.cost?.usd ?? 0 };
65}
66
67function toLimits(rateLimits = []) {
68 return rateLimits.map((r) => ({ kind: r.kind, percentUsed: r.percentUsed }));
69}
70
71function sameAs(a, b) {
72 return a !== undefined && a.tokens === b.tokens && a.usd === b.usd && a.window === b.window;
73}
74
75function band(Box, Text, history, windows, columns) {
76 const now = history[history.length - 1];
77 const prev = history.length > 1 ? history[history.length - 2] : undefined;
78 const level = LEVELS.find((l) => now.percent < l.upTo);
79 const dim = (children) => Text({ dimColor: true, children });
80
81 const parts = [
82 Text({ color: level.color, bold: true, children: `${level.icon} ${now.percent}%` }),
83 dim(` ${short(now.tokens)}/${short(now.window)}`),
84 ];
85 if (columns >= 70 && history.length > 1) {
86 parts.push(Text({ color: level.color, children: ` ${sparkline(history)}` }));
87 }
88
89 parts.push(dim(" · "));
90 parts.push(Text({ bold: true, children: usd(now.usd) }));
91 if (columns >= 60 && prev && now.usd > prev.usd) {
92 parts.push(dim(` +${usd(now.usd - prev.usd)}`));
93 }
94
95 if (columns >= 50) {
96 for (const w of windows) {
97 const name = LIMIT_NAMES[w.kind] ?? w.kind;
98 const color = w.percentUsed >= 90 ? "red" : w.percentUsed >= 75 ? "yellow" : undefined;
99 parts.push(dim(" · "));
100 parts.push(dim(`${name} `));
101 parts.push(Text({ color, bold: color !== undefined, children: `${Math.round(w.percentUsed)}%` }));
102 }
103 }
104
105 return Box({ flexDirection: "row", paddingX: 1, children: parts });
106}
107
108function sparkline(history) {
109 const top = Math.max(...history.map((r) => r.tokens), 1);
110 return history.map((r) => BARS[Math.floor((r.tokens / top) * (BARS.length - 1))]).join("");
111}
112
113function usd(n) {
114 return n < 10 ? `$${n.toFixed(2)}` : `$${n.toFixed(1)}`;
115}
116
117function short(n) {
118 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
119 if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`;
120 return String(n);
121}
122types/index.d.ts 9 lines1export type ContextMeterReading = { tokens: number; window: number; percent: number; usd: number };
2export type ContextMeterLimit = { kind: string; percentUsed: number };
3
4declare module "claude-code" {
5 interface PluginState {
6 "context-meter": { readings: ContextMeterReading[]; limits: ContextMeterLimit[] };
7 }
8}
9