SLOPSHOPPER

verbruik

A VNX mod: one status line with the usage of every subscription and the balance of the API providers. Claude from the session itself, the other providers from…

newspinnercommandstatusprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · verbruik
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /verbruik ⎿ verbruik: Verbruik ⎿ verbruik: claude 5u: 31%, reset 1760003600000 ⎿ verbruik: laatste meting mislukt: vnx gaf geen geldige JSON ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ verbruik: claude 5u 31% · vnx-meting mislukt: vnx gaf geen geldige JSON
README

verbruik

A VNX mod for Claude Code. It puts the usage of every subscription and the balance of the API providers in one status line:

verbruik: claude 5u 34% · week 61% · codex 58% · kimi terug over 1d 19u (uiterlijk 24-10 13:05) !! · deepseek $12.92 · openrouter $4.91 !

The verbruik: at the start is the engine's: it puts the plugin name in front of every status entry. The labels are Dutch (5u, week, vol tot reset, (oud), nog geen meting). It needs VNX 1.7.4 or newer for the providers other than Claude. It ships with a VNX release and lives in this repository under packages/vnx-mods/.

What it shows

SegmentSourceExample
Claude, the 5-hour window$.session.usage().rateLimits, kind five_hourclaude 5u 34%
Claude, the weekly windowthe same, kind seven_dayweek 61%
codexused_percent of its rowcodex 58%
kimistatus of its row as text; the percentage when a later schema carries onekimi vol tot reset, or with resets_at_latest (an upper bound, local time) kimi terug over 1d 19u (uiterlijk 25-10 14:05), and after that bound kimi laatst vol, grens 25-10 14:05 voorbij
deepseekbalances[].total, in its currencydeepseek $12.92
openrouterremainingopenrouter $4.91

The claude row of vnx providers usage is ignored: it says it can only be measured inside a session, and the session is the source.

A figure is never written as 0 when it is missing:

  • No reading from the session: claude: nog geen meting. The session holds none at session.start and fills it at the first session.measure.
  • A row with available: false shows the provider and a short form of its reason (codex geen rate_limits-regel), cut at in or a bracket, without any path or file name.
  • A run that fails (a non-zero exit, no JSON, JSON that is no report, another schema than vnx.providers.usage/1) keeps the last good figures and marks each (oud). The cause is named once, at the end: vnx-meting mislukt: vnx gaf exit 1.

Colours

The status line is plain text. Part 0 of the build measured that $.ui.status takes no style (an escape sequence is drawn as a stray character) and that the engine draws the whole entry in one colour of its own. So the tone of a segment is carried by the text: a warning ends in ! and an error in !!. Below the warn threshold nothing is added.

The tone of a segment is computed per segment (success, warning, error, dim for what could not be measured) and tested on its boundaries.

Figuresuccesswarningerror
a used percentagebelow warnPercentfrom warnPercent up to errorPercentfrom errorPercent up
a balanceabove warnBalanceat or below warnBalance, above errorBalanceat or below errorBalance
kimi statusbereikbaarany other status, and laatst vol, grens ... voorbijvol tot reset and terug over ...

With the defaults 69 is success, 70 and 89 are warning, 90 is error; a balance of 5.01 is success, 5 and 1.01 are warning, 1 is error. A deepseek row with several currencies takes the worst tone of its balances.

The kimi countdown

The bound is the first weekly-limit failure of the current streak plus 7 days: an upper bound, never the exact reset. Before it the text counts down to it: 1d 19u above 24 hours, 5u 07m from 1 to 24 hours, 42m under an hour, rounded up on the smallest unit shown. At or after the bound the vol status is older than the bound and no new measurement, so the text names only the stored bound and the tone drops to a warning. The countdown is not a live ticker: it is computed again on every render (session.start, every session.measure, every refresh, a width change and /verbruik) from the engine's clock.

Width

The status line is given no width. A hook on the prompt hint, which draws nothing, receives the terminal width at every resize, and the mod fits the line to it, reserving 14 cells for the indent, the glyph and the verbruik: prefix. A narrow terminal drops segments in this order: the cause or the old-vnx notice, openrouter, deepseek, kimi, codex, the weekly Claude figure. The Claude 5-hour figure stays. Until the width is known nothing is dropped.

/verbruik

Refreshes the measurement and answers with the full figures as text lines: the Claude windows with their reset time, every other provider with its numbers or its reason, gemeten: <measured_at>, and the cause of a failed run.

An older vnx

An installed fabric older than the release that ships vnx providers usage (1.7.3 and before) refuses the verb: argparse exits 2 with invalid choice: 'usage'. The mod recognises that failure itself, not a version string, and shows only the Claude part with a notice:

verbruik: claude 5u 34% · week 61% · vnx te oud voor providers usage

It asks again at every refresh, so the other providers appear after the fabric is updated.

Install

The mod is part of a VNX release: a release copies packages/ into ~/.vnx-system/versions/<version>/, and ~/.vnx-system/current points at the installed one. Install it from there on each Claude account you use:

claude plugin marketplace add ~/.vnx-system/current/packages/vnx-mods
claude plugin install verbruik@vnx-mods

For development from a checkout of this repository:

claude --plugin-dir packages/vnx-mods/verbruik

Options

Set them in /config, where each one is a row, or in settings under pluginConfigs.verbruik.options.

OptionDefaultWhat it does
vnxBin~/.vnx-system/current/bin/vnxThe vnx executable that runs providers usage. A leading ~ is the HOME of the session.
refreshMinutes10How often the other providers are measured again. 2 minutes at least.
warnPercent70A used percentage from here up is a warning.
errorPercent90A used percentage from here up is an error.
warnBalance5A balance at or below this, in its own currency, is a warning.
errorBalance1A balance at or below this, in its own currency, is an error.

What it reads, and what it never does

  • The Claude figures come from the session. The other figures come from one command, vnx providers usage --json, run with a 25 second timeout and one run at a time. A run asked while another is going joins it.
  • The mod never reads an API key, never calls a provider itself and never runs anything but that command. It shows what the command returns: the source, store and plan_type fields are never read, and a word that looks like a path or a file name is cut from every reason. It does not print the path of vnxBin.
  • The command makes no model call. It reads balance endpoints and local state.

Claude Code versions

Measured on Claude Code 2.1.306 (a Max subscription, a throwaway tmux session, --plugin-dir twice):

  • Two plugins that call $.ui.status each keep their own entry. The engine draws one row per plugin and puts the plugin name in front (probea: ..., voortgang: ...); the last call does not replace the other plugin's. So the mod uses $.ui.status and draws nothing in AbovePrompt.
  • $.session.usage().rateLimits is empty at session.start ([]). After the first session.measure, both on the event and from $.session.usage(), it holds:
[{"kind":"five_hour","percentUsed":18,"resetsAt":"2026-10-21T23:00:00.000Z"},
 {"kind":"seven_day","percentUsed":31,"resetsAt":"2026-10-27T16:00:00.000Z"}]

percentUsed is a percentage from 0 to 100 (18, not 0.18) and resetsAt an ISO timestamp. The measure that carried it named context, rateLimits and cost as changed.

  • Style in a status entry: none. \u001b[32m is drawn as a stray character and the entry has one colour, so the mod marks warnings with ! and !!.
  • Width: only a ui.render hook is told the terminal width (e.viewport.columns). A hook on PromptHint is called at the start and at every resize, and the mod reads the width there.

Development

claude plugin validate packages/vnx-mods
claude plugin validate packages/vnx-mods/verbruik
claude plugin test packages/vnx-mods/verbruik
bash packages/vnx-mods/verbruik/tests/no-operator-names.sh
  • hooks/register.ts: the hooks module (session.start, session.measure, the prompt-hint hook for the width, command.run, the timer).
  • hooks/usage.ts: the pure part. Parsing the report, the state between runs, the tones, the segments, the fit and the text of /verbruik.
  • types/index.d.ts: the shapes the two share.
  • tests/: usage.test.ts for the pure part and hooks.test.ts for the mod in a session over an engine in memory. The fixtures are the rows vnx providers usage --json writes, with source and store left out. tests/no-operator-names.sh greps the package for the operator's own project names.

Type-check it with a tsconfig.json kept outside this folder: strict and noUncheckedIndexedAccess on, noEmit, skipLibCheck, types: [], module: esnext, moduleResolution: bundler, lib and target es2023, jsx: react with jsxFactory: h and jsxFragmentFactory: Fragment, and include naming the claude-code.d.ts the engine writes plus this folder's hooks, types and tests. Run npx --yes -p typescript@5.6.3 tsc -p tsconfig.json; exit 0 is the bar.

Source 3 files
hooks/register.ts 97 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Config, Fleet, RateLimit } from '../types'
4
5import { NEVER_RAN, advance, commandText, configOf, fit, outcomeOf, segmentsOf } from './usage'
6
7const RUN_TIMEOUT_MS = 25_000
8const MINUTE_MS = 60_000
9
10// The figures between hooks. A reload starts them over, and `session.start` fills them again.
11let rateLimits: readonly RateLimit[] = []
12let fleet: Fleet = NEVER_RAN
13// The width the engine last told a render hook; null until it has. Without it nothing is dropped.
14let columns: number | null = null
15// Joins a run asked while one is going: one run at a time.
16let running: Promise<void> | null = null
17
18async function redraw($: EngineInterface, config: Config): Promise<void> {
19  const now = await $.clock.now()
20
21  $.ui.status(fit(segmentsOf(rateLimits, fleet, config, now), columns))
22}
23
24async function binOf($: EngineInterface, config: Config): Promise<string> {
25  if (!config.vnxBin.startsWith('~')) {
26    return config.vnxBin
27  }
28
29  const home = await $.env.get('HOME')
30
31  return home === undefined || home === '' ? config.vnxBin : `${home}${config.vnxBin.slice(1)}`
32}
33
34async function measure($: EngineInterface, config: Config): Promise<void> {
35  try {
36    const run = await $.process.run([await binOf($, config), 'providers', 'usage', '--json'], { timeoutMs: RUN_TIMEOUT_MS })
37
38    fleet = advance(fleet, outcomeOf(run))
39  } catch {
40    fleet = advance(fleet, { kind: 'failed', cause: 'vnx niet uitgevoerd' })
41  } finally {
42    running = null
43  }
44
45  await redraw($, config)
46}
47
48function refresh($: EngineInterface, config: Config): Promise<void> {
49  if (running === null) {
50    running = measure($, config)
51  }
52
53  return running
54}
55
56export const register: Register = (on, options) => {
57  const config = configOf(options)
58
59  on('session.start', async ($, e, next) => {
60    rateLimits = (await $.session.usage()).rateLimits
61    await redraw($, config)
62    await $.command.register({ name: 'verbruik', description: 'Toont het verbruik van elke provider en ververst de meting' })
63    void refresh($, config)
64    $.clock.every(config.refreshMinutes * MINUTE_MS, () => {
65      void refresh($, config)
66    })
67
68    return next(e)
69  })
70
71  on('session.measure', async ($, e, next) => {
72    rateLimits = e.rateLimits
73    await redraw($, config)
74
75    return next(e)
76  })
77
78  // The status line is given no width. The prompt hint is drawn on every width change and carries it; this hook draws nothing.
79  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
80    const width = e.viewport?.columns ?? null
81
82    if (width !== columns) {
83      columns = width
84      await redraw($, config)
85    }
86
87    return next(e)
88  })
89
90  on('command.run', { command: 'verbruik' }, async $ => {
91    rateLimits = (await $.session.usage()).rateLimits
92    await refresh($, config)
93
94    return { text: commandText(rateLimits, fleet, config, await $.clock.now()) }
95  })
96}
97
hooks/usage.ts 428 lines
1// The pure part of the mod: parsing `vnx providers usage --json`, the state between runs, the segments and the fit.
2import type { PluginOptions } from 'claude-code'
3
4import type { Config, Figure, Fleet, Outcome, ProviderName, RateLimit, Report, Segment, SegmentKey, Tone } from '../types'
5
6export const SCHEMA = 'vnx.providers.usage/1'
7export const SEPARATOR = ' · '
8/** The cells the engine spends on a status entry before our text: its indent, the glyph and the `verbruik: ` prefix. */
9export const STATUS_OVERHEAD = 14
10export const MIN_REFRESH_MINUTES = 2
11export const DEFAULTS: Config = {
12  vnxBin: '~/.vnx-system/current/bin/vnx',
13  refreshMinutes: 10,
14  warnPercent: 70,
15  errorPercent: 90,
16  warnBalance: 5,
17  errorBalance: 1,
18}
19
20const PROVIDERS: readonly ProviderName[] = ['codex', 'kimi', 'deepseek', 'openrouter']
21const KIMI_FULL = 'vol tot reset'
22const REASON_CELLS = 32
23/** From the most to the least important: the first is dropped last. The Claude 5-hour figure is not in the list: it stays. */
24const DROP_ORDER: readonly SegmentKey[] = ['notice', 'openrouter', 'deepseek', 'kimi', 'codex', 'claudeWeek']
25const SYMBOLS: Readonly<Record<string, string>> = { USD: '$', EUR: '€', GBP: '£' }
26
27const isRecord = (value: unknown): value is Record<string, unknown> =>
28  typeof value === 'object' && value !== null && !Array.isArray(value)
29
30const isNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
31
32const numberOr = (value: unknown, fallback: number): number => (isNumber(value) ? value : fallback)
33
34export function configOf(options: PluginOptions): Config {
35  const bin = options['vnxBin']
36
37  return {
38    vnxBin: typeof bin === 'string' && bin.trim() !== '' ? bin.trim() : DEFAULTS.vnxBin,
39    refreshMinutes: Math.max(MIN_REFRESH_MINUTES, numberOr(options['refreshMinutes'], DEFAULTS.refreshMinutes)),
40    warnPercent: numberOr(options['warnPercent'], DEFAULTS.warnPercent),
41    errorPercent: numberOr(options['errorPercent'], DEFAULTS.errorPercent),
42    warnBalance: numberOr(options['warnBalance'], DEFAULTS.warnBalance),
43    errorBalance: numberOr(options['errorBalance'], DEFAULTS.errorBalance),
44  }
45}
46
47/** A used percentage: from `errorPercent` up an error, from `warnPercent` up a warning. */
48export function percentTone(percent: number, config: Config): Tone {
49  if (percent >= config.errorPercent) {
50    return 'error'
51  }
52
53  return percent >= config.warnPercent ? 'warning' : 'success'
54}
55
56/** A balance, mirrored: at or below `errorBalance` an error, at or below `warnBalance` a warning. */
57export function balanceTone(amount: number, config: Config): Tone {
58  if (amount <= config.errorBalance) {
59    return 'error'
60  }
61
62  return amount <= config.warnBalance ? 'warning' : 'success'
63}
64
65// ---------------------------------------------------------------------------
66// Reasons: shown short, and never with a path.
67// ---------------------------------------------------------------------------
68
69const isPathLike = (word: string): boolean => /[/\\]|^~|\.jsonl$/.test(word)
70
71/** The text without a word that is a path or a file name. */
72export function withoutPaths(text: string): string {
73  return text.split(/\s+/).filter(word => !isPathLike(word)).join(' ').replace(/\s+in$/, '').trim()
74}
75
76const cut = (text: string, cells: number): string => {
77  const chars = [...text]
78
79  return chars.length <= cells ? text : `${chars.slice(0, cells - 1).join('')}…`
80}
81
82/** The head of a reason, up to ` in ` or a bracket, without paths, cut to a short length. */
83export function shortReason(reason: string): string {
84  const head = withoutPaths((reason.split(/ in |\(/)[0] ?? '').trim())
85
86  return head === '' ? 'niet beschikbaar' : cut(head, REASON_CELLS)
87}
88
89// ---------------------------------------------------------------------------
90// Parsing the report
91// ---------------------------------------------------------------------------
92
93const reasonOf = (row: Record<string, unknown>): string =>
94  typeof row['reason'] === 'string' && row['reason'].trim() !== '' ? row['reason'] : 'niet beschikbaar'
95
96function balancesOf(row: Record<string, unknown>): Figure {
97  const list = Array.isArray(row['balances']) ? row['balances'] : []
98  const balances = list.flatMap(entry => {
99    if (!isRecord(entry) || typeof entry['currency'] !== 'string') {
100      return []
101    }
102
103    const amount = typeof entry['total'] === 'string' ? Number(entry['total']) : entry['total']
104
105    return isNumber(amount) ? [{ currency: entry['currency'], amount }] : []
106  })
107
108  return balances.length > 0 ? { kind: 'balances', balances } : { kind: 'unavailable', reason: 'saldo onleesbaar' }
109}
110
111function figureOf(name: ProviderName, row: unknown): Figure {
112  if (!isRecord(row)) {
113    return { kind: 'unavailable', reason: 'onverwacht antwoord' }
114  }
115
116  if (row['available'] !== true) {
117    return { kind: 'unavailable', reason: reasonOf(row) }
118  }
119
120  if (name === 'deepseek') {
121    return balancesOf(row)
122  }
123
124  if (name === 'openrouter') {
125    return isNumber(row['remaining'])
126      ? { kind: 'balances', balances: [{ currency: 'USD', amount: row['remaining'] }] }
127      : { kind: 'unavailable', reason: 'saldo onleesbaar' }
128  }
129
130  if (isNumber(row['used_percent'])) {
131    return { kind: 'percent', percent: row['used_percent'] }
132  }
133
134  if (name === 'kimi' && typeof row['status'] === 'string' && row['status'].trim() !== '') {
135    const latest = row['resets_at_latest']
136    const status = row['status'].trim()
137
138    return typeof latest === 'string' && Number.isFinite(Date.parse(latest))
139      ? { kind: 'status', text: status, resetsAtLatest: latest }
140      : { kind: 'status', text: status }
141  }
142
143  return { kind: 'unavailable', reason: 'geen cijfer in het antwoord' }
144}
145
146/** The report of a successful run, or the cause why its stdout is no usable report. */
147export function parseReport(stdout: string): { isOk: true; report: Report } | { isOk: false; cause: string } {
148  let body: unknown
149
150  try {
151    body = JSON.parse(stdout)
152  } catch {
153    return { isOk: false, cause: 'vnx gaf geen geldige JSON' }
154  }
155
156  if (!isRecord(body) || !isRecord(body['providers'])) {
157    return { isOk: false, cause: 'vnx gaf geen geldige JSON' }
158  }
159
160  if (body['schema'] !== SCHEMA) {
161    return { isOk: false, cause: 'ander schema' }
162  }
163
164  const providers = body['providers']
165  const rows = PROVIDERS.flatMap(name => (name in providers ? [{ name, figure: figureOf(name, providers[name]) }] : []))
166  const measuredAt = typeof body['measured_at'] === 'string' ? body['measured_at'] : null
167
168  return { isOk: true, report: { measuredAt, rows } }
169}
170
171export type Run = { exitCode: number; stdout: string; stderr: string }
172
173/** What a finished run came to. An older vnx is told by the command's own failure: argparse refusing the verb. */
174export function outcomeOf(run: Run): Outcome {
175  if (run.exitCode === 2 && /invalid choice/i.test(run.stderr)) {
176    return { kind: 'too-old' }
177  }
178
179  if (run.exitCode !== 0) {
180    return { kind: 'failed', cause: `vnx gaf exit ${run.exitCode}` }
181  }
182
183  const parsed = parseReport(run.stdout)
184
185  return parsed.isOk ? { kind: 'ok', report: parsed.report } : { kind: 'failed', cause: parsed.cause }
186}
187
188export const NEVER_RAN: Fleet = { report: null, cause: null, isTooOld: false }
189
190/** The state after a run: a failure keeps the last good report, an older vnx never had one. */
191export function advance(previous: Fleet, outcome: Outcome): Fleet {
192  if (outcome.kind === 'ok') {
193    return { report: outcome.report, cause: null, isTooOld: false }
194  }
195
196  if (outcome.kind === 'too-old') {
197    return { report: null, cause: null, isTooOld: true }
198  }
199
200  return { report: previous.report, cause: outcome.cause, isTooOld: false }
201}
202
203// ---------------------------------------------------------------------------
204// Segments
205// ---------------------------------------------------------------------------
206
207const marked = (text: string, tone: Tone): string => {
208  if (tone === 'error') {
209    return `${text} !!`
210  }
211
212  return tone === 'warning' ? `${text} !` : text
213}
214
215const pad = (value: number): string => String(value).padStart(2, '0')
216
217/** An upper bound as `dd-mm HH:MM` in local time. */
218const latestText = (iso: string): string => {
219  const moment = new Date(iso)
220
221  return `${pad(moment.getDate())}-${pad(moment.getMonth() + 1)} ${pad(moment.getHours())}:${pad(moment.getMinutes())}`
222}
223
224const MINUTE_MS = 60_000
225const HOUR_MS = 60 * MINUTE_MS
226
227/** The time left until `ms` from now, rounded UP on the smallest unit shown so it never promises too early. */
228const leftText = (ms: number): string => {
229  const minutes = Math.ceil(ms / MINUTE_MS)
230
231  if (minutes < 60) {
232    return `${minutes}m`
233  }
234
235  if (minutes <= 24 * 60) {
236    return `${Math.floor(minutes / 60)}u ${pad(minutes % 60)}m`
237  }
238
239  const hours = Math.ceil(ms / HOUR_MS)
240
241  return `${Math.floor(hours / 24)}d ${pad(hours % 24)}u`
242}
243
244/** What a full kimi shows: the time left before the upper bound, the stored bound after it, plain text without one. */
245function kimiFullText(resetsAtLatest: string | undefined, now: number): { shown: string; tone: Tone } {
246  if (resetsAtLatest === undefined) {
247    return { shown: KIMI_FULL, tone: 'error' }
248  }
249
250  const bound = Date.parse(resetsAtLatest)
251
252  if (now >= bound) {
253    return { shown: `laatst vol, grens ${latestText(resetsAtLatest)} voorbij`, tone: 'warning' }
254  }
255
256  return { shown: `terug over ${leftText(bound - now)} (uiterlijk ${latestText(resetsAtLatest)})`, tone: 'error' }
257}
258
259const percentText = (percent: number): string => `${Number(percent.toFixed(1))}%`
260
261const balanceText = (balance: { currency: string; amount: number }): string => {
262  const amount = balance.amount.toFixed(2)
263  const symbol = SYMBOLS[balance.currency]
264
265  return symbol === undefined ? `${amount} ${balance.currency}` : `${symbol}${amount}`
266}
267
268const WORST: readonly Tone[] = ['success', 'warning', 'error']
269
270const worst = (tones: Tone[]): Tone => tones.reduce<Tone>((a, b) => (WORST.indexOf(b) > WORST.indexOf(a) ? b : a), 'success')
271
272function providerSegment(name: ProviderName, figure: Figure, config: Config, isStale: boolean, now: number): Segment {
273  const stale = isStale ? ' (oud)' : ''
274
275  if (figure.kind === 'unavailable') {
276    return { key: name, text: `${name} ${shortReason(figure.reason)}${stale}`, tone: 'dim' }
277  }
278
279  if (figure.kind === 'percent') {
280    const tone = percentTone(figure.percent, config)
281
282    return { key: name, text: `${marked(`${name} ${percentText(figure.percent)}`, tone)}${stale}`, tone }
283  }
284
285  if (figure.kind === 'status') {
286    if (figure.text === KIMI_FULL) {
287      const full = kimiFullText(figure.resetsAtLatest, now)
288
289      return { key: name, text: `${marked(`${name} ${full.shown}`, full.tone)}${stale}`, tone: full.tone }
290    }
291
292    const tone: Tone = figure.text === 'bereikbaar' ? 'success' : 'warning'
293
294    return { key: name, text: `${marked(`${name} ${figure.text}`, tone)}${stale}`, tone }
295  }
296
297  const tone = worst(figure.balances.map(balance => balanceTone(balance.amount, config)))
298  const text = `${name} ${figure.balances.map(balanceText).join(' / ')}`
299
300  return { key: name, text: `${marked(text, tone)}${stale}`, tone }
301}
302
303function claudeSegments(rateLimits: readonly RateLimit[], config: Config): Segment[] {
304  const five = rateLimits.find(limit => limit.kind === 'five_hour')
305  const week = rateLimits.find(limit => limit.kind === 'seven_day')
306
307  if (five === undefined && week === undefined) {
308    return [{ key: 'claude5', text: 'claude: nog geen meting', tone: 'dim' }]
309  }
310
311  const segments: Segment[] = []
312
313  if (five !== undefined) {
314    const tone = percentTone(five.percentUsed, config)
315
316    segments.push({ key: 'claude5', text: marked(`claude 5u ${percentText(five.percentUsed)}`, tone), tone })
317  }
318
319  if (week !== undefined) {
320    const tone = percentTone(week.percentUsed, config)
321    const label = five === undefined ? 'claude week' : 'week'
322
323    segments.push({ key: five === undefined ? 'claude5' : 'claudeWeek', text: marked(`${label} ${percentText(week.percentUsed)}`, tone), tone })
324  }
325
326  return segments
327}
328
329/** The parts of the status line, most important first. */
330/** `now` is the clock reading in milliseconds; the display never reads the time itself. */
331export function segmentsOf(rateLimits: readonly RateLimit[], fleet: Fleet, config: Config, now: number): Segment[] {
332  const segments = claudeSegments(rateLimits, config)
333
334  if (fleet.isTooOld) {
335    return [...segments, { key: 'notice', text: 'vnx te oud voor providers usage', tone: 'dim' }]
336  }
337
338  const stale = fleet.cause !== null
339  const rows = (fleet.report?.rows ?? []).map(row => providerSegment(row.name, row.figure, config, stale, now))
340
341  if (fleet.cause !== null) {
342    rows.push({ key: 'notice', text: `vnx-meting mislukt: ${fleet.cause}`, tone: 'dim' })
343  }
344
345  return [...segments, ...rows]
346}
347
348/** The line, joined, with the least important segments dropped until it fits `columns` (null: no limit). */
349export function fit(segments: readonly Segment[], columns: number | null): string {
350  const kept = [...segments]
351  const width = () => [...kept.map(segment => segment.text).join(SEPARATOR)].length
352
353  if (columns === null) {
354    return kept.map(segment => segment.text).join(SEPARATOR)
355  }
356
357  for (const key of DROP_ORDER) {
358    if (width() <= columns - STATUS_OVERHEAD) {
359      break
360    }
361
362    const index = kept.findIndex(segment => segment.key === key)
363
364    if (index >= 0) {
365      kept.splice(index, 1)
366    }
367  }
368
369  return kept.map(segment => segment.text).join(SEPARATOR)
370}
371
372// ---------------------------------------------------------------------------
373// The /verbruik text
374// ---------------------------------------------------------------------------
375
376function figureLines(name: ProviderName, figure: Figure, config: Config, now: number): string {
377  if (figure.kind === 'unavailable') {
378    return `${name}: niet beschikbaar, ${withoutPaths(figure.reason) || 'geen reden'}`
379  }
380
381  return providerSegment(name, figure, config, false, now).text.replace(`${name} `, `${name}: `)
382}
383
384/** Every provider with its figures or its reason, and when the report was measured. */
385export function commandText(rateLimits: readonly RateLimit[], fleet: Fleet, config: Config, now: number): string {
386  const lines = ['Verbruik']
387  const five = rateLimits.find(limit => limit.kind === 'five_hour')
388  const week = rateLimits.find(limit => limit.kind === 'seven_day')
389
390  if (five === undefined && week === undefined) {
391    lines.push('claude: nog geen meting')
392  }
393
394  for (const [label, limit] of [['claude 5u', five], ['claude week', week]] as const) {
395    if (limit !== undefined) {
396      const reset = limit.resetsAt === undefined ? '' : `, reset ${limit.resetsAt}`
397
398      lines.push(`${marked(`${label}: ${percentText(limit.percentUsed)}`, percentTone(limit.percentUsed, config))}${reset}`)
399    }
400  }
401
402  if (fleet.isTooOld) {
403    lines.push('vnx te oud voor providers usage: alleen Claude wordt getoond')
404
405    return lines.join('\n')
406  }
407
408  const stale = fleet.cause !== null ? ' (oud)' : ''
409
410  for (const row of fleet.report?.rows ?? []) {
411    lines.push(`${figureLines(row.name, row.figure, config, now)}${stale}`)
412  }
413
414  if (fleet.report === null && fleet.cause === null) {
415    lines.push('andere providers: nog geen meting')
416  }
417
418  if (fleet.report !== null) {
419    lines.push(`gemeten: ${fleet.report.measuredAt ?? 'onbekend'}`)
420  }
421
422  if (fleet.cause !== null) {
423    lines.push(`laatste meting mislukt: ${fleet.cause}`)
424  }
425
426  return lines.join('\n')
427}
428
types/index.d.ts 54 lines
1/** How a segment is meant to be drawn; `dim` is a figure that is missing or could not be measured. */
2export type Tone = 'success' | 'warning' | 'error' | 'dim'
3
4/** The other providers `vnx providers usage --json` reports; its `claude` row is ignored. */
5export type ProviderName = 'codex' | 'kimi' | 'deepseek' | 'openrouter'
6
7/** One balance of a provider, in its own currency. */
8export type Balance = { currency: string; amount: number }
9
10/**
11 * What one provider row holds. A figure that could not be read is `unavailable`
12 * with its reason, never a zero.
13 */
14export type Figure =
15  | { kind: 'percent'; percent: number }
16  | { kind: 'status'; text: string; resetsAtLatest?: string }
17  | { kind: 'balances'; balances: Balance[] }
18  | { kind: 'unavailable'; reason: string }
19
20/** A parsed `vnx.providers.usage/1` report: only the figures, no `source`, `store` or path. */
21export type Report = {
22  measuredAt: string | null
23  rows: { name: ProviderName; figure: Figure }[]
24}
25
26/** What one run of `vnx providers usage --json` came to. */
27export type Outcome = { kind: 'ok'; report: Report } | { kind: 'failed'; cause: string } | { kind: 'too-old' }
28
29/** The other providers as the status line knows them: the last good report and why it may be stale. */
30export type Fleet = {
31  report: Report | null
32  /** Why the last run failed; null when it succeeded or none has failed yet. */
33  cause: string | null
34  /** True when the installed vnx has no `providers usage`. */
35  isTooOld: boolean
36}
37
38/** The window of the Claude subscription as `$.session.usage().rateLimits` holds it. */
39export type RateLimit = { kind: string; percentUsed: number; resetsAt?: string }
40
41export type SegmentKey = 'claude5' | 'claudeWeek' | 'codex' | 'kimi' | 'deepseek' | 'openrouter' | 'notice'
42
43/** A part of the status line. The text carries the meaning; a warning or error also ends in `!` or `!!`. */
44export type Segment = { key: SegmentKey; text: string; tone: Tone }
45
46export type Config = {
47  vnxBin: string
48  refreshMinutes: number
49  warnPercent: number
50  errorPercent: number
51  warnBalance: number
52  errorBalance: number
53}
54