@1930dev/opencode-usage 0.1.4 → 0.2.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 (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +54 -4
  3. package/dist/cli.js +185 -167
  4. package/dist/tui.js +192 -155
  5. package/package.json +4 -2
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Agu Rodríguez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -3,6 +3,27 @@
3
3
  Usage tracking, budget percentages and model ranking for [opencode](https://opencode.ai):
4
4
  a CLI, and a `/usage` slash command for the TUI.
5
5
 
6
+ Every connected provider in one table, with each one's remaining budget as a
7
+ comparable percentage — whether the provider bills in tokens, requests, credits or
8
+ neurons.
9
+
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. -->
12
+ ![The /usage dialog](https://raw.githubusercontent.com/1930-dev/opencode-usage/main/docs/demo.gif)
13
+
14
+ [![npm](https://img.shields.io/npm/v/@1930dev/opencode-usage)](https://www.npmjs.com/package/@1930dev/opencode-usage)
15
+ [![license](https://img.shields.io/npm/l/@1930dev/opencode-usage)](./LICENSE)
16
+
17
+ ## Why
18
+
19
+ opencode talks to many providers at once, and each one meters differently: a weekly
20
+ window here, premium requests there, credits, neurons, tokens per day. Nothing tells
21
+ you which one you are about to exhaust.
22
+
23
+ This reads opencode's own SQLite and auth store — no configuration, no account — and
24
+ normalizes every quota into one percentage, so the answer to "which provider still has
25
+ room" is one glance.
26
+
6
27
  ## Install
7
28
 
8
29
  ```bash
@@ -45,7 +66,22 @@ opencode-usage top --limit 10 --json
45
66
  ## Plugin for opencode
46
67
 
47
68
  The package also ships a `/usage` slash command for the opencode TUI. It shows the
48
- same table as the CLI, with the % budget column drawn as a progress bar.
69
+ same table as the CLI, with the % budget column drawn as a progress bar:
70
+
71
+ ```
72
+ Usage — today esc
73
+
74
+ PROVIDER MSGS TOK IN TOK OUT COST BUDGET
75
+ opencode-go 7 0 0 $0.00 ████████████ 100% weekly
76
+ nvidia 98 10.9M 19.9K $0.00
77
+ cloudflare-workers-ai 0 0 0 $0.00
78
+ github-copilot 1 4.2K 830 $12.35 █████░░░░░░░ 42% 1M tok/day
79
+
80
+ TOTAL 106 $12.35
81
+ ```
82
+
83
+ The dialog sizes itself to the terminal: a wide frame spells the budget labels out,
84
+ a narrow one shortens them to the window (`wk`, `mo`, `d`).
49
85
 
50
86
  ```bash
51
87
  opencode plugin @1930dev/opencode-usage
@@ -88,17 +124,26 @@ Normalized percentage of budget consumed per provider, from these sources (in pr
88
124
  - `openrouter`: credits used / total credits
89
125
  - `zai`: coding plan quota
90
126
 
91
- 2. **Documented limits** — published quotas, used when the provider reports none:
127
+ 2. **Documented limits** — published quotas, used when the provider reports none.
128
+ Each one is read against the period it resets on: a daily limit against today,
129
+ a monthly one against the calendar month. The window you asked for (`--since`)
130
+ sizes the table and never the budget.
92
131
  - `cerebras`: 1M tokens/day (free tier)
93
- - `cloudflare-workers-ai`: 100k neurons/day (free tier)
94
132
  - `digitalocean`: 5M tokens/day (paid)
95
133
  - `google`: 1500 requests/day (free tier)
96
134
  - `groq`: 200k tokens/day (free tier)
135
+
136
+ These are documented but not measurable, and show `—`. opencode's database
137
+ records tokens and requests; a credit and a neuron are the provider's own
138
+ unit, derived from the model and the request by a rule it does not record.
139
+ Give them a line in `budgets.json` to get a percentage in USD instead.
140
+ - `cloudflare-workers-ai`: 100k neurons/day (free tier)
97
141
  - `nvidia`: 1000 credits/month (free tier)
98
142
  - `orcarouter`: undocumented
99
143
  - `snowflake-cortex`: 100 credits/month (paid)
100
144
 
101
- 3. **budgets.json** — your monthly USD per provider:
145
+ 3. **budgets.json** — your monthly USD per provider, read against what the
146
+ provider cost so far this calendar month:
102
147
  ```json
103
148
  {
104
149
  "digitalocean": 5,
@@ -151,9 +196,14 @@ bun install # install workspace deps
151
196
  bun run build # write dist/tui.js and dist/cli.js
152
197
  bun run preview # render the /usage dialog headless, at several widths
153
198
  bun test # run tests
199
+ bun run coverage # run tests with the coverage gate
154
200
  bunx tsc --noEmit # typecheck
155
201
  ```
156
202
 
203
+ Every line of every source file is covered, and `bunfig.toml` fails the run if
204
+ that stops being true. `packages/cli/src/cli.ts` holds no logic for that reason:
205
+ the commands live in `main.ts`, which a test drives directly.
206
+
157
207
  The workspace is a Bun monorepo. The packages are listed by dependency order,
158
208
  since `cli` and `plugin` both build on `core`:
159
209
 
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;
@@ -600,135 +589,130 @@ async function buildTop(limit) {
600
589
  }
601
590
  // packages/core/src/budget.ts
602
591
  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
- }
592
+
651
593
  // packages/core/src/limits.ts
652
594
  var PROVIDER_LIMITS = [
653
595
  {
654
- provider: "groq",
655
- metric: "tokens/day",
656
- limit: 200000,
596
+ provider: "cerebras",
657
597
  unit: "tokens",
598
+ period: "day",
599
+ limit: 1e6,
658
600
  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."
601
+ source: "https://cerebras.ai/",
602
+ note: "Free tier estimate. Paid tiers much higher."
670
603
  },
671
604
  {
672
605
  provider: "cloudflare-workers-ai",
673
- metric: "neurons/day",
674
- limit: 1e5,
675
606
  unit: "neurons",
607
+ period: "day",
608
+ limit: 1e5,
676
609
  tier: "free",
677
610
  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."
611
+ note: "A neuron is Cloudflare's own unit, per model. Not measurable from tokens."
688
612
  },
689
613
  {
690
614
  provider: "digitalocean",
691
- metric: "tokens/day",
692
- limit: 5000000,
693
615
  unit: "tokens",
616
+ period: "day",
617
+ limit: 5000000,
694
618
  tier: "paid",
695
619
  source: "https://docs.digitalocean.com/products/genai/",
696
620
  note: "GenAI platform paid plans. Exact limits per model."
697
621
  },
698
622
  {
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."
623
+ provider: "google",
624
+ unit: "requests",
625
+ period: "day",
626
+ limit: 1500,
627
+ tier: "free",
628
+ source: "https://ai.google.dev/gemini-api/docs/rate-limits",
629
+ note: "Free tier: 1500 RPM, 1500 RPD. Paid tiers higher."
706
630
  },
707
631
  {
708
- provider: "cerebras",
709
- metric: "tokens/day",
710
- limit: 1e6,
632
+ provider: "groq",
711
633
  unit: "tokens",
634
+ period: "day",
635
+ limit: 200000,
712
636
  tier: "free",
713
- source: "https://cerebras.ai/",
714
- note: "Free tier estimate. Paid tiers much higher."
637
+ source: "https://console.groq.com/docs/rate-limits",
638
+ note: "Also 30k tokens/minute. Paid tiers higher."
639
+ },
640
+ {
641
+ provider: "nvidia",
642
+ unit: "credits",
643
+ period: "month",
644
+ limit: 1000,
645
+ tier: "free",
646
+ source: "https://build.nvidia.com/",
647
+ note: "NIM free tier ~1000 credits/month. A credit varies by model."
715
648
  },
716
649
  {
717
650
  provider: "orcarouter",
718
- metric: "tokens/day",
719
- limit: 0,
720
651
  unit: "tokens",
652
+ period: "day",
653
+ limit: 0,
721
654
  tier: "unknown",
722
655
  source: "unknown",
723
656
  note: "Proxy service. No public limits documented."
657
+ },
658
+ {
659
+ provider: "snowflake-cortex",
660
+ unit: "credits",
661
+ period: "month",
662
+ limit: 100,
663
+ tier: "paid",
664
+ source: "https://docs.snowflake.com/en/user-guide/snowflake-cortex",
665
+ note: "Credits follow warehouse time, not tokens. Not measurable from usage."
724
666
  }
725
667
  ];
668
+ var MEASURED_UNITS = new Set(["tokens", "requests"]);
669
+ function isMeasurable(limit) {
670
+ return limit.limit > 0 && MEASURED_UNITS.has(limit.unit);
671
+ }
672
+ function limitLabel(limit) {
673
+ return `${limit.limit.toLocaleString()} ${limit.unit}/${limit.period}`;
674
+ }
726
675
  function limitsAsMap() {
727
676
  const m = new Map;
728
677
  for (const l of PROVIDER_LIMITS)
729
678
  m.set(l.provider, l);
730
679
  return m;
731
680
  }
681
+
682
+ // packages/core/src/budget.ts
683
+ async function readBudgets() {
684
+ try {
685
+ return JSON.parse(await readFile4(budgetsPath(), "utf8"));
686
+ } catch {
687
+ return {};
688
+ }
689
+ }
690
+ function pctFromLimit(limit, usage) {
691
+ if (!isMeasurable(limit))
692
+ return;
693
+ const period = usage[limit.period];
694
+ const used = limit.unit === "requests" ? period.requests : period.tokens;
695
+ return used / limit.limit * 100;
696
+ }
697
+ function resolvePct(provider, sources) {
698
+ const live = sources.live.get(provider);
699
+ if (live?.budget && live.ok) {
700
+ return { pct: live.budget.percentUsed, source: "live", label: live.budget.label };
701
+ }
702
+ const budget = sources.budgets[provider];
703
+ if (budget !== undefined) {
704
+ if (budget <= 0)
705
+ return { pct: 0, source: "budgets" };
706
+ return { pct: sources.monthCost / budget * 100, source: "budgets" };
707
+ }
708
+ const limit = sources.limits.get(provider);
709
+ if (limit) {
710
+ const pct = pctFromLimit(limit, sources.usage);
711
+ if (pct !== undefined)
712
+ return { pct, source: "limits", label: limitLabel(limit) };
713
+ }
714
+ return { pct: 0, source: "none" };
715
+ }
732
716
  // packages/core/src/snapshot.ts
733
717
  async function withConnectedProviders(rows) {
734
718
  let connected;
@@ -755,36 +739,48 @@ async function withConnectedProviders(rows) {
755
739
  }));
756
740
  return [...rows, ...idle].sort((a, b) => b.cost - a.cost || b.messages - a.messages || a.provider.localeCompare(b.provider));
757
741
  }
742
+ function periodUsage(usage, provider) {
743
+ const local = usage.get(provider);
744
+ return { tokens: local?.tokens ?? 0, requests: local?.messages ?? 0 };
745
+ }
746
+ async function resolvePctByProvider(rows, day, month) {
747
+ const budgets = await readBudgets();
748
+ const live = new Map;
749
+ for (const status of await providerStatuses({ noNet: false })) {
750
+ if (status.quota)
751
+ live.set(status.provider, status.quota);
752
+ }
753
+ const limits = limitsAsMap();
754
+ const pct = {};
755
+ for (const row of rows) {
756
+ const resolved = resolvePct(row.provider, {
757
+ live,
758
+ budgets,
759
+ monthCost: month.get(row.provider)?.cost ?? 0,
760
+ usage: { day: periodUsage(day, row.provider), month: periodUsage(month, row.provider) },
761
+ limits
762
+ });
763
+ if (resolved.pct > 0 || resolved.source !== "none")
764
+ pct[row.provider] = resolved;
765
+ }
766
+ return pct;
767
+ }
758
768
  async function getUsageSnapshot(sinceMs, groupBy, includePct) {
759
769
  const db = openDb();
760
770
  try {
761
- const rows = groupBy === "provider" ? await withConnectedProviders(usageSince(db, sinceMs, groupBy)) : usageSince(db, sinceMs, groupBy);
771
+ const grouped = usageSince(db, sinceMs, groupBy);
772
+ const rows = groupBy === "provider" ? await withConnectedProviders(grouped) : grouped;
762
773
  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 };
774
+ if (!includePct)
775
+ return { rows, totals };
776
+ const day = localUsageByProvider(db, startOfDayMs());
777
+ const month = localUsageByProvider(db, startOfMonthMs());
778
+ return { rows, totals, pct: await resolvePctByProvider(rows, day, month) };
783
779
  } finally {
784
780
  db.close();
785
781
  }
786
782
  }
787
- // packages/cli/src/cli.ts
783
+ // packages/cli/src/main.ts
788
784
  var USAGE = `opencode-usage \u2014 usage tracking and model ranking for opencode
789
785
 
790
786
  USAGE
@@ -799,12 +795,14 @@ OPTIONS
799
795
  --since lookback window: Nh, Nd or Nw (default 7d)
800
796
  --pct add % BUDGET column (requires --by provider)
801
797
  `;
798
+
799
+ class CliError extends Error {
800
+ }
802
801
  function fail(msg) {
803
- console.error(`opencode-usage: ${msg}
804
- `);
805
- process.exit(1);
806
- throw new Error("unreachable");
802
+ throw new CliError(msg);
807
803
  }
804
+ var GROUP_BYS = ["provider", "model", "day", "project", "agent"];
805
+ var VALUED_FLAGS = ["--since", "--by", "--limit"];
808
806
  function parseArgs(argv) {
809
807
  const cmd = argv[0];
810
808
  if (!cmd || cmd.startsWith("-"))
@@ -820,69 +818,82 @@ ${USAGE}`);
820
818
  const name = `--${key}`;
821
819
  if (val !== undefined)
822
820
  flags.set(name, val);
823
- else if (i + 1 < argv.length && !argv[i + 1].startsWith("--") && ["--since", "--by", "--limit"].includes(name)) {
821
+ else if (i + 1 < argv.length && !argv[i + 1].startsWith("--") && VALUED_FLAGS.includes(name)) {
824
822
  flags.set(name, argv[++i]);
825
823
  } else
826
824
  flags.set(name, true);
827
825
  }
828
826
  return { cmd, flags };
829
827
  }
828
+ function stringFlag(flags, name) {
829
+ const v = flags.get(name);
830
+ return typeof v === "string" ? v : undefined;
831
+ }
832
+ function sinceMsOf(flags) {
833
+ if (flags.has("--today"))
834
+ return startOfDayMs();
835
+ if (!flags.has("--since"))
836
+ return Date.now() - parseDuration("7d");
837
+ const v = stringFlag(flags, "--since");
838
+ if (v === undefined)
839
+ fail("--since needs a value like 7d");
840
+ return Date.now() - parseDuration(v);
841
+ }
830
842
  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))
843
+ const sinceMs = sinceMsOf(flags);
844
+ const groupBy = stringFlag(flags, "--by") ?? "provider";
845
+ if (!GROUP_BYS.includes(groupBy))
839
846
  fail(`invalid --by: ${groupBy}`);
840
847
  const withPct = flags.has("--pct");
841
848
  if (withPct && groupBy !== "provider")
842
849
  fail("--pct only supported with --by provider");
843
850
  const snapshot = await getUsageSnapshot(sinceMs, groupBy, withPct);
844
851
  if (json) {
845
- const out = {
852
+ console.log(JSON.stringify({
846
853
  since: new Date(sinceMs).toISOString(),
847
854
  groupBy,
848
855
  rows: snapshot.rows,
849
856
  totals: snapshot.totals,
850
857
  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));
858
+ }, null, 2));
859
+ return;
856
860
  }
861
+ const label = flags.has("--today") ? "today" : stringFlag(flags, "--since") ?? "7d";
862
+ const pct = snapshot.pct ? new Map(Object.entries(snapshot.pct)) : undefined;
863
+ console.log(usageTable(snapshot.rows, snapshot.totals, label, groupBy, pct));
857
864
  }
858
865
  async function cmdProviders(flags, json) {
859
866
  const statuses = await providerStatuses({ noNet: flags.has("--no-net") });
860
867
  const db = openDb();
861
868
  try {
862
869
  const local = localUsageByProvider(db, Date.now() - parseDuration("7d"));
863
- if (json) {
870
+ if (json)
864
871
  console.log(JSON.stringify({ statuses, local: Object.fromEntries(local) }, null, 2));
865
- } else {
872
+ else
866
873
  console.log(providersTable(statuses, local));
867
- }
868
874
  } finally {
869
875
  db.close();
870
876
  }
871
877
  }
878
+ var TOP_HEADERS = ["MODEL", "NAME", "IQ", "CODING", "$/M", "IQ/$"];
879
+ var TOP_WIDTHS = [28, 26, 5, 7, 8, 6];
880
+ function topLine(cells) {
881
+ return cells.map((c, i) => c.length >= TOP_WIDTHS[i] ? c.slice(0, TOP_WIDTHS[i]) : c.padEnd(TOP_WIDTHS[i])).join(" ");
882
+ }
872
883
  async function cmdTop(flags, json) {
873
- const limit = typeof flags.get("--limit") === "string" ? Number(flags.get("--limit")) : 20;
884
+ const raw = stringFlag(flags, "--limit");
885
+ const limit = raw !== undefined ? Number(raw) : 20;
886
+ if (!Number.isFinite(limit) || limit <= 0)
887
+ fail(`invalid --limit: ${raw}`);
874
888
  const rows = await buildTop(limit);
875
889
  if (json) {
876
890
  console.log(JSON.stringify({ attribution: RANKING_ATTRIBUTION, models: rows }, null, 2));
877
891
  return;
878
892
  }
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(" "));
893
+ console.log(topLine(TOP_HEADERS));
894
+ console.log(TOP_WIDTHS.map((w) => "-".repeat(w)).join(" "));
884
895
  for (const r of rows) {
885
- console.log(line([
896
+ console.log(topLine([
886
897
  r.model,
887
898
  r.name,
888
899
  r.intelligence !== undefined ? r.intelligence.toFixed(0) : "\u2014",
@@ -894,21 +905,28 @@ async function cmdTop(flags, json) {
894
905
  console.log(`
895
906
  ${RANKING_ATTRIBUTION}`);
896
907
  }
897
- if (import.meta.main) {
898
- const { cmd, flags } = parseArgs(process.argv.slice(2));
908
+ async function run(argv) {
909
+ const { cmd, flags } = parseArgs(argv);
899
910
  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}
911
+ if (cmd === "usage")
912
+ await cmdUsage(flags, json);
913
+ else if (cmd === "providers")
914
+ await cmdProviders(flags, json);
915
+ else if (cmd === "top")
916
+ await cmdTop(flags, json);
917
+ else
918
+ fail(`unknown command: ${cmd}
909
919
 
910
920
  ${USAGE}`);
921
+ }
922
+
923
+ // packages/cli/src/cli.ts
924
+ if (import.meta.main) {
925
+ try {
926
+ await run(process.argv.slice(2));
911
927
  } catch (err) {
912
- fail(err.message);
928
+ console.error(`opencode-usage: ${err.message}
929
+ `);
930
+ process.exit(1);
913
931
  }
914
932
  }
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;
@@ -343,135 +354,130 @@ async function providerStatuses(opts) {
343
354
  var TTL_MS2 = 24 * 60 * 60 * 1000;
344
355
  // packages/core/src/budget.ts
345
356
  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
- }
357
+
394
358
  // packages/core/src/limits.ts
395
359
  var PROVIDER_LIMITS = [
396
360
  {
397
- provider: "groq",
398
- metric: "tokens/day",
399
- limit: 200000,
361
+ provider: "cerebras",
400
362
  unit: "tokens",
363
+ period: "day",
364
+ limit: 1e6,
401
365
  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."
366
+ source: "https://cerebras.ai/",
367
+ note: "Free tier estimate. Paid tiers much higher."
413
368
  },
414
369
  {
415
370
  provider: "cloudflare-workers-ai",
416
- metric: "neurons/day",
417
- limit: 1e5,
418
371
  unit: "neurons",
372
+ period: "day",
373
+ limit: 1e5,
419
374
  tier: "free",
420
375
  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."
376
+ note: "A neuron is Cloudflare's own unit, per model. Not measurable from tokens."
431
377
  },
432
378
  {
433
379
  provider: "digitalocean",
434
- metric: "tokens/day",
435
- limit: 5000000,
436
380
  unit: "tokens",
381
+ period: "day",
382
+ limit: 5000000,
437
383
  tier: "paid",
438
384
  source: "https://docs.digitalocean.com/products/genai/",
439
385
  note: "GenAI platform paid plans. Exact limits per model."
440
386
  },
441
387
  {
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."
388
+ provider: "google",
389
+ unit: "requests",
390
+ period: "day",
391
+ limit: 1500,
392
+ tier: "free",
393
+ source: "https://ai.google.dev/gemini-api/docs/rate-limits",
394
+ note: "Free tier: 1500 RPM, 1500 RPD. Paid tiers higher."
449
395
  },
450
396
  {
451
- provider: "cerebras",
452
- metric: "tokens/day",
453
- limit: 1e6,
397
+ provider: "groq",
454
398
  unit: "tokens",
399
+ period: "day",
400
+ limit: 200000,
455
401
  tier: "free",
456
- source: "https://cerebras.ai/",
457
- note: "Free tier estimate. Paid tiers much higher."
402
+ source: "https://console.groq.com/docs/rate-limits",
403
+ note: "Also 30k tokens/minute. Paid tiers higher."
404
+ },
405
+ {
406
+ provider: "nvidia",
407
+ unit: "credits",
408
+ period: "month",
409
+ limit: 1000,
410
+ tier: "free",
411
+ source: "https://build.nvidia.com/",
412
+ note: "NIM free tier ~1000 credits/month. A credit varies by model."
458
413
  },
459
414
  {
460
415
  provider: "orcarouter",
461
- metric: "tokens/day",
462
- limit: 0,
463
416
  unit: "tokens",
417
+ period: "day",
418
+ limit: 0,
464
419
  tier: "unknown",
465
420
  source: "unknown",
466
421
  note: "Proxy service. No public limits documented."
422
+ },
423
+ {
424
+ provider: "snowflake-cortex",
425
+ unit: "credits",
426
+ period: "month",
427
+ limit: 100,
428
+ tier: "paid",
429
+ source: "https://docs.snowflake.com/en/user-guide/snowflake-cortex",
430
+ note: "Credits follow warehouse time, not tokens. Not measurable from usage."
467
431
  }
468
432
  ];
433
+ var MEASURED_UNITS = new Set(["tokens", "requests"]);
434
+ function isMeasurable(limit) {
435
+ return limit.limit > 0 && MEASURED_UNITS.has(limit.unit);
436
+ }
437
+ function limitLabel(limit) {
438
+ return `${limit.limit.toLocaleString()} ${limit.unit}/${limit.period}`;
439
+ }
469
440
  function limitsAsMap() {
470
441
  const m = new Map;
471
442
  for (const l of PROVIDER_LIMITS)
472
443
  m.set(l.provider, l);
473
444
  return m;
474
445
  }
446
+
447
+ // packages/core/src/budget.ts
448
+ async function readBudgets() {
449
+ try {
450
+ return JSON.parse(await readFile3(budgetsPath(), "utf8"));
451
+ } catch {
452
+ return {};
453
+ }
454
+ }
455
+ function pctFromLimit(limit, usage) {
456
+ if (!isMeasurable(limit))
457
+ return;
458
+ const period = usage[limit.period];
459
+ const used = limit.unit === "requests" ? period.requests : period.tokens;
460
+ return used / limit.limit * 100;
461
+ }
462
+ function resolvePct(provider, sources) {
463
+ const live = sources.live.get(provider);
464
+ if (live?.budget && live.ok) {
465
+ return { pct: live.budget.percentUsed, source: "live", label: live.budget.label };
466
+ }
467
+ const budget = sources.budgets[provider];
468
+ if (budget !== undefined) {
469
+ if (budget <= 0)
470
+ return { pct: 0, source: "budgets" };
471
+ return { pct: sources.monthCost / budget * 100, source: "budgets" };
472
+ }
473
+ const limit = sources.limits.get(provider);
474
+ if (limit) {
475
+ const pct = pctFromLimit(limit, sources.usage);
476
+ if (pct !== undefined)
477
+ return { pct, source: "limits", label: limitLabel(limit) };
478
+ }
479
+ return { pct: 0, source: "none" };
480
+ }
475
481
  // packages/core/src/snapshot.ts
476
482
  async function withConnectedProviders(rows) {
477
483
  let connected;
@@ -498,42 +504,57 @@ async function withConnectedProviders(rows) {
498
504
  }));
499
505
  return [...rows, ...idle].sort((a, b) => b.cost - a.cost || b.messages - a.messages || a.provider.localeCompare(b.provider));
500
506
  }
507
+ function periodUsage(usage, provider) {
508
+ const local = usage.get(provider);
509
+ return { tokens: local?.tokens ?? 0, requests: local?.messages ?? 0 };
510
+ }
511
+ async function resolvePctByProvider(rows, day, month) {
512
+ const budgets = await readBudgets();
513
+ const live = new Map;
514
+ for (const status of await providerStatuses({ noNet: false })) {
515
+ if (status.quota)
516
+ live.set(status.provider, status.quota);
517
+ }
518
+ const limits = limitsAsMap();
519
+ const pct = {};
520
+ for (const row of rows) {
521
+ const resolved = resolvePct(row.provider, {
522
+ live,
523
+ budgets,
524
+ monthCost: month.get(row.provider)?.cost ?? 0,
525
+ usage: { day: periodUsage(day, row.provider), month: periodUsage(month, row.provider) },
526
+ limits
527
+ });
528
+ if (resolved.pct > 0 || resolved.source !== "none")
529
+ pct[row.provider] = resolved;
530
+ }
531
+ return pct;
532
+ }
501
533
  async function getUsageSnapshot(sinceMs, groupBy, includePct) {
502
534
  const db = openDb();
503
535
  try {
504
- const rows = groupBy === "provider" ? await withConnectedProviders(usageSince(db, sinceMs, groupBy)) : usageSince(db, sinceMs, groupBy);
536
+ const grouped = usageSince(db, sinceMs, groupBy);
537
+ const rows = groupBy === "provider" ? await withConnectedProviders(grouped) : grouped;
505
538
  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 };
539
+ if (!includePct)
540
+ return { rows, totals };
541
+ const day = localUsageByProvider(db, startOfDayMs());
542
+ const month = localUsageByProvider(db, startOfMonthMs());
543
+ return { rows, totals, pct: await resolvePctByProvider(rows, day, month) };
526
544
  } finally {
527
545
  db.close();
528
546
  }
529
547
  }
530
- // packages/plugin/tui.tsx
531
- import { jsx, jsxs } from "@opentui/solid/jsx-runtime";
532
- var COMMAND = "opencode-usage.show";
548
+ // packages/plugin/layout.ts
533
549
  var SIZE_WIDTH = { medium: 60, large: 88, xlarge: 116 };
534
550
  var PADDING = 1;
551
+ var WIDE_BAR = 12;
552
+ var COMPACT_BAR = 6;
553
+ var BUDGET_FIXED = WIDE_BAR + 6;
554
+ var WIDE_MIN = 80;
555
+ var BUDGET_MIN = 14;
535
556
  function columnsFor(inner) {
536
- const wide = inner >= 80;
557
+ const wide = inner >= WIDE_MIN;
537
558
  const base = wide ? [
538
559
  { title: "PROVIDER", width: 21, align: "left" },
539
560
  { title: "MSGS", width: 5, align: "right" },
@@ -548,7 +569,7 @@ function columnsFor(inner) {
548
569
  { title: "COST", width: 8, align: "right" }
549
570
  ];
550
571
  const used = base.reduce((a, c) => a + c.width, 0) + base.length;
551
- const budget = Math.max(14, inner - used);
572
+ const budget = Math.max(BUDGET_MIN, inner - used);
552
573
  return { cols: [...base, { title: "BUDGET", width: budget, align: "left" }], bar: wide ? WIDE_BAR : COMPACT_BAR };
553
574
  }
554
575
  function fmtTokens(n) {
@@ -569,9 +590,6 @@ function progressBar(pct, width) {
569
590
  const filled = Math.round(Math.min(Math.max(pct, 0), 100) / 100 * width);
570
591
  return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
571
592
  }
572
- var WIDE_BAR = 12;
573
- var COMPACT_BAR = 6;
574
- var BUDGET_FIXED = WIDE_BAR + 6;
575
593
  function compactLabel(label) {
576
594
  return label.replace(/\b\d[\d,]*\b/g, (n) => {
577
595
  const v = Number(n.replace(/,/g, ""));
@@ -601,6 +619,13 @@ function windowSuffix(label) {
601
619
  return "h";
602
620
  return "";
603
621
  }
622
+ function budgetTail(pct, label, colWidth, bar) {
623
+ const head = ` ${pct.toFixed(0).padStart(3)}% `;
624
+ const room = colWidth - bar - head.length;
625
+ const compact = compactLabel(label);
626
+ const text = compact.length <= room ? compact : windowSuffix(label);
627
+ return (head + text).slice(0, colWidth - bar);
628
+ }
604
629
  function toHex(color, fallback) {
605
630
  if (typeof color === "string")
606
631
  return color;
@@ -611,22 +636,12 @@ function toHex(color, fallback) {
611
636
  const hex = (v) => Math.round(Math.min(Math.max(v * scale, 0), 255)).toString(16).padStart(2, "0");
612
637
  return `#${hex(c.r)}${hex(c.g)}${hex(c.b)}`;
613
638
  }
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
- }
639
+ var DANGER_PCT = 90;
640
+ var WARN_PCT = 70;
626
641
  function barColor(pct, p) {
627
- if (pct >= 90)
642
+ if (pct >= DANGER_PCT)
628
643
  return p.danger;
629
- if (pct >= 70)
644
+ if (pct >= WARN_PCT)
630
645
  return p.warn;
631
646
  return p.ok;
632
647
  }
@@ -645,6 +660,23 @@ function widthNeeded(longestLabel) {
645
660
  const base = cols.slice(0, -1).reduce((a, c) => a + c.width, 0) + cols.length - 1;
646
661
  return base + BUDGET_FIXED + longestLabel;
647
662
  }
663
+
664
+ // packages/plugin/tui.tsx
665
+ import { jsx, jsxs } from "@opentui/solid/jsx-runtime";
666
+ var COMMAND = "opencode-usage.show";
667
+ var MAX_ROWS = 20;
668
+ function palette(api) {
669
+ const t = api.theme?.current;
670
+ return {
671
+ accent: toHex(t?.primary, "#a277ff"),
672
+ text: toHex(t?.text, "#e5e5e5"),
673
+ muted: toHex(t?.textMuted, "#8a8a8a"),
674
+ subtle: toHex(t?.borderSubtle, "#4a4a4a"),
675
+ ok: toHex(t?.success, "#4ade80"),
676
+ warn: toHex(t?.warning, "#facc15"),
677
+ danger: toHex(t?.error, "#f87171")
678
+ };
679
+ }
648
680
  function terminalWidth(api) {
649
681
  return api.renderer?.width ?? SIZE_WIDTH.medium;
650
682
  }
@@ -652,6 +684,10 @@ function innerWidth(api, needed) {
652
684
  const terminal = terminalWidth(api);
653
685
  return Math.min(SIZE_WIDTH[chooseSize(terminal, needed)], terminal - 2) - PADDING * 2;
654
686
  }
687
+ function neededWidth(snapshot) {
688
+ const labels = Object.values(snapshot.pct ?? {}).map((b) => compactLabel(b.label ?? b.source).length);
689
+ return widthNeeded(Math.max(0, ...labels));
690
+ }
655
691
  function Frame(props) {
656
692
  props.api.ui.dialog.setSize(chooseSize(terminalWidth(props.api), props.needed));
657
693
  return /* @__PURE__ */ jsxs("box", {
@@ -680,13 +716,6 @@ function Frame(props) {
680
716
  });
681
717
  }
682
718
  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
719
  return /* @__PURE__ */ jsxs("box", {
691
720
  flexDirection: "row",
692
721
  children: [
@@ -698,22 +727,25 @@ function Budget(props) {
698
727
  /* @__PURE__ */ jsx("text", {
699
728
  fg: props.palette.muted,
700
729
  wrapMode: "none",
701
- children: tail()
730
+ children: budgetTail(props.pct, props.label, props.col.width, props.bar)
702
731
  })
703
732
  ]
704
733
  });
705
734
  }
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, [
735
+ function leadCells(cols, r) {
736
+ const upToBudget = cols.slice(0, -1).reduce((a, c) => a + c.width, 0) + cols.length - 2;
737
+ return row(cols, [
711
738
  r.provider,
712
739
  String(r.messages),
713
740
  fmtTokens(r.tokensInput),
714
741
  fmtTokens(r.tokensOutput),
715
742
  `$${r.cost.toFixed(2)}`
716
- ]).trimEnd().padEnd(cols.slice(0, -1).reduce((a, c) => a + c.width, 0) + cols.length - 2) + " ";
743
+ ]).trimEnd().padEnd(upToBudget) + " ";
744
+ }
745
+ function Table(props) {
746
+ const p = palette(props.api);
747
+ const needed = neededWidth(props.snapshot);
748
+ const layout = () => columnsFor(innerWidth(props.api, needed));
717
749
  return /* @__PURE__ */ jsxs(Frame, {
718
750
  api: props.api,
719
751
  palette: p,
@@ -724,7 +756,7 @@ function Table(props) {
724
756
  wrapMode: "none",
725
757
  children: row(layout().cols, layout().cols.map((c) => c.title))
726
758
  }),
727
- props.snapshot.rows.slice(0, 20).map((r) => {
759
+ props.snapshot.rows.slice(0, MAX_ROWS).map((r) => {
728
760
  const budget = props.snapshot.pct?.[r.provider];
729
761
  return /* @__PURE__ */ jsxs("box", {
730
762
  flexDirection: "row",
@@ -732,7 +764,7 @@ function Table(props) {
732
764
  /* @__PURE__ */ jsx("text", {
733
765
  fg: r.messages > 0 ? p.text : p.muted,
734
766
  wrapMode: "none",
735
- children: lead(layout().cols, r)
767
+ children: leadCells(layout().cols, r)
736
768
  }),
737
769
  budget ? /* @__PURE__ */ jsx(Budget, {
738
770
  pct: budget.pct,
@@ -829,5 +861,10 @@ export {
829
861
  Message,
830
862
  Table,
831
863
  tui_default as default,
864
+ innerWidth,
865
+ neededWidth,
866
+ palette,
867
+ show,
868
+ terminalWidth,
832
869
  tui
833
870
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1930dev/opencode-usage",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Usage tracking, budget percentages and model ranking for opencode \u2014 CLI plus a /usage TUI plugin",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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",
@@ -45,6 +46,7 @@
45
46
  },
46
47
  "files": [
47
48
  "dist",
49
+ "LICENSE",
48
50
  "README.md"
49
51
  ],
50
52
  "publishConfig": {