SLOPSHOPPER

usagemeter

A /usagemeter pane with cost, tokens and plan limits across Claude Code, Codex, OpenCode and Antigravity, read from local history and a CLIProxyAPI hub.

newpanecommandprocess
★ 2v0.2.0MITupdated 2026-10-07Elesiann/usagemeter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usagemeter
│ ┃ Usage meter ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Cost 2: Tokens 3: Limits │ r: 7 days │ ┃ ⏺ Read(src/auth.ts) │ ┃ Could not read usage: the helper exited wit… ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ API estimate at list prices ⎿ 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 │ │ › /usagemeter │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Usage meter
1: Cost 2: Tokens 3: Limits │ r: 7 days g: by model u: Could not read usage: the helper exited with code 0 API estimate at list prices
README

usagemeter

CI License: MIT Claude Code 2.1.287+ Node.js 22.5+

Cost, tokens and plan limits of your coding agents, in one /usagemeter pane inside Claude Code.

usagemeter is a Claude Code mod. It reads the local history of Claude Code, Codex, OpenCode and Antigravity, prices every response at API list prices, and draws the result next to your conversation. With a CLIProxyAPI hub it also shows the session and weekly limits of every account the hub holds.

<img src="docs/images/demo.gif" width="900" alt="The pane cycling through its tabs: Cost for the past seven days, Tokens, Limits for Codex, Claude and Antigravity accounts, then Cost over 30 days, 90 days and the past 24 hours">

<sub>Screenshots and the animation use generated demo data.</sub>

Contents

Features

  • Cost and tokens for the past 24 hours (hourly), 7, 30 or 90 days, per agent and per model, with a stacked daily chart.
  • Totals of processed, cached, uncached and output tokens, cache savings, and cost split by token type and by speed (standard, fast).
  • Plan limits with the time left until each reset, its local time, and a pace arrow that shows whether you are using a window faster (↗) or slower (↘) than it elapses.
  • Fast after the first scan: parsed transcripts are cached, so a refresh reads only the lines written since the last one.
  • Same pane in the terminal and the Desktop app's Code tab: the charts are plain text.

<img src="docs/images/cost.png" width="900" alt="The Cost tab: total cost for the past seven days, cost per agent with its share, a stacked daily cost chart, token totals, cost by token type and a breakdown by model">

<img src="docs/images/tokens.png" width="900" alt="The Tokens tab: processed tokens per agent, a stacked daily token chart, totals and tokens by type">

<img src="docs/images/limits.png" width="760" alt="The Limits tab: session and weekly windows for Codex, Claude and Antigravity accounts, each with the share left, a pace arrow, a meter and the time until it resets">

Requirements

VersionWhy
Claude Code2.1.287 or laterMods arrived in 2.1.287
Node.js on PATH22.5 or laterThe helper reads OpenCode and Antigravity history with node:sqlite
CLIProxyAPIOptionalLimits for the Codex, Claude and Antigravity accounts it holds
OpenCode Go planOptionalOpenCode Go limits

usagemeter draws in the Claude Code terminal (including an editor's integrated terminal) and in the Code tab of the Desktop app. The VS Code extension's chat panel, claude -p, and Desktop sessions running inside WSL do not draw mods.

It is developed and tested on Linux and WSL 2. macOS should work the same way; Windows outside WSL is untested.

Install

claude plugin marketplace add Elesiann/usagemeter
claude plugin install usagemeter@usagemeter

Then run /reload-plugins in a running session (or start a new one) and open the pane:

/usagemeter

The first open scans up to 90 days of history. That takes from a few seconds to about a minute on several gigabytes of transcripts; later opens show the last snapshot at once and refresh in the background.

To try a checkout without installing it:

git clone https://github.com/Elesiann/usagemeter
claude --plugin-dir ./usagemeter

Use

KeyDoes
1 2 3Cost, Tokens, Limits
rCycle the range: past 24 hours, 7, 30, 90 days
gGroup the breakdown by model, by agent, or by day
uRefresh now
EscClose the pane

/usagemeter refresh opens the pane and rescans at once.

The keys work while the pane has keyboard focus. It takes focus when you open it from an empty prompt; press Ctrl+X then Tab to focus it later. In a terminal narrower than about 144 columns the pane opens above the prompt; otherwise it docks beside the conversation, and Ctrl+X then ← or → resizes it.

Configure

Plan limits need a CLIProxyAPI hub. Set its address and management key with:

/plugin configure usagemeter@usagemeter
OptionDefault
hubUrlhttp://localhost:8317Base URL of your CLIProxyAPI instance
hubKeyemptyManagement key. Kept in Claude Code's secure storage; empty skips the hub
openCodeGofalseRead OpenCode Go limits with OpenCode's own key
hubAutostarttrueWhen the hub URL is local and nothing answers, start the hub before reading limits

From a shell, pipe the values instead, which keeps the key out of your shell history:

read -rs KEY && printf '{"hubKey":"%s"}' "$KEY" | claude plugin configure usagemeter@usagemeter --values-stdin; unset KEY

Restart Claude Code after changing options.

Environment variables

usagemeter finds history where each agent keeps it, and honors the same variables the agents do:

VariableEffect
CLAUDE_CONFIG_DIRClaude Code's directory (default ~/.claude)
CODEX_HOMEAn extra Codex home, read alongside ~/.codex
OPENCODE_DATA_DIR, XDG_DATA_HOMEOpenCode's data directory (default ~/.local/share/opencode)
ANTIGRAVITY_DATA_DIRAntigravity's conversation directories
USAGEMETER_HOMERead all history from another home, for example /mnt/c/Users/you from WSL
USAGEMETER_CACHE_DIRWhere the scan and price caches live (default ~/.cache/usagemeter)
USAGEMETER_HUB_BINHub binary to start, when it is not ~/.local/bin/cli-proxy-api

CODEX_HOME, OPENCODE_DATA_DIR and ANTIGRAVITY_DATA_DIR accept comma-separated lists.

Where the numbers come from

AgentCost and tokensLimits
Claude Code~/.claude/projects/**/*.jsonlEach Claude account in the hub (session, weekly, weekly per model), or else this session's own windows
Codex~/.codex/sessions/**/*.jsonlEach Codex account in the hub (session, weekly, banked reset credits)
OpenCodeOpenCode's SQLite databaseOpenCode Go (session, weekly, monthly) when openCodeGo is on
AntigravityAntigravity's conversation databasesEach Antigravity account in the hub (session and weekly, for Gemini and for Claude + GPT)

Cost is an estimate at API list prices from LiteLLM's public price table, refreshed once a day. It is what the same usage would cost through the API, not what a subscription bills you. Models missing from the table show as Unpriced.

How it works

flowchart LR
  pane["/usagemeter pane<br/>(the mod, hooks/)"] -- "$.process.run" --> helper["usagemeter-helper<br/>(Node, helper/dist)"]
  helper -- reads --> history[("Local history<br/>Claude Code · Codex<br/>OpenCode · Antigravity")]
  helper -- "once a day" --> prices["LiteLLM price table"]
  helper -- "management API" --> hub["CLIProxyAPI hub"]
  helper -- optional --> go["OpenCode Go"]
  helper -- "JSON on stdout" --> pane
  • The mod (hooks/) keeps the pane's state, draws it, and saves the last snapshot so the pane opens at once.
  • The helper (helper/dist/usagemeter-helper.mjs) does the heavy part in its own process: scan reads and prices history and prints all four ranges as JSON; limits asks the hub and OpenCode Go for plan limits.
  • Transcript parsing, deduplication across resumed sessions, the incremental cache and pricing come from T3 Code, vendored under helper/src/t3/ with its MIT license.

Privacy

  • Transcripts are read locally and never leave your machine. The cache holds token counts, not message content.
  • The helper makes three kinds of requests: the LiteLLM price table, your hub's management API, and OpenCode's usage endpoint when openCodeGo is on.
  • The hub key lives in Claude Code's secure storage and reaches the helper through its environment, never its command line, a file, or the pane.
  • Responses from the hub or providers are never shown as they came.

See SECURITY.md for details and how to report a vulnerability.

Troubleshooting

You seeDo this
A message starting usagemeter needs Node.js 22.5 or later on PATHInstall Node.js 22.5+ and check that node --version works in the shell that starts Claude Code
CLIProxyAPI hub not configuredSet hubKey (and hubUrl if the hub is not on port 8317), then restart
The hub could not be reached (ECONNREFUSED)A local hub is restarted automatically (hubAutostart, needs the binary on PATH or USAGEMETER_HUB_BIN); otherwise start CLIProxyAPI, or fix hubUrl
The hub answered HTTP 401.The management key is wrong; set it again
Claude limits are empty without a hubThis session has had no reply yet; they appear after the first one
/usagemeter is missingRun /plugin, check that usagemeter is enabled, then /reload-plugins
The pane is crampedWiden it with Ctrl+X then ←, or use a wider terminal
A number looks stalePress u; the footer shows when the snapshot was taken

Limitations

  • Costs are list-price estimates, not bills.
  • Antigravity limits come from Google's internal Code Assist endpoints (retrieveUserQuotaSummary, then fetchAvailableModels). They are undocumented and may change.
  • Antigravity sessions started by T3 Code itself live in T3's own data directory and are not counted.
  • Cursor and Grok history are not read yet.

Contributing

Issues and pull requests are welcome. See CONTRIBUTING.md for the layout, the checks to run, and how the helper bundle is built. Changes are listed in CHANGELOG.md.

Credits

  • T3 Code for the transcript parsing, caching and pricing at the heart of the helper (MIT).
  • stream-json and stream-chain, bundled into the helper (BSD-3-Clause).
  • CLIProxyAPI for the account hub and its management API.
  • LiteLLM for the model price table.

Notices for bundled code are in THIRD_PARTY_NOTICES.md.

License

MIT

Source 4 files
hooks/register.js 218 lines
1// usagemeter: a /usagemeter pane with cost, tokens and plan limits.
2//
3// The heavy lifting (reading gigabytes of local transcripts, pricing them,
4// asking the CLIProxyAPI hub for limits) happens in helper/dist/usagemeter-helper.mjs,
5// which this module runs with $.process.run. This file keeps the pane's state
6// and draws it through view.js.
7import { GROUPS, RANGES } from './format.js'
8import { nextOf, renderPane } from './view.js'
9
10const PANE = 'usagemeter'
11/** A snapshot older than this is refreshed when the pane opens. */
12const STALE_MS = 5 * 60 * 1000
13/** A cold 90-day scan reads every transcript once; later scans reuse the helper's cache. */
14const SCAN_TIMEOUT_MS = 10 * 60 * 1000
15const LIMITS_TIMEOUT_MS = 60 * 1000
16
17let config = {}
18let tab = 'cost'
19let range = '7d'
20let group = 'model'
21let usage = null
22let usageError = null
23let scanning = false
24/** A forced refresh that arrived during a scan, run once that scan ends. */
25let rescanQueued = false
26let limits = null
27let limitsError = null
28let limitsBusy = false
29/** This session's own Claude rate limits, the fallback when the hub has no Claude account. */
30let claudeNative = []
31
32const CLAUDE_WINDOWS = {
33  five_hour: { label: 'Session', kind: 'session', windowMins: 300 },
34  seven_day: { label: 'Weekly', kind: 'weekly', windowMins: 7 * 24 * 60 },
35  spend_limit: { label: 'Spend limit', kind: 'other' },
36}
37
38function nativeWindows(rateLimits) {
39  return (rateLimits ?? []).map((limit) => ({
40    id: limit.kind,
41    label: CLAUDE_WINDOWS[limit.kind]?.label ?? limit.kind,
42    kind: CLAUDE_WINDOWS[limit.kind]?.kind ?? 'other',
43    usedPercent: limit.percentUsed,
44    ...(CLAUDE_WINDOWS[limit.kind]?.windowMins ? { windowMins: CLAUDE_WINDOWS[limit.kind].windowMins } : {}),
45    ...(limit.resetsAt ? { resetsAt: limit.resetsAt } : {}),
46  }))
47}
48
49function parseOutput(stdout) {
50  const line = stdout.trim().split('\n').at(-1)
51  try {
52    return line ? JSON.parse(line) : null
53  } catch {
54    return null
55  }
56}
57
58const NODE_HINT = 'usagemeter needs Node.js 22.5 or later on PATH'
59
60const failure = (result, doc) => {
61  if (doc?.error) return doc.error
62  const stderr = result.stderr.trim()
63  // node:sqlite arrived in Node 22.5; an older node cannot load the helper.
64  if (/node:sqlite|ERR_UNKNOWN_BUILTIN_MODULE|SyntaxError/.test(stderr)) return NODE_HINT + ' (' + stderr.split('\n').at(-1) + ')'
65  return stderr.split('\n').at(-1) || 'the helper exited with code ' + result.exitCode
66}
67
68/** The message for a helper that could not run at all, such as node missing from PATH. */
69function startFailure(error) {
70  const message = error instanceof Error ? error.message : String(error)
71  return /ENOENT|not found|no such file/i.test(message) ? NODE_HINT + '.' : message
72}
73
74async function refreshUsage($, force) {
75  if (scanning) {
76    // The running scan may have started before the newest transcript lines.
77    if (force) rescanQueued = true
78    return
79  }
80  scanning = true
81  usageError = null
82  $.ui.invalidate('ui.render')
83  try {
84    const result = await $.process.run(['node', '--no-warnings', $.plugin.root + '/helper/dist/usagemeter-helper.mjs', 'scan'], {
85      timeoutMs: SCAN_TIMEOUT_MS,
86    })
87    const doc = parseOutput(result.stdout)
88    if (result.exitCode !== 0 || !doc || doc.error || !doc.ranges) throw new Error(failure(result, doc))
89    usage = doc
90    await $.store.set('usage', doc)
91  } catch (error) {
92    usageError = startFailure(error)
93  } finally {
94    scanning = false
95    $.ui.invalidate('ui.render')
96  }
97  if (rescanQueued) {
98    rescanQueued = false
99    await refreshUsage($, false)
100  }
101}
102
103async function refreshLimits($) {
104  if (limitsBusy) return
105  limitsBusy = true
106  limitsError = null
107  $.ui.invalidate('ui.render')
108  try {
109    const native = await $.session.usage()
110    if (native.rateLimits.length > 0) claudeNative = nativeWindows(native.rateLimits)
111    // The key travels in the child's environment, never on its command line.
112    const result = await $.process.run(['node', '--no-warnings', $.plugin.root + '/helper/dist/usagemeter-helper.mjs', 'limits'], {
113      timeoutMs: LIMITS_TIMEOUT_MS,
114      env: {
115        USAGEMETER_HUB_URL: String(config.hubUrl ?? ''),
116        USAGEMETER_HUB_KEY: String(config.hubKey ?? ''),
117        USAGEMETER_OPENCODE_GO: config.openCodeGo ? '1' : '0',
118        USAGEMETER_HUB_AUTOSTART: config.hubAutostart === false ? '0' : '1',
119      },
120    })
121    const doc = parseOutput(result.stdout)
122    if (result.exitCode !== 0 || !doc || doc.error || !doc.accounts) throw new Error(failure(result, doc))
123    limits = doc
124    await $.store.set('limits', doc)
125  } catch (error) {
126    limitsError = startFailure(error)
127  } finally {
128    limitsBusy = false
129    $.ui.invalidate('ui.render')
130  }
131}
132
133/**
134 * Usage is rescanned once its snapshot is stale. Limits are read on every
135 * open: the read is quick, and a saved snapshot may predate a change to the
136 * hub settings.
137 */
138function refresh($, force) {
139  if (force || !usage || Date.now() - Date.parse(usage.readAt) > STALE_MS) refreshUsage($, force)
140  refreshLimits($)
141}
142
143export function register(on, options) {
144  config = options ?? {}
145
146  on('session.start', async ($, e, next) => {
147    try {
148      const [savedUsage, savedLimits] = await Promise.all([$.store.get('usage'), $.store.get('limits')])
149      if (savedUsage?.ranges) usage = savedUsage
150      if (savedLimits?.accounts) limits = savedLimits
151    } catch {
152      // No saved snapshot: the first open scans.
153    }
154    await $.command.register({
155      name: 'usagemeter',
156      description: 'Cost, tokens and plan limits across your coding agents',
157      argumentHint: '[refresh]',
158      immediate: true,
159    })
160    return next(e)
161  })
162
163  on('session.measure', async ($, e, next) => {
164    if (e.rateLimits?.length) {
165      claudeNative = nativeWindows(e.rateLimits)
166      $.ui.invalidate('ui.render')
167    }
168    return next(e)
169  })
170
171  on('command.run', { command: 'usagemeter' }, async ($, e) => {
172    await $.ui.open({ id: PANE, title: 'Usage meter', focus: true, closeOnEscape: true, columns: 150 })
173    refresh($, e.args.trim() === 'refresh')
174    return {}
175  })
176
177  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
178    if (e.requestId !== PANE) return next(e)
179    const redraw = () => $.ui.invalidate('ui.render')
180    if (tab === 'limits') {
181      // Read on every draw: a reload clears module state, and the call costs nothing.
182      const native = await $.session.usage()
183      if (native.rateLimits.length > 0) claudeNative = nativeWindows(native.rateLimits)
184    }
185    const state = {
186      tab,
187      range,
188      group,
189      usage,
190      error: usageError,
191      busy: scanning || limitsBusy,
192      limits,
193      limitsError,
194      limitsBusy,
195      claudeNative,
196      width: e.props.bodyColumns ?? 80,
197      nowMs: Date.now(),
198      timeZone: usage?.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
199      act: {
200        tab: (id) => {
201          tab = id
202          redraw()
203        },
204        cycleRange: () => {
205          range = nextOf(RANGES, range)
206          redraw()
207        },
208        cycleGroup: () => {
209          group = nextOf(GROUPS, group)
210          redraw()
211        },
212        refresh: () => refresh($, true),
213      },
214    }
215    return renderPane($.ui.resolve(e), state)
216  })
217}
218
hooks/format.js 80 lines
1// Number, time and label formatting shared by the views.
2
3export const PROVIDERS = {
4  claude: { label: 'Claude Code', color: '#d97757' },
5  codex: { label: 'Codex', color: '#c5c8e0' },
6  opencode: { label: 'OpenCode', color: '#5fa8d3' },
7  antigravity: { label: 'Antigravity', color: '#9b7fd4' },
8  cursor: { label: 'Cursor', color: '#8fbf7f' },
9  grok: { label: 'Grok', color: '#e0c060' },
10  'opencode-go': { label: 'OpenCode Go', color: '#5fa8d3' },
11}
12
13export const providerLabel = (p) => PROVIDERS[p]?.label ?? p
14export const providerColor = (p) => PROVIDERS[p]?.color ?? '#999999'
15
16export function money(n) {
17  const abs = Math.abs(n)
18  const digits = abs > 0 && abs < 0.01 ? 4 : 2
19  return '$' + n.toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits })
20}
21
22export function tokens(n) {
23  if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'
24  if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'
25  if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K'
26  return String(Math.round(n))
27}
28
29export const percent = (part, whole) => (whole > 0 ? ((100 * part) / whole).toFixed(1) : '0.0') + '%'
30
31/** `3d 14h`, `1h 32m`, `12m`: the two largest units of a duration. */
32export function duration(ms) {
33  if (!(ms > 0)) return 'now'
34  const m = Math.floor(ms / 60000)
35  const d = Math.floor(m / 1440)
36  const h = Math.floor((m % 1440) / 60)
37  const mm = m % 60
38  if (d > 0) return d + 'd ' + h + 'h'
39  if (h > 0) return h + 'h ' + mm + 'm'
40  return mm + 'm'
41}
42
43const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
44
45/** `Oct 5` for a `YYYY-MM-DD` day. */
46export function dayLabel(day) {
47  const [, m, d] = day.split('-').map(Number)
48  return MONTHS[m - 1] + ' ' + d
49}
50
51/** `14:00` for an ISO hour, in the given zone. */
52export function hourLabel(iso, timeZone) {
53  try {
54    return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone })
55  } catch {
56    return iso.slice(11, 16)
57  }
58}
59
60export const pointLabel = (point, resolution, timeZone) =>
61  resolution === 'hour' ? hourLabel(point.key, timeZone) : dayLabel(point.key)
62
63/** `22:50` today, or `Oct 11 22:00` on another day, in the given zone. */
64export function clockLabel(iso, nowMs, timeZone) {
65  const date = new Date(iso)
66  const opts = { timeZone }
67  try {
68    const time = date.toLocaleTimeString('en-GB', { ...opts, hour: '2-digit', minute: '2-digit' })
69    const day = (d) => d.toLocaleDateString('en-CA', opts)
70    if (day(date) === day(new Date(nowMs))) return time
71    return date.toLocaleDateString('en-US', { ...opts, month: 'short', day: 'numeric' }) + ' ' + time
72  } catch {
73    return iso.slice(0, 16).replace('T', ' ')
74  }
75}
76
77export const RANGE_LABELS = { '24h': 'Past 24h', '7d': '7 days', '30d': '30 days', '90d': '90 days' }
78export const RANGES = ['24h', '7d', '30d', '90d']
79export const GROUPS = ['model', 'provider', 'day']
80
hooks/view.js 311 lines
1// Builds the /usagemeter pane from plain state. Takes the resolved elements
2// (`$.ui.resolve(e)`), never `$`, so it stays a pure function of its input.
3import { barChart, meter, splitBar } from './charts.js'
4import {
5  GROUPS,
6  RANGE_LABELS,
7  clockLabel,
8  duration,
9  money,
10  percent,
11  pointLabel,
12  providerColor,
13  providerLabel,
14  tokens,
15} from './format.js'
16
17const WIDE = 100
18const SIDE = 40
19
20/** Draws a list of segment rows (from charts.js) as Text lines. */
21function lines(el, rows, key) {
22  const { Box, Text } = el
23  return rows.map((row, i) =>
24    Box({
25      key: key + '-' + i,
26      flexDirection: 'row',
27      children: row.map((seg) =>
28        Text({
29          ...(seg.color ? { color: seg.color } : {}),
30          ...(seg.dim ? { dimColor: true } : {}),
31          ...(seg.bold ? { bold: true } : {}),
32          wrap: 'truncate',
33          children: [seg.text],
34        }),
35      ),
36    }),
37  )
38}
39
40const text = (el, value, props = {}) => el.Text({ wrap: 'truncate', ...props, children: [value] })
41const row = (el, children, props = {}) => el.Box({ flexDirection: 'row', ...props, children })
42const col = (el, children, props = {}) => el.Box({ flexDirection: 'column', ...props, children })
43const blank = (el) => text(el, ' ')
44
45function header(el, s) {
46  const { Button } = el
47  const tab = (id, label, hotkey) =>
48    Button({ key: 'tab-' + id, label, hotkey, plain: true, ...(s.tab === id ? {} : { dimColor: true }), onPress: () => s.act.tab(id) })
49  return row(el, [
50    tab('cost', 'Cost', '1'),
51    tab('tokens', 'Tokens', '2'),
52    tab('limits', 'Limits', '3'),
53    text(el, '│', { dimColor: true }),
54    Button({ key: 'range', label: RANGE_LABELS[s.range], hotkey: 'r', plain: true, onPress: () => s.act.cycleRange() }),
55    Button({ key: 'group', label: 'by ' + s.group, hotkey: 'g', plain: true, onPress: () => s.act.cycleGroup() }),
56    Button({ key: 'refresh', label: s.busy ? 'refreshing…' : 'refresh', hotkey: 'u', plain: true, onPress: () => s.act.refresh() }),
57  ], { columnGap: 2 })
58}
59
60function footer(el, s) {
61  const parts = []
62  if (s.usage) {
63    parts.push('Updated ' + clockLabel(s.usage.readAt, s.nowMs, s.usage.timeZone))
64    parts.push('scan ' + (s.usage.scanMs / 1000).toFixed(1) + 's')
65    if (s.usage.rates.status === 'stale' || s.usage.rates.status === 'unavailable') parts.push('prices ' + s.usage.rates.status)
66  }
67  parts.push('API estimate at list prices')
68  return text(el, parts.join(' · '), { dimColor: true })
69}
70
71/** The headline and per-provider list on the left (or top) of the Cost and Tokens tabs. */
72/** The headline and one entry per provider, each value right-aligned to `width`. */
73function summary(el, s, range, metric, width) {
74  const total = range.total
75  const head = metric === 'cost' ? money(total.costUsd) : tokens(total.tokens)
76  const children = [
77    text(el, head, { bold: true, color: '#aab4e6' }),
78    text(el, total.sessions + ' sessions' + (metric === 'cost' ? ' · API estimate' : ''), { dimColor: true }),
79    blank(el),
80  ]
81  const whole = metric === 'cost' ? total.costUsd : total.tokens
82  for (const p of range.providers) {
83    const value = metric === 'cost' ? money(p.costUsd) : tokens(p.tokens)
84    const other = metric === 'cost' ? tokens(p.tokens) + ' tokens' : money(p.costUsd)
85    const name = providerLabel(p.provider) + ' '
86    const sessions = p.sessions + ' sessions'
87    const gap = Math.max(2, width - 2 - name.length - sessions.length - value.length)
88    children.push(row(el, [
89      text(el, '● ', { color: providerColor(p.provider) }),
90      text(el, name),
91      text(el, sessions, { dimColor: true }),
92      text(el, ' '.repeat(gap) + value, { bold: true }),
93    ]))
94    children.push(text(el, percent(metric === 'cost' ? p.costUsd : p.tokens, whole) + ' of ' + metric + ' · ' + other, { dimColor: true }))
95    children.push(blank(el))
96  }
97  children.pop()
98  return col(el, children)
99}
100
101function chart(el, s, range, metric, width, height) {
102  const columns = range.points.map((point) => ({
103    total: metric === 'cost' ? point.costUsd : point.tokens,
104    label: pointLabel(point, range.resolution, s.usage.timeZone),
105    parts: Object.entries(point.providers).map(([provider, slice]) => ({
106      key: provider,
107      value: metric === 'cost' ? slice.costUsd : slice.tokens,
108      color: providerColor(provider),
109    })),
110  }))
111  const { rows } = barChart({ columns, width, height, format: metric === 'cost' ? (n) => '$' + compact(n) : tokens })
112  const title = (range.resolution === 'hour' ? 'Hourly ' : 'Daily ') + (metric === 'cost' ? 'cost' : 'processed tokens')
113  return col(el, [text(el, title, { bold: true }), ...lines(el, rows, 'chart')])
114}
115
116/** `$1.2K`, `$350`: short money for chart axes and bar labels. */
117function compact(n) {
118  if (n === 0) return '0'
119  if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
120  if (n >= 100) return Math.round(n).toString()
121  if (n >= 1) return n.toFixed(1)
122  return n.toFixed(2)
123}
124
125function totals(el, range, metric, width) {
126  const t = range.total
127  // Five cells share the width; a narrow pane gets the short labels.
128  const cellW = Math.max(8, Math.floor(width / 5))
129  const short = cellW < 17
130  const cells = [
131    [short ? 'Processed' : 'Processed tokens', tokens(t.tokens)],
132    [short ? 'Cached' : 'Cached input', tokens(t.cachedInput)],
133    [short ? 'Uncached' : 'Uncached input', tokens(t.uncachedInput)],
134    ['Output', tokens(t.output)],
135    metric === 'cost' ? [short ? 'Savings' : 'Cache savings', money(t.savingsUsd)] : [short ? 'Writes' : 'Cache write', tokens(t.cacheWrite)],
136  ]
137  return col(el, [
138    text(el, 'Totals', { bold: true }),
139    row(el, cells.map(([k]) => text(el, k.padEnd(cellW).slice(0, cellW), { dimColor: true }))),
140    row(el, cells.map(([, v]) => text(el, v.padEnd(cellW).slice(0, cellW), { bold: true }))),
141  ])
142}
143
144const TYPE_COLORS = { input: '#7f87b8', cacheRead: '#5c6288', cacheWrite: '#9aa3d6', output: '#c9cff5', other: '#555a72' }
145
146function legend(el, entries, key) {
147  return row(el, entries.map(([label, value, color], i) =>
148    row(el, [text(el, '■ ', { color }), text(el, label + ' ', { dimColor: true }), text(el, value)], { key: key + i }),
149  ), { columnGap: 2, flexWrap: 'wrap' })
150}
151
152function byType(el, range, metric, width) {
153  if (metric === 'cost') {
154    const c = range.categoryCost
155    const parts = [['Input', c.input, TYPE_COLORS.input], ['Cache read', c.cacheRead, TYPE_COLORS.cacheRead], ['Cache write', c.cacheWrite, TYPE_COLORS.cacheWrite], ['Output', c.output, TYPE_COLORS.output], ['Other', c.other, TYPE_COLORS.other]]
156    const sp = range.speed
157    return col(el, [
158      text(el, 'Cost by type', { bold: true }),
159      ...lines(el, [splitBar(parts.map(([, value, color]) => ({ value, color })), width)], 'type'),
160      legend(el, parts.filter(([, v]) => Math.abs(v) >= 0.005).map(([l, v, c]) => [l, money(v), c]), 'type-legend'),
161      blank(el),
162      row(el, [text(el, 'Cost by speed', { bold: true }), text(el, sp.premium > 0 ? '   premium ' + money(sp.premium) : '', { dimColor: true })]),
163      legend(el, [['Standard', money(sp.standard), TYPE_COLORS.input], ...(sp.fast > 0 ? [['Fast', money(sp.fast), '#e0a060']] : []), ...(sp.ultrafast > 0 ? [['Ultrafast', money(sp.ultrafast), '#e06060']] : [])], 'speed'),
164    ])
165  }
166  const t = range.total
167  const parts = [['Input', t.uncachedInput, TYPE_COLORS.input], ['Cache read', t.cachedInput, TYPE_COLORS.cacheRead], ['Cache write', t.cacheWrite, TYPE_COLORS.cacheWrite], ['Output', t.output, TYPE_COLORS.output]]
168  return col(el, [
169    text(el, 'Tokens by type', { bold: true }),
170    ...lines(el, [splitBar(parts.map(([, value, color]) => ({ value, color })), width)], 'type'),
171    legend(el, parts.map(([l, v, c]) => [l, tokens(v), c]), 'type-legend'),
172  ])
173}
174
175/** Rows of the breakdown table for the chosen grouping. */
176export function breakdownRows(range, group, timeZone) {
177  if (group === 'provider') return range.providers.map((p) => ({ label: providerLabel(p.provider), provider: p.provider, costUsd: p.costUsd, tokens: p.tokens }))
178  if (group === 'day') {
179    return [...range.points].reverse().filter((p) => p.tokens > 0 || p.costUsd > 0).map((p) => ({
180      label: pointLabel(p, range.resolution, timeZone),
181      provider: Object.entries(p.providers).sort((a, b) => b[1].costUsd - a[1].costUsd)[0]?.[0],
182      costUsd: p.costUsd,
183      tokens: p.tokens,
184    }))
185  }
186  return range.models.map((m) => ({ label: m.model, provider: m.provider, costUsd: m.costUsd, tokens: m.tokens, unpriced: m.unpriced }))
187}
188
189function breakdown(el, s, range, metric, width) {
190  const rows = breakdownRows(range, s.group, s.usage.timeZone)
191  const whole = metric === 'cost' ? range.total.costUsd : range.total.tokens
192  const nameW = Math.max(16, width - 34)
193  const children = [
194    text(el, 'Breakdown · by ' + s.group, { bold: true }),
195    text(el, '  #  ' + (s.group === 'day' ? (range.resolution === 'hour' ? 'Hour' : 'Day') : s.group === 'provider' ? 'Provider' : 'Model').padEnd(nameW) + 'Cost'.padStart(11) + 'Share'.padStart(8) + 'Tokens'.padStart(9), { dimColor: true }),
196  ]
197  rows.slice(0, 20).forEach((r, i) => {
198    children.push(row(el, [
199      text(el, String(i + 1).padStart(3) + '  ', { dimColor: true }),
200      text(el, '● ', { color: providerColor(r.provider) }),
201      text(el, r.label.padEnd(nameW - 2).slice(0, nameW - 2)),
202      text(el, (r.unpriced ? 'Unpriced' : money(r.costUsd)).padStart(11), r.unpriced ? { dimColor: true } : {}),
203      text(el, percent(metric === 'cost' ? r.costUsd : r.tokens, whole).padStart(8), { dimColor: true }),
204      text(el, tokens(r.tokens).padStart(9)),
205    ]))
206  })
207  if (rows.length > 20) children.push(text(el, '     … ' + (rows.length - 20) + ' more', { dimColor: true }))
208  return col(el, children)
209}
210
211function usageTab(el, s, width) {
212  if (!s.usage) {
213    return text(el, s.error ? 'Could not read usage: ' + s.error : 'Scanning local history… the first scan of 90 days can take a minute.', { dimColor: !s.error, ...(s.error ? { color: 'red' } : {}) })
214  }
215  const range = s.usage.ranges[s.range]
216  const metric = s.tab
217  const wide = width >= WIDE
218  const chartW = wide ? width - SIDE - 3 : width
219  // Side by side, the chart (title, bars, x labels) is as tall as the summary
220  // (headline, sessions, blank, then three rows per provider less the last blank).
221  const sideHeight = Math.max(8, Math.min(16, 3 * range.providers.length))
222  const top = wide
223    ? row(el, [col(el, [summary(el, s, range, metric, SIDE)], { width: SIDE }), chart(el, s, range, metric, chartW, sideHeight)], { columnGap: 3 })
224    : col(el, [summary(el, s, range, metric, Math.min(width, SIDE)), blank(el), chart(el, s, range, metric, chartW, 10)])
225  return col(el, [
226    top,
227    blank(el),
228    totals(el, range, metric, Math.min(width, 90)),
229    blank(el),
230    byType(el, range, metric, Math.min(width, 80)),
231    blank(el),
232    breakdown(el, s, range, metric, Math.min(width, 90)),
233  ])
234}
235
236/** `↗` when a window is being used faster than it elapses, `↘` when slower. */
237function pace(window, nowMs) {
238  if (!window.resetsAt || !window.windowMins) return ''
239  const left = Date.parse(window.resetsAt) - nowMs
240  const elapsed = 1 - left / (window.windowMins * 60000)
241  if (!(elapsed > 0.05 && elapsed <= 1)) return ''
242  const delta = window.usedPercent - elapsed * 100
243  return delta > 5 ? ' ↗' : delta < -5 ? ' ↘' : ' →'
244}
245
246function limitsTab(el, s, width) {
247  const groups = []
248  const accounts = s.limits?.accounts ?? []
249  const byProvider = new Map()
250  for (const account of accounts) {
251    const list = byProvider.get(account.provider) ?? []
252    list.push(account)
253    byProvider.set(account.provider, list)
254  }
255  // Without a Claude account from the hub, fall back to this session's own reading.
256  if (!byProvider.has('claude') && s.claudeNative?.length) {
257    byProvider.set('claude', [{ provider: 'claude', label: 'this session', windows: s.claudeNative }])
258  }
259  // Label, percent (9), pace (3) and reset text share the row with the meter.
260  // A narrow pane gets a shorter label and only the time left before reset.
261  const narrow = width < 90
262  const labelW = narrow ? 14 : 26
263  const resetW = narrow ? 10 : 28
264  // At the 40-column minimum: 14 + 9 + 3 + 3 + 10 = 39.
265  const barW = Math.max(3, Math.min(60, width - (labelW + 12 + resetW)))
266  for (const [provider, list] of byProvider) {
267    groups.push(text(el, provider === 'claude' ? 'Claude' : providerLabel(provider), { bold: true, color: providerColor(provider) }))
268    for (const account of list) {
269      const extra = [account.plan, account.resetCredits ? account.resetCredits + ' reset' + (account.resetCredits > 1 ? 's' : '') + ' banked' : null].filter(Boolean).join(' · ')
270      if (list.length > 1 || extra || account.label === 'this session') groups.push(text(el, '  ' + account.label + (extra ? ' · ' + extra : ''), { dimColor: true }))
271      if (account.error) groups.push(text(el, '  ' + account.error, { color: 'red' }))
272      for (const w of account.windows) {
273        const left = Math.max(0, 100 - w.usedPercent)
274        const until = w.resetsAt ? '↻ ' + duration(Date.parse(w.resetsAt) - s.nowMs) : ''
275        const reset = w.resetsAt && !narrow ? until + ' · ' + clockLabel(w.resetsAt, s.nowMs, s.timeZone) : until
276        groups.push(row(el, [
277          // At least one space always separates the label from the percent.
278          text(el, '  ' + w.label.slice(0, labelW - 3).padEnd(labelW - 2)),
279          text(el, (Math.round(left) + '% left').padStart(9), { bold: true }),
280          text(el, (pace(w, s.nowMs) || '  ').padEnd(3), { dimColor: true }),
281          ...lines(el, [meter(left / 100, barW, providerColor(provider))], 'meter-' + w.id),
282          text(el, ('  ' + reset).padEnd(resetW).slice(0, resetW), { dimColor: true }),
283        ]))
284      }
285    }
286    groups.push(blank(el))
287  }
288  if (groups.length === 0) {
289    groups.push(text(el, s.limitsBusy ? 'Reading limits…' : 'No limits to show yet.', { dimColor: true }))
290  }
291  const notes = []
292  const hub = s.limits?.hub
293  if (hub?.status === 'off') notes.push('CLIProxyAPI hub not configured: set hubUrl and hubKey with /plugin configure usagemeter.')
294  if (hub?.status === 'error') notes.push('CLIProxyAPI hub: ' + hub.message)
295  if (hub?.status === 'ok' && hub?.restarted) notes.push('The CLIProxyAPI hub was down and has been restarted.')
296  const go = s.limits?.openCodeGo
297  if (go?.status === 'error' || go?.status === 'unsupported') notes.push('OpenCode Go: ' + go.message)
298  if (s.limitsError) notes.push('Limits: ' + s.limitsError)
299  if (s.limits?.checkedAt) notes.push('Checked ' + clockLabel(s.limits.checkedAt, s.nowMs, s.timeZone))
300  return col(el, [...groups, ...notes.map((n) => text(el, n, { dimColor: true }))])
301}
302
303export function renderPane(el, s) {
304  const width = Math.max(40, s.width)
305  const body = s.tab === 'limits' ? limitsTab(el, s, width) : usageTab(el, s, width)
306  return col(el, [header(el, s), blank(el), body, blank(el), s.tab === 'limits' ? text(el, ' ') : footer(el, s)])
307}
308
309export const nextOf = (list, value) => list[(list.indexOf(value) + 1) % list.length]
310export { GROUPS }
311
hooks/charts.js 143 lines
1// Text-only charts, so the pane draws the same in the terminal and in the
2// Desktop app. A chart is a list of rows; a row is a list of segments
3// `{ text, color?, dim?, bold? }` that the view turns into Text elements.
4
5const EIGHTHS = ['', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
6
7/** Joins neighbouring cells of the same style into one segment. */
8function pack(cells) {
9  const row = []
10  for (const cell of cells) {
11    const last = row.at(-1)
12    if (last && last.color === cell.color && last.dim === cell.dim && last.bold === cell.bold) last.text += cell.text
13    else row.push({ ...cell })
14  }
15  return row
16}
17
18const pad = (text, width, align = 'left') => {
19  const t = text.length > width ? text.slice(0, width) : text
20  return align === 'right' ? t.padStart(width) : align === 'center' ? t.padStart(Math.floor((width + t.length) / 2)).padEnd(width) : t.padEnd(width)
21}
22
23/**
24 * Merges consecutive columns so they fit in `slots`, summing their parts.
25 * Returns the merged columns and, for each, the range of source indexes.
26 */
27export function fitColumns(columns, slots) {
28  const size = Math.max(1, Math.ceil(columns.length / Math.max(1, slots)))
29  const merged = []
30  for (let start = 0; start < columns.length; start += size) {
31    const group = columns.slice(start, start + size)
32    const parts = new Map()
33    for (const column of group) {
34      for (const part of column.parts) {
35        const prev = parts.get(part.key)
36        parts.set(part.key, { ...part, value: (prev?.value ?? 0) + part.value })
37      }
38    }
39    merged.push({
40      total: group.reduce((sum, c) => sum + c.total, 0),
41      parts: [...parts.values()],
42      label: group[0].label,
43      lastLabel: group.at(-1).label,
44      first: start,
45      last: start + group.length - 1,
46    })
47  }
48  return merged
49}
50
51/**
52 * A stacked bar chart with eighth-block tops, a y axis and x labels, in the
53 * style of Codex's /usage.
54 *
55 * `columns`: `{ total, label, parts: [{ key, value, color }] }`, oldest first.
56 */
57export function barChart({ columns, width, height, format = String, showValues = true }) {
58  // Columns merge to fit before the scale is known, so size the axis for the
59  // largest label merging could produce, then scale to the merged columns.
60  const widestAxis = format(columns.reduce((sum, c) => sum + c.total, 0)).length + 1
61  const merged = fitColumns(columns, Math.max(4, width - widestAxis - 1))
62  const max = Math.max(...merged.map((c) => c.total), 0)
63  const axisLabels = [format(max), format(max / 2), format(0)]
64  const axisW = Math.max(...axisLabels.map((l) => l.length)) + 1
65  const plotW = Math.max(4, width - axisW - 1)
66  const slot = Math.max(1, Math.floor(plotW / merged.length))
67  const barW = slot >= 3 ? slot - 1 : slot
68  const heights = merged.map((c) => (max > 0 ? Math.round((c.total / max) * height * 8) : 0))
69  const valueRow = merged.map((c, i) => (showValues && barW >= 5 && c.total > 0 ? Math.max(-1, height - 2 - Math.floor(heights[i] / 8)) : -1))
70
71  const rows = []
72  for (let r = 0; r < height; r++) {
73    const cells = []
74    const axis = r === 0 ? axisLabels[0] : r === Math.floor((height - 1) / 2) ? axisLabels[1] : r === height - 1 ? axisLabels[2] : ''
75    cells.push({ text: pad(axis, axisW - 1, 'right') + ' ', dim: true })
76    cells.push({ text: r === height - 1 ? '┼' : '┤', dim: true })
77    const bottom = (height - 1 - r) * 8
78    merged.forEach((column, i) => {
79      if (valueRow[i] === r) {
80        cells.push({ text: pad(format(column.total), barW, 'center'), dim: true })
81      } else {
82        const h = heights[i]
83        if (h <= bottom) {
84          cells.push({ text: ' '.repeat(barW) })
85        } else {
86          const fill = Math.min(8, h - bottom)
87          // The part that covers the middle of this cell's filled share colours it.
88          const probe = ((bottom + fill / 2) / h) * column.total
89          let acc = 0
90          let color = '#888888'
91          for (const part of column.parts) {
92            acc += part.value
93            color = part.color
94            if (acc >= probe) break
95          }
96          cells.push({ text: EIGHTHS[fill].repeat(barW), color })
97        }
98      }
99      if (slot > barW) cells.push({ text: ' '.repeat(slot - barW) })
100    })
101    rows.push(pack(cells))
102  }
103
104  // X labels: the first, the middle when there is room, and the last.
105  const span = merged.length * slot
106  const first = merged[0]?.label ?? ''
107  const last = merged.at(-1)?.lastLabel ?? ''
108  const mid = merged[Math.floor(merged.length / 2)]?.label ?? ''
109  let labels = pad(first, span)
110  if (span >= first.length + mid.length + last.length + 4 && merged.length > 2) {
111    const midAt = Math.floor(merged.length / 2) * slot
112    labels = labels.slice(0, midAt) + mid + labels.slice(midAt + mid.length)
113  }
114  if (merged.length > 1) labels = labels.slice(0, Math.max(0, span - last.length)) + last
115  rows.push([{ text: ' '.repeat(axisW) + labels, dim: true }])
116  return { rows, merged }
117}
118
119/** A one-line bar split in proportion to `parts` (`{ value, color }`), each nonzero part at least one cell wide. */
120export function splitBar(parts, width) {
121  const total = parts.reduce((sum, p) => sum + Math.max(0, p.value), 0)
122  if (!(total > 0)) return [{ text: '░'.repeat(width), dim: true }]
123  const live = parts.filter((p) => p.value > 0)
124  const widths = live.map((p) => Math.max(1, Math.round((p.value / total) * width)))
125  let over = widths.reduce((a, b) => a + b, 0) - width
126  while (over > 0) {
127    const i = widths.indexOf(Math.max(...widths))
128    widths[i] -= 1
129    over -= 1
130  }
131  if (over < 0) widths[widths.indexOf(Math.max(...widths))] -= over
132  return live.map((p, i) => ({ text: '█'.repeat(widths[i]), color: p.color }))
133}
134
135/** A meter of `fraction` (0..1) filled in `color`, the rest shaded. */
136export function meter(fraction, width, color) {
137  const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width)
138  return [
139    { text: '█'.repeat(filled), color },
140    { text: '░'.repeat(width - filled), dim: true },
141  ].filter((s) => s.text.length > 0)
142}
143