@1930dev/opencode-usage 0.1.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 +144 -0
  2. package/dist/cli.js +922 -0
  3. package/dist/tui.js +833 -0
  4. package/package.json +58 -0
package/dist/tui.js ADDED
@@ -0,0 +1,833 @@
1
+ // @bun
2
+ // packages/core/src/types.ts
3
+ function startOfDayMs(now = Date.now()) {
4
+ const d = new Date(now);
5
+ d.setHours(0, 0, 0, 0);
6
+ return d.getTime();
7
+ }
8
+ function startOfMonthMs(now = Date.now()) {
9
+ const d = new Date(now);
10
+ d.setDate(1);
11
+ d.setHours(0, 0, 0, 0);
12
+ return d.getTime();
13
+ }
14
+ // packages/core/src/db.ts
15
+ import { Database } from "bun:sqlite";
16
+
17
+ // packages/core/src/config.ts
18
+ import path from "path";
19
+ function home() {
20
+ const h = process.env.HOME;
21
+ if (!h)
22
+ throw new Error("HOME is not set");
23
+ return h;
24
+ }
25
+ function dbPath() {
26
+ return process.env.OPENCODE_DB_PATH ?? path.join(home(), ".local/share/opencode/opencode.db");
27
+ }
28
+ function authPath() {
29
+ return process.env.OPENCODE_AUTH_PATH ?? path.join(home(), ".local/share/opencode/auth.json");
30
+ }
31
+ function cacheDir() {
32
+ return process.env.OPENCODE_USAGE_CACHE ?? path.join(home(), ".cache/opencode-usage");
33
+ }
34
+ function budgetsPath() {
35
+ return process.env.OPENCODE_USAGE_BUDGETS ?? path.join(home(), ".config/opencode-usage/budgets.json");
36
+ }
37
+
38
+ // packages/core/src/db.ts
39
+ function openDb() {
40
+ const db = new Database(dbPath(), { readonly: true });
41
+ db.exec("PRAGMA query_only = true");
42
+ return db;
43
+ }
44
+ var COST = "CAST(json_extract(data,'$.cost') AS REAL)";
45
+ var T_IN = "CAST(json_extract(data,'$.tokens.input') AS INTEGER)";
46
+ var T_OUT = "CAST(json_extract(data,'$.tokens.output') AS INTEGER)";
47
+ var T_REA = "CAST(json_extract(data,'$.tokens.reasoning') AS INTEGER)";
48
+ var T_CR = "CAST(json_extract(data,'$.tokens.cache.read') AS INTEGER)";
49
+ var T_CW = "CAST(json_extract(data,'$.tokens.cache.write') AS INTEGER)";
50
+ var CREATED = "CAST(json_extract(data,'$.time.created') AS INTEGER)";
51
+ var GROUPS = {
52
+ provider: "json_extract(data,'$.providerID')",
53
+ model: "json_extract(data,'$.providerID') || '/' || json_extract(data,'$.modelID')",
54
+ day: "strftime('%Y-%m-%d', (${" + CREATED + "})/1000, 'unixepoch')",
55
+ project: "json_extract(data,'$.path.cwd')",
56
+ agent: "json_extract(data,'$.agent')"
57
+ };
58
+ function usageSince(db, sinceMs, groupBy = "provider") {
59
+ const g = GROUPS[groupBy];
60
+ const rows = db.query(`SELECT ${g} AS grp,
61
+ COALESCE(json_extract(data,'$.providerID'),'?') AS provider,
62
+ COALESCE(json_extract(data,'$.modelID'),'?') AS model,
63
+ COUNT(*) AS messages,
64
+ COALESCE(SUM(${COST}),0) AS cost,
65
+ COALESCE(SUM(${T_IN}),0) AS tokensInput,
66
+ COALESCE(SUM(${T_OUT}),0) AS tokensOutput,
67
+ COALESCE(SUM(${T_REA}),0) AS tokensReasoning,
68
+ COALESCE(SUM(${T_CR}),0) AS tokensCacheRead,
69
+ COALESCE(SUM(${T_CW}),0) AS tokensCacheWrite
70
+ FROM message
71
+ WHERE json_extract(data,'$.role')='assistant'
72
+ AND ${CREATED} >= ?
73
+ GROUP BY grp
74
+ ORDER BY cost DESC`).all(sinceMs);
75
+ return rows.map((r) => ({
76
+ group: String(r.grp),
77
+ provider: String(r.provider),
78
+ model: String(r.model),
79
+ day: "",
80
+ project: "",
81
+ agent: "",
82
+ messages: Number(r.messages),
83
+ cost: Number(r.cost),
84
+ tokensInput: Number(r.tokensInput),
85
+ tokensOutput: Number(r.tokensOutput),
86
+ tokensReasoning: Number(r.tokensReasoning),
87
+ tokensCacheRead: Number(r.tokensCacheRead),
88
+ tokensCacheWrite: Number(r.tokensCacheWrite)
89
+ }));
90
+ }
91
+ function usageTotals(db, sinceMs) {
92
+ const r = db.query(`SELECT COUNT(*) AS messages,
93
+ COALESCE(SUM(${COST}),0) AS cost,
94
+ COALESCE(SUM(${T_IN}),0) AS tokensInput,
95
+ COALESCE(SUM(${T_OUT}),0) AS tokensOutput
96
+ FROM message
97
+ WHERE json_extract(data,'$.role')='assistant' AND ${CREATED} >= ?`).get(sinceMs);
98
+ return {
99
+ messages: Number(r?.messages ?? 0),
100
+ cost: Number(r?.cost ?? 0),
101
+ tokensInput: Number(r?.tokensInput ?? 0),
102
+ tokensOutput: Number(r?.tokensOutput ?? 0)
103
+ };
104
+ }
105
+ function monthlyUsageByProvider(db, startOfMonthMs2) {
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
108
+ FROM message
109
+ WHERE json_extract(data,'$.role')='assistant'
110
+ AND CAST(json_extract(data,'$.time.created') AS INTEGER) >= ?
111
+ GROUP BY provider`).all(startOfMonthMs2);
112
+ const map = new Map;
113
+ for (const r of rows) {
114
+ map.set(String(r.provider), Number(r.cost));
115
+ }
116
+ return map;
117
+ }
118
+ // packages/core/src/providers.ts
119
+ import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
120
+ import path2 from "path";
121
+
122
+ // packages/core/src/auth.ts
123
+ import { readFile } from "fs/promises";
124
+ async function readAuth() {
125
+ try {
126
+ return JSON.parse(await readFile(authPath(), "utf8"));
127
+ } catch (err) {
128
+ throw new Error(`cannot read opencode auth store: ${err.message}`);
129
+ }
130
+ }
131
+ function authSecret(entry) {
132
+ return entry.key ?? entry.access;
133
+ }
134
+
135
+ // packages/core/src/quota/shared.ts
136
+ var BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36";
137
+ async function getJson(url, headers) {
138
+ const res = await fetch(url, { headers: { "User-Agent": BROWSER_UA, ...headers }, signal: AbortSignal.timeout(15000) });
139
+ if (!res.ok)
140
+ throw new Error(`HTTP ${res.status} from ${url}`);
141
+ return res.json();
142
+ }
143
+
144
+ // packages/core/src/quota/zen.ts
145
+ async function fetchZen(key) {
146
+ try {
147
+ const data = await getJson("https://opencode.ai/zen/go/v1/usage", {
148
+ Authorization: `Bearer ${key}`
149
+ });
150
+ const u = data.usage;
151
+ const windows = [
152
+ { label: "5h", percentUsed: u.rolling.percent, resetsAt: u.rolling.resetsAt, status: u.rolling.status },
153
+ { label: "weekly", percentUsed: u.weekly.percent, resetsAt: u.weekly.resetsAt, status: u.weekly.status },
154
+ { label: "monthly", percentUsed: u.monthly.percent, resetsAt: u.monthly.resetsAt, status: u.monthly.status }
155
+ ];
156
+ const binding = windows.reduce((a, b) => b.percentUsed > a.percentUsed ? b : a);
157
+ return {
158
+ provider: "opencode-go",
159
+ ok: true,
160
+ windows,
161
+ budget: { percentUsed: binding.percentUsed, label: binding.label },
162
+ raw: data
163
+ };
164
+ } catch (err) {
165
+ return { provider: "opencode-go", ok: false, detail: err.message, windows: [] };
166
+ }
167
+ }
168
+
169
+ // packages/core/src/quota/openrouter.ts
170
+ async function fetchOpenRouter(key) {
171
+ try {
172
+ const credits = await getJson("https://openrouter.ai/api/v1/credits", {
173
+ Authorization: `Bearer ${key}`
174
+ });
175
+ let keyInfo;
176
+ try {
177
+ keyInfo = (await getJson("https://openrouter.ai/api/v1/key", { Authorization: `Bearer ${key}` })).data;
178
+ } catch {
179
+ keyInfo = undefined;
180
+ }
181
+ const totalCredits = credits.data.total_credits;
182
+ const totalUsage = credits.data.total_usage;
183
+ const balance = totalCredits - totalUsage;
184
+ const pct = totalCredits > 0 ? totalUsage / totalCredits * 100 : 0;
185
+ return {
186
+ provider: "openrouter",
187
+ ok: true,
188
+ detail: `balance $${balance.toFixed(2)} of $${totalCredits.toFixed(2)}`,
189
+ windows: keyInfo ? [
190
+ { label: "daily", percentUsed: 0, detail: `$${keyInfo.usage_daily.toFixed(4)}` },
191
+ { label: "weekly", percentUsed: 0, detail: `$${keyInfo.usage_weekly.toFixed(4)}` },
192
+ { label: "monthly", percentUsed: 0, detail: `$${keyInfo.usage_monthly.toFixed(4)}` }
193
+ ] : [],
194
+ budget: totalCredits > 0 ? { percentUsed: pct, label: "credits" } : undefined,
195
+ raw: { credits: credits.data, key: keyInfo }
196
+ };
197
+ } catch (err) {
198
+ return { provider: "openrouter", ok: false, detail: err.message, windows: [] };
199
+ }
200
+ }
201
+
202
+ // packages/core/src/quota/copilot.ts
203
+ function premiumBudget(s) {
204
+ if (s.unlimited && s.entitlement === 0)
205
+ return;
206
+ const remaining = s.remaining ?? s.quota_remaining;
207
+ if (remaining === undefined)
208
+ return;
209
+ const entitlement = s.entitlement;
210
+ if (entitlement <= 0)
211
+ return;
212
+ const used = (entitlement - remaining) / entitlement * 100;
213
+ return { percentUsed: used, label: "premium/mo" };
214
+ }
215
+ async function fetchCopilot(token) {
216
+ try {
217
+ const data = await getJson("https://api.github.com/copilot_internal/user", {
218
+ Authorization: `token ${token}`,
219
+ Accept: "application/json",
220
+ "Editor-Version": "vscode/1.96.2",
221
+ "Editor-Plugin-Version": "copilot-chat/0.26.7",
222
+ "X-Github-Api-Version": "2025-04-01"
223
+ });
224
+ const snaps = data.quota_snapshots ?? {};
225
+ const interesting = ["premium_interactions", "completions", "chat", "agent", "preview_features"];
226
+ const windows = Object.entries(snaps).filter(([id]) => interesting.includes(id)).map(([id, s]) => ({
227
+ label: id.replace(/_/g, " "),
228
+ percentUsed: s.unlimited && s.entitlement === 0 ? 0 : Math.round(100 - s.percent_remaining),
229
+ resetsAt: data.quota_reset_date,
230
+ detail: s.unlimited ? "unlimited" : `${s.quota_remaining} left of ${s.entitlement}`
231
+ }));
232
+ const budget = premiumBudget(snaps.premium_interactions) ?? premiumBudget(snaps.agent) ?? undefined;
233
+ return {
234
+ provider: "github-copilot",
235
+ ok: true,
236
+ detail: `plan ${data.copilot_plan}`,
237
+ windows,
238
+ budget,
239
+ raw: data
240
+ };
241
+ } catch (err) {
242
+ return { provider: "github-copilot", ok: false, detail: err.message, windows: [] };
243
+ }
244
+ }
245
+
246
+ // packages/core/src/quota/zai.ts
247
+ function parseZai(data) {
248
+ if (data.success === false || data.code !== undefined && data.code !== 0) {
249
+ return { provider: "zai", ok: false, detail: data.msg ?? "quota endpoint error", windows: [] };
250
+ }
251
+ const limits = data.data?.limits ?? [];
252
+ const budget = limits.length > 0 ? { percentUsed: limits[0].percentage ?? 0, label: limits[0].name ?? limits[0].limitType ?? "limit" } : undefined;
253
+ return {
254
+ provider: "zai",
255
+ ok: true,
256
+ detail: data.data?.planName,
257
+ windows: limits.map((l) => ({
258
+ label: l.name ?? l.limitType ?? "limit",
259
+ percentUsed: l.percentage ?? 0,
260
+ resetsAt: l.nextResetTime ? new Date(l.nextResetTime).toISOString() : undefined
261
+ })),
262
+ budget,
263
+ raw: data
264
+ };
265
+ }
266
+ async function fetchZai(key) {
267
+ try {
268
+ const data = await getJson("https://api.z.ai/api/monitor/usage/quota/limit", {
269
+ Authorization: `Bearer ${key}`,
270
+ Accept: "application/json"
271
+ });
272
+ return parseZai(data);
273
+ } catch (err) {
274
+ return { provider: "zai", ok: false, detail: err.message, windows: [] };
275
+ }
276
+ }
277
+
278
+ // packages/core/src/providers.ts
279
+ var TTL_MS = 15 * 60 * 1000;
280
+ async function readCache() {
281
+ try {
282
+ const raw = JSON.parse(await readFile2(path2.join(cacheDir(), "quota.json"), "utf8"));
283
+ if (Date.now() - raw.fetchedAt > TTL_MS)
284
+ return null;
285
+ return raw;
286
+ } catch {
287
+ return null;
288
+ }
289
+ }
290
+ async function writeCache(quotas) {
291
+ await mkdir(cacheDir(), { recursive: true });
292
+ const file = { fetchedAt: Date.now(), quotas };
293
+ await writeFile(path2.join(cacheDir(), "quota.json"), JSON.stringify(file));
294
+ }
295
+ var FETCHERS = {
296
+ "opencode-go": fetchZen,
297
+ openrouter: fetchOpenRouter,
298
+ "github-copilot": fetchCopilot,
299
+ zai: fetchZai
300
+ };
301
+ async function providerStatuses(opts) {
302
+ const auth = await readAuth();
303
+ const providers = Object.keys(auth).sort();
304
+ let cached = {};
305
+ let quotaSource = "none";
306
+ if (opts.noNet) {
307
+ const c = await readCache();
308
+ if (c) {
309
+ cached = c.quotas;
310
+ quotaSource = "cache";
311
+ }
312
+ } else {
313
+ const c = await readCache();
314
+ const fresh = {};
315
+ const live = await Promise.all(providers.map(async (p) => {
316
+ const fetcher = FETCHERS[p];
317
+ const secret = authSecret(auth[p]);
318
+ if (!fetcher || !secret)
319
+ return null;
320
+ return fetcher(secret);
321
+ }));
322
+ for (const q of live) {
323
+ if (q)
324
+ fresh[q.provider] = q;
325
+ }
326
+ if (Object.keys(fresh).length > 0) {
327
+ await writeCache(fresh);
328
+ cached = fresh;
329
+ quotaSource = "live";
330
+ } else if (c) {
331
+ cached = c.quotas;
332
+ quotaSource = "cache";
333
+ }
334
+ }
335
+ return providers.map((p) => ({
336
+ provider: p,
337
+ auth: true,
338
+ quota: cached[p] ?? null,
339
+ quotaSource: cached[p] ? quotaSource : "none"
340
+ }));
341
+ }
342
+ // packages/core/src/ranking/fetch.ts
343
+ var TTL_MS2 = 24 * 60 * 60 * 1000;
344
+ // packages/core/src/budget.ts
345
+ 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
+ }
394
+ // packages/core/src/limits.ts
395
+ var PROVIDER_LIMITS = [
396
+ {
397
+ provider: "groq",
398
+ metric: "tokens/day",
399
+ limit: 200000,
400
+ unit: "tokens",
401
+ 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."
413
+ },
414
+ {
415
+ provider: "cloudflare-workers-ai",
416
+ metric: "neurons/day",
417
+ limit: 1e5,
418
+ unit: "neurons",
419
+ tier: "free",
420
+ 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."
431
+ },
432
+ {
433
+ provider: "digitalocean",
434
+ metric: "tokens/day",
435
+ limit: 5000000,
436
+ unit: "tokens",
437
+ tier: "paid",
438
+ source: "https://docs.digitalocean.com/products/genai/",
439
+ note: "GenAI platform paid plans. Exact limits per model."
440
+ },
441
+ {
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."
449
+ },
450
+ {
451
+ provider: "cerebras",
452
+ metric: "tokens/day",
453
+ limit: 1e6,
454
+ unit: "tokens",
455
+ tier: "free",
456
+ source: "https://cerebras.ai/",
457
+ note: "Free tier estimate. Paid tiers much higher."
458
+ },
459
+ {
460
+ provider: "orcarouter",
461
+ metric: "tokens/day",
462
+ limit: 0,
463
+ unit: "tokens",
464
+ tier: "unknown",
465
+ source: "unknown",
466
+ note: "Proxy service. No public limits documented."
467
+ }
468
+ ];
469
+ function limitsAsMap() {
470
+ const m = new Map;
471
+ for (const l of PROVIDER_LIMITS)
472
+ m.set(l.provider, l);
473
+ return m;
474
+ }
475
+ // packages/core/src/snapshot.ts
476
+ async function withConnectedProviders(rows) {
477
+ let connected;
478
+ try {
479
+ connected = Object.keys(await readAuth());
480
+ } catch {
481
+ return rows;
482
+ }
483
+ const seen = new Set(rows.map((r) => r.provider));
484
+ const idle = connected.filter((provider) => !seen.has(provider)).sort().map((provider) => ({
485
+ group: provider,
486
+ provider,
487
+ model: "",
488
+ day: "",
489
+ project: "",
490
+ agent: "",
491
+ messages: 0,
492
+ cost: 0,
493
+ tokensInput: 0,
494
+ tokensOutput: 0,
495
+ tokensReasoning: 0,
496
+ tokensCacheRead: 0,
497
+ tokensCacheWrite: 0
498
+ }));
499
+ return [...rows, ...idle].sort((a, b) => b.cost - a.cost || b.messages - a.messages || a.provider.localeCompare(b.provider));
500
+ }
501
+ async function getUsageSnapshot(sinceMs, groupBy, includePct) {
502
+ const db = openDb();
503
+ try {
504
+ const rows = groupBy === "provider" ? await withConnectedProviders(usageSince(db, sinceMs, groupBy)) : usageSince(db, sinceMs, groupBy);
505
+ 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 };
526
+ } finally {
527
+ db.close();
528
+ }
529
+ }
530
+ // packages/plugin/tui.tsx
531
+ import { jsx, jsxs } from "@opentui/solid/jsx-runtime";
532
+ var COMMAND = "opencode-usage.show";
533
+ var SIZE_WIDTH = { medium: 60, large: 88, xlarge: 116 };
534
+ var PADDING = 1;
535
+ function columnsFor(inner) {
536
+ const wide = inner >= 80;
537
+ const base = wide ? [
538
+ { title: "PROVIDER", width: 21, align: "left" },
539
+ { title: "MSGS", width: 5, align: "right" },
540
+ { title: "TOK IN", width: 8, align: "right" },
541
+ { title: "TOK OUT", width: 8, align: "right" },
542
+ { title: "COST", width: 9, align: "right" }
543
+ ] : [
544
+ { title: "PROVIDER", width: 15, align: "left" },
545
+ { title: "MSGS", width: 4, align: "right" },
546
+ { title: "IN", width: 6, align: "right" },
547
+ { title: "OUT", width: 6, align: "right" },
548
+ { title: "COST", width: 8, align: "right" }
549
+ ];
550
+ const used = base.reduce((a, c) => a + c.width, 0) + base.length;
551
+ const budget2 = Math.max(14, inner - used);
552
+ return { cols: [...base, { title: "BUDGET", width: budget2, align: "left" }], bar: wide ? WIDE_BAR : COMPACT_BAR };
553
+ }
554
+ function fmtTokens(n) {
555
+ if (n >= 1e6)
556
+ return `${(n / 1e6).toFixed(1)}M`;
557
+ if (n >= 1000)
558
+ return `${(n / 1000).toFixed(1)}K`;
559
+ return String(n);
560
+ }
561
+ function pad(s, width, align) {
562
+ const v = s.length > width ? s.slice(0, width) : s;
563
+ return align === "right" ? v.padStart(width) : v.padEnd(width);
564
+ }
565
+ function row(cols, values) {
566
+ return cols.map((c, i) => pad(values[i] ?? "", c.width, c.align)).join(" ");
567
+ }
568
+ function progressBar(pct, width) {
569
+ const filled = Math.round(Math.min(Math.max(pct, 0), 100) / 100 * width);
570
+ return "\u2588".repeat(filled) + "\u2591".repeat(width - filled);
571
+ }
572
+ var WIDE_BAR = 12;
573
+ var COMPACT_BAR = 6;
574
+ var BUDGET_FIXED = WIDE_BAR + 6;
575
+ function compactLabel(label) {
576
+ return label.replace(/\b\d[\d,]*\b/g, (n) => {
577
+ const v = Number(n.replace(/,/g, ""));
578
+ if (!Number.isFinite(v))
579
+ return n;
580
+ if (v >= 1e6)
581
+ return `${+(v / 1e6).toFixed(1)}M`;
582
+ if (v >= 1000)
583
+ return `${+(v / 1000).toFixed(1)}K`;
584
+ return String(v);
585
+ }).replace(/\btokens\b/g, "tok").replace(/\brequests\b/g, "req");
586
+ }
587
+ function windowSuffix(label) {
588
+ const l = label.toLowerCase();
589
+ const hours = l.match(/\b(\d+)\s*h\b/);
590
+ if (hours)
591
+ return `${hours[1]}h`;
592
+ if (/\byear|\/yr\b/.test(l))
593
+ return "yr";
594
+ if (/\bmonth|\/mo\b/.test(l))
595
+ return "mo";
596
+ if (/\bweek|\/wk\b/.test(l))
597
+ return "wk";
598
+ if (/\bday|daily|\/d\b/.test(l))
599
+ return "d";
600
+ if (/\bhour/.test(l))
601
+ return "h";
602
+ return "";
603
+ }
604
+ function toHex(color, fallback) {
605
+ if (typeof color === "string")
606
+ return color;
607
+ const c = color;
608
+ if (!c || typeof c.r !== "number" || typeof c.g !== "number" || typeof c.b !== "number")
609
+ return fallback;
610
+ const scale = c.r <= 1 && c.g <= 1 && c.b <= 1 ? 255 : 1;
611
+ const hex = (v) => Math.round(Math.min(Math.max(v * scale, 0), 255)).toString(16).padStart(2, "0");
612
+ return `#${hex(c.r)}${hex(c.g)}${hex(c.b)}`;
613
+ }
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
+ }
626
+ function barColor(pct, p) {
627
+ if (pct >= 90)
628
+ return p.danger;
629
+ if (pct >= 70)
630
+ return p.warn;
631
+ return p.ok;
632
+ }
633
+ function chooseSize(terminal, needed) {
634
+ const held = (s) => terminal >= SIZE_WIDTH[s] + 2;
635
+ if (held("large") && SIZE_WIDTH.large - PADDING * 2 >= needed)
636
+ return "large";
637
+ if (held("xlarge"))
638
+ return "xlarge";
639
+ if (held("large"))
640
+ return "large";
641
+ return "medium";
642
+ }
643
+ function widthNeeded(longestLabel) {
644
+ const { cols } = columnsFor(SIZE_WIDTH.xlarge);
645
+ const base = cols.slice(0, -1).reduce((a, c) => a + c.width, 0) + cols.length - 1;
646
+ return base + BUDGET_FIXED + longestLabel;
647
+ }
648
+ function terminalWidth(api) {
649
+ return api.renderer?.width ?? SIZE_WIDTH.medium;
650
+ }
651
+ function innerWidth(api, needed) {
652
+ const terminal = terminalWidth(api);
653
+ return Math.min(SIZE_WIDTH[chooseSize(terminal, needed)], terminal - 2) - PADDING * 2;
654
+ }
655
+ function Frame(props) {
656
+ props.api.ui.dialog.setSize(chooseSize(terminalWidth(props.api), props.needed));
657
+ return /* @__PURE__ */ jsxs("box", {
658
+ flexDirection: "column",
659
+ flexShrink: 0,
660
+ padding: PADDING,
661
+ children: [
662
+ /* @__PURE__ */ jsxs("box", {
663
+ flexDirection: "row",
664
+ justifyContent: "space-between",
665
+ children: [
666
+ /* @__PURE__ */ jsx("text", {
667
+ fg: props.palette.text,
668
+ bold: true,
669
+ children: "Usage \u2014 today"
670
+ }),
671
+ /* @__PURE__ */ jsx("text", {
672
+ fg: props.palette.muted,
673
+ children: "esc"
674
+ })
675
+ ]
676
+ }),
677
+ /* @__PURE__ */ jsx("text", {}),
678
+ props.children
679
+ ]
680
+ });
681
+ }
682
+ 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
+ return /* @__PURE__ */ jsxs("box", {
691
+ flexDirection: "row",
692
+ children: [
693
+ /* @__PURE__ */ jsx("text", {
694
+ fg: barColor(props.pct, props.palette),
695
+ wrapMode: "none",
696
+ children: progressBar(props.pct, props.bar)
697
+ }),
698
+ /* @__PURE__ */ jsx("text", {
699
+ fg: props.palette.muted,
700
+ wrapMode: "none",
701
+ children: tail()
702
+ })
703
+ ]
704
+ });
705
+ }
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, [
711
+ r.provider,
712
+ String(r.messages),
713
+ fmtTokens(r.tokensInput),
714
+ fmtTokens(r.tokensOutput),
715
+ `$${r.cost.toFixed(2)}`
716
+ ]).trimEnd().padEnd(cols.slice(0, -1).reduce((a, c) => a + c.width, 0) + cols.length - 2) + " ";
717
+ return /* @__PURE__ */ jsxs(Frame, {
718
+ api: props.api,
719
+ palette: p,
720
+ needed,
721
+ children: [
722
+ /* @__PURE__ */ jsx("text", {
723
+ fg: p.accent,
724
+ wrapMode: "none",
725
+ children: row(layout().cols, layout().cols.map((c) => c.title))
726
+ }),
727
+ props.snapshot.rows.slice(0, 20).map((r) => {
728
+ const budget2 = props.snapshot.pct?.[r.provider];
729
+ return /* @__PURE__ */ jsxs("box", {
730
+ flexDirection: "row",
731
+ children: [
732
+ /* @__PURE__ */ jsx("text", {
733
+ fg: r.messages > 0 ? p.text : p.muted,
734
+ wrapMode: "none",
735
+ children: lead(layout().cols, r)
736
+ }),
737
+ budget2 ? /* @__PURE__ */ jsx(Budget, {
738
+ pct: budget2.pct,
739
+ label: budget2.label ?? budget2.source,
740
+ palette: p,
741
+ col: layout().cols[layout().cols.length - 1],
742
+ bar: layout().bar
743
+ }) : null
744
+ ]
745
+ });
746
+ }),
747
+ /* @__PURE__ */ jsx("text", {}),
748
+ /* @__PURE__ */ jsx("text", {
749
+ fg: p.text,
750
+ bold: true,
751
+ wrapMode: "none",
752
+ children: row(layout().cols, [
753
+ "TOTAL",
754
+ String(props.snapshot.totals.messages),
755
+ "",
756
+ "",
757
+ `$${props.snapshot.totals.cost.toFixed(2)}`
758
+ ])
759
+ })
760
+ ]
761
+ });
762
+ }
763
+ function Message(props) {
764
+ const p = palette(props.api);
765
+ return /* @__PURE__ */ jsx(Frame, {
766
+ api: props.api,
767
+ palette: p,
768
+ needed: 0,
769
+ children: /* @__PURE__ */ jsx("text", {
770
+ fg: props.color ?? p.muted,
771
+ wrapMode: "none",
772
+ children: props.text
773
+ })
774
+ });
775
+ }
776
+ async function show(api, dialog) {
777
+ dialog.replace(() => /* @__PURE__ */ jsx(Message, {
778
+ api,
779
+ text: "Loading usage\u2026"
780
+ }));
781
+ let snapshot2;
782
+ try {
783
+ snapshot2 = await getUsageSnapshot(startOfDayMs(), "provider", true);
784
+ } catch (err) {
785
+ const message = err instanceof Error ? err.message : String(err);
786
+ dialog.replace(() => /* @__PURE__ */ jsx(Message, {
787
+ api,
788
+ text: `Failed to read usage: ${message}`,
789
+ color: palette(api).danger
790
+ }));
791
+ return;
792
+ }
793
+ dialog.replace(() => /* @__PURE__ */ jsx(Table, {
794
+ api,
795
+ snapshot: snapshot2
796
+ }));
797
+ }
798
+ var tui = async (api) => {
799
+ const keymap = api.keymap;
800
+ if (typeof keymap.registerLayer === "function") {
801
+ keymap.registerLayer({
802
+ commands: [
803
+ {
804
+ namespace: "palette",
805
+ name: COMMAND,
806
+ title: "Usage",
807
+ desc: "Show opencode usage and budget per provider",
808
+ category: "Usage",
809
+ slashName: "usage",
810
+ run: () => show(api, api.ui.dialog)
811
+ }
812
+ ]
813
+ });
814
+ return;
815
+ }
816
+ api.command?.register(() => [
817
+ {
818
+ title: "Usage",
819
+ description: "Show opencode usage and budget per provider",
820
+ value: COMMAND,
821
+ category: "Usage",
822
+ slash: { name: "usage" },
823
+ onSelect: (dialog) => show(api, dialog ?? api.ui.dialog)
824
+ }
825
+ ]);
826
+ };
827
+ var tui_default = { id: "opencode-usage", tui };
828
+ export {
829
+ tui,
830
+ tui_default as default,
831
+ Table,
832
+ Message
833
+ };