SLOPSHOPPER

usage-bar

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…

newbandguardcommandtoastprompt
★ 2v1.0.1no licenseupdated 2026-10-05abyigiter/claude-usage-bar/usage-bar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-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 › /budget ⎿ usage-bar: No budget set. /budget 10 sets a session budget, /budget day 50 a daily one. 5h ━━━━━━ 31% ctx ━━━━━━ 49% 97.4k/200k $0.42 $0.00/h 9 calls 361d 21h opus-5.5 ▾ more ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
5h ━━━━━━ 31% ctx ━━━━━━ 49% 97.4k/200k $0.42 $0.00/h 9 calls 361d 21h opus-5.5 ▾ more
README

usage-bar

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.

Desktop header

Desktop detail panel

Terminal header

Terminal detail panel

Install

/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.

pi

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).

The header

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.

PillShows
5H / 7DRate-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.
CTXContext window fill, tokens, and the last turn's growth.
● 0:42A live timer while Claude works.
gitBranch, lines added and removed against HEAD (+421 −68), untracked files (?1), ahead and behind.
PRThe 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.
COSTSession spend and burn rate ($/h), against your budget when one is set.
TODAYSpend across every session today, and the last 7 days.

In the terminal the same figures are tinted chips.

The detail panel

▾ more opens four cards on desktop (aligned rows in the terminal). Cards with nothing to show yet are left out.

  • Context: a stacked bar by /context category (messages, tools, MCP, memory, skills) and how many turns are left before auto-compact at your average growth.
  • Spend: total, today, last turn, average per turn, a bar per turn.
  • Turns: count, average and longest duration, prompt cache hit rate, tokens written, a bar per turn.
  • Tools: the top five tools (MCP names shortened). Failing CI checks are listed under the panel.

Budgets

/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.

Today's spend

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.

/usage

Prints 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.

What it reads and runs

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 Codepi
Usage and costClaude Code's own figures ($.session.usage()), the same numbers as /cost and the status lineThe cost pi records on each message, read from your session files under ~/.pi/agent/sessions
Context breakdownClaude Code's local /context estimate (breakdown: "summary", no token-count requests)Not available
Commands it runs, in the session's directorygit status --porcelain=v1 -b, git diff HEAD --shortstat, gh pr view --json number,url,state,isDraft,reviewDecision,statusCheckRollupThe same three
What it writesBudgets and per-day totals in the plugin's own storeBudgets 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.

Building it

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.

Source 2 files
hooks/usage-bar.mjs 1234 lines
1// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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 lines
1export 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}