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

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>
<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">
| Version | Why | |
|---|---|---|
| Claude Code | 2.1.287 or later | Mods arrived in 2.1.287 |
Node.js on PATH | 22.5 or later | The helper reads OpenCode and Antigravity history with node:sqlite |
| CLIProxyAPI | Optional | Limits for the Codex, Claude and Antigravity accounts it holds |
| OpenCode Go plan | Optional | OpenCode 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.
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
| Key | Does |
|---|---|
1 2 3 | Cost, Tokens, Limits |
r | Cycle the range: past 24 hours, 7, 30, 90 days |
g | Group the breakdown by model, by agent, or by day |
u | Refresh now |
Esc | Close 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.
Plan limits need a CLIProxyAPI hub. Set its address and management key with:
/plugin configure usagemeter@usagemeter
| Option | Default | |
|---|---|---|
hubUrl | http://localhost:8317 | Base URL of your CLIProxyAPI instance |
hubKey | empty | Management key. Kept in Claude Code's secure storage; empty skips the hub |
openCodeGo | false | Read OpenCode Go limits with OpenCode's own key |
hubAutostart | true | When 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.
usagemeter finds history where each agent keeps it, and honors the same variables the agents do:
| Variable | Effect |
|---|---|
CLAUDE_CONFIG_DIR | Claude Code's directory (default ~/.claude) |
CODEX_HOME | An extra Codex home, read alongside ~/.codex |
OPENCODE_DATA_DIR, XDG_DATA_HOME | OpenCode's data directory (default ~/.local/share/opencode) |
ANTIGRAVITY_DATA_DIR | Antigravity's conversation directories |
USAGEMETER_HOME | Read all history from another home, for example /mnt/c/Users/you from WSL |
USAGEMETER_CACHE_DIR | Where the scan and price caches live (default ~/.cache/usagemeter) |
USAGEMETER_HUB_BIN | Hub 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.
| Agent | Cost and tokens | Limits |
|---|---|---|
| Claude Code | ~/.claude/projects/**/*.jsonl | Each Claude account in the hub (session, weekly, weekly per model), or else this session's own windows |
| Codex | ~/.codex/sessions/**/*.jsonl | Each Codex account in the hub (session, weekly, banked reset credits) |
| OpenCode | OpenCode's SQLite database | OpenCode Go (session, weekly, monthly) when openCodeGo is on |
| Antigravity | Antigravity's conversation databases | Each 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.
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
hooks/) keeps the pane's state, draws it, and saves the last snapshot so the pane opens at once.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.helper/src/t3/ with its MIT license.openCodeGo is on.See SECURITY.md for details and how to report a vulnerability.
| You see | Do this |
|---|---|
A message starting usagemeter needs Node.js 22.5 or later on PATH | Install Node.js 22.5+ and check that node --version works in the shell that starts Claude Code |
CLIProxyAPI hub not configured | Set 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 hub | This session has had no reply yet; they appear after the first one |
/usagemeter is missing | Run /plugin, check that usagemeter is enabled, then /reload-plugins |
| The pane is cramped | Widen it with Ctrl+X then ←, or use a wider terminal |
| A number looks stale | Press u; the footer shows when the snapshot was taken |
retrieveUserQuotaSummary, then fetchAvailableModels). They are undocumented and may change.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.
Notices for bundled code are in THIRD_PARTY_NOTICES.md.
hooks/register.js 218 lines1// 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}
218hooks/format.js 80 lines1// 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']
80hooks/view.js 311 lines1// 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 }
311hooks/charts.js 143 lines1// 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