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…

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/.
| Segment | Source | Example |
|---|---|---|
| Claude, the 5-hour window | $.session.usage().rateLimits, kind five_hour | claude 5u 34% |
| Claude, the weekly window | the same, kind seven_day | week 61% |
| codex | used_percent of its row | codex 58% |
| kimi | status of its row as text; the percentage when a later schema carries one | kimi 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 |
| deepseek | balances[].total, in its currency | deepseek $12.92 |
| openrouter | remaining | openrouter $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:
claude: nog geen meting. The session holds none at session.start and fills it at the first session.measure.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.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.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.
| Figure | success | warning | error |
|---|---|---|---|
| a used percentage | below warnPercent | from warnPercent up to errorPercent | from errorPercent up |
| a balance | above warnBalance | at or below warnBalance, above errorBalance | at or below errorBalance |
| kimi status | bereikbaar | any other status, and laatst vol, grens ... voorbij | vol 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 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.
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.
/verbruikRefreshes 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 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.
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
Set them in /config, where each one is a row, or in settings under pluginConfigs.verbruik.options.
| Option | Default | What it does |
|---|---|---|
vnxBin | ~/.vnx-system/current/bin/vnx | The vnx executable that runs providers usage. A leading ~ is the HOME of the session. |
refreshMinutes | 10 | How often the other providers are measured again. 2 minutes at least. |
warnPercent | 70 | A used percentage from here up is a warning. |
errorPercent | 90 | A used percentage from here up is an error. |
warnBalance | 5 | A balance at or below this, in its own currency, is a warning. |
errorBalance | 1 | A balance at or below this, in its own currency, is an error. |
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.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.Measured on Claude Code 2.1.306 (a Max subscription, a throwaway tmux session, --plugin-dir twice):
$.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.
\u001b[32m is drawn as a stray character and the entry has one colour, so the mod marks warnings with ! and !!.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.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.
hooks/register.ts 97 lines1import 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}
97hooks/usage.ts 428 lines1// 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}
428types/index.d.ts 54 lines1/** 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