SLOPSHOPPER

cost-bar

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

newband
★ 1v0.1.0MITupdated 2026-10-04Justmalhar/awesome-claude-mods/mods/cost-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cost-bar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ⟨Claude Code's own drawing⟩ $0.42 5h 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ $0.42 5h 31%
README

Cost 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%

What this shows

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.

Demo

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.

cost-bar below powerline-bar

The budget bar and its colours are covered by tests only.

How it works

  • On turn.complete and session.measure, await $.session.usage() into a module variable, then $.ui.invalidate("ui.render"). ui.render never calls usage.
  • The band shows 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%.
  • Nothing is drawn until the first reading. A missing cost hides the cost, empty rateLimits hide the limits, and a failed refresh keeps the last reading.
  • Composing: 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.

Run it

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.

Notes / limitations

  • Surfaces: draws in the terminal and the Desktop Code tab. Hooks run elsewhere but nothing is shown (VS Code, claude -p, cloud), and there is no text fallback.
  • Uses the AbovePrompt band. It composes with other bands, but the row order is fixed (ours last).
  • The budget is a display threshold, not a limit: nothing is blocked at 100%.
  • Changing 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.
  • Tests cover the budget colours through the exported meter() function, not a mounted band: the test kit loads the mod with default options only. The mounted band is tested without a budget.
  • Not checked in a live session.

Dependencies

None.

Source 1 files
hooks/cost-bar.mjs 67 lines
1// 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