SLOPSHOPPER

usage-gauge

(superseded by rd-ledger) A live cost chip in the prompt footer and a /spend Usage pane: the session's cost, context by category, rate limits, tokens and cache…

newpanespinnerguardcommand
v0.1.4MITupdated 2026-10-12Joseph-Ayoub/claude-mods/usage-gauge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-gauge
│ ┃ Usage ✕ › fix the failing auth test and add an audit log call │ ┃ Cost │ ┃ Session ⏺ Read(src/auth.ts) │ ┃ Active ⎿ Read 6 lines │ ┃ API ⏺ Update(src/auth.ts) │ ┃ Turns ⎿ Added 2 lines, removed 1 line │ ┃ Requests ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Context ████████████████ │ ┃ Window 97k ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Rate limits ✻ Worked for 42s · done 4:20 PM │ ┃ 5-hour │ ┃ › /spend │ ┃ Models │ ┃ No requests counted yet. │ ┃ │ ┃ What's contributing │ ┃ Nothing counted yet. │ ┃ │ ┃ Code changes │ ┃ +0 −0 lines · 0 edits │ ┃ │ ┃ Per-model, per-agent and code figures count │ ┃ from when usage-gauge loaded; the cost is │ ┃ the engine's own, from the session's start. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ⟨Claude Code's own drawing⟩ $0.42

Draws

Pane · Usage
Cost Session Active API Turns Requests Context ████████████████ Window 97k Rate limits 5-hour Models No requests counted yet. What's contributing Nothing counted yet. Code changes +0 −0 lines · 0 edits Per-model, per-agent and code figures count from when usage-gauge loaded; the cost is the engine's own, from the session's start. [ Refresh ] [ Close ]
Prompt hint
⟨Claude Code's own drawing⟩ $0.42
README

usage-gauge

Superseded on 2026-10-13 by rd-ledger's Usage pane; kept for reference. The Usage pane, /spend and the footer cost chip now live in the rd-ledger mod (/rd usage, /spend, and one footer chip reading $123 ⧗2.4h that opens Usage with the R&D ledger as the tab beside it), so the two panes can sit side by side. Its estimates price cache writes by the TTL in force rather than a fixed 1.25×. Disable this mod when rd-ledger is enabled, or the footer shows two cost chips and both register /spend.

What this session has cost and used, live, without typing /usage and getting a card dropped into the transcript. A small chip in the prompt footer reads $123; click it (or type /spend) and a Usage pane opens with the cost, time, context window, rate limits, per-model tokens and cache hit, what the subagents spent, and the lines the session changed. Nothing lands in the transcript, nothing is written to disk, and nothing is sent anywhere.

What you see

  • The chip, in the prompt footer. On the desktop app it sits in the footer's mode row, right beside the context-window circle: $123, the session's cost (cents under $100, $12.34; whole dollars from there). With the chipContext option on it adds how full the context window is: $123 · ctx 12%. Before the first cost reading it reads $ --; on a host with no cost ledger it is not drawn (with chipContext on, ctx 12% alone). On the terminal it is the same chip on the hint line under the prompt, after the engine's own hint.
  • The Usage pane (click the chip, or /spend):
  • Header: the headline figures, label left and the figure flush with the pane's right edge (the Context and Rate limits rows line up the same way): Cost (the engine's own ledger, the same figure /cost and the status line show), Session (how long since the session began), Active (time the main loop spent answering), API (every model request's wall time, across every loop, so parallel subagents add up past the session's length), Turns, Requests, and Agent turns once a subagent has run.
  • Context bar: a small 2 px pill at the right of the Context title, flush with the pane's edge just above the Window figure: a plain gray track and a fill in the theme's text colour (white on a dark theme) that animates to the new fill each time the window moves (still under Reduce Motion). On the terminal it is 16 cells, the track dim.
  • Context: 120k of 1.0M (12%), then, once the pane has read the breakdown (on open and on Refresh), tokens left until compaction and up to 8 categories (Messages, System prompt, ...) with their tokens and share of the window; free space and the compaction buffer come last, dim.
  • Rate limits: one row per window the engine reports (5-hour, 7-day, spend limit), its percent used and when it resets in local time; none reported off a subscription.
  • Models: one row per model seen: requests, input, output, cache read, cache write, cache hit (cache read over everything the input was answered over), an estimate at list price and its share of the priced total, and a total row. A model the price table does not know reads --, never a guess.
  • What's contributing: main loop 40% · subagents 60% by estimated cost (by tokens when nothing is priced), then the top five subagent types (builder 35% · verifier 15% · scout 5%).
  • Code changes: +4,321 −321 lines · 42 edits, counted from the Edit, Write and NotebookEdit results (a refused or failed edit counts nothing).
  • Refresh (r) re-reads the figures and the breakdown; Close (c, or Esc).
  • On a narrow pane (under 60 columns) the model table keeps only output, est. $ and share; the bar stays.

Install and load

From the marketplace (terminal session):

/plugin install usage-gauge --marketplace Joseph-Ayoub/claude-mods

While developing, load it from this folder:

claude --plugin-dir /absolute/path/to/claude-mods/usage-gauge

or install it at user scope from the local marketplace (claude plugin install usage-gauge@joseph-mods --scope user) so every session the desktop app or the terminal starts loads it (see mods/README.md).

Options

OptionDefaultWhat it does
footerButtontrueDraw the footer figure as a button (a grey chip on the desktop) that opens the Usage pane; off, it is dim text and /spend opens the pane.
chipContextfalseAlso show the context fill in the chip ($123 · ctx 12%). Off by default so the footer strip stays short (see Limits); the figure is in the pane.

How it works

HookWhy
session.startregisters /spend; reads $.session.usage() (start time, context, rate limits, cost); records when counting began, keeping an earlier value so a reload does not reset the counts
session.measurekeeps the latest context, rate limits and cost as the engine pushes them
turn.stepone model request in any loop: passes the response through untouched (yield* next(e), called once) and then counts its tokens by model and by loop, and its wall time; a subagent's type is asked of $.agent.list() once per new agent id
turn.completemain loop: one turn and its duration (Active); a subagent: one agent turn
tool.call Edit, Write, NotebookEditafter the tool ran, counts the added and removed lines of its result's patch; the result is returned as the tool gave it, and the tool never runs twice
command.run /spendopens the focused pane for your own origins (typed at the prompt, the Remote Control bridge, the SDK host behind the desktop app); where no pane can be placed (claude -p) answers a short plain table; a peer session, a plugin, a schedule or an unknown origin gets one summary line and no pane
ui.render SessionMode (desktop), PromptHint (terminal)the chip
ui.render Pane usage-gaugethe pane, drawn from the plugin's state only

Every accounting hook only observes: it hands the event on unchanged and keeps its bookkeeping inside a try/catch. The counts live in the plugin's state, which a hot reload keeps. No prompt, answer, file path or tool argument is ever stored or drawn; model ids, agent types and category names are drawn as one capped line (control characters, escape codes and line breaks become spaces; bidi, zero-width and tag characters are dropped), so none can forge lines in the pane or the /spend table.

Tests

claude plugin validate mods/usage-gauge and claude plugin test mods/usage-gauge. The tests cover:

  • the pure helpers: dollars, tokens, durations, short model names, cache hit, patch counting (refused, failed and held edits count nothing), shares, bounded records, the chip label in each state, the breakdown, the plain report; the price table's prefix rule and estimates;
  • the chip on the desktop mode row and the terminal hint line, each state of its label, footerButton and chipContext off, and a press opening the pane;
  • a request's chunks and result passing through untouched with the model asked once, and an Edit returned as the tool gave it;
  • the pane on terminal (cell bar) and desktop (pill bar, and that it animates from the last fill) after a busy session: a measurement, a main-loop request on Opus 5.5 and a subagent request on Sonnet 5.5 with the clock moved between, both turns, an Edit and a refused one; model rows, shares, main loop against subagents, the subagent named from the agent list, the lines changed; Refresh drawing the category rows; narrow panes on all four surfaces; usage refused; before any reading;
  • /spend from the person, from a plugin, headless and unplaced;
  • hostile model ids, agent types and category names; a second start (as a reload) keeping the counts.

Limits

  • The desktop's context-window popover is the app's own. The circle beside the model name, its "until compaction" bar and its "View usage" arrow (which sends /usage) are drawn by the desktop app itself, not by the engine, and no mod can draw inside them. So the chip sits right beside that icon in the footer instead, and opens its own pane. /usage and /cost are left alone.
  • Two clocks. The headline cost is the engine's own ledger, from the session's start. The per-model, per-agent, time and code-change figures are counted by this mod from when it loaded; in a session it joined late (installed mid-session, or the first load after an update), they cover only the part since.
  • Estimates are list price (as of 2026-10-06, copied from rd-ledger's table) with every cache write at the 5-minute rate (1.25x input), so a 1-hour cache write reads low; they are for comparing models and agents, never a bill.
  • API time adds up across loops: three subagents answering at once count three times. It is the model's working time, not the session's.
  • Rate limits are whatever the last response reported; none off a subscription.
  • The footer strip is narrow and all-or-nothing. The desktop draws every plugin's footer chips in one nowrap strip (overflow: hidden; text-overflow: ellipsis) beside its own controls; once the chips overflow it, the end of the row is cut to … (the full text shows in a tooltip). Plugins loaded earlier draw first, so with mem-gauge's memory and disk chips ahead of this one, this chip is the first to go. That is why the label is short and the context is off by default. If … still shows: widen the window, keep chipContext off, or set mem-gauge's footerButtons off (plain text takes no chip padding).
  • Where the chip can sit. The desktop app raises the footer's mode row to plugins but not the hint line; the terminal raises the hint line. VS Code and mobile raise neither, so there /spend is the way in. That a click reaches the chip is proven by the test kit; confirm it with a live click.
  • No status line. The mod never sets one (on the desktop host clearing one stops every later render from reaching the plugin).
Source 4 files
hooks/register.tsx 565 lines
1// usage-gauge: what this session has cost and used, live, without /usage
2// dropping a card into the transcript. A chip in the prompt footer (beside the
3// desktop's context-window icon, which is the app's own and no mod can draw
4// inside) reads `$123.45 · ctx 12%` and opens a Usage pane: the engine's cost,
5// session and API time, the context window by category, rate limits, tokens
6// and cache hit per model with list-price estimates, the subagents' share, and
7// the lines the session's edits changed.
8//
9// Every accounting hook only observes: it passes the event on unchanged, keeps
10// its bookkeeping inside try/catch, and writes $.state, which a hot reload
11// keeps. Drawings read $.state only. Nothing is written to disk, run, fetched
12// or sent; no prompt, answer, path or tool argument is stored.
13//
14// The engine requires `$` to be handed only to functions declared at the top of
15// this file, so every function that touches `$` lives here; the formatting and
16// the arithmetic are pure and live in format.ts and prices.ts.
17
18import { atom, read, update } from 'claude-code'
19import type { EngineInterface, Register, SessionUsageArgs, TurnStepResult } from 'claude-code'
20
21import type { View } from './format'
22import {
23  CAP,
24  PRICE_NOTE,
25  addRequest,
26  BAR_CELLS,
27  BAR_HEIGHT_PX,
28  BAR_WIDTH_PX,
29  barParts,
30  boundedKey,
31  breakdownFrom,
32  categoryRows,
33  changesLine,
34  chipLabel,
35  compactReport,
36  contextLine,
37  contributionLines,
38  countPatch,
39  emptyCounts,
40  emptyTotals,
41  formatCount,
42  formatDuration,
43  formatPercent,
44  formatTokens,
45  formatUsd,
46  keepLatest,
47  modelLines,
48  oneLine,
49  ownGet,
50  rateLine,
51  pillSvg,
52  sessionMs,
53  summaryLine,
54  unknownUsage,
55  untilCompaction,
56  usageFrom,
57  viewNow,
58} from './format'
59import type { ModelLine } from './format'
60import { estimateUsd } from './prices'
61
62type Dollar = EngineInterface
63
64// $.state values, one literal plugin/key reference each, so `claude plugin
65// validate` holds them to types/index.d.ts. Written only from hooks and
66// handlers, never while drawing.
67const usageRef = atom({ plugin: 'usage-gauge', key: 'usage' } as const, null)
68const breakdownRef = atom({ plugin: 'usage-gauge', key: 'breakdown' } as const, null)
69const totalsRef = atom({ plugin: 'usage-gauge', key: 'totals' } as const, emptyTotals())
70const modelsRef = atom({ plugin: 'usage-gauge', key: 'models' } as const, {})
71const loopsRef = atom({ plugin: 'usage-gauge', key: 'loops' } as const, { main: emptyCounts(), agents: {} })
72const agentTypesRef = atom({ plugin: 'usage-gauge', key: 'agentTypes' } as const, {})
73const refreshingRef = atom({ plugin: 'usage-gauge', key: 'refreshing' } as const, false)
74
75const PANE = 'usage-gauge'
76/** The agent ids whose type is remembered; older ones are looked up again if they ever step. */
77const MAX_AGENT_IDS = 500
78/** Distinct models and agent types kept apart; any more fold into `other`. */
79const MAX_MODELS = 50
80const MAX_AGENT_TYPES = 50
81/** Category rows the pane lists. */
82const MAX_CATEGORIES = 8
83/**
84 * The origins that count as the person: their own Enter, the Remote Control
85 * bridge, and the SDK host (the desktop Code tab drives the engine through
86 * it). Only these open (and focus) the pane. Any other origin, and any kind
87 * this list does not know, gets one summary line, so no peer session, plugin
88 * or schedule can pop a pane in front of the person.
89 */
90const PANE_ORIGINS = new Set(['composer', 'bridge', 'sdk'])
91
92// Per-load options, not state: register() sets them and a reload re-reads them.
93let config = { footerButton: true, chipContext: false }
94// The context percent the bar last drew, so the next drawing animates from it (a load starts from empty).
95let lastBarPercent: number | null = null
96
97/** An error's kind only: a message could carry a path or a value. */
98const kindOf = (err: unknown): string => (err instanceof Error && err.name ? err.name : 'error')
99
100/**
101 * Reads the status line's figures (and, with `breakdown`, the window by
102 * category) into $.state. A refused read leaves every figure unknown rather
103 * than absent, so the pane draws `--` instead of waiting forever.
104 */
105async function readUsage($: Dollar, args?: SessionUsageArgs): Promise<void> {
106  const now = await $.clock.now()
107  let reading
108  try {
109    reading = await $.session.usage(args)
110  } catch {
111    await update($, usageRef, prev => prev ?? unknownUsage(now))
112    return
113  }
114  await update($, usageRef, prev => usageFrom(reading, prev, now, true))
115  if (args?.breakdown) await update($, breakdownRef, () => breakdownFrom(reading.context.breakdown))
116}
117
118/** Pane open and Refresh: the figures and the breakdown (`summary`: estimated locally, nothing is sent). */
119async function refresh($: Dollar, columns?: number): Promise<void> {
120  if (await read($, refreshingRef)) return
121  await update($, refreshingRef, () => true)
122  try {
123    await readUsage($, columns === undefined ? { breakdown: 'summary' } : { breakdown: 'summary', columns })
124  } finally {
125    await update($, refreshingRef, () => false).catch(() => undefined)
126  }
127}
128
129/** Opens the Usage pane (from the chip or /spend) and reads the breakdown behind it. */
130async function openPane($: Dollar) {
131  const opened = await $.ui.open({ id: PANE, title: 'Usage', focus: true, closeOnEscape: true })
132  if (opened.isPlaced) void refresh($).catch(() => undefined)
133  return opened
134}
135
136/** The type an agent id was spawned as, looked up once and remembered; `agent` when it is not listed. */
137async function agentTypeOf($: Dollar, agentId: string): Promise<string> {
138  const id = agentId.slice(0, 128)
139  const known = ownGet(await read($, agentTypesRef), id)
140  if (known !== undefined) return known
141  let type = 'agent'
142  try {
143    const hit = (await $.agent.list()).find(a => a.id === agentId)
144    if (hit && typeof hit.type === 'string') type = oneLine(hit.type, CAP.agentType) || 'agent'
145  } catch {
146    // Unknown stays `agent`; it is remembered so the list is not asked again for it.
147  }
148  await update($, agentTypesRef, m => keepLatest({ ...m, [id]: type }, MAX_AGENT_IDS))
149  return type
150}
151
152/** One model request, counted under its model and its loop. */
153async function recordStep($: Dollar, agentId: string | undefined, model: string, r: TurnStepResult | undefined, t0: number | null): Promise<void> {
154  const now = await $.clock.now()
155  const apiMs = t0 === null ? 0 : Math.max(0, now - t0)
156  const usage = r?.usage ?? null
157  const rawModel = typeof usage?.model === 'string' && usage.model ? usage.model : model
158  const counts = usage
159    ? { input: usage.input_tokens, output: usage.output_tokens, cacheRead: usage.cache_read_input_tokens, cacheWrite: usage.cache_creation_input_tokens }
160    : null
161  // A request with no usage (failed, interrupted) has no tokens to price.
162  const est = counts ? estimateUsd(rawModel, counts) : 0
163  const modelKey = oneLine(rawModel, CAP.model) || 'unknown'
164  const type = agentId === undefined ? null : await agentTypeOf($, agentId)
165
166  await update($, modelsRef, m => {
167    const key = boundedKey(m, modelKey, MAX_MODELS)
168    return { ...m, [key]: addRequest(ownGet(m, key), counts, apiMs, est) }
169  })
170  await update($, loopsRef, l => {
171    if (type === null) return { ...l, main: addRequest(l.main, counts, apiMs, est) }
172    const key = boundedKey(l.agents, type, MAX_AGENT_TYPES)
173    return { ...l, agents: { ...l.agents, [key]: addRequest(ownGet(l.agents, key), counts, apiMs, est) } }
174  })
175  await update($, totalsRef, t => ({ ...t, loadedAt: t.loadedAt || now, updatedAt: now, requests: t.requests + 1, apiMs: t.apiMs + apiMs }))
176}
177
178/** A turn's end: the main loop's count and wall time, or a subagent's count. */
179async function recordTurn($: Dollar, agentId: string | undefined, durationMs: number): Promise<void> {
180  const now = await $.clock.now()
181  const ms = Number.isFinite(durationMs) && durationMs > 0 ? durationMs : 0
182  await update($, totalsRef, t =>
183    agentId === undefined
184      ? { ...t, loadedAt: t.loadedAt || now, updatedAt: now, turns: t.turns + 1, activeMs: t.activeMs + ms }
185      : { ...t, loadedAt: t.loadedAt || now, updatedAt: now, agentTurns: t.agentTurns + 1 },
186  )
187}
188
189/** An Edit / Write / NotebookEdit result's lines; a denied or failed call counts nothing. */
190async function recordEdit($: Dollar, ran: unknown): Promise<void> {
191  const counted = countPatch(ran)
192  if (!counted) return
193  const now = await $.clock.now()
194  await update($, totalsRef, t => ({
195    ...t,
196    loadedAt: t.loadedAt || now,
197    updatedAt: now,
198    linesAdded: t.linesAdded + counted.added,
199    linesRemoved: t.linesRemoved + counted.removed,
200    edits: t.edits + 1,
201  }))
202}
203
204/** Everything the pane and /spend draw. */
205async function readView($: Dollar): Promise<View> {
206  return {
207    usage: await read($, usageRef),
208    breakdown: await read($, breakdownRef),
209    totals: await read($, totalsRef),
210    models: await read($, modelsRef),
211    loops: await read($, loopsRef),
212  }
213}
214
215export const register: Register = (on, options) => {
216  config = {
217    footerButton: options['footerButton'] !== false,
218    chipContext: options['chipContext'] === true,
219  }
220
221  on('session.start', async ($, e, next) => {
222    try {
223      await $.command.register({
224        name: 'spend',
225        description: 'Open the Usage pane: cost, context, rate limits, models, subagents, code changes',
226        immediate: true,
227      })
228    } catch (err) {
229      $.ui.log(`usage-gauge: /spend not registered (${kindOf(err)})`, { to: 'debug' })
230    }
231    const result = await next(e)
232    try {
233      const now = await $.clock.now()
234      // A reload (or a second start) keeps the accumulators and when they began.
235      await update($, totalsRef, t => (t.loadedAt > 0 ? t : { ...t, loadedAt: now, updatedAt: Math.max(t.updatedAt, now) }))
236      // A load starts with no Refresh in flight: a reload mid-Refresh must not leave the button at Refreshing.
237      await update($, refreshingRef, () => false)
238      await readUsage($)
239    } catch {
240      // Observing only.
241    }
242    return result
243  })
244
245  on('session.measure', async ($, e, next) => {
246    try {
247      const now = await $.clock.now()
248      await update($, usageRef, prev => usageFrom(e, prev, now, false))
249    } catch {
250      // Observing only.
251    }
252    return next(e)
253  })
254
255  // One model request in any loop. Observing a stream: the chunks pass through
256  // untouched (`yield* next(e)`), `next(e)` is called exactly once, and nothing
257  // before it can throw (a hook that fails before calling it would be skipped,
258  // but one that fails after would cut the stream). Its wall time counts the
259  // request from send to its last chunk.
260  on('turn.step', async function* ($, e, next) {
261    let t0: number | null = null
262    try {
263      t0 = await $.clock.now()
264    } catch {
265      t0 = null
266    }
267    const result = yield* next(e)
268    try {
269      await recordStep($, e.agentId, e.model, result, t0)
270    } catch {
271      // Observing only.
272    }
273    return result
274  })
275
276  on('turn.complete', async ($, e, next) => {
277    try {
278      await recordTurn($, e.agentId, e.durationMs)
279    } catch {
280      // Observing only.
281    }
282    return next(e)
283  })
284
285  // The tool runs once, beneath, whatever this hook does: it counts after
286  // `next(e)` settled and returns that result unchanged.
287  on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
288    const ran = await next(e)
289    try {
290      await recordEdit($, ran)
291    } catch {
292      // Observing only.
293    }
294    return ran
295  }).catch(($, e, next) =>
296    // Should the hook overrun anyway: inside a catch `next` is replay-safe, so
297    // once called it answers what the tool already returned without running it
298    // again, and uncalled it runs the tool once. Never a deny: an observer
299    // must not refuse a tool.
300    next(e),
301  )
302
303  // Read-only, so any origin may run it, but only the person's own origins open
304  // the focused pane. Where none can be placed (`claude -p`), the person gets
305  // the short table; any other origin gets one summary line.
306  on('command.run', { command: 'spend' }, async ($, e) => {
307    const surfaces = await $.session.surfaces().catch(() => [])
308    const isPerson = PANE_ORIGINS.has(e.origin.kind)
309    if (surfaces.length > 0 && isPerson) {
310      let isPlaced = false
311      try {
312        isPlaced = (await openPane($)).isPlaced
313      } catch {
314        isPlaced = false
315      }
316      // The pane is the answer: no transcript row (CommandRunResult.text is a transcript line).
317      if (isPlaced) return {}
318    }
319    const view = await readView($)
320    return { text: isPerson ? compactReport(view) : summaryLine(view) }
321  }).catch(async ($, e) => {
322    // Past its budget or failed: answer from the state rather than leave a
323    // registered command with no output.
324    const view = await readView($).catch(() => null)
325    if (!view) return { text: 'usage-gauge: no reading yet.' }
326    return { text: PANE_ORIGINS.has(e.origin.kind) ? compactReport(view) : summaryLine(view) }
327  })
328
329  // The chip on the terminal: the hint line under the prompt, after the
330  // engine's own line so its pills stay live. Only the terminal raises it.
331  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
332    const engine = await next(e)
333    if (e.surface !== 'terminal') return engine
334    const label = chipLabel(await read($, usageRef), config.chipContext)
335    if (!label) return engine
336    const { Box, Text, Button } = $.ui.resolve(e)
337    const open = () => void openPane($).catch(() => undefined)
338    return (
339      <Box flexDirection="row">
340        {engine}
341        <Box marginLeft={2} flexDirection="row">
342          {config.footerButton ? <Button key="usage-gauge" plain dimColor label={label} onPress={open} /> : <Text dimColor>{label}</Text>}
343        </Box>
344      </Box>
345    )
346  })
347
348  // The chip on the desktop: the prompt footer's mode row, right beside the
349  // context-window icon, the one site under the composer the desktop raises.
350  // That strip draws text and native chips only (no vector element, no Box
351  // hover or display) and lays every Button before every Text whatever the
352  // tree's order, so nothing here relies on order.
353  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
354    const engine = await next(e)
355    if (e.surface !== 'desktop') return engine
356    const label = chipLabel(await read($, usageRef), config.chipContext)
357    if (!label) return engine
358    const { Box, Text, Button } = $.ui.resolve(e)
359    const open = () => void openPane($).catch(() => undefined)
360    return (
361      <Box flexDirection="row" alignItems="center" gap={1}>
362        {config.footerButton ? <Button key="usage-gauge" plain label={label} onPress={open} /> : <Text dimColor>{label}</Text>}
363        {engine}
364      </Box>
365    )
366  })
367
368  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
369    const { Box, Text, Button } = $.ui.resolve(e)
370    const v = await readView($)
371    const isRefreshing = await read($, refreshingRef)
372    const cols = Math.max(24, Math.floor(e.props.bodyColumns))
373    const isNarrow = cols < 60
374
375    const controls = (
376      <Box key="controls" flexDirection="row" gap={2} marginTop={1}>
377        <Button
378          key="refresh"
379          label={isRefreshing ? 'Refreshing' : 'Refresh'}
380          hotkey="r"
381          dimColor={isRefreshing}
382          onPress={() => void refresh($, cols).catch(() => undefined)}
383        />
384        <Button key="close" label="Close" hotkey="c" role="dismiss" onPress={() => void $.ui.close({ id: PANE }).catch(() => undefined)} />
385      </Box>
386    )
387    if (!v.usage && v.totals.requests === 0) {
388      return (
389        <Box flexDirection="column">
390          <Text dimColor>Reading usage…</Text>
391          {controls}
392        </Box>
393      )
394    }
395
396    const now = viewNow(v)
397    const pct = v.usage?.context.percent ?? null
398    const t = v.totals
399
400    // Label left in a fixed column, figure flush with the pane's right edge: the
401    // value box grows to the rest of the row, so the figures line up on the
402    // right whatever the pane's width.
403    type Pair = { key: string; label: string; value: string; isDim?: boolean }
404    const headline: Pair[] = [
405      { key: 'cost', label: 'Cost', value: formatUsd(v.usage?.costUsd ?? null) },
406      { key: 'session', label: 'Session', value: formatDuration(sessionMs(v)) },
407      { key: 'active', label: 'Active', value: formatDuration(t.activeMs) },
408      { key: 'api', label: 'API', value: formatDuration(t.apiMs) },
409      { key: 'turns', label: 'Turns', value: formatCount(t.turns) },
410      { key: 'requests', label: 'Requests', value: formatCount(t.requests) },
411    ]
412    if (t.agentTurns > 0) headline.push({ key: 'agent-turns', label: 'Agent turns', value: formatCount(t.agentTurns) })
413    if (v.usage && !v.usage.hasCost) headline[0] = { key: 'cost', label: 'Cost', value: 'no ledger here', isDim: true }
414
415    const context: Pair[] = [{ key: 'window', label: 'Window', value: contextLine(v.usage) }]
416    const left = untilCompaction(v.breakdown)
417    if (left !== null) context.push({ key: 'until', label: 'Until compaction', value: formatTokens(left) })
418    for (const c of categoryRows(v.breakdown, MAX_CATEGORIES)) {
419      context.push({ key: `cat:${c.name}`, label: c.name, value: `${formatTokens(c.tokens)} · ${formatPercent(c.percent)}`, isDim: c.isDim })
420    }
421
422    const limits: Pair[] = (v.usage?.rateLimits ?? []).map((r, i) => {
423      const line = rateLine(r, now)
424      return { key: `rate:${i}`, label: line.label, value: line.value }
425    })
426
427    const labelWidth = isNarrow ? 14 : 20
428    const pairRows = (pairs: readonly Pair[]) =>
429      pairs.map(p => (
430        <Box key={p.key} flexDirection="row">
431          <Box width={labelWidth}>
432            <Text wrap="truncate" dimColor>
433              {p.label}
434            </Text>
435          </Box>
436          <Box flexGrow={1} justifyContent="flex-end">
437            <Text wrap="truncate" dimColor={p.isDim === true}>
438              {p.value}
439            </Text>
440          </Box>
441        </Box>
442      ))
443
444    const header = (
445      <Box key="header" flexDirection="column">
446        {pairRows(headline)}
447      </Box>
448    )
449
450    // The context fill as a small thin bar at the right of the Context title,
451    // flush with the pane's edge, so it sits just above the Window figure: on
452    // the terminal two runs of cells (the track dim); elsewhere a 2 px pill
453    // SVG that animates from the fill it last drew.
454    let contextBar
455    if (e.surface === 'terminal') {
456      const b = barParts(pct, Math.min(BAR_CELLS, cols - 10))
457      contextBar = (
458        <Box key="context-bar" flexDirection="row">
459          {b.filled !== '' && <Text>{b.filled}</Text>}
460          {b.track !== '' && <Text dimColor>{b.track}</Text>}
461        </Box>
462      )
463    } else {
464      const { Svg } = $.ui.resolve(e)
465      const shown = pct === null ? '--' : `${Math.round(pct)}%`
466      contextBar = <Svg source={pillSvg(pct, lastBarPercent)} alt={`Context ${shown} used`} width={BAR_WIDTH_PX} height={BAR_HEIGHT_PX} />
467      lastBarPercent = pct
468    }
469
470    // The model table: columns sized to the body; on a narrow pane only
471    // output, est. $ and share stay, and on a middling one the least telling
472    // columns drop until the model name has room.
473    type Col = { key: string; title: string; width: number; value: (l: ModelLine) => string }
474    const allCols: Col[] = [
475      { key: 'req', title: 'Req', width: 7, value: l => formatCount(l.requests) },
476      { key: 'in', title: 'Input', width: 9, value: l => formatTokens(l.input) },
477      { key: 'out', title: 'Output', width: 9, value: l => formatTokens(l.output) },
478      { key: 'cr', title: 'Cache rd', width: 10, value: l => formatTokens(l.cacheRead) },
479      { key: 'cw', title: 'Cache wr', width: 10, value: l => formatTokens(l.cacheWrite) },
480      { key: 'hit', title: 'Hit', width: 6, value: l => formatPercent(l.hit) },
481      // A total with an unpriced model in it is a floor, marked `+`, never a complete figure.
482      { key: 'est', title: 'Est. $', width: 11, value: l => (l.estUsd === null ? '--' : `${formatUsd(l.estUsd)}${l.isPartial ? '+' : ''}`) },
483      { key: 'share', title: 'Share', width: 7, value: l => (l.share === null ? '--' : formatPercent(l.share)) },
484    ]
485    let cols2 = isNarrow ? allCols.filter(c => c.key === 'out' || c.key === 'est' || c.key === 'share') : allCols
486    for (const drop of ['req', 'cw', 'in', 'cr']) {
487      if (isNarrow || cols - cols2.reduce((s, c) => s + c.width, 0) >= 14) break
488      cols2 = cols2.filter(c => c.key !== drop)
489    }
490    const nameWidth = Math.max(8, cols - cols2.reduce((s, c) => s + c.width, 0))
491    const { rows: modelRows, total } = modelLines(v.models)
492    const tableRow = (key: string, name: string, values: string[], isDim: boolean, isBold: boolean) => (
493      <Box key={key} flexDirection="row">
494        <Box width={nameWidth}>
495          <Text wrap="truncate" dimColor={isDim} bold={isBold}>
496            {name}
497          </Text>
498        </Box>
499        {cols2.map((c, i) => (
500          <Box key={`${key}:${c.key}`} width={c.width} justifyContent="flex-end">
501            <Text wrap="truncate" dimColor={isDim} bold={isBold}>
502              {values[i] ?? ''}
503            </Text>
504          </Box>
505        ))}
506      </Box>
507    )
508
509    const contributing = contributionLines(v.loops)
510    const section = (title: string) => <Text bold>{title}</Text>
511
512    return (
513      <Box flexDirection="column">
514        {header}
515
516        <Box key="context" flexDirection="column" marginTop={1}>
517          <Box key="context-title" flexDirection="row" justifyContent="space-between" alignItems="center">
518            {section('Context')}
519            {contextBar}
520          </Box>
521          {pairRows(context)}
522        </Box>
523
524        <Box key="limits" flexDirection="column" marginTop={1}>
525          {section('Rate limits')}
526          {limits.length === 0 ? <Text dimColor>none reported</Text> : pairRows(limits)}
527        </Box>
528
529        <Box key="models" flexDirection="column" marginTop={1}>
530          {section('Models')}
531          {modelRows.length === 0 ? (
532            <Text dimColor>No requests counted yet.</Text>
533          ) : (
534            <Box flexDirection="column">
535              {tableRow('model-head', 'Model', cols2.map(c => c.title), true, false)}
536              {modelRows.map(l => tableRow(`model:${l.model}`, l.name, cols2.map(c => c.value(l)), false, false))}
537              {modelRows.length > 1 && tableRow('model-total', 'Total', cols2.map(c => c.value(total)), false, true)}
538              <Text dimColor wrap="wrap">
539                {PRICE_NOTE}
540              </Text>
541            </Box>
542          )}
543        </Box>
544
545        <Box key="contributing" flexDirection="column" marginTop={1}>
546          {section("What's contributing")}
547          {contributing.length === 0 ? <Text dimColor>Nothing counted yet.</Text> : contributing.map((line, i) => <Text key={`contrib:${i}`}>{line}</Text>)}
548        </Box>
549
550        <Box key="changes" flexDirection="column" marginTop={1}>
551          {section('Code changes')}
552          <Text>{changesLine(t)}</Text>
553        </Box>
554
555        <Box marginTop={1}>
556          <Text dimColor wrap="wrap">
557            Per-model, per-agent and code figures count from when usage-gauge loaded; the cost is the engine's own, from the session's start.
558          </Text>
559        </Box>
560        {controls}
561      </Box>
562    )
563  })
564}
565
hooks/format.ts 543 lines
1// usage-gauge: pure helpers, everything the chip, the pane and /spend draw
2// from $.state. Nothing here touches `$` (the engine's rule keeps every
3// function that does at the top of register.tsx), so all of it is unit-tested
4// in format.test.ts.
5
6import type { Breakdown, CategoryRow, Counts, RateLimitRow, Totals, Usage } from '../types'
7import { CACHE_WRITE_MULTIPLIER, PRICES_AS_OF } from './prices'
8
9/** Caps on drawn text, so nothing an id or a name holds can widen a row past these. */
10export const CAP = { model: 32, agentType: 24, category: 40, rateKind: 24 } as const
11
12/**
13 * Text that may hold anything (a model id, an agent type, a category name)
14 * made safe to draw as one line: control characters, escape codes and line
15 * breaks become spaces; format characters (bidi overrides, zero-width,
16 * Unicode tags) are dropped; runs of whitespace collapse; the result is cut
17 * to `max` code points. Without this a crafted name could forge extra lines
18 * in the /spend table the model reads. Copied from mem-gauge's parse.ts.
19 */
20export const oneLine = (text: string, max: number): string =>
21  [
22    ...text
23      .replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ')
24      .replace(/[\p{Cf}\p{Cs}\p{Co}]/gu, '')
25      .replace(/\s+/g, ' ')
26      .trim(),
27  ]
28    .slice(0, max)
29    .join('')
30    .trim()
31
32const group = (digits: string): string => digits.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
33
34const isNum = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n)
35
36/** A whole count with thousands separators: `1,203`. */
37export const formatCount = (n: number): string => (isNum(n) ? `${n < 0 ? '-' : ''}${group(String(Math.round(Math.abs(n))))}` : '--')
38
39/** Dollars with two decimals and thousands separators (`$123.45`, `$1,204.10`); `$ --` when unknown. */
40export function formatUsd(usd: number | null): string {
41  if (usd === null || !isNum(usd)) return '$ --'
42  const [whole = '0', cents = '00'] = Math.abs(usd).toFixed(2).split('.')
43  return `${usd < 0 ? '-' : ''}$${group(whole)}.${cents}`
44}
45
46/**
47 * The chip's dollars, as short as the footer strip needs: cents under $100
48 * (`$12.34`), whole dollars from there (`$123`, `$1,234`); `$ --` unknown.
49 */
50export function formatUsdChip(usd: number | null): string {
51  if (usd === null || !isNum(usd)) return '$ --'
52  if (Math.abs(usd) < 100) return formatUsd(usd)
53  return `${usd < 0 ? '-' : ''}$${group(String(Math.round(Math.abs(usd))))}`
54}
55
56/** Tokens, short: `572`, `345.3k`, `152.3M`; `digits` is the decimals under a million (`152k` with 0). */
57export function formatTokens(n: number | null, digits = 1): string {
58  if (n === null || !isNum(n)) return '--'
59  const abs = Math.max(0, n)
60  if (abs < 1000) return String(Math.round(abs))
61  const k = (abs / 1000).toFixed(digits)
62  if (Number(k) < 1000) return `${k}k`
63  return `${(abs / 1_000_000).toFixed(1)}M`
64}
65
66/** `4s`, `8m 4s`, `2h 14m`, `31h 24m`; `--` when unknown or negative. */
67export function formatDuration(ms: number | null): string {
68  if (ms === null || !isNum(ms) || ms < 0) return '--'
69  const s = Math.floor(ms / 1000)
70  if (s < 60) return `${s}s`
71  const m = Math.floor(s / 60)
72  if (m < 60) return `${m}m ${s % 60}s`
73  return `${Math.floor(m / 60)}h ${m % 60}m`
74}
75
76/** A share as a whole percent; a positive share under one percent reads `<1%`. */
77export const formatPercent = (p: number | null): string =>
78  p === null || !isNum(p) ? '--' : p > 0 && p < 1 ? '<1%' : `${Math.round(p)}%`
79
80/** Pads or cuts `text` to exactly `width` columns (with an ellipsis when cut). */
81export function fit(text: string, width: number, align: 'left' | 'right' = 'left'): string {
82  if (width <= 0) return ''
83  const chars = [...text]
84  if (chars.length > width) return width === 1 ? '…' : `${chars.slice(0, width - 1).join('')}…`
85  const pad = ' '.repeat(width - chars.length)
86  return align === 'left' ? text + pad : pad + text
87}
88
89/** A text gauge for surfaces with no Svg: "███████░░░░░ 62%". */
90/**
91 * The terminal's context bar: a run of filled cells and a run of track cells,
92 * drawn as two spans (the track dim, so it reads as a plain gray; no hatching).
93 */
94export function barParts(percent: number | null, width: number): { filled: string; track: string; label: string } {
95  const cells = Math.max(4, Math.floor(width))
96  if (percent === null || !isNum(percent)) return { filled: '', track: '█'.repeat(cells), label: '--' }
97  const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * cells)
98  return { filled: '█'.repeat(filled), track: '█'.repeat(cells - filled), label: `${Math.round(percent)}%` }
99}
100
101/** Apple system gray: the pill's ink where a surface hands the SVG no theme colour. */
102const FALLBACK_INK = '#8E8E93'
103
104const xmlText = (text: string): string => text.replace(/[<>&"']/g, c => `&#${c.charCodeAt(0)};`)
105
106/** The context bar on the desktop: a 2 px pill about as wide as the Window figure under it, at the right of the Context title. */
107export const BAR_WIDTH_PX = 120
108export const BAR_HEIGHT_PX = 2
109/** The same bar on the terminal, in cells. */
110export const BAR_CELLS = 16
111
112const clampPct = (p: number | null): number => (p === null || !isNum(p) ? 0 : Math.min(100, Math.max(0, p)))
113
114/**
115 * The context bar: a small thin pill (`width` x `height` px, drawn 1:1), a
116 * muted track and a fill in the surface's own ink, that grows from the last
117 * drawn percent to this one. The desktop draws a plugin SVG as an image and
118 * stamps the theme's text colour on its root, so `currentColor` is white on a
119 * dark theme and near-black on a light one; a surface that stamps none gets
120 * the system gray. The growth is a CSS animation on the fill's width (off
121 * under reduced motion; a renderer that cannot animate it still draws the
122 * final width). No script, no external refs.
123 */
124export function pillSvg(percent: number | null, from: number | null, width = BAR_WIDTH_PX, height = BAR_HEIGHT_PX, title?: string): string {
125  const to = clampPct(percent)
126  const start = clampPct(from)
127  const label = percent === null || !isNum(percent) ? '--' : `${Math.round(percent)}%`
128  const aria = xmlText(title ?? `Context ${label} used`)
129  const r = height / 2
130  const toW = ((to / 100) * width).toFixed(1)
131  const fromW = ((start / 100) * width).toFixed(1)
132  return (
133    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="${aria}">` +
134    `<style>svg:not([color]){color:${FALLBACK_INK}}` +
135    `.fill{animation:grow 480ms cubic-bezier(0.2,0,0,1) both}` +
136    `@keyframes grow{from{width:${fromW}px}to{width:${toW}px}}` +
137    `@media (prefers-reduced-motion: reduce){.fill{animation:none}}</style>` +
138    `<rect x="0" y="0" width="${width}" height="${height}" rx="${r}" fill="currentColor" fill-opacity="0.22"/>` +
139    `<rect class="fill" x="0" y="0" width="${toW}" height="${height}" rx="${r}" fill="currentColor"/>` +
140    '</svg>'
141  )
142}
143
144const capital = (word: string): string => (word ? word[0]?.toUpperCase() + word.slice(1) : word)
145
146/**
147 * A model id as people say it: `claude-opus-5-5` -> `Opus 5.5`,
148 * `claude-fable-5-1[1m]` -> `Fable 5.1`, `claude-3-5-sonnet-20241022` ->
149 * `Sonnet 3.5`, `claude-opus-4-20250514` -> `Opus 4`. Anything else is the
150 * id without `claude-`, as one capped line.
151 */
152export function shortModel(model: string): string {
153  const id = oneLine(model, 120).toLowerCase()
154  const named = /^claude-([a-z]+)-(\d{1,2})(?:-(\d{1,2}))?(?!\d)/.exec(id)
155  if (named?.[1] && named[2]) return oneLine(`${capital(named[1])} ${named[2]}${named[3] ? `.${named[3]}` : ''}`, CAP.model)
156  const legacy = /^claude-(\d{1,2})(?:-(\d{1,2}))?-([a-z]+)/.exec(id)
157  if (legacy?.[1] && legacy[3]) return oneLine(`${capital(legacy[3])} ${legacy[1]}${legacy[2] ? `.${legacy[2]}` : ''}`, CAP.model)
158  return oneLine(model.replace(/^claude-/, ''), CAP.model) || '?'
159}
160
161/** Cache read over everything the requests were answered over, as a percent; null with no input at all. */
162export function cacheHitPercent(input: number, cacheRead: number, cacheWrite: number): number | null {
163  const total = Math.max(0, input) + Math.max(0, cacheRead) + Math.max(0, cacheWrite)
164  return total > 0 ? (Math.max(0, cacheRead) / total) * 100 : null
165}
166
167const lineCount = (text: string): number => (text === '' ? 0 : text.split('\n').length - (text.endsWith('\n') ? 1 : 0))
168
169/**
170 * The lines one Edit / Write / NotebookEdit call changed, from its result's
171 * `structuredPatch` hunks (`+` lines added, `-` removed); a Write that created
172 * a file with no hunks counts its content's lines. Null (count nothing) for a
173 * denied call, an error result, or a change held for review (`staged`).
174 */
175export function countPatch(ran: unknown): { added: number; removed: number } | null {
176  if (!ran || typeof ran !== 'object') return null
177  const o = ran as Record<string, unknown>
178  if (typeof o['deny'] === 'string' || o['isError'] === true) return null
179  const result = o['result']
180  if (!result || typeof result !== 'object') return null
181  const r = result as Record<string, unknown>
182  if (r['staged'] === true) return null
183  let added = 0
184  let removed = 0
185  const hunks = Array.isArray(r['structuredPatch']) ? (r['structuredPatch'] as unknown[]) : []
186  for (const hunk of hunks) {
187    const lines = hunk && typeof hunk === 'object' ? (hunk as Record<string, unknown>)['lines'] : null
188    if (!Array.isArray(lines)) continue
189    for (const line of lines) {
190      if (typeof line !== 'string') continue
191      if (line.startsWith('+')) added += 1
192      else if (line.startsWith('-')) removed += 1
193    }
194  }
195  if (hunks.length === 0 && r['type'] === 'create' && typeof r['content'] === 'string') added = lineCount(r['content'])
196  return { added, removed }
197}
198
199/** Each entry's share of the positive total, biggest first; empty when the total is zero. */
200export function shares(entries: readonly (readonly [string, number])[]): { key: string; percent: number }[] {
201  const positive = entries.filter(([, v]) => isNum(v) && v > 0)
202  const total = positive.reduce((sum, [, v]) => sum + v, 0)
203  if (total <= 0) return []
204  return positive.map(([key, v]) => ({ key, percent: (v / total) * 100 })).sort((a, b) => b.percent - a.percent)
205}
206
207export const emptyCounts = (): Counts => ({ requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, apiMs: 0, estUsd: 0, unpriced: 0 })
208
209export const emptyTotals = (loadedAt = 0): Totals => ({
210  loadedAt,
211  updatedAt: loadedAt,
212  turns: 0,
213  agentTurns: 0,
214  requests: 0,
215  activeMs: 0,
216  apiMs: 0,
217  linesAdded: 0,
218  linesRemoved: 0,
219  edits: 0,
220})
221
222const tokensOf = (c: Counts): number => c.input + c.output + c.cacheRead + c.cacheWrite
223
224/** One request added to a row: its four counts, its wall time and its estimate (null: unpriced). */
225export function addRequest(
226  row: Counts | undefined,
227  usage: { input: number; output: number; cacheRead: number; cacheWrite: number } | null,
228  apiMs: number,
229  estUsd: number | null,
230): Counts {
231  const c = { ...emptyCounts(), ...row }
232  const n = (v: number) => (isNum(v) && v > 0 ? v : 0)
233  c.requests += 1
234  c.apiMs += n(apiMs)
235  if (usage) {
236    c.input += n(usage.input)
237    c.output += n(usage.output)
238    c.cacheRead += n(usage.cacheRead)
239    c.cacheWrite += n(usage.cacheWrite)
240  }
241  if (estUsd === null) c.unpriced += 1
242  else c.estUsd += n(estUsd)
243  return c
244}
245
246/** The record's own value at `key` (never one inherited, so a model named `constructor` is just a model). */
247export const ownGet = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
248  Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined
249
250/** A record keyed by `key` with at most `max` keys: a new key past the cap folds into `other`. */
251export function boundedKey(record: Readonly<Record<string, unknown>>, key: string, max: number): string {
252  // `__proto__` as a computed key is an own property here, but it does not survive the
253  // state's JSON round-trip as one, so that one name is spelled out instead.
254  const safe = key === '__proto__' ? '(__proto__)' : key
255  return ownGet(record, safe) !== undefined || Object.keys(record).length < max ? safe : 'other'
256}
257
258/** The latest `max` entries of an insertion-ordered record. */
259export function keepLatest<T>(record: Readonly<Record<string, T>>, max: number): Record<string, T> {
260  const entries = Object.entries(record)
261  return entries.length <= max ? { ...record } : Object.fromEntries(entries.slice(entries.length - max))
262}
263
264/** A rate-limit window's kind spelled out: `5-hour`, `7-day`, `spend limit`. */
265export function rateKind(kind: string): string {
266  return (
267    oneLine(kind, CAP.rateKind)
268      .toLowerCase()
269      .replace(/^five_hour/, '5-hour')
270      .replace(/^seven_day/, '7-day')
271      .replace(/_/g, ' ') || 'limit'
272  )
273}
274
275const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
276
277/** When a window resets, in local time: `3:40 PM` within a day, `Tue 3:40 PM` past it; null when unreadable. */
278export function resetText(iso: string | null, now: number): string | null {
279  if (!iso) return null
280  const at = Date.parse(iso)
281  if (!isNum(at)) return null
282  const d = new Date(at)
283  const h = d.getHours()
284  const time = `${h % 12 === 0 ? 12 : h % 12}:${String(d.getMinutes()).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'}`
285  return at - now < 24 * 60 * 60_000 ? time : `${WEEKDAYS[d.getDay()] ?? ''} ${time}`.trim()
286}
287
288/** One rate-limit row for the pane and /spend: `5-hour` and `23.5%`, `resets 3:40 PM`. */
289export function rateLine(r: RateLimitRow, now: number): { label: string; value: string } {
290  const reset = resetText(r.resetsAt, now)
291  return { label: rateKind(r.kind), value: `${r.percentUsed}%${reset ? ` · resets ${reset}` : ''}` }
292}
293
294/** What a usage reading or a measurement holds, as the engine gives it. */
295export type Reading = {
296  startedAt?: unknown
297  context?: { tokens?: unknown; window?: unknown; percent?: unknown } | null
298  rateLimits?: readonly { kind?: unknown; percentUsed?: unknown; resetsAt?: unknown }[] | null
299  cost?: { usd?: unknown } | null
300}
301
302const numOrNull = (v: unknown): number | null => (isNum(v) ? v : null)
303
304/**
305 * The `usage` atom from a reading. `isFull` is `$.session.usage()` (its
306 * missing cost means the host keeps no ledger); a `session.measure` reading
307 * keeps the last cost when it carries none, and keeps `startedAt` always.
308 */
309export function usageFrom(r: Reading, prev: Usage | null, at: number, isFull: boolean): Usage {
310  const ctx = r.context ?? {}
311  const tokens = numOrNull(ctx.tokens)
312  const window = numOrNull(ctx.window)
313  let percent = numOrNull(ctx.percent)
314  if (percent === null && tokens !== null && window !== null && window > 0) percent = (tokens / window) * 100
315  const rateLimits: RateLimitRow[] = []
316  for (const row of (Array.isArray(r.rateLimits) ? r.rateLimits : []).slice(0, 8)) {
317    if (!row || typeof row.kind !== 'string' || !isNum(row.percentUsed)) continue
318    const resetsAt = typeof row.resetsAt === 'string' && row.resetsAt.length <= 64 ? row.resetsAt : null
319    rateLimits.push({ kind: oneLine(row.kind, CAP.rateKind), percentUsed: row.percentUsed, resetsAt })
320  }
321  const usd = r.cost && typeof r.cost === 'object' ? numOrNull(r.cost.usd) : null
322  const costUsd = usd !== null ? usd : isFull ? null : (prev?.costUsd ?? null)
323  const hasCost = usd !== null ? true : isFull ? false : (prev?.hasCost ?? true)
324  return {
325    startedAt: isFull ? numOrNull(r.startedAt) : (prev?.startedAt ?? null),
326    context: { tokens, window, percent },
327    rateLimits,
328    costUsd,
329    hasCost,
330    at,
331  }
332}
333
334/** The usage atom when the figures cannot be read: every figure unknown, so the pane draws `--`. */
335export const unknownUsage = (at: number): Usage => ({
336  startedAt: null,
337  context: { tokens: null, window: null, percent: null },
338  rateLimits: [],
339  costUsd: null,
340  hasCost: true,
341  at,
342})
343
344const KINDS: readonly CategoryRow['kind'][] = ['used', 'free', 'buffer', 'deferred']
345
346/** The breakdown atom from `context.breakdown`, names made one line; null when it is not one. */
347export function breakdownFrom(raw: unknown): Breakdown | null {
348  if (!raw || typeof raw !== 'object') return null
349  const b = raw as Record<string, unknown>
350  if (!isNum(b['totalTokens']) || !isNum(b['rawMaxTokens'])) return null
351  const categories: CategoryRow[] = []
352  for (const c of (Array.isArray(b['categories']) ? (b['categories'] as unknown[]) : []).slice(0, 40)) {
353    if (!c || typeof c !== 'object') continue
354    const row = c as Record<string, unknown>
355    if (typeof row['name'] !== 'string' || !isNum(row['tokens'])) continue
356    const kind = KINDS.includes(row['kind'] as CategoryRow['kind']) ? (row['kind'] as CategoryRow['kind']) : 'used'
357    categories.push({ name: oneLine(row['name'], CAP.category) || '?', kind, tokens: Math.max(0, row['tokens']) })
358  }
359  return {
360    totalTokens: b['totalTokens'],
361    rawMaxTokens: b['rawMaxTokens'],
362    percentage: isNum(b['percentage']) ? b['percentage'] : 0,
363    autoCompactThreshold: numOrNull(b['autoCompactThreshold']),
364    isAutoCompactEnabled: b['isAutoCompactEnabled'] === true,
365    model: typeof b['model'] === 'string' ? oneLine(b['model'], CAP.model) : '',
366    categories,
367  }
368}
369
370/** The chip's label: `$123.45 · ctx 12%`, `$ -- · ctx 12%`, `ctx 12%`; null with neither figure. */
371export function chipLabel(usage: Usage | null, withContext: boolean): string | null {
372  if (!usage) return null
373  const pct = usage.context.percent
374  const ctx = withContext && pct !== null ? `ctx ${Math.round(pct)}%` : null
375  // The desktop strip truncates the whole row to `…` once its chips overflow, so the
376  // label stays short: whole dollars from $100, and the context only when asked.
377  // Drawn once any figure is known: a reading with neither draws nothing.
378  const cost = usage.hasCost && (usage.costUsd !== null || pct !== null) ? formatUsdChip(usage.costUsd) : null
379  if (cost === null && ctx === null) return null
380  return [cost, ctx].filter(Boolean).join(' · ')
381}
382
383/** `120k of 1.0M (12%)`. */
384export function contextLine(usage: Usage | null): string {
385  const c = usage?.context
386  if (!c || (c.tokens === null && c.window === null)) return '--'
387  return `${formatTokens(c.tokens, 0)} of ${formatTokens(c.window, 0)} (${c.percent === null ? '--' : `${Math.round(c.percent)}%`})`
388}
389
390/** Tokens left before auto-compaction runs; null when it is off or the threshold is unknown. */
391export function untilCompaction(b: Breakdown | null): number | null {
392  if (!b || !b.isAutoCompactEnabled || b.autoCompactThreshold === null) return null
393  return Math.max(0, b.autoCompactThreshold - b.totalTokens)
394}
395
396/** Up to `max` category rows: what is used, biggest first, then free space and the buffer, dim. */
397export function categoryRows(b: Breakdown | null, max = 8): { name: string; tokens: number; percent: number | null; isDim: boolean }[] {
398  if (!b) return []
399  const rank = (c: CategoryRow) => (c.kind === 'used' ? 0 : 1)
400  return [...b.categories]
401    .sort((x, y) => rank(x) - rank(y) || y.tokens - x.tokens)
402    .slice(0, max)
403    .map(c => ({ name: c.name, tokens: c.tokens, percent: b.rawMaxTokens > 0 ? (c.tokens / b.rawMaxTokens) * 100 : null, isDim: c.kind !== 'used' }))
404}
405
406export type ModelLine = {
407  model: string
408  name: string
409  requests: number
410  input: number
411  output: number
412  cacheRead: number
413  cacheWrite: number
414  hit: number | null
415  /** Null when the price table does not know the model. */
416  estUsd: number | null
417  /** Of the priced total; null when unpriced. */
418  share: number | null
419  /** True on a total that leaves an unpriced model out: its est. $ is a floor, its share unknown. */
420  isPartial: boolean
421}
422
423/** The model rows, the priciest first, and their total. */
424export function modelLines(models: Readonly<Record<string, Counts>>): { rows: ModelLine[]; total: ModelLine } {
425  const entries = Object.entries(models)
426  const priced = entries.reduce((sum, [, c]) => sum + (c.unpriced > 0 ? 0 : c.estUsd), 0)
427  const rows = entries.map(([model, c]): ModelLine => {
428    const estUsd = c.unpriced > 0 ? null : c.estUsd
429    return {
430      model,
431      name: shortModel(model),
432      requests: c.requests,
433      input: c.input,
434      output: c.output,
435      cacheRead: c.cacheRead,
436      cacheWrite: c.cacheWrite,
437      hit: cacheHitPercent(c.input, c.cacheRead, c.cacheWrite),
438      estUsd,
439      share: estUsd !== null && priced > 0 ? (estUsd / priced) * 100 : null,
440      isPartial: false,
441    }
442  })
443  rows.sort((a, b) => (b.estUsd ?? -1) - (a.estUsd ?? -1) || b.output - a.output)
444  const sum = (f: (l: ModelLine) => number) => rows.reduce((s, l) => s + f(l), 0)
445  const isPartial = rows.some(l => l.estUsd === null)
446  const total: ModelLine = {
447    model: 'total',
448    name: 'Total',
449    requests: sum(l => l.requests),
450    input: sum(l => l.input),
451    output: sum(l => l.output),
452    cacheRead: sum(l => l.cacheRead),
453    cacheWrite: sum(l => l.cacheWrite),
454    hit: cacheHitPercent(sum(l => l.input), sum(l => l.cacheRead), sum(l => l.cacheWrite)),
455    estUsd: priced > 0 || rows.some(l => l.estUsd !== null) ? priced : null,
456    share: priced > 0 && !isPartial ? 100 : null,
457    isPartial,
458  }
459  return { rows, total }
460}
461
462/** Main loop against subagents, and the top subagent types, by est. cost (by tokens when nothing is priced). */
463export function contributions(loops: { main: Counts; agents: Readonly<Record<string, Counts>> }): {
464  basis: 'cost' | 'tokens'
465  main: number
466  agents: number
467  types: { key: string; percent: number }[]
468} | null {
469  const agents = Object.entries(loops.agents)
470  const isCost = loops.main.estUsd + agents.reduce((s, [, c]) => s + c.estUsd, 0) > 0
471  const of = (c: Counts) => (isCost ? c.estUsd : tokensOf(c))
472  const all = shares([['main', of(loops.main)], ...agents.map(([type, c]): [string, number] => [`agent:${type}`, of(c)])])
473  if (all.length === 0) return null
474  const main = all.find(s => s.key === 'main')?.percent ?? 0
475  const types = all.filter(s => s.key.startsWith('agent:')).map(s => ({ key: s.key.slice(6), percent: s.percent }))
476  return { basis: isCost ? 'cost' : 'tokens', main, agents: 100 - main, types: types.slice(0, 5) }
477}
478
479/** `main loop 40% · subagents 60%`, and `builder 35% · verifier 15%`. */
480export function contributionLines(loops: { main: Counts; agents: Readonly<Record<string, Counts>> }): string[] {
481  const c = contributions(loops)
482  if (!c) return []
483  const lines = [`main loop ${formatPercent(c.main)} · subagents ${formatPercent(c.agents)}`]
484  if (c.types.length > 0) lines.push(c.types.map(t => `${oneLine(t.key, CAP.agentType)} ${formatPercent(t.percent)}`).join(' · '))
485  return lines
486}
487
488/** `+4,321 −321 lines · 42 edits`. */
489export const changesLine = (t: Totals): string =>
490  `+${formatCount(t.linesAdded)} −${formatCount(t.linesRemoved)} lines · ${formatCount(t.edits)} edit${t.edits === 1 ? '' : 's'}`
491
492export const PRICE_NOTE = `Estimates at list price (as of ${PRICES_AS_OF}, cache writes at ${CACHE_WRITE_MULTIPLIER}×); the headline cost is the engine's own ledger.`
493
494/** Everything the pane and /spend draw, read from $.state. */
495export type View = {
496  usage: Usage | null
497  breakdown: Breakdown | null
498  totals: Totals
499  models: Readonly<Record<string, Counts>>
500  loops: { main: Counts; agents: Readonly<Record<string, Counts>> }
501}
502
503/** The latest moment the state knows of: the pane's "now", so drawing makes no clock call. */
504export const viewNow = (v: View): number => Math.max(v.usage?.at ?? 0, v.totals.updatedAt, v.totals.loadedAt)
505
506/** How long the session has run, from the engine's start to the latest reading. */
507export const sessionMs = (v: View): number | null => (v.usage?.startedAt == null ? null : Math.max(0, viewNow(v) - v.usage.startedAt))
508
509/** One line for an origin that is not the person: cost, context and requests. */
510export function summaryLine(v: View): string {
511  const parts = [formatUsd(v.usage?.costUsd ?? null)]
512  if (v.usage?.context.percent != null) parts.push(`ctx ${Math.round(v.usage.context.percent)}%`)
513  parts.push(`${formatCount(v.totals.requests)} request${v.totals.requests === 1 ? '' : 's'}`)
514  return `Usage: ${parts.join(' · ')}`
515}
516
517/** The /spend answer where no pane can be placed (`claude -p`): a short plain table. */
518export function compactReport(v: View): string {
519  const now = viewNow(v)
520  const label = (text: string) => fit(text, 13)
521  const lines = [
522    'Usage (usage-gauge)',
523    `${label('Cost')}${formatUsd(v.usage?.costUsd ?? null)}${v.usage && !v.usage.hasCost ? ' (no cost ledger on this host)' : ''}`,
524    `${label('Session')}${formatDuration(sessionMs(v))} · active ${formatDuration(v.totals.activeMs)} · API ${formatDuration(v.totals.apiMs)}`,
525    `${label('Turns')}${formatCount(v.totals.turns)} · ${formatCount(v.totals.requests)} requests`,
526    `${label('Context')}${contextLine(v.usage)}`,
527  ]
528  const limits = v.usage?.rateLimits ?? []
529  lines.push(`${label('Rate limits')}${limits.length === 0 ? 'none reported' : limits.map(r => { const l = rateLine(r, now); return `${l.label} ${l.value}` }).join(' · ')}`)
530  const { rows } = modelLines(v.models)
531  for (const row of rows) {
532    lines.push(
533      `${label(row === rows[0] ? 'Models' : '')}${row.name}: ${formatCount(row.requests)} req, out ${formatTokens(row.output)}, ` +
534        `cache hit ${formatPercent(row.hit)}, est ${row.estUsd === null ? '--' : formatUsd(row.estUsd)}${row.share === null ? '' : ` (${formatPercent(row.share)})`}`,
535    )
536  }
537  const contributing = contributionLines(v.loops)
538  if (contributing.length > 0) lines.push(`${label('Contributing')}${contributing.join(' · ')}`)
539  lines.push(`${label('Code changes')}${changesLine(v.totals)}`)
540  if (rows.length > 0) lines.push(PRICE_NOTE)
541  return lines.join('\n')
542}
543
hooks/prices.ts 78 lines
1// usage-gauge: list-price estimates for the pane's per-model rows. Pure.
2//
3// The table, the as-of date, the cache-write multiplier and the first-prefix
4// rule are copied from mods/rd-ledger/hooks/prices.ts (prices as of
5// 2026-10-06). Mods are independent plugin folders, so this is a copy, not an
6// import: change both together. The headline cost is never priced here; it is
7// the engine's own ledger (`cost.usd`).
8
9/** `[prefix, input $/MTok, output $/MTok, cacheRead $/MTok]`, longer prefixes first, first match wins. */
10export const PRICES: readonly (readonly [string, number, number, number])[] = [
11  ['claude-fable-5-1', 10, 50, 0.25],
12  ['claude-mythos-5-1', 10, 50, 0.25],
13  ['claude-fable-5', 10, 50, 1.0],
14  ['claude-mythos', 10, 50, 1.0],
15  ['claude-opus-5-5', 4, 20, 0.2],
16  ['claude-opus-5', 5, 25, 0.5],
17  ['claude-opus-4-8', 5, 25, 0.5],
18  ['claude-opus-4-7', 5, 25, 0.5],
19  ['claude-opus-4-6', 5, 25, 0.5],
20  ['claude-opus-4-5', 5, 25, 0.5],
21  ['claude-opus-4-1', 15, 75, 1.5],
22  ['claude-opus-4', 15, 75, 1.5],
23  ['claude-sonnet-5-5', 2, 10, 0.2],
24  ['claude-sonnet-5', 2, 10, 0.2],
25  ['claude-sonnet-4-6', 3, 15, 0.3],
26  ['claude-sonnet-4-5', 3, 15, 0.3],
27  ['claude-sonnet-4', 3, 15, 0.3],
28  ['claude-3-7-sonnet', 3, 15, 0.3],
29  ['claude-3-5-sonnet', 3, 15, 0.3],
30  ['claude-haiku-5-5', 0.1, 0.5, 0.01],
31  ['claude-haiku-4-5', 1, 5, 0.1],
32  ['claude-3-5-haiku', 0.8, 4, 0.08],
33  ['claude-3-haiku', 0.25, 1.25, 0.025],
34  ['claude-3-opus', 15, 75, 1.5],
35]
36export const PRICES_AS_OF = '2026-10-06'
37/**
38 * The engine reports cache writes without their TTL, so every write is priced
39 * at the 5-minute rate (1.25x input); a 1-hour write (2x) reads low.
40 */
41export const CACHE_WRITE_MULTIPLIER = 1.25
42
43export type PriceRow = { prefix: string; input: number; output: number; cacheRead: number }
44
45/**
46 * The price row for a model id (first prefix match), or null. The prefix must
47 * end the id or be followed by a character that is not a letter or digit
48 * (`-`, `[`, `@`, `.`), so `claude-opus-50` is unknown rather than priced as
49 * `claude-opus-5`: an unknown model reads `--`, never a guess.
50 */
51export const priceRow = (model: string | null): PriceRow | null => {
52  if (typeof model !== 'string' || model.length === 0) return null
53  for (const [prefix, input, output, cacheRead] of PRICES) {
54    if (!model.startsWith(prefix)) continue
55    const next = model.charAt(prefix.length)
56    if (next === '' || !/[A-Za-z0-9]/.test(next)) return { prefix, input, output, cacheRead }
57  }
58  return null
59}
60
61const tokens = (n: number): number => (Number.isFinite(n) && n > 0 ? n : 0)
62
63export type TokenCounts = { input: number; output: number; cacheRead: number; cacheWrite: number }
64
65/** What `counts` cost at list price for `model`, in USD; null for a model the table does not know. */
66export const estimateUsd = (model: string | null, counts: TokenCounts): number | null => {
67  const row = priceRow(model)
68  if (!row) return null
69  const cacheWrite = row.input * CACHE_WRITE_MULTIPLIER
70  return (
71    (tokens(counts.input) * row.input +
72      tokens(counts.output) * row.output +
73      tokens(counts.cacheRead) * row.cacheRead +
74      tokens(counts.cacheWrite) * cacheWrite) /
75    1_000_000
76  )
77}
78
types/index.d.ts 90 lines
1// The $.state contract of usage-gauge. Every key here is named by a literal
2// plugin/key reference in hooks/register.tsx, and every value is JSON-safe
3// (null, never undefined). The file exports types so it is a module: without a
4// top-level export, `declare module` below would declare a new ambient module
5// instead of augmenting 'claude-code'.
6
7/** One rate-limit window as the engine reported it; `kind` is the engine's (`five_hour`), sanitized. */
8export type RateLimitRow = { kind: string; percentUsed: number; resetsAt: string | null }
9
10/** The status line's figures: `$.session.usage()` at start and on Refresh, `session.measure` in between. */
11export type Usage = {
12  /** When the session's figures count from (ms since the epoch); null when usage could not be read. */
13  startedAt: number | null
14  context: { tokens: number | null; window: number | null; percent: number | null }
15  rateLimits: RateLimitRow[]
16  /** The engine's own ledger, `cost.usd`; null before the first reading. */
17  costUsd: number | null
18  /** False once a reading said the host keeps no cost ledger (the chip then shows the context alone). */
19  hasCost: boolean
20  /** When these figures were read (ms since the epoch). */
21  at: number
22}
23
24/** One row of the context breakdown, its name sanitized to one line. */
25export type CategoryRow = { name: string; kind: 'used' | 'free' | 'buffer' | 'deferred'; tokens: number }
26
27/** The window by category (`$.session.usage({ breakdown: 'summary' })`), read on pane open and Refresh. */
28export type Breakdown = {
29  totalTokens: number
30  rawMaxTokens: number
31  percentage: number
32  /** Absent when auto-compaction is off. */
33  autoCompactThreshold: number | null
34  isAutoCompactEnabled: boolean
35  model: string
36  categories: CategoryRow[]
37}
38
39/** The session's running totals since the mod loaded (`loadedAt`); a hot reload keeps them. */
40export type Totals = {
41  loadedAt: number
42  /** The last time an accounting hook wrote (ms): the pane's "now" for the session length. */
43  updatedAt: number
44  /** Main-loop turns. */
45  turns: number
46  /** Subagent turns. */
47  agentTurns: number
48  /** Model requests (`turn.step`) in every loop. */
49  requests: number
50  /** Main-loop turn wall time. */
51  activeMs: number
52  /** Every request's wall time, summed across every loop (overlaps count twice). */
53  apiMs: number
54  linesAdded: number
55  linesRemoved: number
56  edits: number
57}
58
59/** Token counts and time for one model or one loop. */
60export type Counts = {
61  requests: number
62  input: number
63  output: number
64  cacheRead: number
65  cacheWrite: number
66  apiMs: number
67  /** List-price estimate of the requests the price table knows (USD). */
68  estUsd: number
69  /** Requests whose model the price table does not know (not in `estUsd`). */
70  unpriced: number
71}
72
73declare module 'claude-code' {
74  interface PluginState {
75    'usage-gauge': {
76      usage: Usage | null
77      breakdown: Breakdown | null
78      totals: Totals
79      /** By sanitized model id. */
80      models: Record<string, Counts>
81      /** The main loop, and the subagents by their agent type. */
82      loops: { main: Counts; agents: Record<string, Counts> }
83      /** agentId -> agent type, the latest 500 ids. */
84      agentTypes: Record<string, string>
85      /** A Refresh is in flight: the button reads Refreshing. */
86      refreshing: boolean
87    }
88  }
89}
90