oc-go-usage-display 1.0.2 → 2.0.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 (41) hide show
  1. package/README.md +249 -68
  2. package/bin/lib.js +433 -29
  3. package/bin/oc-go-usage-display-init.js +42 -13
  4. package/bin/oc-go-usage-display-remove.js +38 -0
  5. package/bin/oc-go-usage-display-show.js +47 -39
  6. package/bin/oc-go-usage-display-status.js +33 -42
  7. package/bin/oc-go-usage-display-update.js +27 -14
  8. package/dist/helpers.d.ts +116 -0
  9. package/dist/helpers.d.ts.map +1 -0
  10. package/dist/helpers.js +568 -0
  11. package/dist/helpers.js.map +1 -0
  12. package/dist/index.d.ts +5 -9
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +258 -278
  15. package/dist/index.js.map +1 -1
  16. package/dist/plugins/oc-go-usage-display.kilo.ts +678 -0
  17. package/dist/plugins/oc-go-usage-display.kilo.tsx +1272 -0
  18. package/dist/plugins/oc-go-usage-display.ts +678 -0
  19. package/dist/plugins/oc-go-usage-display.tsx +1018 -0
  20. package/dist/shared.d.ts +96 -0
  21. package/dist/shared.d.ts.map +1 -0
  22. package/dist/shared.js +575 -0
  23. package/dist/shared.js.map +1 -0
  24. package/dist/tui-shared.d.ts +109 -0
  25. package/dist/tui-shared.d.ts.map +1 -0
  26. package/dist/tui-shared.js +372 -0
  27. package/dist/tui-shared.js.map +1 -0
  28. package/dist/tui.d.ts.map +1 -1
  29. package/dist/tui.js +226 -377
  30. package/dist/tui.js.map +1 -1
  31. package/dist/tui.kilo.d.ts +8 -0
  32. package/dist/tui.kilo.d.ts.map +1 -0
  33. package/dist/tui.kilo.js +502 -0
  34. package/dist/tui.kilo.js.map +1 -0
  35. package/package.json +47 -13
  36. package/src/helpers.ts +733 -0
  37. package/src/index.ts +331 -304
  38. package/src/shared.ts +696 -0
  39. package/src/tui-shared.tsx +705 -0
  40. package/src/tui.kilo.tsx +816 -0
  41. package/src/tui.tsx +356 -465
package/src/helpers.ts ADDED
@@ -0,0 +1,733 @@
1
+ // Pure helpers shared by the plugin entry modules.
2
+ //
3
+ // IMPORTANT: these live OUTSIDE `src/index.ts` / `src/tui.tsx`. OpenCode's
4
+ // loader enumerates every export of a plugin entry module and (when the
5
+ // default is not a `{ id, server }` module) invokes each one as a plugin
6
+ // factory. An entry module must therefore export ONLY its default module;
7
+ // keeping the helper surface here makes that contract structural instead of
8
+ // accidental (a stray `export function` returning null used to crash
9
+ // `Provider.list`).
10
+ //
11
+ // Unit-tested via `dist/helpers.js` (see test/unit/server.test.js and
12
+ // test/unit/tui.test.js).
13
+
14
+ import * as fs from "node:fs";
15
+ import {
16
+ resolveHostRoots,
17
+ formatResetDuration,
18
+ hostEnv,
19
+ isRecord,
20
+ resolveUsageHost,
21
+ safeJoinPath,
22
+ toNonEmptyString,
23
+ toUsageCount,
24
+ CACHE_RATE_DECIMALS,
25
+ CACHE_RATE_EMPTY,
26
+ GO_MODEL_MIX_BUDGET,
27
+ GO_MODEL_MIX_NAME_MAX_CHARS,
28
+ GO_MODEL_MIX_SEPARATOR,
29
+ GO_PROVIDER_ID,
30
+ PERCENT_CELL_WIDTH,
31
+ KILO_MODEL_NAME_MAX_CHARS,
32
+ KILO_TOKEN_USAGE_ROWS,
33
+ TOP_GO_MODELS_LIMIT,
34
+ } from "./shared.js";
35
+ import type { ModelUsage, UsageHost, UsageSnapshot, UsageTokens, UsageWindow } from "./shared.js";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Server: compact one-line snapshot summary (keeps the rolling reset suffix)
39
+ // ---------------------------------------------------------------------------
40
+
41
+ function formatWindow(window: UsageWindow | null): string {
42
+ if (!window) return "n/a";
43
+ return `${window.percent}%`;
44
+ }
45
+
46
+ export function formatServerLine(snapshot: UsageSnapshot): string {
47
+ if (snapshot.apiUnavailable || (!snapshot.rolling && !snapshot.weekly && !snapshot.monthly)) {
48
+ const reason = snapshot.apiError ?? "unknown error";
49
+ return `Go n/a (${reason})`;
50
+ }
51
+ const rollingReset =
52
+ formatResetDuration(snapshot.rolling?.resetInSec ?? null) ??
53
+ snapshot.rolling?.resetText ??
54
+ null;
55
+ const rollingText =
56
+ snapshot.rolling === null
57
+ ? "5h n/a"
58
+ : `5h ${snapshot.rolling.percent}%${rollingReset ? ` (reset ${rollingReset})` : ""}`;
59
+ return `Go ${rollingText} | 7d ${formatWindow(snapshot.weekly)} | 30d ${formatWindow(snapshot.monthly)}`;
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Server: auth cookie boundary (malformed-cookie rejection; never logged)
64
+ // ---------------------------------------------------------------------------
65
+
66
+ export type FileConfig = { workspaceId: string | null; authCookie: string | null };
67
+
68
+ export function fileConfigPath(host: UsageHost = "opencode"): string {
69
+ return safeJoinPath(resolveHostRoots(host).configDir, "oc-go-usage-display.json");
70
+ }
71
+
72
+ export function readFileConfig(host: UsageHost = "opencode"): FileConfig {
73
+ let raw: string;
74
+ try {
75
+ raw = fs.readFileSync(fileConfigPath(host), "utf8");
76
+ } catch {
77
+ return { workspaceId: null, authCookie: null };
78
+ }
79
+ let parsed: unknown;
80
+ try {
81
+ parsed = JSON.parse(raw);
82
+ } catch {
83
+ return { workspaceId: null, authCookie: null };
84
+ }
85
+ if (!isRecord(parsed)) return { workspaceId: null, authCookie: null };
86
+ return {
87
+ workspaceId: toNonEmptyString(parsed.workspaceId),
88
+ authCookie: toNonEmptyString(parsed.authCookie),
89
+ };
90
+ }
91
+
92
+ export function isMalformedAuthCookie(cookie: string): boolean {
93
+ // Reject CR/LF (header injection), separators that confuse cookie jars,
94
+ // plus tab, NUL, and double-quote (never valid in a cookie value).
95
+ return /[\r\n;,\t\0"]/.test(cookie);
96
+ }
97
+
98
+ export function hasMalformedAuthCookie(
99
+ fileConfig: FileConfig = readFileConfig(),
100
+ host: UsageHost = resolveUsageHost(),
101
+ ): boolean {
102
+ const raw = toNonEmptyString(hostEnv(host, "AUTH_COOKIE")) ?? fileConfig.authCookie;
103
+ if (!raw) return false;
104
+ return isMalformedAuthCookie(raw);
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Server: cookie-fetch redirect policy
109
+ // ---------------------------------------------------------------------------
110
+ //
111
+ // The workspace scrape carries the user's `auth` cookie. Automatic redirect
112
+ // following may re-send caller headers (including the cookie) to a cross-origin
113
+ // Location, and runtime header stripping cannot be relied on to prevent that.
114
+ // The cookie path therefore requests `redirect: "manual"` and re-sends only
115
+ // after these helpers approve a Location on the allowlisted canonical HTTPS
116
+ // hosts. `fetchViaApiKey` is untouched (anonymous API path).
117
+
118
+ const ALLOWED_REDIRECT_HOSTS = new Set(["opencode.ai", "auth.opencode.ai"]);
119
+
120
+ // Follow at most this many redirects before giving up (a redirect loop or an
121
+ // endless login bounce becomes a clear unavailable snapshot instead of a hang).
122
+ export const MAX_REDIRECT_HOPS = 3;
123
+
124
+ export type RedirectDecision =
125
+ | { follow: true; url: string }
126
+ | { follow: false; reason: string };
127
+
128
+ // Only the canonical HTTPS origins may receive the auth cookie: no explicit
129
+ // ports, no userinfo, and an exact allowlisted hostname (so `opencode.ai.evil`
130
+ // never matches).
131
+ function isAllowedHttpsUrl(url: URL): boolean {
132
+ return (
133
+ url.protocol === "https:" &&
134
+ url.username === "" &&
135
+ url.password === "" &&
136
+ url.port === "" &&
137
+ ALLOWED_REDIRECT_HOSTS.has(url.hostname)
138
+ );
139
+ }
140
+
141
+ // Resolve `location` against `fromUrl`; null unless BOTH the current URL and
142
+ // the resolved target are allowed HTTPS origins.
143
+ function resolveRedirectUrl(fromUrl: string, location: string): URL | null {
144
+ let base: URL;
145
+ try {
146
+ base = new URL(fromUrl);
147
+ } catch {
148
+ return null;
149
+ }
150
+ if (!isAllowedHttpsUrl(base)) return null;
151
+ let next: URL;
152
+ try {
153
+ next = new URL(location, base);
154
+ } catch {
155
+ return null;
156
+ }
157
+ return isAllowedHttpsUrl(next) ? next : null;
158
+ }
159
+
160
+ function redirectTargetHost(fromUrl: string, location: string): string | null {
161
+ try {
162
+ const host = new URL(location, fromUrl).host;
163
+ return host.length > 0 ? host : null;
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
168
+
169
+ // True when `location` resolves to an allowed canonical HTTPS host relative to
170
+ // an allowed `fromUrl`. Relative locations inherit the current host.
171
+ export function isAllowedRedirect(fromUrl: string, location: string): boolean {
172
+ return resolveRedirectUrl(fromUrl, location) !== null;
173
+ }
174
+
175
+ // Pure redirect decision: follow (with the resolved absolute URL) or stop with
176
+ // a user-facing reason. Never throws.
177
+ export function resolveAllowedRedirect(
178
+ fromUrl: string,
179
+ location: string | null,
180
+ hopsFollowed: number,
181
+ ): RedirectDecision {
182
+ if (hopsFollowed >= MAX_REDIRECT_HOPS) {
183
+ return { follow: false, reason: `too many redirects (limit ${MAX_REDIRECT_HOPS})` };
184
+ }
185
+ if (location === null || location.trim().length === 0) {
186
+ return { follow: false, reason: "redirect without a Location header" };
187
+ }
188
+ const next = resolveRedirectUrl(fromUrl, location);
189
+ if (next === null) {
190
+ const host = redirectTargetHost(fromUrl, location);
191
+ return {
192
+ follow: false,
193
+ reason: `redirect blocked (target not allowed${host ? `: ${host}` : ""})`,
194
+ };
195
+ }
196
+ return { follow: true, url: next.toString() };
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // TUI: display-mode + snapshot shaping
201
+ // ---------------------------------------------------------------------------
202
+
203
+ export type DisplayMode = "sidebar" | "statusline" | "both";
204
+
205
+ export type SurfaceSelection = {
206
+ sidebar: boolean;
207
+ statusline: boolean;
208
+ };
209
+
210
+ // One label/value row, the grammar the host's own panels use.
211
+ export type UsageRow = {
212
+ label: string;
213
+ value: string;
214
+ };
215
+
216
+ export function isDisplayMode(value: unknown): value is DisplayMode {
217
+ return value === "sidebar" || value === "statusline" || value === "both";
218
+ }
219
+
220
+ // Where the Go block sits in Kilo's sidebar ladder:
221
+ // - `integrated` — takes over the host's own token-usage band and retires
222
+ // its panel, so both usage readouts are one block instead of two.
223
+ // - `standalone` — a free band of our own, host panel untouched.
224
+ export type SidebarMode = "integrated" | "standalone";
225
+
226
+ export const DEFAULT_SIDEBAR_MODE: SidebarMode = "integrated";
227
+
228
+ // Tolerant like `parseBooleanFlag`, because the same value reaches us from a
229
+ // hand-written env var and from typed JSON: null means "unset or unrecognized",
230
+ // and the caller keeps falling through to the next source.
231
+ export function parseSidebarMode(value: unknown): SidebarMode | null {
232
+ if (typeof value !== "string") return null;
233
+ const normalized = value.trim().toLowerCase();
234
+ return normalized === "integrated" || normalized === "standalone" ? normalized : null;
235
+ }
236
+
237
+ // Which provider a session is actually running, for the Go-only display gate.
238
+ // Pure so the precedence is unit-testable: the TUI entry reads this on every
239
+ // render rather than latching a value at init.
240
+ export type ProviderSource = {
241
+ config?: { model?: unknown } | undefined;
242
+ session?: { get?: ((sessionID: string) => { model?: { providerID?: unknown } | undefined } | undefined) | undefined } | undefined;
243
+ };
244
+
245
+ export function providerIdFromModel(model: unknown): string | undefined {
246
+ if (typeof model !== "string" || model.length === 0) return undefined;
247
+ const provider = model.split("/")[0];
248
+ return provider !== undefined && provider.length > 0 ? provider : undefined;
249
+ }
250
+
251
+ export function resolveProviderId(
252
+ state: ProviderSource | undefined,
253
+ sessionId: string,
254
+ fallback: string | undefined,
255
+ ): string | undefined {
256
+ // The session's own model is the one in use, so it outranks the config default.
257
+ try {
258
+ const fromSession = state?.session?.get?.(sessionId)?.model?.providerID;
259
+ if (typeof fromSession === "string" && fromSession.length > 0) return fromSession;
260
+ } catch {
261
+ // Fall through: a throwing store is not a reason to hide the panel.
262
+ }
263
+ try {
264
+ const fromConfig = providerIdFromModel(state?.config?.model);
265
+ if (fromConfig !== undefined) return fromConfig;
266
+ } catch {
267
+ // Fall through to the event-signal fallback.
268
+ }
269
+ return fallback;
270
+ }
271
+
272
+ export function surfaceSelectionFromDisplayMode(mode: DisplayMode): SurfaceSelection {
273
+ return { sidebar: mode !== "statusline", statusline: mode !== "sidebar" };
274
+ }
275
+
276
+ export function parseBooleanFlag(value: unknown): boolean | null {
277
+ if (typeof value === "boolean") return value;
278
+ if (typeof value === "number") {
279
+ if (value === 1) return true;
280
+ if (value === 0) return false;
281
+ return null;
282
+ }
283
+ if (typeof value !== "string") return null;
284
+ const normalized = value.trim().toLowerCase();
285
+ if (normalized === "1" || normalized === "true") return true;
286
+ if (normalized === "0" || normalized === "false") return false;
287
+ return null;
288
+ }
289
+
290
+ export function isSnapshotEmpty(snapshot: UsageSnapshot): boolean {
291
+ return snapshot.rolling === null && snapshot.weekly === null && snapshot.monthly === null;
292
+ }
293
+
294
+ // The countdown the plan is currently waiting on, as the label of the window it
295
+ // belongs to plus the text to print. `null` when the plan has no usable reset
296
+ // at all, and every surface then prints nothing rather than a placeholder.
297
+ export type ResetCountdown = { label: string; text: string };
298
+
299
+ // Which window's countdown the plan is waiting on. One decision, read by the
300
+ // sidebar's reset line, by the per-row suffixes under a capped meter and by the
301
+ // statusline, so no two of them can name different windows.
302
+ //
303
+ // A capped window outranks a nearer one, and the longest cap outranks the
304
+ // shorter ones: an exhausted `30d` is what actually stops work, so a `5h` that
305
+ // rolls over in two hours is not the fact worth the cells. With nothing capped
306
+ // the soonest reset is the answer, because that is the first moment the
307
+ // percentages printed beside it will move.
308
+ //
309
+ // The capped case is read back out of `buildPlanRows` instead of re-deriving
310
+ // what "capped" means, so the statusline cannot drift from the sidebar. Only
311
+ // `resetInSec` is ever compared; a window that carries nothing but the host's
312
+ // free text can be printed once it has been chosen, never compared as a string.
313
+ export function relevantReset(snapshot: UsageSnapshot): ResetCountdown | null {
314
+ const rows = buildPlanRows(snapshot);
315
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
316
+ const row = rows[i];
317
+ if (row?.reset != null) return { label: row.label, text: row.reset };
318
+ }
319
+ return soonestReset(snapshot);
320
+ }
321
+
322
+ // The soonest reset across the windows, capped or not. A stale payload, clock
323
+ // skew or a reset that fired mid-flight can all send a negative span, and an
324
+ // elapsed countdown is as unusable as an unreadable one.
325
+ function soonestReset(snapshot: UsageSnapshot): ResetCountdown | null {
326
+ const windows: ReadonlyArray<readonly [string, UsageWindow | null]> = [
327
+ ["5h", snapshot.rolling],
328
+ ["7d", snapshot.weekly],
329
+ ["30d", snapshot.monthly],
330
+ ];
331
+ let soonest: { label: string; seconds: number } | null = null;
332
+ for (const [name, window] of windows) {
333
+ if (window === null) continue;
334
+ const seconds = window.resetInSec;
335
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) continue;
336
+ if (soonest !== null && seconds >= soonest.seconds) continue;
337
+ soonest = { label: name, seconds };
338
+ }
339
+ if (soonest === null) return null;
340
+ const text = formatResetDuration(soonest.seconds);
341
+ return text === null ? null : { label: soonest.label, text };
342
+ }
343
+
344
+ export function formatStatusline(snapshot: UsageSnapshot): string {
345
+ const rolling = snapshot.rolling === null ? "5h n/a" : `5h ${snapshot.rolling.percent}%`;
346
+ const weekly = snapshot.weekly === null ? "7d n/a" : `7d ${snapshot.weekly.percent}%`;
347
+ const monthly = snapshot.monthly === null ? "30d n/a" : `30d ${snapshot.monthly.percent}%`;
348
+ const windows = `Go ${rolling} | ${weekly} | ${monthly}`;
349
+ // The same countdown the sidebar shows, without its window label: the
350
+ // percentages are right there in the same order, so naming the window again
351
+ // would spend cells restating what the line already says.
352
+ const reset = relevantReset(snapshot);
353
+ return reset === null ? windows : `${windows} · resets in ${reset.text}`;
354
+ }
355
+
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // TUI: plan meters (gauge fill + threshold severity)
359
+ // ---------------------------------------------------------------------------
360
+ //
361
+ // Severity is a name, not a color: this module is inlined into the server
362
+ // bundle too, and only the TUI entry can resolve it against a theme.
363
+
364
+ export type UsageMeterSeverity = "muted" | "warning" | "error";
365
+
366
+ const WARNING_PERCENT = 75;
367
+ const ERROR_PERCENT = 90;
368
+
369
+ // A capped window is a hard stop rather than a percentage: `limited` wins over
370
+ // whatever the gauge says, so a window that is capped at 3% still reads as error
371
+ // instead of reassuring the eye.
372
+ export function usageMeterSeverity(window: UsageWindow): UsageMeterSeverity {
373
+ if (window.limited) return "error";
374
+ return meterSeverityForPercent(window.percent);
375
+ }
376
+
377
+ // The threshold ladder on its own, so a gauge that is not backed by a plan
378
+ // window (the per-model Go share) colors by the same rules.
379
+ export function meterSeverityForPercent(percent: number): UsageMeterSeverity {
380
+ if (!Number.isFinite(percent)) return "muted";
381
+ if (percent >= ERROR_PERCENT) return "error";
382
+ if (percent >= WARNING_PERCENT) return "warning";
383
+ return "muted";
384
+ }
385
+
386
+ // The meter's filled fraction, clamped to 0-100: the API can report a percent
387
+ // outside the range and a NaN would be a layout error rather than a drawing one.
388
+ //
389
+ // The meter itself is TWO BOXES -- a filled one at this percentage and a track
390
+ // for the rest -- not a string of block glyphs. A glyph string has a fixed cell
391
+ // count, and a plugin cannot measure the sidebar it is rendering into (opentui
392
+ // resolves a text node's `width` as a wrapping bound, and no layout callback
393
+ // reaches a plugin), so a glyph meter is either too short for a wide sidebar or
394
+ // clipped by a narrow one. Boxes fill whatever the row gives them, which is why
395
+ // the meter now reaches the sidebar's right edge on any host and any width.
396
+ export function meterFillPercent(percent: number): number {
397
+ if (!Number.isFinite(percent)) return 0;
398
+ return Math.min(Math.max(percent, 0), 100);
399
+ }
400
+
401
+ export type PlanRow = {
402
+ label: string;
403
+ percent: number;
404
+ severity: UsageMeterSeverity;
405
+ reset: string | null;
406
+ };
407
+
408
+ // `meterWidth` is the sidebar's budget, not the bar's design: 16 cells fit next
409
+ // to Kilo's model table, 10 fit opencode's ~30-cell sidebar. The glyphs and the
410
+ // saturating fill are identical, so two widths are the same bar at two sizes.
411
+ export function buildPlanRows(snapshot: UsageSnapshot): PlanRow[] {
412
+ const windows: ReadonlyArray<readonly [string, UsageWindow | null]> = [
413
+ ["5h", snapshot.rolling],
414
+ ["7d", snapshot.weekly],
415
+ ["30d", snapshot.monthly],
416
+ ];
417
+ const rows: PlanRow[] = [];
418
+ for (const [label, window] of windows) {
419
+ if (window === null) continue;
420
+ rows.push({
421
+ label,
422
+ percent: window.percent,
423
+ severity: usageMeterSeverity(window),
424
+ // A countdown only tells the user something once the window is capped;
425
+ // before that the schedule is noise, and the header rows already show it.
426
+ reset: window.limited ? formatResetDuration(window.resetInSec) ?? window.resetText : null,
427
+ });
428
+ }
429
+ return rows;
430
+ }
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // TUI: session model usage (the integrated panel's replacement for Kilo's own
434
+ // token-usage band)
435
+ // ---------------------------------------------------------------------------
436
+ //
437
+ // The host hands over raw counts and a USD cost with no display metadata, so
438
+ // every number crosses a formatter. The formatters below guard first: a bare
439
+ // `Intl.NumberFormat` prints NaN as "NaN" and Infinity as "∞", and either one
440
+ // silently stretches a sidebar column that is width-budgeted by the host.
441
+
442
+ const COUNT_FORMAT = new Intl.NumberFormat("en-US");
443
+ const CURRENCY_FORMAT = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
444
+
445
+ export function formatUsageCount(value: unknown): string {
446
+ return COUNT_FORMAT.format(toUsageCount(value));
447
+ }
448
+
449
+ export function formatUsageCost(value: unknown): string {
450
+ return CURRENCY_FORMAT.format(toUsageCount(value));
451
+ }
452
+
453
+ // Every bucket a token can land in. Cache is included because cached reads and
454
+ // writes are billed work too; excluding them would make the per-model totals
455
+ // disagree with the host's own `totals` row.
456
+ export function usageTokenCount(tokens: UsageTokens): number {
457
+ return (
458
+ toUsageCount(tokens.input) +
459
+ toUsageCount(tokens.output) +
460
+ toUsageCount(tokens.reasoning) +
461
+ toUsageCount(tokens.cache.read) +
462
+ toUsageCount(tokens.cache.write)
463
+ );
464
+ }
465
+
466
+ // Kilo's cache rate: the read share of the three buckets a cache hit can be
467
+ // served from. Output and reasoning are excluded on purpose — neither can be
468
+ // cached, so counting them would deflate the rate towards a value that means
469
+ // nothing.
470
+ export function cacheRatePercent(tokens: UsageTokens): string {
471
+ const denominator =
472
+ toUsageCount(tokens.input) + toUsageCount(tokens.cache.read) + toUsageCount(tokens.cache.write);
473
+ if (denominator === 0) return CACHE_RATE_EMPTY;
474
+ return `${((toUsageCount(tokens.cache.read) / denominator) * 100).toFixed(CACHE_RATE_DECIMALS)}%`;
475
+ }
476
+
477
+ // A model's share of the Go tokens spent in this session tree. This is a share
478
+ // of Go tokens and nothing else: it is not a share of the plan, a quota, or a
479
+ // price, and it never carries a currency. A zero total is 0% rather than NaN so
480
+ // the row still renders before any Go tokens are attributed.
481
+ export function goSharePercent(modelTokens: unknown, totalGoTokens: unknown): number {
482
+ const total = toUsageCount(totalGoTokens);
483
+ if (total === 0) return 0;
484
+ return (toUsageCount(modelTokens) / total) * 100;
485
+ }
486
+
487
+ export function totalGoTokens(models: readonly ModelUsage[]): number {
488
+ let total = 0;
489
+ for (const model of models) {
490
+ if (model.providerID !== GO_PROVIDER_ID) continue;
491
+ total += usageTokenCount(model.tokens);
492
+ }
493
+ return total;
494
+ }
495
+
496
+ export type ModelProviderGroup = {
497
+ providerID: string;
498
+ providerName: string;
499
+ models: ModelUsage[];
500
+ };
501
+
502
+ // Group in first-seen order (the host's own `models[]` order), which keeps the
503
+ // active provider on top. A provider with no catalog entry falls back to its
504
+ // raw id rather than dropping its models.
505
+ export function groupModelsByProvider(
506
+ models: readonly ModelUsage[],
507
+ providerNames: ReadonlyMap<string, string> = new Map(),
508
+ ): ModelProviderGroup[] {
509
+ const groups = new Map<string, ModelProviderGroup>();
510
+ for (const model of models) {
511
+ const group = groups.get(model.providerID) ?? {
512
+ providerID: model.providerID,
513
+ providerName: providerNames.get(model.providerID) ?? model.providerID,
514
+ models: [],
515
+ };
516
+ group.models.push(model);
517
+ groups.set(model.providerID, group);
518
+ }
519
+ return [...groups.values()];
520
+ }
521
+
522
+ // Kilo's truncation: a single ellipsis appended to a fixed-width budget, never
523
+ // a trailing "..." that would make the budget worth two extra cells.
524
+ export function truncateModelName(value: string, max: number = KILO_MODEL_NAME_MAX_CHARS): string {
525
+ if (value.length <= max) return value;
526
+ const budget = Math.max(max - 1, 0);
527
+ return `${value.slice(0, budget)}…`;
528
+ }
529
+
530
+ // Kilo's display normalization, applied before truncation: strip a `vendor:`
531
+ // prefix, a lone leading `vendor/`, and a trailing discount badge, then split a
532
+ // letter run that runs straight into digits. Without it a catalog name like
533
+ // "anthropic/claude-sonnet-4" spends the whole 19-cell budget on the vendor.
534
+ export function displayModelName(name: string): string {
535
+ return name
536
+ .trim()
537
+ .replace(/^[^:]+:\s+/, "")
538
+ .replace(/^[^/\s]+\/(?=[^/]+$)/, "")
539
+ .replace(/\s*\([^)]*%\s*off[^)]*\)\s*$/i, "")
540
+ .replace(/^([A-Za-z]{2,})(?=\d)/, "$1 ")
541
+ .replace(/\s+/g, " ");
542
+ }
543
+
544
+ export function modelDisplayName(catalogName: string | null, modelID: string, max: number = KILO_MODEL_NAME_MAX_CHARS): string {
545
+ const raw = toNonEmptyString(catalogName) ?? toNonEmptyString(modelID) ?? "";
546
+ return truncateModelName(displayModelName(raw), max);
547
+ }
548
+
549
+ // The first segment of a model name, for a mix line that has to fit several of
550
+ // them in one line: `mimo-v2.6-pro` -> `mimo`, `qwen3-max` -> `qwen3`, `gpt-5.1`
551
+ // -> `gpt`. A name with no separator is already short and is only truncated.
552
+ // Lowercased: the rest of the sidebar is lowercase, and the mix line is a
553
+ // ranking of three labels rather than three model names to copy out of.
554
+ export function shortModelName(value: string, max: number = GO_MODEL_MIX_NAME_MAX_CHARS): string {
555
+ const trimmed = value.trim().toLowerCase();
556
+ if (trimmed.length === 0) return "";
557
+ const head = trimmed.split(/[-._/]/)[0] ?? trimmed;
558
+ return truncateModelName(head, max);
559
+ }
560
+
561
+ // The plan window label cell ("5h", "7d", "30d"): exactly the longest label the
562
+ // plan has, so every meter starts on the same column and none of the width goes
563
+ // to padding.
564
+ export const PLAN_LABEL_WIDTH = 3;
565
+
566
+ // A percent padded into a fixed-width cell, right-aligned by construction. A
567
+ // non-finite percent renders as 0 rather than "NaN%", which would break the
568
+ // column it sits in.
569
+ export function formatPercentCell(percent: number, width: number = PERCENT_CELL_WIDTH): string {
570
+ const cells = Math.max(Math.floor(width), 1);
571
+ const rounded = Number.isFinite(percent) ? Math.round(percent) : 0;
572
+ return `${`${rounded}%`}`.padStart(cells, " ");
573
+ }
574
+
575
+ // A token count as a weight, not an invoice: short enough to sit next to a bar
576
+ // in a 30-cell sidebar, precise enough to rank models. `610K` rather than
577
+ // `0.61M` — below a million the integer form is both shorter and the one people
578
+ // say out loud.
579
+ export function formatTokenCount(value: unknown): string {
580
+ const count = toUsageCount(value);
581
+ if (count >= 1_000_000_000) return `${trimTrailingZeros((count / 1_000_000_000).toFixed(2))}B`;
582
+ if (count >= 1_000_000) return `${trimTrailingZeros((count / 1_000_000).toFixed(2))}M`;
583
+ if (count >= 1_000) return `${Math.round(count / 1_000)}K`;
584
+ return COUNT_FORMAT.format(count);
585
+ }
586
+
587
+ // `1.20M` claims a precision the API never had, and spends a cell on it. Only
588
+ // `toFixed` output goes through this, which always carries a decimal point -- so
589
+ // the trailing zeros it strips are always fractional ones, never `100`.
590
+ function trimTrailingZeros(value: string): string {
591
+ return value.replace(/\.?0+$/, "");
592
+ }
593
+
594
+ // ---------------------------------------------------------------------------
595
+ // TUI: per-model weights (the opencode sidebar's model mix)
596
+ // ---------------------------------------------------------------------------
597
+ //
598
+ // A model's WEIGHT is its share of the Go tokens spent in this session. It is
599
+ // not a share of the plan, a quota, or a price, and it never carries a currency:
600
+ // the plan's absolute limits are not client-visible, so a number that looked
601
+ // like one would be a guess dressed as a measurement. The bars reuse the plan's
602
+ // glyphs and the same threshold coloring, which is why a heavily used model
603
+ // paints like a hot plan window.
604
+
605
+ export type GoModelWeight = {
606
+ providerID: string;
607
+ modelID: string;
608
+ tokens: number;
609
+ share: number;
610
+ steps: number;
611
+ cost: number;
612
+ };
613
+
614
+ export type GoModelWeights = {
615
+ // The heaviest Go models, already ranked and sliced to `limit`.
616
+ models: GoModelWeight[];
617
+ // How many Go models the session used in total, so a section titled
618
+ // `Top Go models (7)` above three rows is explained by the rows themselves.
619
+ goModels: number;
620
+ goTokens: number;
621
+ listedTokens: number;
622
+ listedShare: number;
623
+ otherCount: number;
624
+ // Session totals over EVERY Go model, not just the listed ones: the footer is
625
+ // about the session, the rows are about the ranking.
626
+ steps: number;
627
+ cost: number;
628
+ };
629
+
630
+ // The heaviest Go models by token count. Ties break on steps and then on the
631
+ // model id, so the same session always ranks the same way — a list that reshuffles
632
+ // between renders would make the weights unreadable. `otherCount` counts the
633
+ // models the limit dropped, and `goTokens`/`steps`/`cost` cover all of them.
634
+ export function weightGoModels(
635
+ models: readonly ModelUsage[],
636
+ limit: number = TOP_GO_MODELS_LIMIT,
637
+ ): GoModelWeights {
638
+ const goModels = models.filter((model) => model.providerID === GO_PROVIDER_ID);
639
+ const goTokens = goModels.reduce((sum, model) => sum + usageTokenCount(model.tokens), 0);
640
+ const ranked = [...goModels].sort(
641
+ (left, right) =>
642
+ usageTokenCount(right.tokens) - usageTokenCount(left.tokens) ||
643
+ toUsageCount(right.steps) - toUsageCount(left.steps) ||
644
+ left.modelID.localeCompare(right.modelID),
645
+ );
646
+ const top = ranked.slice(0, Math.max(Math.floor(limit), 0));
647
+ const listedTokens = top.reduce((sum, model) => sum + usageTokenCount(model.tokens), 0);
648
+ return {
649
+ models: top.map((model) => {
650
+ const tokens = usageTokenCount(model.tokens);
651
+ const share = goSharePercent(tokens, goTokens);
652
+ return {
653
+ providerID: model.providerID,
654
+ modelID: model.modelID,
655
+ tokens,
656
+ share,
657
+ steps: toUsageCount(model.steps),
658
+ cost: toUsageCount(model.cost),
659
+ };
660
+ }),
661
+ goModels: goModels.length,
662
+ goTokens,
663
+ listedTokens,
664
+ listedShare: goSharePercent(listedTokens, goTokens),
665
+ otherCount: Math.max(goModels.length - top.length, 0),
666
+ steps: goModels.reduce((sum, model) => sum + toUsageCount(model.steps), 0),
667
+ cost: goModels.reduce((sum, model) => sum + toUsageCount(model.cost), 0),
668
+ };
669
+ }
670
+
671
+ // The collapsed mix line: `mimo 59%·qwen 25%·gpt 12%`, as many entries as fit
672
+ // `budget` cells. Built cell by cell rather than joined from a fixed list because
673
+ // a narrow sidebar truncates mid-entry, and a half-written `qwen 2…` reads as a
674
+ // different number than `qwen 25%`.
675
+ //
676
+ // The leader is always included even when it alone overruns the budget: a mix
677
+ // line without the heaviest model is not a summary of anything, and the entry
678
+ // cannot actually be that wide because the names are capped (see
679
+ // `shortModelName`), so the budget only ever decides the second entry onwards.
680
+ export function buildModelMixSummary(
681
+ entries: ReadonlyArray<{ name: string; share: number }>,
682
+ budget: number = GO_MODEL_MIX_BUDGET,
683
+ ): string {
684
+ const first = entries[0];
685
+ if (first === undefined) return "";
686
+ const parts: string[] = [];
687
+ let used = 0;
688
+ for (const entry of entries) {
689
+ const part = `${entry.name} ${Math.round(entry.share)}%`;
690
+ const width = part.length + (parts.length === 0 ? 0 : GO_MODEL_MIX_SEPARATOR.length);
691
+ if (parts.length > 0 && used + width > budget) break;
692
+ parts.push(part);
693
+ used += width;
694
+ }
695
+ return parts.join(GO_MODEL_MIX_SEPARATOR);
696
+ }
697
+
698
+ // Two muted lines under the rows: what the listed models account for, and the
699
+ // session's own Go totals. Both are counts of this session's tokens and cost —
700
+ // never of the plan.
701
+ export function buildGoModelFooters(weights: GoModelWeights): string[] {
702
+ if (weights.models.length === 0) return [];
703
+ return [
704
+ `${formatTokenCount(weights.listedTokens)} of ${formatTokenCount(weights.goTokens)} Go tokens`,
705
+ `${formatUsageCount(weights.steps)} steps · ${formatUsageCost(weights.cost)}`,
706
+ ];
707
+ }
708
+
709
+ // One row per entry of KILO_TOKEN_USAGE_ROWS, in that order. A label with no
710
+ // value is dropped rather than rendered blank, so the row list and the constant
711
+ // can only disagree in a way the unit tier fails on.
712
+ export function buildTokenUsageRows(totals: { cost: number; tokens: UsageTokens }): UsageRow[] {
713
+ const values: Readonly<Record<string, string>> = {
714
+ Input: formatUsageCount(totals.tokens.input),
715
+ Output: formatUsageCount(totals.tokens.output),
716
+ Reasoning: formatUsageCount(totals.tokens.reasoning),
717
+ "Cache read": formatUsageCount(totals.tokens.cache.read),
718
+ "Cache write": formatUsageCount(totals.tokens.cache.write),
719
+ "Cache rate": cacheRatePercent(totals.tokens),
720
+ Cost: formatUsageCost(totals.cost),
721
+ };
722
+ return KILO_TOKEN_USAGE_ROWS.flatMap((label) => {
723
+ const value = values[label];
724
+ return value === undefined ? [] : [{ label, value }];
725
+ });
726
+ }
727
+
728
+ // The per-model token breakdown shown when a model row is expanded: the same
729
+ // labels and order as the totals block, minus `Cost`, which the model row
730
+ // already carries in its own cost column.
731
+ export function buildModelTokenRows(tokens: UsageTokens): UsageRow[] {
732
+ return buildTokenUsageRows({ cost: 0, tokens }).filter((row) => row.label !== "Cost");
733
+ }