@1930dev/opencode-usage 0.1.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +20 -4
  2. package/dist/cli.js +219 -168
  3. package/dist/tui.js +226 -156
  4. package/package.json +5 -4
package/README.md CHANGED
@@ -7,7 +7,8 @@ Every connected provider in one table, with each one's remaining budget as a
7
7
  comparable percentage — whether the provider bills in tokens, requests, credits or
8
8
  neurons.
9
9
 
10
- <!-- Absolute URL so the image also renders on npm, which resolves nothing relative. -->
10
+ <!-- Absolute URL so the image also renders on npm, which resolves nothing relative.
11
+ It also means updating the file on main updates both, with no republish. -->
11
12
  ![The /usage dialog](https://raw.githubusercontent.com/1930-dev/opencode-usage/main/docs/demo.gif)
12
13
 
13
14
  [![npm](https://img.shields.io/npm/v/@1930dev/opencode-usage)](https://www.npmjs.com/package/@1930dev/opencode-usage)
@@ -118,22 +119,32 @@ Restart opencode after a rebuild: the bundle is read once at start.
118
119
  Normalized percentage of budget consumed per provider, from these sources (in priority):
119
120
 
120
121
  1. **Live quota** — provider-reported usage:
122
+ - `amd`: daily spend ceiling (USD/day)
121
123
  - `github-copilot`: premium requests entitlement (7000/mo)
122
124
  - `opencode-go` (Zen): rolling 5h / weekly / monthly % (binding window)
123
125
  - `openrouter`: credits used / total credits
124
126
  - `zai`: coding plan quota
125
127
 
126
- 2. **Documented limits** — published quotas, used when the provider reports none:
128
+ 2. **Documented limits** — published quotas, used when the provider reports none.
129
+ Each one is read against the period it resets on: a daily limit against today,
130
+ a monthly one against the calendar month. The window you asked for (`--since`)
131
+ sizes the table and never the budget.
127
132
  - `cerebras`: 1M tokens/day (free tier)
128
- - `cloudflare-workers-ai`: 100k neurons/day (free tier)
129
133
  - `digitalocean`: 5M tokens/day (paid)
130
134
  - `google`: 1500 requests/day (free tier)
131
135
  - `groq`: 200k tokens/day (free tier)
136
+
137
+ These are documented but not measurable, and show `—`. opencode's database
138
+ records tokens and requests; a credit and a neuron are the provider's own
139
+ unit, derived from the model and the request by a rule it does not record.
140
+ Give them a line in `budgets.json` to get a percentage in USD instead.
141
+ - `cloudflare-workers-ai`: 100k neurons/day (free tier)
132
142
  - `nvidia`: 1000 credits/month (free tier)
133
143
  - `orcarouter`: undocumented
134
144
  - `snowflake-cortex`: 100 credits/month (paid)
135
145
 
136
- 3. **budgets.json** — your monthly USD per provider:
146
+ 3. **budgets.json** — your monthly USD per provider, read against what the
147
+ provider cost so far this calendar month:
137
148
  ```json
138
149
  {
139
150
  "digitalocean": 5,
@@ -186,9 +197,14 @@ bun install # install workspace deps
186
197
  bun run build # write dist/tui.js and dist/cli.js
187
198
  bun run preview # render the /usage dialog headless, at several widths
188
199
  bun test # run tests
200
+ bun run coverage # run tests with the coverage gate
189
201
  bunx tsc --noEmit # typecheck
190
202
  ```
191
203
 
204
+ Every line of every source file is covered, and `bunfig.toml` fails the run if
205
+ that stops being true. `packages/cli/src/cli.ts` holds no logic for that reason:
206
+ the commands live in `main.ts`, which a test drives directly.
207
+
192
208
  The workspace is a Bun monorepo. The packages are listed by dependency order,
193
209
  since `cli` and `plugin` both build on `core`:
194
210
 
package/dist/cli.js CHANGED
@@ -61,7 +61,7 @@ var CREATED = "CAST(json_extract(data,'$.time.created') AS INTEGER)";
61
61
  var GROUPS = {
62
62
  provider: "json_extract(data,'$.providerID')",
63
63
  model: "json_extract(data,'$.providerID') || '/' || json_extract(data,'$.modelID')",
64
- day: "strftime('%Y-%m-%d', (${" + CREATED + "})/1000, 'unixepoch')",
64
+ day: `strftime('%Y-%m-%d', (${CREATED})/1000, 'unixepoch')`,
65
65
  project: "json_extract(data,'$.path.cwd')",
66
66
  agent: "json_extract(data,'$.agent')"
67
67
  };
@@ -134,19 +134,6 @@ function localUsageByProvider(db, sinceMs) {
134
134
  }
135
135
  return map;
136
136
  }
137
- function monthlyUsageByProvider(db, startOfMonthMs) {
138
- const rows = db.query(`SELECT COALESCE(json_extract(data,'$.providerID'),'?') AS provider,
139
- COALESCE(SUM(CAST(json_extract(data,'$.cost') AS REAL)),0) AS cost
140
- FROM message
141
- WHERE json_extract(data,'$.role')='assistant'
142
- AND CAST(json_extract(data,'$.time.created') AS INTEGER) >= ?
143
- GROUP BY provider`).all(startOfMonthMs);
144
- const map = new Map;
145
- for (const r of rows) {
146
- map.set(String(r.provider), Number(r.cost));
147
- }
148
- return map;
149
- }
150
137
  // packages/core/src/providers.ts
151
138
  import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
152
139
  import path2 from "path";
@@ -233,6 +220,8 @@ async function fetchOpenRouter(key) {
233
220
 
234
221
  // packages/core/src/quota/copilot.ts
235
222
  function premiumBudget(s) {
223
+ if (!s)
224
+ return;
236
225
  if (s.unlimited && s.entitlement === 0)
237
226
  return;
238
227
  const remaining = s.remaining ?? s.quota_remaining;
@@ -307,6 +296,38 @@ async function fetchZai(key) {
307
296
  }
308
297
  }
309
298
 
299
+ // packages/core/src/quota/amd.ts
300
+ async function fetchAmd(key) {
301
+ try {
302
+ const data = await getJson("https://developer.amd.com.cn/radeon/api/v1/usage", {
303
+ Authorization: `Bearer ${key}`
304
+ });
305
+ if (data.status && data.status !== "ok") {
306
+ return { provider: "amd", ok: false, detail: `usage endpoint reports ${data.status}`, windows: [] };
307
+ }
308
+ const limit = data.daily_cost_limit_usd ?? 0;
309
+ const used = data.daily_cost_used_usd ?? 0;
310
+ const pct = limit > 0 ? Math.round(used / limit * 1000) / 10 : 0;
311
+ const windows = [
312
+ {
313
+ label: "daily",
314
+ percentUsed: pct,
315
+ resetsAt: data.daily_reset_at,
316
+ detail: `$${used.toFixed(4)} of $${limit.toFixed(2)}`
317
+ }
318
+ ];
319
+ return {
320
+ provider: "amd",
321
+ ok: true,
322
+ windows,
323
+ budget: limit > 0 ? { percentUsed: pct, label: `$${limit}/d` } : undefined,
324
+ raw: data
325
+ };
326
+ } catch (err) {
327
+ return { provider: "amd", ok: false, detail: err.message, windows: [] };
328
+ }
329
+ }
330
+
310
331
  // packages/core/src/providers.ts
311
332
  var TTL_MS = 15 * 60 * 1000;
312
333
  async function readCache() {
@@ -328,7 +349,8 @@ var FETCHERS = {
328
349
  "opencode-go": fetchZen,
329
350
  openrouter: fetchOpenRouter,
330
351
  "github-copilot": fetchCopilot,
331
- zai: fetchZai
352
+ zai: fetchZai,
353
+ amd: fetchAmd
332
354
  };
333
355
  async function providerStatuses(opts) {
334
356
  const auth = await readAuth();
@@ -600,135 +622,130 @@ async function buildTop(limit) {
600
622
  }
601
623
  // packages/core/src/budget.ts
602
624
  import { readFile as readFile4 } from "fs/promises";
603
- async function readBudgets() {
604
- try {
605
- return JSON.parse(await readFile4(budgetsPath(), "utf8"));
606
- } catch {
607
- return {};
608
- }
609
- }
610
- function pctFromLimit(provider, limit, usageTokens, usageRequests, isMonthly) {
611
- if (limit.limit <= 0)
612
- return;
613
- const effectiveLimit = limit.metric.endsWith("/day") && limit.metric !== "neurons/day" ? limit.limit * 30 : limit.limit;
614
- const metric = limit.metric.replace("/day", "").replace("/month", "");
615
- switch (metric) {
616
- case "tokens":
617
- if (effectiveLimit > 0)
618
- return usageTokens / effectiveLimit * 100;
619
- return;
620
- case "requests":
621
- if (effectiveLimit > 0)
622
- return usageRequests / effectiveLimit * 100;
623
- return;
624
- case "credits":
625
- case "neurons":
626
- return;
627
- default:
628
- return;
629
- }
630
- }
631
- function resolvePct(provider, live, budgets, monthCost, usageTokens, usageRequests, limits) {
632
- const liveQ = live.get(provider);
633
- if (liveQ?.budget && liveQ.ok) {
634
- return { pct: liveQ.budget.percentUsed, source: "live", label: liveQ.budget.label };
635
- }
636
- if (budgets[provider] !== undefined) {
637
- const budget = budgets[provider];
638
- if (budget <= 0)
639
- return { pct: 0, source: "budgets" };
640
- return { pct: monthCost / budget * 100, source: "budgets" };
641
- }
642
- const limit = limits.get(provider);
643
- if (limit) {
644
- const pct = pctFromLimit(provider, limit, usageTokens, usageRequests, true);
645
- if (pct !== undefined) {
646
- return { pct, source: "limits", label: `${limit.limit.toLocaleString()} ${limit.unit}/${limit.metric.split("/")[1]}` };
647
- }
648
- }
649
- return { pct: 0, source: "none" };
650
- }
625
+
651
626
  // packages/core/src/limits.ts
652
627
  var PROVIDER_LIMITS = [
653
628
  {
654
- provider: "groq",
655
- metric: "tokens/day",
656
- limit: 200000,
629
+ provider: "cerebras",
657
630
  unit: "tokens",
631
+ period: "day",
632
+ limit: 1e6,
658
633
  tier: "free",
659
- source: "https://console.groq.com/docs/rate-limits",
660
- note: "Also 30k tokens/minute. Paid tiers higher."
661
- },
662
- {
663
- provider: "google",
664
- metric: "requests/day",
665
- limit: 1500,
666
- unit: "requests",
667
- tier: "free",
668
- source: "https://ai.google.dev/gemini-api/docs/rate-limits",
669
- note: "Free tier: 1500 RPM, 1500 RPD. Paid tiers higher."
634
+ source: "https://cerebras.ai/",
635
+ note: "Free tier estimate. Paid tiers much higher."
670
636
  },
671
637
  {
672
638
  provider: "cloudflare-workers-ai",
673
- metric: "neurons/day",
674
- limit: 1e5,
675
639
  unit: "neurons",
640
+ period: "day",
641
+ limit: 1e5,
676
642
  tier: "free",
677
643
  source: "https://developers.cloudflare.com/workers-ai/platform/limits/",
678
- note: "100k neurons/day free. Workers AI paid plans higher."
679
- },
680
- {
681
- provider: "nvidia",
682
- metric: "credits/month",
683
- limit: 1000,
684
- unit: "credits",
685
- tier: "free",
686
- source: "https://build.nvidia.com/",
687
- note: "NIM free tier ~1000 credits/month. Varies by model."
644
+ note: "A neuron is Cloudflare's own unit, per model. Not measurable from tokens."
688
645
  },
689
646
  {
690
647
  provider: "digitalocean",
691
- metric: "tokens/day",
692
- limit: 5000000,
693
648
  unit: "tokens",
649
+ period: "day",
650
+ limit: 5000000,
694
651
  tier: "paid",
695
652
  source: "https://docs.digitalocean.com/products/genai/",
696
653
  note: "GenAI platform paid plans. Exact limits per model."
697
654
  },
698
655
  {
699
- provider: "snowflake-cortex",
700
- metric: "credits/month",
701
- limit: 100,
702
- unit: "credits",
703
- tier: "paid",
704
- source: "https://docs.snowflake.com/en/user-guide/snowflake-cortex",
705
- note: "Cortex functions consume credits. Budget per warehouse."
656
+ provider: "google",
657
+ unit: "requests",
658
+ period: "day",
659
+ limit: 1500,
660
+ tier: "free",
661
+ source: "https://ai.google.dev/gemini-api/docs/rate-limits",
662
+ note: "Free tier: 1500 RPM, 1500 RPD. Paid tiers higher."
706
663
  },
707
664
  {
708
- provider: "cerebras",
709
- metric: "tokens/day",
710
- limit: 1e6,
665
+ provider: "groq",
711
666
  unit: "tokens",
667
+ period: "day",
668
+ limit: 200000,
712
669
  tier: "free",
713
- source: "https://cerebras.ai/",
714
- note: "Free tier estimate. Paid tiers much higher."
670
+ source: "https://console.groq.com/docs/rate-limits",
671
+ note: "Also 30k tokens/minute. Paid tiers higher."
672
+ },
673
+ {
674
+ provider: "nvidia",
675
+ unit: "credits",
676
+ period: "month",
677
+ limit: 1000,
678
+ tier: "free",
679
+ source: "https://build.nvidia.com/",
680
+ note: "NIM free tier ~1000 credits/month. A credit varies by model."
715
681
  },
716
682
  {
717
683
  provider: "orcarouter",
718
- metric: "tokens/day",
719
- limit: 0,
720
684
  unit: "tokens",
685
+ period: "day",
686
+ limit: 0,
721
687
  tier: "unknown",
722
688
  source: "unknown",
723
689
  note: "Proxy service. No public limits documented."
690
+ },
691
+ {
692
+ provider: "snowflake-cortex",
693
+ unit: "credits",
694
+ period: "month",
695
+ limit: 100,
696
+ tier: "paid",
697
+ source: "https://docs.snowflake.com/en/user-guide/snowflake-cortex",
698
+ note: "Credits follow warehouse time, not tokens. Not measurable from usage."
724
699
  }
725
700
  ];
701
+ var MEASURED_UNITS = new Set(["tokens", "requests"]);
702
+ function isMeasurable(limit) {
703
+ return limit.limit > 0 && MEASURED_UNITS.has(limit.unit);
704
+ }
705
+ function limitLabel(limit) {
706
+ return `${limit.limit.toLocaleString()} ${limit.unit}/${limit.period}`;
707
+ }
726
708
  function limitsAsMap() {
727
709
  const m = new Map;
728
710
  for (const l of PROVIDER_LIMITS)
729
711
  m.set(l.provider, l);
730
712
  return m;
731
713
  }
714
+
715
+ // packages/core/src/budget.ts
716
+ async function readBudgets() {
717
+ try {
718
+ return JSON.parse(await readFile4(budgetsPath(), "utf8"));
719
+ } catch {
720
+ return {};
721
+ }
722
+ }
723
+ function pctFromLimit(limit, usage) {
724
+ if (!isMeasurable(limit))
725
+ return;
726
+ const period = usage[limit.period];
727
+ const used = limit.unit === "requests" ? period.requests : period.tokens;
728
+ return used / limit.limit * 100;
729
+ }
730
+ function resolvePct(provider, sources) {
731
+ const live = sources.live.get(provider);
732
+ if (live?.budget && live.ok) {
733
+ return { pct: live.budget.percentUsed, source: "live", label: live.budget.label };
734
+ }
735
+ const budget = sources.budgets[provider];
736
+ if (budget !== undefined) {
737
+ if (budget <= 0)
738
+ return { pct: 0, source: "budgets" };
739
+ return { pct: sources.monthCost / budget * 100, source: "budgets" };
740
+ }
741
+ const limit = sources.limits.get(provider);
742
+ if (limit) {
743
+ const pct = pctFromLimit(limit, sources.usage);
744
+ if (pct !== undefined)
745
+ return { pct, source: "limits", label: limitLabel(limit) };
746
+ }
747
+ return { pct: 0, source: "none" };
748
+ }
732
749
  // packages/core/src/snapshot.ts
733
750
  async function withConnectedProviders(rows) {
734
751
  let connected;
@@ -755,36 +772,48 @@ async function withConnectedProviders(rows) {
755
772
  }));
756
773
  return [...rows, ...idle].sort((a, b) => b.cost - a.cost || b.messages - a.messages || a.provider.localeCompare(b.provider));
757
774
  }
775
+ function periodUsage(usage, provider) {
776
+ const local = usage.get(provider);
777
+ return { tokens: local?.tokens ?? 0, requests: local?.messages ?? 0 };
778
+ }
779
+ async function resolvePctByProvider(rows, day, month) {
780
+ const budgets = await readBudgets();
781
+ const live = new Map;
782
+ for (const status of await providerStatuses({ noNet: false })) {
783
+ if (status.quota)
784
+ live.set(status.provider, status.quota);
785
+ }
786
+ const limits = limitsAsMap();
787
+ const pct = {};
788
+ for (const row of rows) {
789
+ const resolved = resolvePct(row.provider, {
790
+ live,
791
+ budgets,
792
+ monthCost: month.get(row.provider)?.cost ?? 0,
793
+ usage: { day: periodUsage(day, row.provider), month: periodUsage(month, row.provider) },
794
+ limits
795
+ });
796
+ if (resolved.pct > 0 || resolved.source !== "none")
797
+ pct[row.provider] = resolved;
798
+ }
799
+ return pct;
800
+ }
758
801
  async function getUsageSnapshot(sinceMs, groupBy, includePct) {
759
802
  const db = openDb();
760
803
  try {
761
- const rows = groupBy === "provider" ? await withConnectedProviders(usageSince(db, sinceMs, groupBy)) : usageSince(db, sinceMs, groupBy);
804
+ const grouped = usageSince(db, sinceMs, groupBy);
805
+ const rows = groupBy === "provider" ? await withConnectedProviders(grouped) : grouped;
762
806
  const totals = usageTotals(db, sinceMs);
763
- let pct;
764
- if (includePct) {
765
- const budgets = await readBudgets();
766
- const live = await providerStatuses({ noNet: false });
767
- const liveMap = new Map;
768
- for (const s of live) {
769
- if (s.quota)
770
- liveMap.set(s.provider, s.quota);
771
- }
772
- const monthCosts = monthlyUsageByProvider(db, startOfMonthMs());
773
- const limits = limitsAsMap();
774
- const pctMap = new Map;
775
- for (const r of rows) {
776
- const p = resolvePct(r.provider, liveMap, budgets, monthCosts.get(r.provider) ?? 0, r.tokensInput + r.tokensOutput, r.messages, limits);
777
- if (p.pct > 0 || p.source !== "none")
778
- pctMap.set(r.provider, p);
779
- }
780
- pct = pctMap;
781
- }
782
- return { rows, totals, pct: pct ? Object.fromEntries(pct) : undefined };
807
+ if (!includePct)
808
+ return { rows, totals };
809
+ const day = localUsageByProvider(db, startOfDayMs());
810
+ const month = localUsageByProvider(db, startOfMonthMs());
811
+ return { rows, totals, pct: await resolvePctByProvider(rows, day, month) };
783
812
  } finally {
784
813
  db.close();
785
814
  }
786
815
  }
787
- // packages/cli/src/cli.ts
816
+ // packages/cli/src/main.ts
788
817
  var USAGE = `opencode-usage \u2014 usage tracking and model ranking for opencode
789
818
 
790
819
  USAGE
@@ -799,12 +828,14 @@ OPTIONS
799
828
  --since lookback window: Nh, Nd or Nw (default 7d)
800
829
  --pct add % BUDGET column (requires --by provider)
801
830
  `;
831
+
832
+ class CliError extends Error {
833
+ }
802
834
  function fail(msg) {
803
- console.error(`opencode-usage: ${msg}
804
- `);
805
- process.exit(1);
806
- throw new Error("unreachable");
835
+ throw new CliError(msg);
807
836
  }
837
+ var GROUP_BYS = ["provider", "model", "day", "project", "agent"];
838
+ var VALUED_FLAGS = ["--since", "--by", "--limit"];
808
839
  function parseArgs(argv) {
809
840
  const cmd = argv[0];
810
841
  if (!cmd || cmd.startsWith("-"))
@@ -820,69 +851,82 @@ ${USAGE}`);
820
851
  const name = `--${key}`;
821
852
  if (val !== undefined)
822
853
  flags.set(name, val);
823
- else if (i + 1 < argv.length && !argv[i + 1].startsWith("--") && ["--since", "--by", "--limit"].includes(name)) {
854
+ else if (i + 1 < argv.length && !argv[i + 1].startsWith("--") && VALUED_FLAGS.includes(name)) {
824
855
  flags.set(name, argv[++i]);
825
856
  } else
826
857
  flags.set(name, true);
827
858
  }
828
859
  return { cmd, flags };
829
860
  }
861
+ function stringFlag(flags, name) {
862
+ const v = flags.get(name);
863
+ return typeof v === "string" ? v : undefined;
864
+ }
865
+ function sinceMsOf(flags) {
866
+ if (flags.has("--today"))
867
+ return startOfDayMs();
868
+ if (!flags.has("--since"))
869
+ return Date.now() - parseDuration("7d");
870
+ const v = stringFlag(flags, "--since");
871
+ if (v === undefined)
872
+ fail("--since needs a value like 7d");
873
+ return Date.now() - parseDuration(v);
874
+ }
830
875
  async function cmdUsage(flags, json) {
831
- const sinceMs = flags.has("--today") ? startOfDayMs() : flags.has("--since") ? (() => {
832
- const v = flags.get("--since");
833
- if (typeof v !== "string")
834
- fail("--since needs a value like 7d");
835
- return Date.now() - parseDuration(v);
836
- })() : Date.now() - parseDuration("7d");
837
- const groupBy = typeof flags.get("--by") === "string" ? flags.get("--by") : "provider";
838
- if (!["provider", "model", "day", "project", "agent"].includes(groupBy))
876
+ const sinceMs = sinceMsOf(flags);
877
+ const groupBy = stringFlag(flags, "--by") ?? "provider";
878
+ if (!GROUP_BYS.includes(groupBy))
839
879
  fail(`invalid --by: ${groupBy}`);
840
880
  const withPct = flags.has("--pct");
841
881
  if (withPct && groupBy !== "provider")
842
882
  fail("--pct only supported with --by provider");
843
883
  const snapshot = await getUsageSnapshot(sinceMs, groupBy, withPct);
844
884
  if (json) {
845
- const out = {
885
+ console.log(JSON.stringify({
846
886
  since: new Date(sinceMs).toISOString(),
847
887
  groupBy,
848
888
  rows: snapshot.rows,
849
889
  totals: snapshot.totals,
850
890
  pct: snapshot.pct
851
- };
852
- console.log(JSON.stringify(out, null, 2));
853
- } else {
854
- const label = flags.has("--today") ? "today" : flags.get("--since") ?? "7d";
855
- console.log(usageTable(snapshot.rows, snapshot.totals, label, groupBy, snapshot.pct ? new Map(Object.entries(snapshot.pct)) : undefined));
891
+ }, null, 2));
892
+ return;
856
893
  }
894
+ const label = flags.has("--today") ? "today" : stringFlag(flags, "--since") ?? "7d";
895
+ const pct = snapshot.pct ? new Map(Object.entries(snapshot.pct)) : undefined;
896
+ console.log(usageTable(snapshot.rows, snapshot.totals, label, groupBy, pct));
857
897
  }
858
898
  async function cmdProviders(flags, json) {
859
899
  const statuses = await providerStatuses({ noNet: flags.has("--no-net") });
860
900
  const db = openDb();
861
901
  try {
862
902
  const local = localUsageByProvider(db, Date.now() - parseDuration("7d"));
863
- if (json) {
903
+ if (json)
864
904
  console.log(JSON.stringify({ statuses, local: Object.fromEntries(local) }, null, 2));
865
- } else {
905
+ else
866
906
  console.log(providersTable(statuses, local));
867
- }
868
907
  } finally {
869
908
  db.close();
870
909
  }
871
910
  }
911
+ var TOP_HEADERS = ["MODEL", "NAME", "IQ", "CODING", "$/M", "IQ/$"];
912
+ var TOP_WIDTHS = [28, 26, 5, 7, 8, 6];
913
+ function topLine(cells) {
914
+ return cells.map((c, i) => c.length >= TOP_WIDTHS[i] ? c.slice(0, TOP_WIDTHS[i]) : c.padEnd(TOP_WIDTHS[i])).join(" ");
915
+ }
872
916
  async function cmdTop(flags, json) {
873
- const limit = typeof flags.get("--limit") === "string" ? Number(flags.get("--limit")) : 20;
917
+ const raw = stringFlag(flags, "--limit");
918
+ const limit = raw !== undefined ? Number(raw) : 20;
919
+ if (!Number.isFinite(limit) || limit <= 0)
920
+ fail(`invalid --limit: ${raw}`);
874
921
  const rows = await buildTop(limit);
875
922
  if (json) {
876
923
  console.log(JSON.stringify({ attribution: RANKING_ATTRIBUTION, models: rows }, null, 2));
877
924
  return;
878
925
  }
879
- const head = ["MODEL", "NAME", "IQ", "CODING", "$/M", "IQ/$"];
880
- const widths = [28, 26, 5, 7, 8, 6];
881
- const line = (cells) => cells.map((c, i) => c.length >= widths[i] ? c.slice(0, widths[i]) : c + " ".repeat(widths[i] - c.length)).join(" ");
882
- console.log(line(head));
883
- console.log(widths.map((w) => "-".repeat(w)).join(" "));
926
+ console.log(topLine(TOP_HEADERS));
927
+ console.log(TOP_WIDTHS.map((w) => "-".repeat(w)).join(" "));
884
928
  for (const r of rows) {
885
- console.log(line([
929
+ console.log(topLine([
886
930
  r.model,
887
931
  r.name,
888
932
  r.intelligence !== undefined ? r.intelligence.toFixed(0) : "\u2014",
@@ -894,21 +938,28 @@ async function cmdTop(flags, json) {
894
938
  console.log(`
895
939
  ${RANKING_ATTRIBUTION}`);
896
940
  }
897
- if (import.meta.main) {
898
- const { cmd, flags } = parseArgs(process.argv.slice(2));
941
+ async function run(argv) {
942
+ const { cmd, flags } = parseArgs(argv);
899
943
  const json = flags.has("--json");
900
- try {
901
- if (cmd === "usage")
902
- await cmdUsage(flags, json);
903
- else if (cmd === "providers")
904
- await cmdProviders(flags, json);
905
- else if (cmd === "top")
906
- await cmdTop(flags, json);
907
- else
908
- fail(`unknown command: ${cmd}
944
+ if (cmd === "usage")
945
+ await cmdUsage(flags, json);
946
+ else if (cmd === "providers")
947
+ await cmdProviders(flags, json);
948
+ else if (cmd === "top")
949
+ await cmdTop(flags, json);
950
+ else
951
+ fail(`unknown command: ${cmd}
909
952
 
910
953
  ${USAGE}`);
954
+ }
955
+
956
+ // packages/cli/src/cli.ts
957
+ if (import.meta.main) {
958
+ try {
959
+ await run(process.argv.slice(2));
911
960
  } catch (err) {
912
- fail(err.message);
961
+ console.error(`opencode-usage: ${err.message}
962
+ `);
963
+ process.exit(1);
913
964
  }
914
965
  }
package/dist/tui.js CHANGED
@@ -51,7 +51,7 @@ var CREATED = "CAST(json_extract(data,'$.time.created') AS INTEGER)";
51
51
  var GROUPS = {
52
52
  provider: "json_extract(data,'$.providerID')",
53
53
  model: "json_extract(data,'$.providerID') || '/' || json_extract(data,'$.modelID')",
54
- day: "strftime('%Y-%m-%d', (${" + CREATED + "})/1000, 'unixepoch')",
54
+ day: `strftime('%Y-%m-%d', (${CREATED})/1000, 'unixepoch')`,
55
55
  project: "json_extract(data,'$.path.cwd')",
56
56
  agent: "json_extract(data,'$.agent')"
57
57
  };
@@ -102,16 +102,25 @@ function usageTotals(db, sinceMs) {
102
102
  tokensOutput: Number(r?.tokensOutput ?? 0)
103
103
  };
104
104
  }
105
- function monthlyUsageByProvider(db, startOfMonthMs) {
105
+ function localUsageByProvider(db, sinceMs) {
106
106
  const rows = db.query(`SELECT COALESCE(json_extract(data,'$.providerID'),'?') AS provider,
107
- COALESCE(SUM(CAST(json_extract(data,'$.cost') AS REAL)),0) AS cost
107
+ COUNT(*) AS messages,
108
+ COALESCE(SUM(${COST}),0) AS cost,
109
+ COALESCE(SUM(${T_IN} + ${T_OUT}),0) AS tokens,
110
+ MAX(${CREATED}) AS lastUsedMs
108
111
  FROM message
109
112
  WHERE json_extract(data,'$.role')='assistant'
110
- AND CAST(json_extract(data,'$.time.created') AS INTEGER) >= ?
111
- GROUP BY provider`).all(startOfMonthMs);
113
+ AND ${CREATED} >= ?
114
+ GROUP BY provider`).all(sinceMs);
112
115
  const map = new Map;
113
116
  for (const r of rows) {
114
- map.set(String(r.provider), Number(r.cost));
117
+ map.set(String(r.provider), {
118
+ provider: String(r.provider),
119
+ messages: Number(r.messages),
120
+ cost: Number(r.cost),
121
+ tokens: Number(r.tokens),
122
+ lastUsedMs: Number(r.lastUsedMs)
123
+ });
115
124
  }
116
125
  return map;
117
126
  }
@@ -201,6 +210,8 @@ async function fetchOpenRouter(key) {
201
210
 
202
211
  // packages/core/src/quota/copilot.ts
203
212
  function premiumBudget(s) {
213
+ if (!s)
214
+ return;
204
215
  if (s.unlimited && s.entitlement === 0)
205
216
  return;
206
217
  const remaining = s.remaining ?? s.quota_remaining;
@@ -275,6 +286,38 @@ async function fetchZai(key) {
275
286
  }
276
287
  }
277
288
 
289
+ // packages/core/src/quota/amd.ts
290
+ async function fetchAmd(key) {
291
+ try {
292
+ const data = await getJson("https://developer.amd.com.cn/radeon/api/v1/usage", {
293
+ Authorization: `Bearer ${key}`
294
+ });
295
+ if (data.status && data.status !== "ok") {
296
+ return { provider: "amd", ok: false, detail: `usage endpoint reports ${data.status}`, windows: [] };
297
+ }
298
+ const limit = data.daily_cost_limit_usd ?? 0;
299
+ const used = data.daily_cost_used_usd ?? 0;
300
+ const pct = limit > 0 ? Math.round(used / limit * 1000) / 10 : 0;
301
+ const windows = [
302
+ {
303
+ label: "daily",
304
+ percentUsed: pct,
305
+ resetsAt: data.daily_reset_at,
306
+ detail: `$${used.toFixed(4)} of $${limit.toFixed(2)}`
307
+ }
308
+ ];
309
+ return {
310
+ provider: "amd",
311
+ ok: true,
312
+ windows,
313
+ budget: limit > 0 ? { percentUsed: pct, label: `$${limit}/d` } : undefined,
314
+ raw: data
315
+ };
316
+ } catch (err) {
317
+ return { provider: "amd", ok: false, detail: err.message, windows: [] };
318
+ }
319
+ }
320
+
278
321
  // packages/core/src/providers.ts
279
322
  var TTL_MS = 15 * 60 * 1000;
280
323
  async function readCache() {
@@ -296,7 +339,8 @@ var FETCHERS = {
296
339
  "opencode-go": fetchZen,
297
340
  openrouter: fetchOpenRouter,
298
341
  "github-copilot": fetchCopilot,
299
- zai: fetchZai
342
+ zai: fetchZai,
343
+ amd: fetchAmd
300
344
  };
301
345
  async function providerStatuses(opts) {
302
346
  const auth = await readAuth();
@@ -343,135 +387,130 @@ async function providerStatuses(opts) {
343
387
  var TTL_MS2 = 24 * 60 * 60 * 1000;
344
388
  // packages/core/src/budget.ts
345
389
  import { readFile as readFile3 } from "fs/promises";
346
- async function readBudgets() {
347
- try {
348
- return JSON.parse(await readFile3(budgetsPath(), "utf8"));
349
- } catch {
350
- return {};
351
- }
352
- }
353
- function pctFromLimit(provider, limit, usageTokens, usageRequests, isMonthly) {
354
- if (limit.limit <= 0)
355
- return;
356
- const effectiveLimit = limit.metric.endsWith("/day") && limit.metric !== "neurons/day" ? limit.limit * 30 : limit.limit;
357
- const metric = limit.metric.replace("/day", "").replace("/month", "");
358
- switch (metric) {
359
- case "tokens":
360
- if (effectiveLimit > 0)
361
- return usageTokens / effectiveLimit * 100;
362
- return;
363
- case "requests":
364
- if (effectiveLimit > 0)
365
- return usageRequests / effectiveLimit * 100;
366
- return;
367
- case "credits":
368
- case "neurons":
369
- return;
370
- default:
371
- return;
372
- }
373
- }
374
- function resolvePct(provider, live, budgets, monthCost, usageTokens, usageRequests, limits) {
375
- const liveQ = live.get(provider);
376
- if (liveQ?.budget && liveQ.ok) {
377
- return { pct: liveQ.budget.percentUsed, source: "live", label: liveQ.budget.label };
378
- }
379
- if (budgets[provider] !== undefined) {
380
- const budget = budgets[provider];
381
- if (budget <= 0)
382
- return { pct: 0, source: "budgets" };
383
- return { pct: monthCost / budget * 100, source: "budgets" };
384
- }
385
- const limit = limits.get(provider);
386
- if (limit) {
387
- const pct = pctFromLimit(provider, limit, usageTokens, usageRequests, true);
388
- if (pct !== undefined) {
389
- return { pct, source: "limits", label: `${limit.limit.toLocaleString()} ${limit.unit}/${limit.metric.split("/")[1]}` };
390
- }
391
- }
392
- return { pct: 0, source: "none" };
393
- }
390
+
394
391
  // packages/core/src/limits.ts
395
392
  var PROVIDER_LIMITS = [
396
393
  {
397
- provider: "groq",
398
- metric: "tokens/day",
399
- limit: 200000,
394
+ provider: "cerebras",
400
395
  unit: "tokens",
396
+ period: "day",
397
+ limit: 1e6,
401
398
  tier: "free",
402
- source: "https://console.groq.com/docs/rate-limits",
403
- note: "Also 30k tokens/minute. Paid tiers higher."
404
- },
405
- {
406
- provider: "google",
407
- metric: "requests/day",
408
- limit: 1500,
409
- unit: "requests",
410
- tier: "free",
411
- source: "https://ai.google.dev/gemini-api/docs/rate-limits",
412
- note: "Free tier: 1500 RPM, 1500 RPD. Paid tiers higher."
399
+ source: "https://cerebras.ai/",
400
+ note: "Free tier estimate. Paid tiers much higher."
413
401
  },
414
402
  {
415
403
  provider: "cloudflare-workers-ai",
416
- metric: "neurons/day",
417
- limit: 1e5,
418
404
  unit: "neurons",
405
+ period: "day",
406
+ limit: 1e5,
419
407
  tier: "free",
420
408
  source: "https://developers.cloudflare.com/workers-ai/platform/limits/",
421
- note: "100k neurons/day free. Workers AI paid plans higher."
422
- },
423
- {
424
- provider: "nvidia",
425
- metric: "credits/month",
426
- limit: 1000,
427
- unit: "credits",
428
- tier: "free",
429
- source: "https://build.nvidia.com/",
430
- note: "NIM free tier ~1000 credits/month. Varies by model."
409
+ note: "A neuron is Cloudflare's own unit, per model. Not measurable from tokens."
431
410
  },
432
411
  {
433
412
  provider: "digitalocean",
434
- metric: "tokens/day",
435
- limit: 5000000,
436
413
  unit: "tokens",
414
+ period: "day",
415
+ limit: 5000000,
437
416
  tier: "paid",
438
417
  source: "https://docs.digitalocean.com/products/genai/",
439
418
  note: "GenAI platform paid plans. Exact limits per model."
440
419
  },
441
420
  {
442
- provider: "snowflake-cortex",
443
- metric: "credits/month",
444
- limit: 100,
445
- unit: "credits",
446
- tier: "paid",
447
- source: "https://docs.snowflake.com/en/user-guide/snowflake-cortex",
448
- note: "Cortex functions consume credits. Budget per warehouse."
421
+ provider: "google",
422
+ unit: "requests",
423
+ period: "day",
424
+ limit: 1500,
425
+ tier: "free",
426
+ source: "https://ai.google.dev/gemini-api/docs/rate-limits",
427
+ note: "Free tier: 1500 RPM, 1500 RPD. Paid tiers higher."
449
428
  },
450
429
  {
451
- provider: "cerebras",
452
- metric: "tokens/day",
453
- limit: 1e6,
430
+ provider: "groq",
454
431
  unit: "tokens",
432
+ period: "day",
433
+ limit: 200000,
455
434
  tier: "free",
456
- source: "https://cerebras.ai/",
457
- note: "Free tier estimate. Paid tiers much higher."
435
+ source: "https://console.groq.com/docs/rate-limits",
436
+ note: "Also 30k tokens/minute. Paid tiers higher."
437
+ },
438
+ {
439
+ provider: "nvidia",
440
+ unit: "credits",
441
+ period: "month",
442
+ limit: 1000,
443
+ tier: "free",
444
+ source: "https://build.nvidia.com/",
445
+ note: "NIM free tier ~1000 credits/month. A credit varies by model."
458
446
  },
459
447
  {
460
448
  provider: "orcarouter",
461
- metric: "tokens/day",
462
- limit: 0,
463
449
  unit: "tokens",
450
+ period: "day",
451
+ limit: 0,
464
452
  tier: "unknown",
465
453
  source: "unknown",
466
454
  note: "Proxy service. No public limits documented."
455
+ },
456
+ {
457
+ provider: "snowflake-cortex",
458
+ unit: "credits",
459
+ period: "month",
460
+ limit: 100,
461
+ tier: "paid",
462
+ source: "https://docs.snowflake.com/en/user-guide/snowflake-cortex",
463
+ note: "Credits follow warehouse time, not tokens. Not measurable from usage."
467
464
  }
468
465
  ];
466
+ var MEASURED_UNITS = new Set(["tokens", "requests"]);
467
+ function isMeasurable(limit) {
468
+ return limit.limit > 0 && MEASURED_UNITS.has(limit.unit);
469
+ }
470
+ function limitLabel(limit) {
471
+ return `${limit.limit.toLocaleString()} ${limit.unit}/${limit.period}`;
472
+ }
469
473
  function limitsAsMap() {
470
474
  const m = new Map;
471
475
  for (const l of PROVIDER_LIMITS)
472
476
  m.set(l.provider, l);
473
477
  return m;
474
478
  }
479
+
480
+ // packages/core/src/budget.ts
481
+ async function readBudgets() {
482
+ try {
483
+ return JSON.parse(await readFile3(budgetsPath(), "utf8"));
484
+ } catch {
485
+ return {};
486
+ }
487
+ }
488
+ function pctFromLimit(limit, usage) {
489
+ if (!isMeasurable(limit))
490
+ return;
491
+ const period = usage[limit.period];
492
+ const used = limit.unit === "requests" ? period.requests : period.tokens;
493
+ return used / limit.limit * 100;
494
+ }
495
+ function resolvePct(provider, sources) {
496
+ const live = sources.live.get(provider);
497
+ if (live?.budget && live.ok) {
498
+ return { pct: live.budget.percentUsed, source: "live", label: live.budget.label };
499
+ }
500
+ const budget = sources.budgets[provider];
501
+ if (budget !== undefined) {
502
+ if (budget <= 0)
503
+ return { pct: 0, source: "budgets" };
504
+ return { pct: sources.monthCost / budget * 100, source: "budgets" };
505
+ }
506
+ const limit = sources.limits.get(provider);
507
+ if (limit) {
508
+ const pct = pctFromLimit(limit, sources.usage);
509
+ if (pct !== undefined)
510
+ return { pct, source: "limits", label: limitLabel(limit) };
511
+ }
512
+ return { pct: 0, source: "none" };
513
+ }
475
514
  // packages/core/src/snapshot.ts
476
515
  async function withConnectedProviders(rows) {
477
516
  let connected;
@@ -498,42 +537,57 @@ async function withConnectedProviders(rows) {
498
537
  }));
499
538
  return [...rows, ...idle].sort((a, b) => b.cost - a.cost || b.messages - a.messages || a.provider.localeCompare(b.provider));
500
539
  }
540
+ function periodUsage(usage, provider) {
541
+ const local = usage.get(provider);
542
+ return { tokens: local?.tokens ?? 0, requests: local?.messages ?? 0 };
543
+ }
544
+ async function resolvePctByProvider(rows, day, month) {
545
+ const budgets = await readBudgets();
546
+ const live = new Map;
547
+ for (const status of await providerStatuses({ noNet: false })) {
548
+ if (status.quota)
549
+ live.set(status.provider, status.quota);
550
+ }
551
+ const limits = limitsAsMap();
552
+ const pct = {};
553
+ for (const row of rows) {
554
+ const resolved = resolvePct(row.provider, {
555
+ live,
556
+ budgets,
557
+ monthCost: month.get(row.provider)?.cost ?? 0,
558
+ usage: { day: periodUsage(day, row.provider), month: periodUsage(month, row.provider) },
559
+ limits
560
+ });
561
+ if (resolved.pct > 0 || resolved.source !== "none")
562
+ pct[row.provider] = resolved;
563
+ }
564
+ return pct;
565
+ }
501
566
  async function getUsageSnapshot(sinceMs, groupBy, includePct) {
502
567
  const db = openDb();
503
568
  try {
504
- const rows = groupBy === "provider" ? await withConnectedProviders(usageSince(db, sinceMs, groupBy)) : usageSince(db, sinceMs, groupBy);
569
+ const grouped = usageSince(db, sinceMs, groupBy);
570
+ const rows = groupBy === "provider" ? await withConnectedProviders(grouped) : grouped;
505
571
  const totals = usageTotals(db, sinceMs);
506
- let pct;
507
- if (includePct) {
508
- const budgets = await readBudgets();
509
- const live = await providerStatuses({ noNet: false });
510
- const liveMap = new Map;
511
- for (const s of live) {
512
- if (s.quota)
513
- liveMap.set(s.provider, s.quota);
514
- }
515
- const monthCosts = monthlyUsageByProvider(db, startOfMonthMs());
516
- const limits = limitsAsMap();
517
- const pctMap = new Map;
518
- for (const r of rows) {
519
- const p = resolvePct(r.provider, liveMap, budgets, monthCosts.get(r.provider) ?? 0, r.tokensInput + r.tokensOutput, r.messages, limits);
520
- if (p.pct > 0 || p.source !== "none")
521
- pctMap.set(r.provider, p);
522
- }
523
- pct = pctMap;
524
- }
525
- return { rows, totals, pct: pct ? Object.fromEntries(pct) : undefined };
572
+ if (!includePct)
573
+ return { rows, totals };
574
+ const day = localUsageByProvider(db, startOfDayMs());
575
+ const month = localUsageByProvider(db, startOfMonthMs());
576
+ return { rows, totals, pct: await resolvePctByProvider(rows, day, month) };
526
577
  } finally {
527
578
  db.close();
528
579
  }
529
580
  }
530
- // packages/plugin/tui.tsx
531
- import { jsx, jsxs } from "@opentui/solid/jsx-runtime";
532
- var COMMAND = "opencode-usage.show";
581
+ // packages/plugin/layout.ts
533
582
  var SIZE_WIDTH = { medium: 60, large: 88, xlarge: 116 };
534
583
  var PADDING = 1;
584
+ var WIDE_BAR = 12;
585
+ var COMPACT_BAR = 6;
586
+ var BUDGET_FIXED = WIDE_BAR + 6;
587
+ var WIDE_MIN = 80;
588
+ var BUDGET_MIN = 14;
535
589
  function columnsFor(inner) {
536
- const wide = inner >= 80;
590
+ const wide = inner >= WIDE_MIN;
537
591
  const base = wide ? [
538
592
  { title: "PROVIDER", width: 21, align: "left" },
539
593
  { title: "MSGS", width: 5, align: "right" },
@@ -548,7 +602,7 @@ function columnsFor(inner) {
548
602
  { title: "COST", width: 8, align: "right" }
549
603
  ];
550
604
  const used = base.reduce((a, c) => a + c.width, 0) + base.length;
551
- const budget = Math.max(14, inner - used);
605
+ const budget = Math.max(BUDGET_MIN, inner - used);
552
606
  return { cols: [...base, { title: "BUDGET", width: budget, align: "left" }], bar: wide ? WIDE_BAR : COMPACT_BAR };
553
607
  }
554
608
  function fmtTokens(n) {
@@ -569,9 +623,6 @@ function progressBar(pct, width) {
569
623
  const filled = Math.round(Math.min(Math.max(pct, 0), 100) / 100 * width);
570
624
  return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
571
625
  }
572
- var WIDE_BAR = 12;
573
- var COMPACT_BAR = 6;
574
- var BUDGET_FIXED = WIDE_BAR + 6;
575
626
  function compactLabel(label) {
576
627
  return label.replace(/\b\d[\d,]*\b/g, (n) => {
577
628
  const v = Number(n.replace(/,/g, ""));
@@ -601,6 +652,13 @@ function windowSuffix(label) {
601
652
  return "h";
602
653
  return "";
603
654
  }
655
+ function budgetTail(pct, label, colWidth, bar) {
656
+ const head = ` ${pct.toFixed(0).padStart(3)}% `;
657
+ const room = colWidth - bar - head.length;
658
+ const compact = compactLabel(label);
659
+ const text = compact.length <= room ? compact : windowSuffix(label);
660
+ return (head + text).slice(0, colWidth - bar);
661
+ }
604
662
  function toHex(color, fallback) {
605
663
  if (typeof color === "string")
606
664
  return color;
@@ -611,22 +669,12 @@ function toHex(color, fallback) {
611
669
  const hex = (v) => Math.round(Math.min(Math.max(v * scale, 0), 255)).toString(16).padStart(2, "0");
612
670
  return `#${hex(c.r)}${hex(c.g)}${hex(c.b)}`;
613
671
  }
614
- function palette(api) {
615
- const t = api.theme?.current;
616
- return {
617
- accent: toHex(t?.primary, "#a277ff"),
618
- text: toHex(t?.text, "#e5e5e5"),
619
- muted: toHex(t?.textMuted, "#8a8a8a"),
620
- subtle: toHex(t?.borderSubtle, "#4a4a4a"),
621
- ok: toHex(t?.success, "#4ade80"),
622
- warn: toHex(t?.warning, "#facc15"),
623
- danger: toHex(t?.error, "#f87171")
624
- };
625
- }
672
+ var DANGER_PCT = 90;
673
+ var WARN_PCT = 70;
626
674
  function barColor(pct, p) {
627
- if (pct >= 90)
675
+ if (pct >= DANGER_PCT)
628
676
  return p.danger;
629
- if (pct >= 70)
677
+ if (pct >= WARN_PCT)
630
678
  return p.warn;
631
679
  return p.ok;
632
680
  }
@@ -645,6 +693,23 @@ function widthNeeded(longestLabel) {
645
693
  const base = cols.slice(0, -1).reduce((a, c) => a + c.width, 0) + cols.length - 1;
646
694
  return base + BUDGET_FIXED + longestLabel;
647
695
  }
696
+
697
+ // packages/plugin/tui.tsx
698
+ import { jsx, jsxs } from "@opentui/solid/jsx-runtime";
699
+ var COMMAND = "opencode-usage.show";
700
+ var MAX_ROWS = 20;
701
+ function palette(api) {
702
+ const t = api.theme?.current;
703
+ return {
704
+ accent: toHex(t?.primary, "#a277ff"),
705
+ text: toHex(t?.text, "#e5e5e5"),
706
+ muted: toHex(t?.textMuted, "#8a8a8a"),
707
+ subtle: toHex(t?.borderSubtle, "#4a4a4a"),
708
+ ok: toHex(t?.success, "#4ade80"),
709
+ warn: toHex(t?.warning, "#facc15"),
710
+ danger: toHex(t?.error, "#f87171")
711
+ };
712
+ }
648
713
  function terminalWidth(api) {
649
714
  return api.renderer?.width ?? SIZE_WIDTH.medium;
650
715
  }
@@ -652,6 +717,10 @@ function innerWidth(api, needed) {
652
717
  const terminal = terminalWidth(api);
653
718
  return Math.min(SIZE_WIDTH[chooseSize(terminal, needed)], terminal - 2) - PADDING * 2;
654
719
  }
720
+ function neededWidth(snapshot) {
721
+ const labels = Object.values(snapshot.pct ?? {}).map((b) => compactLabel(b.label ?? b.source).length);
722
+ return widthNeeded(Math.max(0, ...labels));
723
+ }
655
724
  function Frame(props) {
656
725
  props.api.ui.dialog.setSize(chooseSize(terminalWidth(props.api), props.needed));
657
726
  return /* @__PURE__ */ jsxs("box", {
@@ -680,13 +749,6 @@ function Frame(props) {
680
749
  });
681
750
  }
682
751
  function Budget(props) {
683
- const tail = () => {
684
- const head = ` ${props.pct.toFixed(0).padStart(3)}% `;
685
- const room = props.col.width - props.bar - head.length;
686
- const compact = compactLabel(props.label);
687
- const label = compact.length <= room ? compact : windowSuffix(props.label);
688
- return (head + label).slice(0, props.col.width - props.bar);
689
- };
690
752
  return /* @__PURE__ */ jsxs("box", {
691
753
  flexDirection: "row",
692
754
  children: [
@@ -698,22 +760,25 @@ function Budget(props) {
698
760
  /* @__PURE__ */ jsx("text", {
699
761
  fg: props.palette.muted,
700
762
  wrapMode: "none",
701
- children: tail()
763
+ children: budgetTail(props.pct, props.label, props.col.width, props.bar)
702
764
  })
703
765
  ]
704
766
  });
705
767
  }
706
- function Table(props) {
707
- const p = palette(props.api);
708
- const needed = widthNeeded(Math.max(0, ...Object.values(props.snapshot.pct ?? {}).map((b) => compactLabel(b.label ?? b.source).length)));
709
- const layout = () => columnsFor(innerWidth(props.api, needed));
710
- const lead = (cols, r) => row(cols, [
768
+ function leadCells(cols, r) {
769
+ const upToBudget = cols.slice(0, -1).reduce((a, c) => a + c.width, 0) + cols.length - 2;
770
+ return row(cols, [
711
771
  r.provider,
712
772
  String(r.messages),
713
773
  fmtTokens(r.tokensInput),
714
774
  fmtTokens(r.tokensOutput),
715
775
  `$${r.cost.toFixed(2)}`
716
- ]).trimEnd().padEnd(cols.slice(0, -1).reduce((a, c) => a + c.width, 0) + cols.length - 2) + " ";
776
+ ]).trimEnd().padEnd(upToBudget) + " ";
777
+ }
778
+ function Table(props) {
779
+ const p = palette(props.api);
780
+ const needed = neededWidth(props.snapshot);
781
+ const layout = () => columnsFor(innerWidth(props.api, needed));
717
782
  return /* @__PURE__ */ jsxs(Frame, {
718
783
  api: props.api,
719
784
  palette: p,
@@ -724,7 +789,7 @@ function Table(props) {
724
789
  wrapMode: "none",
725
790
  children: row(layout().cols, layout().cols.map((c) => c.title))
726
791
  }),
727
- props.snapshot.rows.slice(0, 20).map((r) => {
792
+ props.snapshot.rows.slice(0, MAX_ROWS).map((r) => {
728
793
  const budget = props.snapshot.pct?.[r.provider];
729
794
  return /* @__PURE__ */ jsxs("box", {
730
795
  flexDirection: "row",
@@ -732,7 +797,7 @@ function Table(props) {
732
797
  /* @__PURE__ */ jsx("text", {
733
798
  fg: r.messages > 0 ? p.text : p.muted,
734
799
  wrapMode: "none",
735
- children: lead(layout().cols, r)
800
+ children: leadCells(layout().cols, r)
736
801
  }),
737
802
  budget ? /* @__PURE__ */ jsx(Budget, {
738
803
  pct: budget.pct,
@@ -829,5 +894,10 @@ export {
829
894
  Message,
830
895
  Table,
831
896
  tui_default as default,
897
+ innerWidth,
898
+ neededWidth,
899
+ palette,
900
+ show,
901
+ terminalWidth,
832
902
  tui
833
903
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@1930dev/opencode-usage",
3
- "version": "0.1.5",
4
- "description": "Usage tracking, budget percentages and model ranking for opencode \u2014 CLI plus a /usage TUI plugin",
3
+ "version": "0.3.0",
4
+ "description": "Usage tracking, budget percentages and model ranking for opencode CLI plus a /usage TUI plugin",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "opencode-usage": "./dist/cli.js"
@@ -11,7 +11,8 @@
11
11
  "typecheck": "tsc --noEmit",
12
12
  "build": "bun scripts/build.ts",
13
13
  "preview": "bun run build && bun packages/plugin/preview.ts",
14
- "prepublishOnly": "bun run build && bun test && tsc --noEmit"
14
+ "prepublishOnly": "bun run build && bun test --coverage && tsc --noEmit",
15
+ "coverage": "bun test --coverage"
15
16
  },
16
17
  "repository": {
17
18
  "type": "git",
@@ -27,7 +28,7 @@
27
28
  "tokens",
28
29
  "llm"
29
30
  ],
30
- "author": "Agu Rodr\u00edguez <me@agu.uy>",
31
+ "author": "Agu Rodríguez <me@agu.uy>",
31
32
  "license": "MIT",
32
33
  "devDependencies": {
33
34
  "@opencode-ai/plugin": "^1.18.26",