A one-row band above the prompt with session cost, 5h/7d plan limits and an optional budget bar.

A one-row band above the prompt: session cost, plan-limit windows, and an optional budget bar.
$1.23 ███░░░░░░░ 31% 5h 42% · 7d 18%
turn.complete and session.measure refreshing module state from $.session.usage(), and a composing ui.render hook for the AbovePrompt band that only formats that state.
Captured in a terminal on Claude Code 2.1.289, stacked under powerline-bar: session cost on the left, then the 5-hour and 7-day plan limits. The budget bar isn't shown because budget_usd was left empty.

The budget bar and its colours are covered by tests only.
turn.complete and session.measure, await $.session.usage() into a module variable, then $.ui.invalidate("ui.render"). ui.render never calls usage.cost.usd as $1.23, each rate-limit window as 5h 42% / 7d 18% (a gateway spend_limit as spend), and, when budget_usd is set, a 10-block bar. The cost and bar are green under 70% of budget, yellow from 70%, red from 90%.cost hides the cost, empty rateLimits hide the limits, and a failed refresh keeps the last reading.const theirs = await next(e), then a column [theirs, ours], so this band is the bottom row.What claude plugin validate reports:
hooks: turn.complete, session.measure, ui.render{component=AbovePrompt}
calls: $.session.usage (via refresh), $.ui.invalidate (via refresh), $.ui.resolve
Setting: budget_usd (string, empty = no budget), for example 5. Read when the mod loads.
Requires Claude Code 2.1.289 or later.
claude --plugin-dir ./mods/cost-bar # one session
claude plugin marketplace add justmalhar/awesome-claude-mods
claude plugin install cost-bar@awesome-claude-mods --scope user
Test it: claude plugin test mods/cost-bar.
claude -p, cloud), and there is no text fallback.AbovePrompt band. It composes with other bands, but the row order is fixed (ours last).budget_usd needs a mod reload. The first reading arrives after a turn or the first session.measure.cost is Claude Code's own ledger for this session. rateLimits is empty off a subscription.meter() function, not a mounted band: the test kit loads the mod with default options only. The mounted band is tested without a budget.None.
hooks/cost-bar.mjs 67 lines1// Cost Bar: one AbovePrompt row with session cost, plan limits and a budget bar.
2//
3// turn.complete and session.measure read $.session.usage() into `usage`, then
4// invalidate; ui.render only formats it (no host calls while drawing).
5// Layout when composing: [other mods' band, this band] (ours is the bottom row).
6
7let usage = null; // module state; resets on reload
8let budget = 0; // USD, 0 = no budget
9
10const LABEL = { five_hour: "5h", seven_day: "7d", spend_limit: "spend" };
11
12async function refresh($) {
13 try {
14 usage = await $.session.usage();
15 $.ui.invalidate("ui.render");
16 } catch {} // keep the last reading; a refresh must never break the turn
17}
18
19// Budget bar: { color, text } for usd spent of budget, or null with no budget.
20export function meter(usd, budget) {
21 if (!(budget > 0) || typeof usd !== "number") return null;
22 const pct = (usd / budget) * 100;
23 const filled = Math.min(10, Math.round(pct / 10));
24 return {
25 color: pct >= 90 ? "red" : pct >= 70 ? "yellow" : "green",
26 text: "█".repeat(filled) + "░".repeat(10 - filled) + ` ${Math.round(pct)}%`,
27 };
28}
29
30export function register(on, options) {
31 const n = parseFloat(options?.budget_usd);
32 budget = n > 0 ? n : 0; // ponytail: read once at load, reload the mod after changing it
33
34 on("turn.complete", async ($, e, next) => {
35 await refresh($);
36 return next(e);
37 });
38
39 on("session.measure", async ($, e, next) => {
40 await refresh($);
41 return next(e);
42 });
43
44 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
45 const theirs = await next(e);
46 try {
47 if (!usage) return theirs; // nothing read yet: draw nothing
48 const { Box, Text } = $.ui.resolve(e);
49 const parts = [];
50 const usd = usage.cost?.usd;
51 const m = meter(usd, budget);
52 const color = m?.color;
53 if (typeof usd === "number") parts.push(Text({ key: "cost", color, children: `$${usd.toFixed(2)}` }));
54 if (m) parts.push(Text({ key: "bar", color, children: m.text }));
55 const limits = (usage.rateLimits ?? [])
56 .map((r) => `${LABEL[r.kind] ?? r.kind} ${Math.round(r.percentUsed)}%`)
57 .join(" · ");
58 if (limits) parts.push(Text({ key: "limits", dimColor: true, children: limits }));
59 if (!parts.length) return theirs;
60 const mine = Box({ key: "cost-bar", flexDirection: "row", columnGap: 2, children: parts });
61 return theirs ? Box({ flexDirection: "column", children: [theirs, mine] }) : mine;
62 } catch {
63 return theirs; // a draw hook must never throw
64 }
65 });
66}
67