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