See your Claude Code session at a glance: context left, cost and today's spend, rate limits, git diff and PR status, right above the prompt. Set /budget limits…

A Claude Code mod: a live band above the prompt with your context window, spend, rate limits, git state and pull request, plus a ▾ more panel that shows where the context and the money went.
Works in the Claude Code CLI, the desktop app's Code tab (Claude Code 2.1.287 or later), and pi.




/plugin marketplace add abyigiter/claude-usage-bar
/plugin install usage-bar@abyigiter-mods
/reload-plugins
Update later with /plugin marketplace update abyigiter-mods, then /reload-plugins.
The same bar runs in pi as a widget above the editor:
pi install git:github.com/abyigiter/claude-usage-bar
Chips for context, cost (against your budget), today's spend, the running turn, git diff, the branch's PR, tool calls, session time and model. /usage toggles the detail panel (context forecast, spend per turn, turns and cache hit, top tools, edited files, failing checks); /usage text prints the figures. /budget works as below, stored in ~/.pi/agent/usage-bar.json.
Today and the last 7 days are exact in pi: they are summed from the cost pi records on every message in every session file under ~/.pi/agent/sessions. There is no rate-limit chip, since pi talks to many providers. It sits happily next to a custom footer such as pi-powerline-footer.
Tests: npm test (the drawing and parsing in pi/core.ts).
Each figure is a pill colored by what it is, turning yellow or red when it needs attention. Pills flow in one row and wrap as units.
| Pill | Shows |
|---|---|
5H / 7D | Rate-limit use with a bar, a pace tick at the window's elapsed time, and the reset countdown. Yellow when your pace would hit 100% before reset, red past 150% pace or 90% used. Hidden off a subscription. |
CTX | Context window fill, tokens, and the last turn's growth. |
● 0:42 | A live timer while Claude works. |
| git | Branch, lines added and removed against HEAD (+421 −68), untracked files (?1), ahead and behind. |
| PR | The branch's pull request through gh: state, review, CI (✓ pass, ✗ fail, ● running). Re-checked every 5 minutes, every minute while CI runs, with a toast when it finishes. Needs the GitHub CLI signed in. |
COST | Session spend and burn rate ($/h), against your budget when one is set. |
TODAY | Spend across every session today, and the last 7 days. |
In the terminal the same figures are tinted chips.
▾ more opens four cards on desktop (aligned rows in the terminal). Cards with nothing to show yet are left out.
/context category (messages, tools, MCP, memory, skills) and how many turns are left before auto-compact at your average growth./budget 10 session budget of $10
/budget day 50 daily budget of $50
/budget off clear both (or /budget off day, /budget off session)
/budget show where you stand
A toast fires at 80% and at 100% of each budget, and once when your burn rate will cross the session budget within 15 minutes.
Each session keeps its own running total per local day in the plugin store (kept 30 days); today is the sum over sessions. A session that began today counts in full, one carried over from an earlier day counts from its first reading today.
/usagePrints everything above as text: limits with pace, context, session cost, today and the last 7 days, budgets, turns and cache hit rate, tools, edited files, git and PR with failing checks, model.
No API calls of its own, no keys, no telemetry. Everything is local except the PR lookup, which goes through your own gh login.
| Claude Code | pi | |
|---|---|---|
| Usage and cost | Claude Code's own figures ($.session.usage()), the same numbers as /cost and the status line | The cost pi records on each message, read from your session files under ~/.pi/agent/sessions |
| Context breakdown | Claude Code's local /context estimate (breakdown: "summary", no token-count requests) | Not available |
| Commands it runs, in the session's directory | git status --porcelain=v1 -b, git diff HEAD --shortstat, gh pr view --json number,url,state,isDraft,reviewDecision,statusCheckRollup | The same three |
| What it writes | Budgets and per-day totals in the plugin's own store | Budgets in ~/.pi/agent/usage-bar.json |
git runs every minute and after each turn. gh runs at most every 5 minutes (every minute while CI runs) and only on a branch, and it does nothing when gh is missing or signed out. It never reads .env files, credentials or the contents of your repo; the git commands only return file counts and line totals.
A standard mod plugin: .claude-plugin/plugin.json, hooks/hooks.json, one hooks module. Readings come from $.session.usage() on session.measure, turn.complete and a one-minute timer; the context breakdown from $.session.usage({ breakdown: "summary" }), a local estimate. The header and cards on desktop are SVG drawings; the terminal draws Ink text. State lives in $.state so it survives hot reloads; budgets and the day totals in $.store.
Tests: claude plugin test ./usage-bar.
hooks/usage-bar.mjs 1234 lines1// Usage bar: a strip above the prompt with rate limits, context, cost, git,
2// a live turn timer, activity, and model. ▾ opens a detail panel (context
3// breakdown and compaction forecast, spend per turn, turn timing and cache hit
4// rate, top tools); /usage prints the same as text.
5
6const READING = { plugin: "usage-bar", key: "last" };
7const ACTIVITY = { plugin: "usage-bar", key: "activity" };
8const GIT = { plugin: "usage-bar", key: "git" };
9const TURN = { plugin: "usage-bar", key: "turn" };
10const UI = { plugin: "usage-bar", key: "ui" };
11const TURNS = { plugin: "usage-bar", key: "turns" };
12const CTXMAP = { plugin: "usage-bar", key: "ctxmap" };
13const BUDGET = { plugin: "usage-bar", key: "budget" };
14const LEDGER = { plugin: "usage-bar", key: "ledger" };
15const ALERTS = { plugin: "usage-bar", key: "alerts" };
16const BAR_FILLED = "▰";
17const BAR_EMPTY = "▱";
18const BAR_WIDTH = 8;
19const BARS = "▁▂▃▄▅▆▇█";
20const HISTORY = 12;
21const TURN_LOG = 24;
22const PR_TTL = 5 * 60_000;
23const PR_TTL_PENDING = 60_000; // while CI runs, so its result toasts soon after
24const LEDGER_DAYS = 30;
25const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
26let commandName;
27let turnStart = 0; // ms, 0 when no turn is running
28
29export function register(on) {
30 on("session.start", async ($, e, next) => {
31 const result = await next(e);
32 await takeReading($);
33 await $.state.set(BUDGET, (await storeGet($, "budget")) ?? {});
34 await syncLedger($);
35 try { await $.store.delete("ledger"); } catch { /* 0.9.0's shared ledger, replaced */ }
36 $.clock.every(60_000, () => takeReading($)); // keep reset countdowns fresh
37 $.clock.every(1000, () => { // live turn timer: redraw each second while a turn runs
38 if (turnStart && Date.now() - turnStart < 2 * 3_600_000) $.state.get(TURN).then(({ value: t }) => $.state.set(TURN, { ...t, startedAt: turnStart, tick: Date.now() }));
39 });
40 try {
41 await $.command.register({ name: "budget", description: "Spend budget: /budget 10 (session), /budget day 50, /budget off" });
42 } catch {
43 // taken by another plugin
44 }
45 for (const name of ["usage", "context"]) {
46 try {
47 await $.command.register({ name, description: "Rate limits, context forecast, cost, and activity" });
48 commandName = name;
49 break;
50 } catch {
51 // name taken; try the next one
52 }
53 }
54 return result;
55 });
56
57 on("command.run", { command: "usage" }, ($, e, next) => answerUsage($, e, next));
58 on("command.run", { command: "context" }, ($, e, next) => answerUsage($, e, next));
59 on("command.run", { command: "budget" }, ($, e) => answerBudget($, e));
60
61 on("prompt.submit", async ($, e, next) => {
62 turnStart = Date.now();
63 const { value: u } = await $.state.get(READING);
64 await $.state.set(TURN, { startedAt: turnStart, tick: turnStart, costAt: u?.cost?.usd });
65 return next(e);
66 });
67
68 on("tool.call", async ($, e, next) => {
69 const result = await next(e);
70 const { value: a = { calls: 0, files: [] } } = await $.state.get(ACTIVITY);
71 const files = [...a.files];
72 const p = e.input?.file_path ?? e.input?.notebook_path;
73 if (EDIT_TOOLS.has(e.tool) && p && !files.includes(p)) files.push(p);
74 const tools = { ...a.tools, [e.tool]: (a.tools?.[e.tool] ?? 0) + 1 };
75 await $.state.set(ACTIVITY, { calls: a.calls + 1, files: files.slice(-50), tools });
76 return result;
77 });
78
79 on("session.measure", async ($, e, next) => {
80 const result = await next(e);
81 const { value: prev } = await $.state.get(READING);
82 await $.state.set(READING, mergeReading(prev, {
83 context: e.context,
84 rateLimits: e.rateLimits,
85 cost: e.cost,
86 model: prev?.model,
87 }));
88 return result;
89 });
90
91 on("turn.complete", async ($, e, next) => {
92 const result = await next(e);
93 if (!e.agentId) { // main-loop turns only
94 turnStart = 0;
95 await takeReading($);
96 const { value: after } = await $.state.get(READING);
97 const { value: turn } = await $.state.get(TURN);
98 await logTurn($, e, turn?.costAt, after);
99 await checkBudget($);
100 const { value: ui } = await $.state.get(UI);
101 if (ui?.expanded) await takeBreakdown($);
102 }
103 return result;
104 });
105
106 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
107 const { value: u } = await $.state.get(READING);
108 const { value: a } = await $.state.get(ACTIVITY);
109 const hasUsage = u && (u.rateLimits?.length || u.context?.window || typeof u.cost?.usd === "number");
110 if (!hasUsage && !a?.calls) return next(e);
111 if (e.props.hasSurvey) return next(e);
112 const { value: git } = await $.state.get(GIT);
113 const { value: turn } = await $.state.get(TURN);
114 const { value: ui } = await $.state.get(UI);
115 const { value: budget = {} } = await $.state.get(BUDGET);
116 const { value: ledger } = await $.state.get(LEDGER);
117 const view = {
118 u, a, git, budget, ledger,
119 working: e.props.isWorking && turn?.startedAt ? Date.now() - turn.startedAt : null,
120 columns: e.props.bodyColumns,
121 };
122 const els = $.ui.resolve(e);
123 const isDesktop = e.surface === "desktop";
124 const { Box, Button } = els;
125 const expanded = !!ui?.expanded;
126 const more = Button({
127 key: "more",
128 label: expanded ? "▴ less" : "▾ more",
129 plain: true,
130 dimColor: true,
131 onPress: async () => {
132 await $.state.set(UI, { expanded: !expanded });
133 if (!expanded) await takeBreakdown($);
134 },
135 });
136 let header;
137 if (isDesktop) {
138 const { left, right } = desktopRow(els, view);
139 if (!left.length && !right.length) return next(e);
140 // one even flow: pills wrap as units in a fixed order, never as two
141 // ragged groups when one pill (a long branch, a PR) grows
142 header = Box({ flexDirection: "row", flexWrap: "wrap", alignItems: "center", columnGap: 1, paddingX: 1, children: [...left, ...right, more] });
143 } else {
144 const row = terminalRow(els, view);
145 if (!row.length) return next(e);
146 header = Box({ flexDirection: "row", flexWrap: "wrap", alignItems: "center", columnGap: 1, paddingX: 1, children: [...row, more] });
147 }
148 const body = [header];
149 if (expanded) {
150 const { value: turns = [] } = await $.state.get(TURNS);
151 const { value: map } = await $.state.get(CTXMAP);
152 const d = { ...insights(u, a, turns, map), budget, ledger };
153 // the panel sits on the header's own inset, so cards line up with the pills
154 body.push(isDesktop
155 ? Box({ key: "details-box", flexDirection: "column", paddingX: 1, paddingTop: 1, children: [desktopDetails(els, d, git, u)] })
156 : terminalDetails(els, d, git, u, view.columns));
157 const links = failingChecks(els, git?.pr);
158 if (links) body.push(links);
159 }
160 return Box({ flexDirection: "column", children: body });
161 });
162}
163
164// Failing checks by name, under the detail panel; only when something failed.
165function failingChecks({ Box, Text }, pr) {
166 if (!pr?.failing?.length) return null;
167 const kids = [Text({ children: "✗ failing ", color: "red", bold: true }), Text({ children: pr.failing.map((c) => c.name).join(" · "), dimColor: true })];
168 return Box({ key: "pr-links", flexDirection: "row", flexWrap: "wrap", alignItems: "center", paddingX: 1, children: kids });
169}
170
171// One record per main-loop turn: how long, what it cost, what it read and wrote.
172// The cost is measured from the prompt's submit: session.measure has already
173// moved the reading by the time the turn completes.
174async function logTurn($, e, costAt, after) {
175 const usd = typeof after?.cost?.usd === "number" && typeof costAt === "number" ? Math.max(0, after.cost.usd - costAt) : null;
176 const k = e.usage ?? {};
177 const rec = {
178 ms: e.durationMs ?? 0,
179 usd,
180 in: k.input_tokens ?? 0,
181 out: k.output_tokens ?? 0,
182 cr: k.cache_read_input_tokens ?? 0,
183 cw: k.cache_creation_input_tokens ?? 0,
184 };
185 const { value: turns = [] } = await $.state.get(TURNS);
186 await $.state.set(TURNS, [...turns, rec].slice(-TURN_LOG));
187 await syncLedger($, true);
188}
189
190// $.store, failing soft: a host without it keeps budgets and the ledger off.
191async function storeGet($, key) {
192 try {
193 return await $.store.get(key);
194 } catch {
195 return undefined;
196 }
197}
198
199async function storeSet($, key, value) {
200 try {
201 await $.store.set(key, value);
202 } catch {
203 // no store on this host
204 }
205}
206
207// ---- budgets and the daily ledger ----------------------------------------------
208// Each session keeps its own running total per local day in $.store, under
209// `day:<date>:<session>`, so parallel sessions never overwrite each other.
210// A session that began today counts in full; one carried over from an earlier
211// day counts from its first reading today. Today is the sum over sessions.
212let sessionKey;
213
214function dayKey(t = Date.now()) {
215 const d = new Date(t);
216 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
217}
218
219async function syncLedger($, isTurn = false) {
220 const { value: u } = await $.state.get(READING);
221 const cost = u?.cost?.usd;
222 if (typeof cost !== "number") return;
223 if (!sessionKey) {
224 try {
225 sessionKey = await $.session.id();
226 } catch {
227 sessionKey = `s${u.startedAt ?? Date.now()}`;
228 }
229 }
230 const day = dayKey();
231 const key = `day:${day}:${sessionKey}`;
232 const prev = await storeGet($, key);
233 const startedToday = typeof u.startedAt === "number" && dayKey(u.startedAt) === day;
234 const entry = prev ?? { spent: startedToday ? cost : 0, last: cost, turns: 0 };
235 if (prev) {
236 const delta = cost - prev.last;
237 entry.spent = prev.spent + (delta < 0 ? cost : delta); // a reset cost counts from zero
238 entry.last = cost;
239 }
240 if (isTurn) entry.turns += 1;
241 if (!prev || prev.spent !== entry.spent || prev.last !== entry.last || isTurn) await storeSet($, key, entry);
242 await refreshLedger($);
243}
244
245async function refreshLedger($) {
246 let keys = [];
247 try {
248 keys = (await $.store.keys()).filter((k) => k.startsWith("day:"));
249 } catch {
250 return;
251 }
252 const days = [...Array(7)].map((_, i) => dayKey(Date.now() - i * 86_400_000)).reverse();
253 const totals = Object.fromEntries(days.map((d) => [d, { usd: 0, turns: 0 }]));
254 const oldest = dayKey(Date.now() - LEDGER_DAYS * 86_400_000);
255 for (const k of keys) {
256 const d = k.split(":")[1];
257 if (d < oldest) {
258 try { await $.store.delete(k); } catch { /* keep */ }
259 continue;
260 }
261 if (!totals[d]) continue;
262 const v = await storeGet($, k);
263 totals[d].usd += v?.spent ?? 0;
264 totals[d].turns += v?.turns ?? 0;
265 }
266 const today = totals[days.at(-1)];
267 await $.state.set(LEDGER, {
268 date: days.at(-1),
269 today: today.usd,
270 todayTurns: today.turns,
271 week: days.reduce((s, d) => s + totals[d].usd, 0),
272 days: days.map((d) => totals[d].usd),
273 });
274}
275
276async function answerBudget($, e) {
277 const [a, b] = String(e.args ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
278 const budget = (await storeGet($, "budget")) ?? {};
279 const amount = (v) => (v != null && Number.isFinite(+v.replace("$", "")) && +v.replace("$", "") > 0 ? +v.replace("$", "") : null);
280 if (a === "off" || a === "clear") {
281 if (b === "day") delete budget.day;
282 else if (b === "session") delete budget.session;
283 else { delete budget.day; delete budget.session; }
284 } else if (a === "day" && amount(b)) budget.day = amount(b);
285 else if (a === "session" && amount(b)) budget.session = amount(b);
286 else if (amount(a)) budget.session = amount(a);
287 else if (a) return { text: "Usage: /budget 10 (session), /budget day 50, /budget off [day|session]" };
288 await storeSet($, "budget", budget);
289 await $.state.set(BUDGET, budget);
290 await $.state.set(ALERTS, {});
291 await checkBudget($);
292 const { value: u } = await $.state.get(READING);
293 const { value: l } = await $.state.get(LEDGER);
294 return { text: budgetLines(budget, u, l).join("\n") || "No budget set. /budget 10 sets a session budget, /budget day 50 a daily one." };
295}
296
297function budgetLines(budget = {}, u, l) {
298 const lines = [];
299 const cost = u?.cost?.usd ?? 0;
300 if (budget.session) lines.push(`Session budget: $${cost.toFixed(2)} of $${budget.session.toFixed(2)} (${Math.round((cost / budget.session) * 100)}%)`);
301 if (budget.day) lines.push(`Daily budget: $${(l?.today ?? 0).toFixed(2)} of $${budget.day.toFixed(2)} (${Math.round(((l?.today ?? 0) / budget.day) * 100)}%)`);
302 return lines;
303}
304
305// Toast once per level (80%, 100%) per scope, and once when the burn rate will
306// cross the session budget within 15 minutes.
307async function checkBudget($) {
308 const { value: budget = {} } = await $.state.get(BUDGET);
309 if (!budget.session && !budget.day) return;
310 const { value: u } = await $.state.get(READING);
311 const { value: l } = await $.state.get(LEDGER);
312 const { value: seen = {} } = await $.state.get(ALERTS);
313 const next = { ...seen };
314 const burn = burnRate(u);
315 const scopes = [
316 ["session", budget.session, u?.cost?.usd ?? 0, "Session"],
317 ["day", budget.day, l?.today ?? 0, "Today's"],
318 ];
319 for (const [key, limit, spent, name] of scopes) {
320 if (!limit) continue;
321 const level = spent >= limit ? 100 : spent >= limit * 0.8 ? 80 : 0;
322 if (level > (seen[key] ?? 0)) {
323 $.ui.toast(level === 100
324 ? `${name} spend $${spent.toFixed(2)} is over the $${limit.toFixed(2)} budget`
325 : `${name} spend $${spent.toFixed(2)} is at ${Math.round((spent / limit) * 100)}% of the $${limit.toFixed(2)} budget`, { timeoutMs: 8000 });
326 next[key] = level;
327 }
328 if (key === "session" && !seen.pace && level < 80 && burn) {
329 const mins = ((limit - spent) / burn) * 60;
330 if (mins <= 15) {
331 $.ui.toast(`At $${burn.toFixed(2)}/h the $${limit.toFixed(2)} session budget is reached in ~${fmtMinutes(mins)}`, { timeoutMs: 8000 });
332 next.pace = true;
333 }
334 }
335 }
336 await $.state.set(ALERTS, next);
337}
338
339// 0..1+ of the tighter budget, and which one, for the cost figure's color.
340function budgetUse(budget = {}, u, l) {
341 const uses = [
342 budget.session && { scope: "session", limit: budget.session, frac: (u?.cost?.usd ?? 0) / budget.session },
343 budget.day && { scope: "day", limit: budget.day, frac: (l?.today ?? 0) / budget.day },
344 ].filter(Boolean);
345 return uses.sort((x, y) => y.frac - x.frac)[0] ?? null;
346}
347
348function budgetTone(use) {
349 return !use ? null : use.frac >= 1 ? "red" : use.frac >= 0.8 ? "yellow" : "green";
350}
351
352// /context's category rows, estimated locally (no token-count requests).
353async function takeBreakdown($) {
354 try {
355 const usage = await $.session.usage({ breakdown: "summary" });
356 const b = usage?.context?.breakdown;
357 if (!b?.categories) return;
358 await $.state.set(CTXMAP, {
359 at: Date.now(),
360 cats: b.categories.filter((c) => !c.isDeferred).map((c) => ({ name: c.name, tokens: c.tokens, kind: c.kind })),
361 });
362 } catch {
363 // no breakdown on this host
364 }
365}
366
367// Figures the detail views share, derived once per draw.
368function insights(u = {}, a = { calls: 0, files: [] }, turns = [], map) {
369 const ctx = u.context?.window ? { tokens: u.context.tokens ?? 0, window: u.context.window, pct: ctxPct(u.context) } : null;
370 const used = (map?.cats ?? []).filter((c) => c.kind === "used" && c.tokens > 0).sort((x, y) => y.tokens - x.tokens);
371 const free = (map?.cats ?? []).find((c) => c.kind === "free")?.tokens;
372 const growth = (u.history ?? []).slice(1).map((v, i) => v - u.history[i]).filter((d) => d > 0);
373 const perTurn = growth.length ? growth.reduce((s, d) => s + d, 0) / growth.length : null;
374 const turnsLeft = free != null && perTurn ? Math.floor(free / perTurn) : null;
375 const priced = turns.filter((t) => t.usd != null);
376 const spent = priced.reduce((s, t) => s + t.usd, 0);
377 const read = turns.reduce((s, t) => s + t.cr, 0);
378 const fed = turns.reduce((s, t) => s + t.in + t.cr + t.cw, 0);
379 const tools = Object.entries(a.tools ?? {}).sort((x, y) => y[1] - x[1]).slice(0, 5).map(([n, c]) => [toolName(n), c]);
380 return {
381 ctx, used, free, perTurn, turnsLeft,
382 cost: typeof u.cost?.usd === "number" ? u.cost.usd : null,
383 burn: burnRate(u),
384 lastCost: u.costDelta > 0 ? u.costDelta : null,
385 avgCost: priced.length ? spent / priced.length : null,
386 turnCosts: priced.map((t) => t.usd),
387 turns: turns.length,
388 avgMs: turns.length ? turns.reduce((s, t) => s + t.ms, 0) / turns.length : null,
389 maxMs: turns.length ? Math.max(...turns.map((t) => t.ms)) : null,
390 turnMs: turns.map((t) => t.ms),
391 out: turns.reduce((s, t) => s + t.out, 0),
392 cacheHit: fed ? Math.round((read / fed) * 100) : null,
393 calls: a.calls,
394 tools,
395 files: a.files.slice(-4).map((f) => f.split("/").pop()),
396 session: duration(u.startedAt),
397 };
398}
399
400// mcp__Claude_Browser__browser_batch -> browser_batch
401function toolName(n) {
402 return n.startsWith("mcp__") ? n.split("__").pop() : n;
403}
404
405function secs(ms) {
406 if (ms == null) return "–";
407 const s = Math.round(ms / 1000);
408 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
409}
410
411async function answerUsage($, e, next) {
412 if (e.command !== commandName) return next(e);
413 await takeReading($);
414 const { value: u = {} } = await $.state.get(READING);
415 const { value: a = { calls: 0, files: [] } } = await $.state.get(ACTIVITY);
416 const { value: git } = await $.state.get(GIT);
417 const { value: turns = [] } = await $.state.get(TURNS);
418 const { value: budget } = await $.state.get(BUDGET);
419 const { value: ledger } = await $.state.get(LEDGER);
420 return { text: usageLines(u, a, git, turns, budget, ledger).join("\n") };
421}
422
423function usageLines(u = {}, a = { calls: 0, files: [] }, git, turns = [], budget, ledger) {
424 const lines = [];
425 if (u.rateLimits?.length) {
426 lines.push("Rate limits:");
427 for (const rl of u.rateLimits) {
428 const p = clampPct(rl.percentUsed);
429 const reset = resetsIn(rl.resetsAt);
430 const pace = paceOf(rl);
431 const note = pace?.projected != null && pace.projected >= 100
432 ? ` · on pace for ${Math.round(pace.projected)}%, hits limit in ~${fmtMinutes(pace.minutesToLimit)}`
433 : pace ? " · pace ok" : "";
434 lines.push(` ${limitLabel(rl)} ${bar(p, pace?.elapsed)} ${p}%${reset ? ` · resets in ${reset}` : ""}${note}`);
435 }
436 }
437 if (u.context?.window) {
438 const pct = ctxPct(u.context);
439 const spark = sparkline(u.history);
440 lines.push(`Context: ${short(u.context.tokens ?? 0)} / ${short(u.context.window)} (${pct}%)${spark ? ` ${spark}` : ""}`);
441 if (u.delta) lines.push(` last turn: ${u.delta > 0 ? `▲ +${short(u.delta)}` : `▼ ${short(-u.delta)}`}`);
442 }
443 const cost = typeof u.cost?.usd === "number" ? `$${u.cost.usd.toFixed(2)}` : null;
444 const dur = duration(u.startedAt);
445 const burn = burnRate(u);
446 const extras = [cost, burn && `$${burn.toFixed(2)}/h`, u.costDelta > 0 && `last turn +$${u.costDelta.toFixed(2)}`, dur].filter(Boolean);
447 if (extras.length) lines.push(`Session: ${extras.join(" · ")}`);
448 if (turns.length) {
449 const d = insights(u, a, turns);
450 lines.push(`Turns: ${d.turns} · avg ${secs(d.avgMs)} · longest ${secs(d.maxMs)}${d.cacheHit != null ? ` · cache hit ${d.cacheHit}%` : ""}${d.avgCost != null ? ` · avg $${d.avgCost.toFixed(2)}/turn` : ""}`);
451 }
452 lines.push(`Activity: ${a.calls} tool call${a.calls === 1 ? "" : "s"}${a.files.length ? ` · ${a.files.length} file${a.files.length === 1 ? "" : "s"} edited` : ""}`);
453 const top = Object.entries(a.tools ?? {}).sort((x, y) => y[1] - x[1]).slice(0, 5);
454 if (top.length) lines.push(` tools: ${top.map(([n, c]) => `${n}×${c}`).join(" ")}`);
455 if (a.files.length) lines.push(` edited: ${a.files.slice(-5).map((f) => f.split("/").pop()).join(", ")}`);
456 if (ledger) lines.push(`Today: $${ledger.today.toFixed(2)} over ${ledger.todayTurns} turn${ledger.todayTurns === 1 ? "" : "s"} · last 7 days $${ledger.week.toFixed(2)}`);
457 lines.push(...budgetLines(budget, u, ledger));
458 if (git?.branch) lines.push(`Git: ${gitText(git)}`);
459 for (const c of git?.pr?.failing ?? []) lines.push(` ✗ ${c.name} ${c.url}`);
460 if (git?.pr?.url) lines.push(` ${git.pr.url}`);
461 if (u.model) lines.push(`Model: ${shortModel(u.model)}`);
462 return lines;
463}
464
465function gitText(g) {
466 return [
467 g.branch,
468 (g.added || g.removed) && `+${g.added ?? 0} −${g.removed ?? 0}`,
469 g.untracked && `${g.untracked} new`,
470 g.ahead && `↑${g.ahead}`,
471 g.behind && `↓${g.behind}`,
472 g.pr && prText(g.pr),
473 ].filter(Boolean).join(" · ");
474}
475
476function prText(pr) {
477 const checks = { pass: "✓ checks", fail: "✗ checks", pending: "● checks" }[pr.checks];
478 const review = { approved: "approved", changes: "changes requested" }[pr.review];
479 return [`PR #${pr.number} ${pr.state}`, review, checks].filter(Boolean).join(" ");
480}
481
482// PR tone: what needs attention first (failing checks, requested changes).
483function prTone(pr) {
484 if (pr.checks === "fail" || pr.review === "changes" || pr.state === "closed") return "red";
485 if (pr.state === "merged") return "purple";
486 if (pr.state === "draft") return "slate";
487 if (pr.checks === "pending") return "yellow";
488 return "green";
489}
490
491// dollars per hour since the session began; needs a few minutes to mean anything
492function burnRate(u) {
493 if (typeof u?.cost?.usd !== "number" || typeof u.startedAt !== "number") return null;
494 const hours = (Date.now() - u.startedAt) / 3_600_000;
495 return hours >= 0.05 && u.cost.usd > 0 ? u.cost.usd / hours : null;
496}
497
498function mmss(ms) {
499 const t = Math.floor(ms / 1000);
500 return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`;
501}
502
503async function takeReading($) {
504 const usage = await $.session.usage();
505 const { value: prev } = await $.state.get(READING);
506 await $.state.set(READING, mergeReading(prev, { ...usage, model: await $.session.model() }));
507 await syncLedger($);
508 await takeGit($);
509}
510
511async function takeGit($) {
512 let git = {};
513 try {
514 const r = await $.process.run(["git", "status", "--porcelain=v1", "-b"], { timeoutMs: 3000 });
515 if (r.exitCode === 0) {
516 const [head, ...rest] = r.stdout.split("\n");
517 const m = /^## (?:No commits yet on |Initial commit on )?(.+?)(?:\.\.\.\S+)?(?: \[(.*)\])?$/.exec(head);
518 const track = m?.[2] ?? "";
519 const files = rest.filter(Boolean);
520 git = {
521 branch: m?.[1]?.startsWith("HEAD (no branch)") ? "detached" : m?.[1],
522 dirty: files.length,
523 untracked: files.filter((l) => l.startsWith("??")).length,
524 ahead: +(/ahead (\d+)/.exec(track)?.[1] ?? 0),
525 behind: +(/behind (\d+)/.exec(track)?.[1] ?? 0),
526 ...(await diffStat($)),
527 };
528 }
529 } catch {
530 // not a repo, or no git
531 }
532 const { value: prev } = await $.state.get(GIT);
533 // the PR changes rarely: look again on a branch switch or every few minutes
534 const ttl = prev?.pr?.checks === "pending" ? PR_TTL_PENDING : PR_TTL;
535 const stale = !prev?.prCheckedAt || prev.branch !== git.branch || Date.now() - prev.prCheckedAt > ttl;
536 if (git.branch && git.branch !== "detached" && stale) {
537 Object.assign(git, await lookupPr($));
538 const was = prev?.branch === git.branch ? prev?.pr : null;
539 if (was?.checks === "pending" && git.pr?.number === was.number && git.pr.checks && git.pr.checks !== "pending") {
540 $.ui.toast(git.pr.checks === "pass"
541 ? `PR #${git.pr.number}: CI passed`
542 : `PR #${git.pr.number}: CI failed (${git.pr.failing.map((c) => c.name).join(", ")})`, { timeoutMs: 10_000 });
543 }
544 }
545 else if (git.branch && prev?.branch === git.branch) Object.assign(git, { pr: prev.pr, prCheckedAt: prev.prCheckedAt });
546 await $.state.set(GIT, git);
547}
548
549// Lines added and removed against HEAD, staged and unstaged together.
550async function diffStat($) {
551 try {
552 const r = await $.process.run(["git", "diff", "HEAD", "--shortstat"], { timeoutMs: 3000 });
553 if (r.exitCode !== 0) return {};
554 return {
555 added: +(/(\d+) insertion/.exec(r.stdout)?.[1] ?? 0),
556 removed: +(/(\d+) deletion/.exec(r.stdout)?.[1] ?? 0),
557 };
558 } catch {
559 return {};
560 }
561}
562
563// The branch's pull request through the GitHub CLI; nothing when gh is missing,
564// signed out, or the branch has none.
565async function lookupPr($) {
566 const checkedAt = Date.now();
567 try {
568 const r = await $.process.run(["gh", "pr", "view", "--json", "number,url,state,isDraft,reviewDecision,statusCheckRollup"], { timeoutMs: 8000 });
569 if (r.exitCode !== 0) return { pr: null, prCheckedAt: checkedAt };
570 const p = JSON.parse(r.stdout);
571 const rollup = p.statusCheckRollup ?? [];
572 const verdict = (c) => (c.conclusion || c.state || c.status || "").toUpperCase();
573 const runs = rollup.map(verdict);
574 const failing = rollup
575 .filter((c) => ["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED"].includes(verdict(c)))
576 .slice(0, 5)
577 .map((c) => ({ name: c.name || c.context || "check", url: c.detailsUrl || c.targetUrl || p.url }));
578 const checks = !runs.length ? null
579 : runs.some((x) => ["FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED"].includes(x)) ? "fail"
580 : runs.every((x) => ["SUCCESS", "NEUTRAL", "SKIPPED"].includes(x)) ? "pass" : "pending";
581 return {
582 pr: {
583 number: p.number,
584 url: p.url,
585 failing,
586 pending: runs.filter((x) => !["SUCCESS", "NEUTRAL", "SKIPPED", "FAILURE", "ERROR", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED"].includes(x)).length,
587 state: p.isDraft ? "draft" : String(p.state ?? "").toLowerCase(),
588 review: p.reviewDecision === "APPROVED" ? "approved" : p.reviewDecision === "CHANGES_REQUESTED" ? "changes" : null,
589 checks,
590 },
591 prCheckedAt: checkedAt,
592 };
593 } catch {
594 return { pr: null, prCheckedAt: checkedAt };
595 }
596}
597
598// Deltas are how much the last turn added to context and to cost. Both
599// session.measure and turn.complete refresh the reading in one turn, so only move
600// a delta (and the sparkline history) when its figure actually changed.
601function mergeReading(prev, next) {
602 const startedAt = next.startedAt ?? prev?.startedAt;
603 const usd = next.cost?.usd;
604 const costMoved = usd != null && prev?.cost?.usd != null && usd !== prev.cost.usd;
605 const costDelta = costMoved ? usd - prev.cost.usd : prev?.costDelta;
606 const prevTokens = prev?.context?.tokens;
607 const tokens = next.context?.tokens;
608 if (tokens == null || prevTokens == null || tokens === prevTokens) {
609 return { ...next, startedAt, costDelta, prevTokens: prev?.prevTokens, delta: prev?.delta, history: prev?.history };
610 }
611 return {
612 ...next,
613 startedAt,
614 costDelta,
615 prevTokens,
616 delta: tokens - prevTokens,
617 history: [...(prev?.history ?? []), tokens].slice(-HISTORY),
618 };
619}
620
621// ---- terminal: tinted chips ---------------------------------------------------
622// Each chip is one Box of Texts on a shared tint, glued with no gap inside, so
623// the row wraps between chips and never through one.
624const STATUS_COLOR = { ok: "green", warn: "yellow", crit: "red" };
625const TERM_BAR = 6;
626// [tint, text, accent, muted]
627const CHIP = {
628 green: ["#1c3328", "#d6f2e1", "#5fcf9a", "#7fa892"],
629 yellow: ["#372f15", "#f5e8b8", "#e6b830", "#ad9b62"],
630 red: ["#432020", "#fadad4", "#f47563", "#b8837b"],
631 blue: ["#1f2a46", "#d9e3fa", "#7b9cf2", "#8190b3"],
632 purple: ["#2b2645", "#e0daf8", "#a996ff", "#8f88b3"],
633 cyan: ["#16323a", "#d0eef2", "#5cc9d6", "#76a3a9"],
634 slate: ["#29292f", "#e2e2e8", "#a3a3b0", "#82828e"],
635};
636const CTX_TONE = (pct) => (pct < 50 ? "blue" : pct < 75 ? "yellow" : "red");
637
638// parts: [text, "label" | "value" | "aside" | "accent" | "off" | "mark"]
639function chip(Text, tone, parts, key) {
640 const [bg, fg, ac, mu] = CHIP[tone];
641 const style = {
642 label: { color: ac, bold: true },
643 value: { color: fg, bold: true },
644 aside: { color: mu },
645 accent: { color: ac },
646 off: { color: mu, dimColor: true },
647 mark: { color: fg, bold: true },
648 add: { color: "#6fdc9a", bold: true },
649 del: { color: "#f5806f", bold: true },
650 };
651 const last = parts.length - 1;
652 return {
653 key,
654 children: parts.map(([s, kind], i) => Text({
655 backgroundColor: bg,
656 ...style[kind],
657 children: `${i === 0 ? " " : ""}${s}${i === last ? " " : ""}`,
658 })),
659 };
660}
661
662function chipBar(p, elapsed, width = TERM_BAR) {
663 const filled = Math.round((p / 100) * width);
664 const mark = elapsed == null ? -1 : Math.min(width - 1, Math.floor(elapsed * width));
665 const runs = [];
666 for (let i = 0; i < width; i++) {
667 const kind = i === mark ? "mark" : i < filled ? "accent" : "off";
668 const g = kind === "mark" ? "╋" : "━";
669 if (runs.at(-1)?.[1] === kind) runs.at(-1)[0] += g;
670 else runs.push([g, kind]);
671 }
672 return runs;
673}
674
675function terminalRow({ Box, Text }, { u, a, git, working, columns, budget, ledger }) {
676 const wide = columns == null || columns >= 100;
677 const chips = [];
678
679 for (const rl of u?.rateLimits ?? []) {
680 const p = clampPct(rl.percentUsed);
681 const reset = resetsIn(rl.resetsAt);
682 const parts = [[`${limitLabel(rl)} `, "label"], ...chipBar(p, paceOf(rl)?.elapsed), [` ${p}%`, "value"]];
683 if (reset && wide) parts.push([` ↻ ${reset}`, "aside"]);
684 chips.push(chip(Text, STATUS_COLOR[limitStatus(rl)], parts, `rl-${rl.kind}`));
685 }
686
687 if (u?.context?.window) {
688 const pct = ctxPct(u.context);
689 const parts = [["ctx ", "label"], ...chipBar(pct), [` ${pct}%`, "value"], [` ${short(u.context.tokens ?? 0)}/${short(u.context.window)}`, "aside"]];
690 const spark = sparkline(u.history);
691 if (spark && (columns == null || columns >= 110)) parts.push([` ${spark}`, "accent"]);
692 if (u.delta) parts.push([u.delta > 0 ? ` ▲ +${short(u.delta)}` : ` ▼ ${short(-u.delta)}`, "aside"]);
693 chips.push(chip(Text, CTX_TONE(pct), parts, "ctx"));
694 }
695
696 if (typeof u?.cost?.usd === "number") {
697 const use = budgetUse(budget, u, ledger);
698 const parts = [[`$${u.cost.usd.toFixed(2)}`, "value"]];
699 if (use?.scope === "session") parts.push([` / $${use.limit.toFixed(0)}`, "aside"]);
700 const burn = burnRate(u);
701 if (burn && wide) parts.push([` $${burn.toFixed(2)}/h`, "aside"]);
702 if (ledger?.today > 0 && wide) parts.push([` today $${ledger.today.toFixed(2)}${budget?.day ? ` / $${budget.day.toFixed(0)}` : ""}`, "aside"]);
703 chips.push(chip(Text, budgetTone(use) ?? "yellow", parts, "cost"));
704 }
705
706 if (working != null) chips.push(chip(Text, "purple", [["● ", "accent"], [mmss(working), "value"]], "turn"));
707
708 if (git?.branch && wide) {
709 const parts = [["git ", "label"], [git.branch.length > 24 ? `${git.branch.slice(0, 23)}…` : git.branch, "value"]];
710 if (git.added || git.removed) parts.push([` +${git.added ?? 0}`, "add"], [` −${git.removed ?? 0}`, "del"]);
711 else if (git.dirty) parts.push([` ±${git.dirty}`, "accent"]);
712 if (git.untracked) parts.push([` ?${git.untracked}`, "aside"]);
713 const sync = [git.ahead && `↑${git.ahead}`, git.behind && `↓${git.behind}`].filter(Boolean).join(" ");
714 if (sync) parts.push([` ${sync}`, "aside"]);
715 chips.push(chip(Text, "cyan", parts, "git"));
716 }
717
718 if (git?.pr && wide) {
719 const pr = git.pr;
720 const parts = [["PR ", "label"], [`#${pr.number}`, "value"], [` ${pr.state}`, "aside"]];
721 if (pr.review) parts.push([pr.review === "approved" ? " ✓ approved" : " ✗ changes", pr.review === "approved" ? "add" : "del"]);
722 if (pr.checks) parts.push([{ pass: " ✓ ci", fail: " ✗ ci", pending: " ● ci" }[pr.checks], { pass: "add", fail: "del", pending: "accent" }[pr.checks]]);
723 chips.push(chip(Text, prTone(pr), parts, "pr"));
724 }
725
726 if (a?.calls && wide) {
727 const parts = [[`${a.calls} calls`, "value"]];
728 if (a.files.length) parts.push([` ✎ ${a.files.length}`, "accent"]);
729 chips.push(chip(Text, "purple", parts, "tools"));
730 }
731
732 const dur = duration(u?.startedAt);
733 if (dur && wide) chips.push(chip(Text, "slate", [[dur, "value"]], "time"));
734 if (u?.model) chips.push(chip(Text, "slate", [[shortModel(u.model), "aside"]], "model"));
735
736 return chips.map((c) => Box({ key: c.key, flexDirection: "row", flexShrink: 0, children: c.children }));
737}
738
739// ---- terminal: detail panel ------------------------------------------------
740// Aligned rows under a rule: a colored label column, then figures and charts.
741const CAT_COLORS = ["blue", "magenta", "cyan", "green", "yellow", "red", "white"];
742
743function terminalDetails({ Box, Text }, d, git, u, columns = 100) {
744 const T = (children, o = {}) => Text({ children, ...o });
745 const rows = [];
746 const row = (label, color, kids) => rows.push(Box({ key: `d-${label}`, flexDirection: "row", children: [T(label.padEnd(9), { color, bold: true }), ...kids] }));
747 const barW = Math.max(12, Math.min(30, columns - 60));
748
749 rows.push(T("─".repeat(Math.max(10, Math.min(columns - 4, 96))), { dimColor: true }));
750
751 if (d.ctx) {
752 const kids = [];
753 if (d.used.length) {
754 // stacked: one colored run per category, then the free part dim
755 let drawn = 0;
756 d.used.forEach((c, i) => {
757 const n = Math.round((c.tokens / d.ctx.window) * barW);
758 if (n > 0) kids.push(T("━".repeat(n), { color: CAT_COLORS[i % CAT_COLORS.length] }));
759 drawn += n;
760 });
761 kids.push(T("━".repeat(Math.max(0, barW - drawn)), { dimColor: true }));
762 } else {
763 const n = Math.round((d.ctx.pct / 100) * barW);
764 kids.push(T("━".repeat(n), { color: STATUS_TERM[CTX_TONE(d.ctx.pct)] }), T("━".repeat(barW - n), { dimColor: true }));
765 }
766 kids.push(T(` ${d.ctx.pct}%`, { bold: true }), T(` ${short(d.ctx.tokens)} / ${short(d.ctx.window)}`, { dimColor: true }));
767 if (d.free != null) kids.push(T(` compacts in ${short(d.free)}${d.turnsLeft != null ? ` · ≈${d.turnsLeft} turns` : ""}`, { color: d.turnsLeft != null && d.turnsLeft < 5 ? "red" : undefined, dimColor: !(d.turnsLeft != null && d.turnsLeft < 5) }));
768 row("Context", "blue", kids);
769 if (d.used.length) {
770 const legend = [];
771 d.used.slice(0, 6).forEach((c, i) => legend.push(T("● ", { color: CAT_COLORS[i % CAT_COLORS.length] }), T(`${c.name} ${short(c.tokens)} `, { dimColor: true })));
772 rows.push(Box({ key: "d-legend", flexDirection: "row", flexWrap: "wrap", paddingLeft: 9, children: legend }));
773 }
774 }
775
776 if (d.cost != null) {
777 const kids = [T(`$${d.cost.toFixed(2)}`, { bold: true })];
778 if (d.burn) kids.push(T(` $${d.burn.toFixed(2)}/h`, { dimColor: true }));
779 if (d.lastCost) kids.push(T(` last +$${d.lastCost.toFixed(2)}`, { dimColor: true }));
780 if (d.avgCost != null) kids.push(T(` avg $${d.avgCost.toFixed(2)}/turn`, { dimColor: true }));
781 if (d.ledger) kids.push(T(` today $${d.ledger.today.toFixed(2)} 7d $${d.ledger.week.toFixed(2)}`, { dimColor: true }));
782 const use = budgetUse(d.budget, u, d.ledger);
783 if (use) kids.push(T(` ${Math.round(use.frac * 100)}% of $${use.limit.toFixed(0)} ${use.scope} budget`, { color: { green: "green", yellow: "yellow", red: "red" }[budgetTone(use)] }));
784 const spark = sparkline(d.turnCosts);
785 if (spark) kids.push(T(` ${spark}`, { color: "yellow" }));
786 row("Spend", "yellow", kids);
787 }
788
789 if (d.turns) {
790 const kids = [T(`${d.turns}`, { bold: true }), T(` avg ${secs(d.avgMs)} longest ${secs(d.maxMs)}`, { dimColor: true })];
791 if (d.cacheHit != null) kids.push(T(" cache ", { dimColor: true }), T(`${d.cacheHit}%`, { color: d.cacheHit >= 70 ? "green" : d.cacheHit >= 40 ? "yellow" : "red" }));
792 if (d.out) kids.push(T(` out ${short(d.out)}`, { dimColor: true }));
793 const spark = sparkline(d.turnMs);
794 if (spark) kids.push(T(` ${spark}`, { color: "magenta" }));
795 row("Turns", "magenta", kids);
796 }
797
798 if (d.tools.length) {
799 const top = d.tools[0][1];
800 const kids = [];
801 d.tools.forEach(([n, c], i) => {
802 kids.push(T(`${i ? " " : ""}${n} `, { dimColor: true }), T("━".repeat(Math.max(1, Math.round((c / top) * 8))), { color: "cyan" }), T(` ${c}`, { bold: true }));
803 });
804 row("Tools", "cyan", kids);
805 }
806
807 if (d.files.length) row("Edited", "green", [T(d.files.join(", "), { dimColor: true })]);
808 const meta = [git?.branch && gitText(git), u?.model && shortModel(u.model), d.session && `session ${d.session}`].filter(Boolean);
809 if (meta.length) row("Session", "white", [T(meta.join(" · "), { dimColor: true })]);
810
811 return Box({ key: "details", flexDirection: "column", paddingX: 1, children: rows });
812}
813const STATUS_TERM = { blue: "blue", yellow: "yellow", red: "red" };
814
815function bar(p, elapsed) {
816 const filled = Math.round((p / 100) * BAR_WIDTH);
817 const cells = [...(BAR_FILLED.repeat(filled) + BAR_EMPTY.repeat(BAR_WIDTH - filled))];
818 if (elapsed != null) cells[Math.min(BAR_WIDTH - 1, Math.floor(elapsed * BAR_WIDTH))] = "┃"; // pace marker
819 return cells.join("");
820}
821
822function sparkline(history) {
823 if (!history || history.length < 2) return null;
824 const top = Math.max(...history, 1);
825 return history.map((v) => BARS[Math.min(BARS.length - 1, Math.floor((v / top) * (BARS.length - 1)))]).join("");
826}
827
828function clampPct(p) {
829 return Math.max(0, Math.min(100, Math.round(p ?? 0)));
830}
831
832function ctxPct(ctx) {
833 return ctx.percent ?? Math.round(((ctx.tokens ?? 0) / ctx.window) * 100);
834}
835
836function resetsIn(resetsAt) {
837 if (typeof resetsAt !== "string") return null;
838 const t = Date.parse(resetsAt);
839 if (Number.isNaN(t)) return null;
840 const m = Math.round((t - Date.now()) / 60_000);
841 if (m <= 0) return null;
842 if (m < 60) return `${m}m`;
843 const h = Math.floor(m / 60);
844 if (h < 24) return `${h}h ${m % 60}m`;
845 return `${Math.floor(h / 24)}d ${h % 24}h`;
846}
847
848function duration(startedAt) {
849 if (typeof startedAt !== "number") return null;
850 const m = Math.floor((Date.now() - startedAt) / 60_000);
851 if (m < 1) return null;
852 if (m < 60) return `${m}m`;
853 const h = Math.floor(m / 60);
854 if (h < 24) return `${h}h ${m % 60}m`;
855 return `${Math.floor(h / 24)}d ${h % 24}h`;
856}
857
858function shortModel(id) {
859 return String(id)
860 .replace(/^claude-/, "")
861 .replace(/-\d{8}$/, "")
862 .replace(/-(\d+)-(\d+)$/, "-$1.$2");
863}
864
865function short(n) {
866 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
867 if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`;
868 return String(n);
869}
870
871// ---- pace logic -----------------------------------------------------------
872const WINDOW_MS = { five_hour: 5 * 3_600_000, seven_day: 7 * 24 * 3_600_000 };
873
874function limitLabel(rl) {
875 return rl.kind === "five_hour" ? "5h" : rl.kind === "seven_day" ? "7d" : rl.kind;
876}
877
878// How far into the window we are, and where the current burn rate lands at reset.
879function paceOf(rl) {
880 const win = WINDOW_MS[rl.kind];
881 const t = Date.parse(rl.resetsAt ?? "");
882 if (!win || Number.isNaN(t)) return null;
883 const left = Math.max(0, t - Date.now());
884 const elapsed = Math.min(1, Math.max(0, 1 - left / win));
885 if (elapsed < 0.1) return { elapsed };
886 const p = clampPct(rl.percentUsed);
887 const projected = p / elapsed;
888 const minutesToLimit = p > 0 && projected > 100 ? ((100 - p) / p) * elapsed * (win / 60_000) : null;
889 return { elapsed, projected, minutesToLimit };
890}
891
892// ok / warn / crit: absolute level, or burning faster than the window allows.
893function limitStatus(rl) {
894 const p = clampPct(rl.percentUsed);
895 if (p >= 90) return "crit";
896 const pace = paceOf(rl);
897 if (pace?.projected != null && pace.projected >= 150 && p >= 30) return "crit";
898 if (p >= 70 || (pace?.projected != null && pace.projected >= 100 && p >= 20)) return "warn";
899 return "ok";
900}
901
902function fmtMinutes(m) {
903 m = Math.max(1, Math.round(m));
904 if (m < 60) return `${m}m`;
905 const h = Math.floor(m / 60);
906 return h < 24 ? `${h}h ${m % 60}m` : `${Math.floor(h / 24)}d ${h % 24}h`;
907}
908
909// ---- desktop: one SVG strip ------------------------------------------------
910// The whole band is a single drawing, so it never wraps into ragged rows. It
911// sits on the host's own band background: no tile fills, color only on the
912// figures that carry status. Monospace, so widths are exact without stretching.
913const H = 30;
914const MID = H / 2;
915const FONT = "ui-monospace,SFMono-Regular,Menlo,Consolas,monospace";
916const CW = 0.6; // monospace advance, em
917const SIZE = { label: 9, value: 12, aside: 10.5 };
918const TRACK = { label: 0.8, value: 0, aside: 0 };
919const BAR_W = 34;
920const PAL = {
921 card: ["#0000000a", "#ffffff0a"],
922 // [light, dark]
923 fg: ["#1f1f24", "#ececf1"],
924 mu: ["#74747f", "#9b9ba8"],
925 lb: ["#8b8b96", "#7d7d8a"],
926 rule: ["#00000018", "#ffffff1c"],
927 track: ["#0000001a", "#ffffff1f"],
928 green: ["#23875a", "#5fd19c"],
929 yellow: ["#a87708", "#e8bd3f"],
930 red: ["#c4402b", "#ff7b67"],
931 blue: ["#3563d1", "#86a6ff"],
932 purple: ["#6a52cc", "#a996ff"],
933 cyan: ["#167f8c", "#5cc9d6"],
934};
935const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
936const textW = (kind, s) => [...s].length * (SIZE[kind] * CW + TRACK[kind]);
937
938// item: {label} | {value, tone} | {aside, tone?} | {bar:{pct,pace,tone}} | {spark, tone} | {dot, tone}
939function itemWidth(it) {
940 if (it.label != null) return textW("label", it.label);
941 if (it.value != null) return textW("value", it.value);
942 if (it.aside != null) return textW("aside", it.aside);
943 if (it.bar) return BAR_W;
944 if (it.spark) return it.spark.length * 3.5 - 1.5;
945 if (it.dot) return 7;
946 if (it.icon) return 10;
947 return 0;
948}
949
950function drawItem(it, x) {
951 const text = (kind, s, cls) => {
952 const fs = SIZE[kind];
953 const weight = kind === "value" ? 650 : kind === "label" ? 700 : 500;
954 const ls = TRACK[kind] ? ` letter-spacing="${TRACK[kind]}"` : "";
955 return `<text class="${cls}" x="${x.toFixed(1)}" y="${(MID + fs * 0.36).toFixed(1)}" font-size="${fs}" font-weight="${weight}"${ls}>${esc(s)}</text>`;
956 };
957 if (it.label != null) return text("label", it.label, it.cls ?? "lb");
958 if (it.value != null) return text("value", it.value, it.tone ?? "fg");
959 if (it.aside != null) return text("aside", it.aside, it.tone ?? "mu");
960 if (it.icon) {
961 const t = MID - 5.5, b = MID + 5.5;
962 return `<g class="mu-s" fill="none" stroke-width="1.4" stroke-linecap="round"><circle cx="${x + 2.5}" cy="${b - 1.5}" r="1.5"/><circle cx="${x + 2.5}" cy="${t + 1.5}" r="1.5"/><circle cx="${x + 8}" cy="${t + 3}" r="1.5"/><path d="M${x + 2.5} ${t + 3}V${b - 3}M${x + 8} ${t + 4.5}c0 3-5.5 2.5-5.5 5"/></g>`;
963 }
964 if (it.dot) return `<circle class="${it.tone}" cx="${x + 3.5}" cy="${MID}" r="3.5"/>`;
965 if (it.bar) {
966 const w = BAR_W, y = MID - 2;
967 const fill = it.bar.pct > 0 ? Math.max(4, (it.bar.pct / 100) * w) : 0;
968 let s = `<rect class="track" x="${x}" y="${y}" width="${w}" height="4" rx="2"/>`;
969 if (fill) s += `<rect class="${it.bar.tone}" x="${x}" y="${y}" width="${fill.toFixed(1)}" height="4" rx="2"/>`;
970 if (it.bar.pace != null) {
971 const px = x + Math.min(w - 1, Math.max(1, it.bar.pace * w));
972 s += `<rect class="fg" x="${(px - 0.75).toFixed(1)}" y="${y - 3}" width="1.5" height="10" rx=".75"/>`;
973 }
974 return s;
975 }
976 if (it.spark) {
977 const lo = Math.min(...it.spark), hi = Math.max(...it.spark);
978 return it.spark.map((v, j) => {
979 const h = 3 + (hi === lo ? 0.5 : (v - lo) / (hi - lo)) * 9;
980 const op = j === it.spark.length - 1 ? 1 : 0.45;
981 return `<rect class="${it.tone}" x="${x + j * 3.5}" y="${(MID + 6 - h).toFixed(1)}" width="2" height="${h.toFixed(1)}" rx="1" opacity="${op}"/>`;
982 }).join("");
983 }
984 return "";
985}
986
987// "1h 40m" -> "1h40m", "3d 0h" -> "3d"
988function compactSpan(s) {
989 return s.replace(/ 0[hm]$/, "").replace(" ", "");
990}
991
992// A pill: one figure on a soft tint of its tone with a hairline edge. Each pill
993// is its own drawing, so the band's flex layout wraps them as units.
994const PILL_TONES = ["green", "yellow", "red", "blue", "purple", "cyan"];
995function pillCss(i) {
996 const fg = PAL.fg[i];
997 return PILL_TONES.map((k) => `.pb-${k}{fill:${PAL[k][i]};fill-opacity:${i ? 0.17 : 0.12};stroke:${PAL[k][i]};stroke-opacity:${i ? 0.42 : 0.35}}`).join("") +
998 `.pb-n{fill:${fg};fill-opacity:${i ? 0.06 : 0.045};stroke:${fg};stroke-opacity:${i ? 0.14 : 0.12}}`;
999}
1000
1001// Card tints: fainter than a pill, so four of them side by side stay calm.
1002function cardCss(i) {
1003 return PILL_TONES.map((k) => `.cb-${k}{fill:${PAL[k][i]};fill-opacity:${i ? 0.07 : 0.05};stroke:${PAL[k][i]};stroke-opacity:${i ? 0.2 : 0.18}}`).join("");
1004}
1005
1006function svgPill(tone, items) {
1007 const GAP = 5, PADX = 10;
1008 let x = PADX, body = "";
1009 items = items.map((it) => (it.label != null && tone && !it.cls ? { ...it, cls: tone } : it));
1010 items.forEach((it, j) => {
1011 if (j) x += it.tight ? 3 : GAP;
1012 body += drawItem(it, x);
1013 x += itemWidth(it);
1014 });
1015 const width = Math.ceil(x + PADX);
1016 const cls = (i) => Object.entries(PAL).map(([k, v]) => `.${k}{fill:${v[i]}}.${k}-s{stroke:${v[i]}}`).join("") + pillCss(i);
1017 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${H}" viewBox="0 0 ${width} ${H}" font-family="${FONT}">` +
1018 `<style>${cls(0)}@media (prefers-color-scheme:dark){${cls(1)}}</style>` +
1019 `<rect class="pb-${tone ?? "n"}" x=".5" y="3.5" width="${width - 1}" height="${H - 7}" rx="${(H - 7) / 2}" stroke-width="1"/>${body}</svg>`;
1020 return { source, width };
1021}
1022
1023// Session figures on the left (limits, context, live turn); work and money on
1024// the right (git, PR, cost, today). Each side wraps on its own.
1025function desktopRow({ Svg }, { u, git, working, budget, ledger }) {
1026 const left = [];
1027 const right = [];
1028 const add = (side, key, tone, items, alt) => {
1029 const { source, width } = svgPill(tone, items);
1030 side.push(Svg({ key, source, alt, width, height: H }));
1031 };
1032
1033 for (const rl of u?.rateLimits ?? []) {
1034 const p = clampPct(rl.percentUsed);
1035 const pace = paceOf(rl);
1036 const reset = resetsIn(rl.resetsAt);
1037 const tone = STATUS_COLOR[limitStatus(rl)];
1038 const items = [{ label: limitLabel(rl).toUpperCase() }, { bar: { pct: p, pace: pace?.elapsed, tone } }, { value: `${p}%`, tone }];
1039 if (pace?.projected != null && pace.projected >= 100) items.push({ aside: `→${Math.min(999, Math.round(pace.projected))}%`, tone, tight: true });
1040 if (reset) items.push({ aside: `↻${compactSpan(reset)}` });
1041 add(left, `rl-${rl.kind}`, tone, items, `${limitLabel(rl)} ${p}%${reset ? ` resets in ${reset}` : ""}`);
1042 }
1043
1044 if (u?.context?.window) {
1045 const pct = ctxPct(u.context);
1046 const tone = CTX_TONE(pct);
1047 const items = [{ label: "CTX" }, { bar: { pct, tone } }, { value: `${pct}%`, tone }, { aside: `${short(u.context.tokens ?? 0)}/${short(u.context.window)}` }];
1048 if (u.delta) items.push({ aside: u.delta > 0 ? `+${short(u.delta)}` : `−${short(-u.delta)}`, tone });
1049 add(left, "ctx", tone, items, `context ${pct}%`);
1050 }
1051
1052 if (working != null) add(left, "turn", "purple", [{ dot: true, tone: "purple" }, { value: mmss(working), tone: "purple", tight: true }], `turn ${mmss(working)}`);
1053
1054 if (git?.branch) {
1055 const items = [{ icon: "branch" }, { value: git.branch.length > 22 ? `${git.branch.slice(0, 21)}…` : git.branch, tight: true }];
1056 if (git.added || git.removed) items.push({ aside: `+${git.added ?? 0}`, tone: "green" }, { aside: `−${git.removed ?? 0}`, tone: "red", tight: true });
1057 else if (git.dirty) items.push({ aside: `±${git.dirty}`, tone: "yellow" });
1058 if (git.untracked) items.push({ aside: `?${git.untracked}` });
1059 const sync = [git.ahead && `↑${git.ahead}`, git.behind && `↓${git.behind}`].filter(Boolean).join(" ");
1060 if (sync) items.push({ aside: sync });
1061 add(right, "git", "cyan", items, `git ${gitText(git)}`);
1062 }
1063
1064 if (git?.pr) {
1065 const pr = git.pr, tone = prTone(pr);
1066 const items = [{ dot: true, tone }, { value: `#${pr.number}`, tone, tight: true }, { aside: pr.state }];
1067 if (pr.review) items.push({ aside: pr.review === "approved" ? "✓ approved" : "✗ changes", tone: pr.review === "approved" ? "green" : "red" });
1068 if (pr.checks) items.push({ aside: { pass: "✓ ci", fail: `✗ ${pr.failing?.length || ""} ci`.replace(" ", " "), pending: "● ci" }[pr.checks], tone: { pass: "green", fail: "red", pending: "yellow" }[pr.checks] });
1069 // no link here: the desktop app draws its own clickable PR bar above the band
1070 add(right, "pr", tone, items, prText(pr));
1071 }
1072
1073 if (typeof u?.cost?.usd === "number") {
1074 const use = budgetUse(budget, u, ledger);
1075 const tone = budgetTone(use);
1076 const items = [{ label: "COST" }, { value: `$${u.cost.usd.toFixed(2)}`, tone: tone && tone !== "green" ? tone : undefined }];
1077 if (use?.scope === "session") items.push({ aside: `/ $${use.limit.toFixed(0)}`, tight: true });
1078 const burn = burnRate(u);
1079 if (burn) items.push({ aside: `$${burn.toFixed(2)}/h` });
1080 add(right, "cost", tone && tone !== "green" ? tone : "yellow", items, `cost $${u.cost.usd.toFixed(2)}`);
1081 }
1082
1083 if (ledger?.today > 0) {
1084 const use = budget?.day ? ledger.today / budget.day : null;
1085 const tone = use == null ? null : use >= 1 ? "red" : use >= 0.8 ? "yellow" : null;
1086 const items = [{ label: "TODAY" }, { value: `$${ledger.today.toFixed(2)}`, tone: tone ?? undefined }];
1087 if (budget?.day) items.push({ aside: `/ $${budget.day.toFixed(0)}`, tight: true });
1088 if (ledger.week > ledger.today) items.push({ aside: `7d $${ledger.week.toFixed(0)}` });
1089 add(right, "today", tone ?? "green", items, `today $${ledger.today.toFixed(2)}`);
1090 }
1091
1092 return { left, right };
1093}
1094
1095// ---- desktop: detail cards ---------------------------------------------------
1096// Four compact cards in one row (context, spend, turns, tools) over a footer
1097// line, as one SVG about 130px tall so the band shows it whole.
1098const CARD_W = 184;
1099const CARD_H = 108;
1100const GRID_GAP = 8;
1101const CAT_TONES = ["blue", "purple", "cyan", "green", "yellow", "red", "mu"];
1102
1103function desktopDetails({ Svg }, d, git, u) {
1104 const cardsShown = [d.ctx, d.cost != null, d.turns > 0, d.tools.length > 0].filter(Boolean).length || 1;
1105 const W = Math.max(CARD_W * 2 + GRID_GAP, CARD_W * cardsShown + GRID_GAP * (cardsShown - 1));
1106 const t = (x, y, s, { size = 10, weight = 500, cls = "fg", anchor, ls } = {}) =>
1107 `<text class="${cls}" x="${x.toFixed(1)}" y="${y.toFixed(1)}" font-size="${size}" font-weight="${weight}"${anchor ? ` text-anchor="${anchor}"` : ""}${ls ? ` letter-spacing="${ls}"` : ""}>${esc(s)}</text>`;
1108 const w = (s, size) => [...s].length * size * CW;
1109 const fit = (s, size, room) => {
1110 const max = Math.floor(room / (size * CW));
1111 return s.length > max ? `${s.slice(0, Math.max(1, max - 1))}…` : s;
1112 };
1113 const P = 12; // card padding
1114 const title = (x, s, right, tone = "lb") => t(x + P, 19, s, { size: 8.5, weight: 700, cls: tone, ls: 0.8 }) +
1115 (right ? t(x + CARD_W - P, 19, fit(right.s, 9.5, CARD_W - 2 * P - w(s, 8.5) - 10), { size: 9.5, cls: right.cls ?? "mu", anchor: "end" }) : "");
1116 const big = (x, s, cls = "fg", side) => t(x + P, 45, s, { size: 19, weight: 700, cls }) +
1117 (side ? t(x + P + w(s, 19) + 6, 45, fit(side, 9.5, CARD_W - 2 * P - w(s, 19) - 6), { size: 9.5, cls: "mu" }) : "");
1118 const line = (x, s, cls = "mu") => t(x + P, 62, fit(s, 9.5, CARD_W - 2 * P), { size: 9.5, cls });
1119 const cols = (x, values, cls) => {
1120 if (values.length < 2) return "";
1121 const top = Math.max(...values, 1e-9), n = values.length, gw = CARD_W - 2 * P, bw = Math.min(8, (gw - (n - 1) * 2.5) / n);
1122 return values.map((v, i) => {
1123 const h = Math.max(2, (v / top) * 26);
1124 return `<rect class="${cls}" x="${(x + P + i * (bw + 2.5)).toFixed(1)}" y="${(CARD_H - 10 - h).toFixed(1)}" width="${bw.toFixed(1)}" height="${h.toFixed(1)}" rx="1.5" opacity="${i === n - 1 ? 1 : 0.55}"/>`;
1125 }).join("");
1126 };
1127 const card = (x, tone) => `<rect class="cb-${tone}" x="${x + 0.5}" y=".5" width="${CARD_W - 1}" height="${CARD_H - 1}" rx="11"/>`;
1128 const at = (i) => i * (CARD_W + GRID_GAP);
1129 let body = "";
1130 // only the cards with something to say, packed left
1131 const show = [d.ctx && "context", d.cost != null && "spend", d.turns > 0 && "turns", d.tools.length > 0 && "tools"].filter(Boolean);
1132 const slot = (name) => show.indexOf(name);
1133
1134 // context
1135 if (slot("context") >= 0) {
1136 const x = at(slot("context"));
1137 body += card(x, "blue") + title(x, "CONTEXT", d.ctx ? { s: `${short(d.ctx.tokens)}/${short(d.ctx.window)}` } : null, "blue");
1138 if (d.ctx) {
1139 const warn = d.turnsLeft != null && d.turnsLeft < 5;
1140 body += big(x, `${d.ctx.pct}%`, CTX_TONE(d.ctx.pct), d.turnsLeft != null ? `≈${d.turnsLeft} turns left` : d.free != null ? `${short(d.free)} free` : null);
1141 if (warn) body += t(x + CARD_W - P, 45, "compact soon", { size: 9, cls: "red", anchor: "end" });
1142 const bx = x + P, by = 54, bw = CARD_W - 2 * P;
1143 body += `<rect class="track" x="${bx}" y="${by}" width="${bw}" height="6" rx="3"/>`;
1144 if (d.used.length) {
1145 let cx = bx;
1146 body += `<clipPath id="cb"><rect x="${bx}" y="${by}" width="${bw}" height="6" rx="3"/></clipPath><g clip-path="url(#cb)">`;
1147 d.used.forEach((c, i) => {
1148 const cw = (c.tokens / d.ctx.window) * bw;
1149 if (cw > 0.5) body += `<rect class="${CAT_TONES[i % CAT_TONES.length]}" x="${cx.toFixed(1)}" y="${by}" width="${Math.max(1, cw - 1).toFixed(1)}" height="6"/>`;
1150 cx += cw;
1151 });
1152 body += "</g>";
1153 d.used.slice(0, 3).forEach((c, i) => {
1154 const ly = 76 + i * 12.5;
1155 body += `<circle class="${CAT_TONES[i % CAT_TONES.length]}" cx="${bx + 3}" cy="${ly - 3.2}" r="2.6"/>`;
1156 body += t(bx + 10, ly, fit(c.name, 9, bw - 50), { size: 9, cls: "mu" }) + t(bx + bw, ly, short(c.tokens), { size: 9, weight: 650, anchor: "end" });
1157 });
1158 } else {
1159 body += `<rect class="${CTX_TONE(d.ctx.pct)}" x="${bx}" y="${by}" width="${Math.max(d.ctx.pct > 0 ? 3 : 0, (d.ctx.pct / 100) * bw).toFixed(1)}" height="6" rx="3"/>`;
1160 body += t(bx, 80, "breakdown after next turn", { size: 9, cls: "lb" });
1161 }
1162 }
1163 }
1164
1165 // spend
1166 if (slot("spend") >= 0) {
1167 const x = at(slot("spend"));
1168 const use = budgetUse(d.budget, u, d.ledger);
1169 body += card(x, "yellow") + title(x, "SPEND", use ? { s: `${Math.round(use.frac * 100)}% of $${use.limit.toFixed(0)}`, cls: budgetTone(use) } : d.burn ? { s: `$${d.burn.toFixed(2)}/h` } : null, "yellow");
1170 if (d.cost != null) {
1171 body += big(x, `$${d.cost.toFixed(2)}`, "fg", d.ledger?.today > 0 ? `today $${d.ledger.today.toFixed(2)}` : d.burn && use ? `$${d.burn.toFixed(2)}/h` : null);
1172 body += line(x, [d.lastCost && `last +$${d.lastCost.toFixed(2)}`, d.avgCost != null && `avg $${d.avgCost.toFixed(2)}`].filter(Boolean).join(" · ") || (d.turns ? "per turn from next turn" : d.session ? `over ${d.session}` : ""));
1173 body += cols(x, d.turnCosts, "yellow");
1174 }
1175 }
1176
1177 // turns
1178 if (slot("turns") >= 0) {
1179 const x = at(slot("turns"));
1180 const hit = d.cacheHit;
1181 body += card(x, "purple") + title(x, "TURNS", hit != null ? { s: `cache ${hit}%`, cls: hit >= 70 ? "green" : hit >= 40 ? "yellow" : "red" } : null, "purple");
1182 body += big(x, `${d.turns}`, "purple", d.turns ? `avg ${secs(d.avgMs)}` : "none yet");
1183 if (d.turns) body += line(x, [`max ${secs(d.maxMs)}`, d.out && `${short(d.out)} out`].filter(Boolean).join(" · "));
1184 body += cols(x, d.turnMs, "purple");
1185 }
1186
1187 // tools
1188 if (slot("tools") >= 0) {
1189 const x = at(slot("tools"));
1190 body += card(x, "cyan") + title(x, "TOOLS", d.calls ? { s: `${d.calls} calls` } : null, "cyan");
1191 const top = d.tools[0]?.[1] ?? 1;
1192 d.tools.forEach(([n, c], i) => {
1193 const ry = 36 + i * 15;
1194 body += t(x + P, ry, fit(n, 9, 66), { size: 9, cls: "mu" });
1195 const bx = x + P + 70, bw = CARD_W - 2 * P - 70 - 22;
1196 body += `<rect class="track" x="${bx}" y="${ry - 5}" width="${bw}" height="4" rx="2"/>`;
1197 body += `<rect class="cyan" x="${bx}" y="${ry - 5}" width="${Math.max(3, (c / top) * bw).toFixed(1)}" height="4" rx="2"/>`;
1198 body += t(x + CARD_W - P, ry, String(c), { size: 9, weight: 650, anchor: "end" });
1199 });
1200 if (!d.tools.length) body += t(x + P, 45, "no tool calls yet", { size: 9.5, cls: "lb" });types/index.d.ts 49 lines1export type UsageRateLimit = { kind: string; percentUsed: number; resetsAt?: string };
2export type UsageReading = {
3 startedAt?: number;
4 context?: { tokens?: number; window: number; percent?: number };
5 rateLimits?: UsageRateLimit[];
6 cost?: { usd?: number };
7 model?: string;
8 prevTokens?: number;
9 costDelta?: number;
10 delta?: number;
11 history?: number[];
12};
13export type UsageActivity = { calls: number; files: string[]; tools?: Record<string, number> };
14export type UsageTurn = { ms: number; usd: number | null; in: number; out: number; cr: number; cw: number };
15export type UsageContextMap = { at: number; cats: { name: string; tokens: number; kind: string }[] };
16export type UsagePr = {
17 number: number;
18 url?: string;
19 failing?: { name: string; url: string }[];
20 pending?: number;
21 state: string; review: "approved" | "changes" | null; checks: "pass" | "fail" | "pending" | null };
22export type UsageGit = {
23 branch?: string;
24 dirty?: number;
25 untracked?: number;
26 added?: number;
27 removed?: number;
28 ahead?: number;
29 behind?: number;
30 pr?: UsagePr | null;
31 prCheckedAt?: number;
32};
33
34declare module "claude-code" {
35 interface PluginState {
36 "usage-bar": {
37 last?: UsageReading;
38 activity?: UsageActivity;
39 git?: UsageGit;
40 turn?: { startedAt: number; tick: number; costAt?: number };
41 ui?: { expanded: boolean };
42 turns?: UsageTurn[];
43 ctxmap?: UsageContextMap;
44 budget?: { session?: number; day?: number };
45 ledger?: { date: string; today: number; todayTurns: number; week: number; days: number[] };
46 alerts?: { session?: number; day?: number; pace?: boolean };
47 };
48 }
49}