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/shared.ts ADDED
@@ -0,0 +1,696 @@
1
+ // Shared pure helpers for the server (`src/index.ts`) and TUI (`src/tui.tsx`)
2
+ // targets: auth.json resolution, tolerant API-payload parsing, snapshot
3
+ // builders, and small parsing/formatting primitives.
4
+ //
5
+ // Single source of truth for the duplicated logic so both surfaces parse the
6
+ // usage API identically. Secrets are never logged here. Behavior must stay
7
+ // identical for both consumers (import via `./shared.js` under NodeNext).
8
+
9
+ import * as fs from "node:fs";
10
+ import * as os from "node:os";
11
+ import * as path from "node:path";
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Constants
15
+ // ---------------------------------------------------------------------------
16
+
17
+ // Resolve the user home directory honoring a runtime HOME override.
18
+ // Bun's `os.homedir()` caches the home directory at process startup and does
19
+ // NOT respect runtime `process.env.HOME` changes, which breaks hermetic test
20
+ // overrides that set HOME before dynamic imports. Reading `process.env.HOME`
21
+ // directly (with `os.homedir()` fallback for when HOME is unset) makes path
22
+ // resolution hermetic under both `node --test` and `bun test`.
23
+ function resolveHomedir(): string {
24
+ const envHome = process.env.HOME;
25
+ if (envHome && envHome.length > 0) return envHome;
26
+ return os.homedir();
27
+ }
28
+
29
+ // Path construction is best-effort. `resolveHomedir()` can throw when no home
30
+ // directory is resolvable, and a throwing top-level expression aborts the
31
+ // host's plugin import; degrade to a relative config path instead. `homedir`
32
+ // is injectable so the fallback is directly unit-testable.
33
+ export function resolveConfigDir(homedir: () => string = resolveHomedir): string {
34
+ try {
35
+ return path.join(homedir(), ".config", "opencode");
36
+ } catch {
37
+ return ".config/opencode";
38
+ }
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Host roots (opencode vs its Kilo fork): each host keeps its own config and
43
+ // data stores, and each entry module must read the credentials of the host it
44
+ // runs under. opencode: `$XDG_DATA_HOME/opencode/auth.json` (or
45
+ // `~/.local/share/opencode/auth.json`), then its config dir. Kilo:
46
+ // `$XDG_DATA_HOME/kilo/auth.json` (or `~/.local/share/kilo/auth.json`), then
47
+ // `$KILO_CONFIG_DIR` / `$XDG_CONFIG_HOME/kilo` (or `~/.config/kilo`).
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export type UsageHost = "opencode" | "kilo";
51
+
52
+ export type HostRoots = { configDir: string; dataDir: string };
53
+
54
+ // Pure host selection: only the explicit "kilo" marker selects the Kilo
55
+ // stores; every other value (including unset) means opencode.
56
+ export function usageHostFromEnv(env: NodeJS.ProcessEnv | undefined): UsageHost {
57
+ return env?.OC_GO_USAGE_HOST === "kilo" ? "kilo" : "opencode";
58
+ }
59
+
60
+ // Runtime host for the entry module. The Kilo server bundle is built with
61
+ // `process.env.OC_GO_USAGE_HOST` replaced by the literal "kilo"
62
+ // (scripts/build-plugins.mjs), so dist/index.js stays opencode while
63
+ // dist/plugins/oc-go-usage-display.kilo.ts is deterministic. The kilocode TUI
64
+ // entry passes its host explicitly instead.
65
+ export function resolveUsageHost(): UsageHost {
66
+ return process.env.OC_GO_USAGE_HOST === "kilo" ? "kilo" : "opencode";
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Host-scoped environment variables
71
+ // ---------------------------------------------------------------------------
72
+ //
73
+ // Settings and credentials are namespaced per coding agent, so one shell can
74
+ // drive the two hosts differently:
75
+ //
76
+ // OPENCODE_OC_GO_SIDEBAR=0 opencode
77
+ // KILO_OC_GO_SIDEBAR=1 kilo
78
+ //
79
+ // The prefix comes from the host baked into the running bundle, so a Kilo entry
80
+ // only ever reads `KILO_OC_GO_*` and can never be steered by an opencode-prefixed
81
+ // name. Reading a variable the host does not own is a bug, not a feature: the
82
+ // two hosts keep separate auth stores and separate config dirs for exactly this
83
+ // reason.
84
+ //
85
+ const ENV_PREFIX: Record<UsageHost, string> = {
86
+ opencode: "OPENCODE_OC_GO_",
87
+ kilo: "KILO_OC_GO_",
88
+ };
89
+
90
+ export function hostEnvName(host: UsageHost, suffix: string): string {
91
+ return `${ENV_PREFIX[host]}${suffix}`;
92
+ }
93
+
94
+ export function hostEnv(
95
+ host: UsageHost,
96
+ suffix: string,
97
+ env: NodeJS.ProcessEnv | undefined = process.env,
98
+ ): string | undefined {
99
+ return env?.[hostEnvName(host, suffix)];
100
+ }
101
+
102
+ // `resolveHomedir` can throw when no home directory is resolvable; plugin
103
+ // entry modules build these paths at import time, so degrade to an empty
104
+ // segment instead of throwing.
105
+ function homedirOrEmpty(homedir: () => string): string {
106
+ try {
107
+ return homedir();
108
+ } catch {
109
+ return "";
110
+ }
111
+ }
112
+
113
+ // Host config/data roots. Both hosts are XDG-based: `$XDG_CONFIG_HOME` /
114
+ // `$XDG_DATA_HOME` win with `~/.config` / `~/.local/share` as fallbacks, and
115
+ // Kilo additionally honors `KILO_CONFIG_DIR` (its documented config override).
116
+ // Never throws.
117
+ export function resolveHostRoots(
118
+ host: UsageHost,
119
+ env: NodeJS.ProcessEnv = process.env,
120
+ homedir: () => string = resolveHomedir,
121
+ ): HostRoots {
122
+ const home = homedirOrEmpty(homedir);
123
+ const configRoot = toNonEmptyString(env.XDG_CONFIG_HOME) ?? safeJoinPath(home, ".config");
124
+ const dataRoot = toNonEmptyString(env.XDG_DATA_HOME) ?? safeJoinPath(home, ".local", "share");
125
+ if (host === "kilo") {
126
+ return {
127
+ configDir: toNonEmptyString(env.KILO_CONFIG_DIR) ?? safeJoinPath(configRoot, "kilo"),
128
+ dataDir: safeJoinPath(dataRoot, "kilo"),
129
+ };
130
+ }
131
+ return {
132
+ configDir: safeJoinPath(configRoot, "opencode"),
133
+ dataDir: safeJoinPath(dataRoot, "opencode"),
134
+ };
135
+ }
136
+
137
+ // Legacy opencode config dir (no XDG): kept as an exported constant because
138
+ // the hermeticity tests assert every runtime path stays inside their tmp root.
139
+ export const CONFIG_DIR = resolveHostRoots("opencode").configDir;
140
+
141
+ // Coerce any runtime value into a path segment deterministically: strings pass
142
+ // through, every other value uses its string form, and only an object with a
143
+ // throwing `toString` degrades to "" (an empty segment, which `path.join`
144
+ // ignores). Never throws.
145
+ function toPathSegment(value: unknown): string {
146
+ if (typeof value === "string") return value;
147
+ try {
148
+ return String(value);
149
+ } catch {
150
+ return "";
151
+ }
152
+ }
153
+
154
+ // Tolerant `path.join` for module-level path construction. `path.join` throws
155
+ // on a non-string segment; plugin entry modules build cache/config paths at
156
+ // import time, so a throw there would crash startup. Non-string segments are
157
+ // coerced (never silently dropped or thrown away) and the resulting string
158
+ // join cannot throw.
159
+ export function safeJoinPath(base: string, ...segments: unknown[]): string {
160
+ return path.join(toPathSegment(base), ...segments.map(toPathSegment));
161
+ }
162
+
163
+ export function dataShareAuthPath(
164
+ host: UsageHost = "opencode",
165
+ env: NodeJS.ProcessEnv = process.env,
166
+ homedir: () => string = resolveHomedir,
167
+ ): string {
168
+ return safeJoinPath(resolveHostRoots(host, env, homedir).dataDir, "auth.json");
169
+ }
170
+
171
+ export function authJsonPaths(
172
+ host: UsageHost = "opencode",
173
+ env: NodeJS.ProcessEnv = process.env,
174
+ homedir: () => string = resolveHomedir,
175
+ ): string[] {
176
+ const roots = resolveHostRoots(host, env, homedir);
177
+ return [safeJoinPath(roots.dataDir, "auth.json"), safeJoinPath(roots.configDir, "auth.json")];
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Trusted types (parsed at the boundary, trusted internally)
182
+ // ---------------------------------------------------------------------------
183
+
184
+ export type UsageWindow = {
185
+ percent: number;
186
+ status: string | null;
187
+ limited: boolean;
188
+ resetInSec: number | null;
189
+ resetText: string | null;
190
+ };
191
+
192
+ export type UsageSnapshot = {
193
+ rolling: UsageWindow | null;
194
+ weekly: UsageWindow | null;
195
+ monthly: UsageWindow | null;
196
+ source: "api" | "scrape" | "mock" | "unavailable";
197
+ fetchedAt: number;
198
+ apiUnavailable?: boolean;
199
+ apiError?: string;
200
+ };
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Small pure helpers
204
+ // ---------------------------------------------------------------------------
205
+
206
+ export function isRecord(value: unknown): value is Record<string, unknown> {
207
+ return typeof value === "object" && value !== null && !Array.isArray(value);
208
+ }
209
+
210
+ export function toFiniteNumber(value: unknown): number | null {
211
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
212
+ return value;
213
+ }
214
+
215
+ // A host-reported count or cost, coerced into something a formatter can print.
216
+ // Clamping at 0 also folds `-0` into `0`, so no renderer can emit "-0" for a
217
+ // quantity that is zero.
218
+ export function toUsageCount(value: unknown): number {
219
+ if (typeof value !== "number" || !Number.isFinite(value)) return 0;
220
+ return Math.max(value, 0);
221
+ }
222
+
223
+ export function toNonEmptyString(value: unknown): string | null {
224
+ if (typeof value !== "string") return null;
225
+ const trimmed = value.trim();
226
+ return trimmed.length > 0 ? trimmed : null;
227
+ }
228
+
229
+ // Stable, human-readable message for any thrown value. Never throws itself, so
230
+ // callers can include it in best-effort logs without a second failure mode.
231
+ export function errorMessage(error: unknown): string {
232
+ if (error === null || error === undefined) return "unknown error";
233
+ if (error instanceof Error) {
234
+ return toNonEmptyString(error.message) ?? toNonEmptyString(error.name) ?? "unknown error";
235
+ }
236
+ try {
237
+ return toNonEmptyString(String(error)) ?? "unknown error";
238
+ } catch {
239
+ return "unknown error";
240
+ }
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // Kilo sidebar ladder (host-owned; asserted by the e2e contract test)
245
+ // ---------------------------------------------------------------------------
246
+ //
247
+ // The `sidebar_content` orders Kilo registers internally, read from the shipped
248
+ // binary. The plugin cannot query them at runtime, so they live here as data: a
249
+ // comment would be unenforceable, and the point of the contract test is to fail
250
+ // when a Kilo release adds a panel inside the band we chose.
251
+ export const KILO_SIDEBAR_ORDERS: Readonly<Record<string, number>> = {
252
+ "internal:kilo-sidebar-pr": 50,
253
+ "internal:sidebar-context": 100,
254
+ "internal:kilo-sidebar-usage": 150,
255
+ "internal:sidebar-mcp": 200,
256
+ "internal:kilo-sidebar-indexing": 225,
257
+ "internal:kilo-sidebar-background-processes": 250,
258
+ "internal:sidebar-lsp": 300,
259
+ "internal:sidebar-todo": 400,
260
+ "internal:sidebar-files": 500,
261
+ "internal:kilo-sidebar-memory": 1000,
262
+ };
263
+
264
+ // Free band between the context panel and the token-usage panel, so the Go block
265
+ // renders directly between them and never ties with a host panel.
266
+ export const KILO_SLOT_ORDER = 125;
267
+
268
+ // Integrated mode takes over the token-usage panel's own band, so the Go block
269
+ // lands where Kilo already draws usage. It ties with
270
+ // `internal:kilo-sidebar-usage` by construction — which is why that panel is
271
+ // retired rather than extended (see KILO_USAGE_PANEL_PLUGIN_ID): a Kilo
272
+ // release that moves it off 150 leaves us in a free band again, harmlessly.
273
+ export const KILO_INTEGRATED_SLOT_ORDER = 150;
274
+
275
+ // The host panel integrated mode replaces. `api.slots` is a per-plugin facade
276
+ // that exposes only `register`, so this panel cannot be extended or
277
+ // monkey-patched: the only supported way to take its band is to switch it off
278
+ // through the plugin lifecycle (`plugin_enabled` in tui.json at config time,
279
+ // `api.plugins.deactivate` at runtime).
280
+ export const KILO_USAGE_PANEL_PLUGIN_ID = "internal:kilo-sidebar-usage";
281
+
282
+ // Rows the integrated mode mirrors from Kilo's own `Token Usage` panel, in the
283
+ // order it renders them. Kept here so a reword upstream is a test failure rather
284
+ // than a silent divergence between our panel and the host's.
285
+ export const KILO_TOKEN_USAGE_ROWS: readonly string[] = [
286
+ "Input",
287
+ "Output",
288
+ "Reasoning",
289
+ "Cache read",
290
+ "Cache write",
291
+ "Cache rate",
292
+ "Cost",
293
+ ];
294
+
295
+ // The provider whose plan this plugin exists for, and whose per-model group
296
+ // carries the Go meters.
297
+ export const GO_PROVIDER_ID = "opencode-go";
298
+
299
+ // Layout constants of the Models table Kilo renders, in the same block as the
300
+ // ladder above: a Kilo release that re-lays the table changes these, and a
301
+ // constant is the only thing a drift check can assert against.
302
+ export const KILO_MODEL_NAME_MAX_CHARS = 19;
303
+ export const KILO_STEPS_COLUMN_WIDTH = 5;
304
+ export const KILO_COST_COLUMN_WIDTH = 9;
305
+
306
+ // The sidebar's disclosure glyphs: the section caret and the per-model fold
307
+ // marker. Both hosts draw their own sections with these (Kilo's `Token Usage`
308
+ // and `Models` panels, opencode's `MCP` panel), so the plugin uses the hosts'
309
+ // shapes rather than inventing a third: a caret that does not match the ones
310
+ // around it reads as a different control than the one it is.
311
+ export const SIDEBAR_COLLAPSED_GLYPH = "▶";
312
+ export const SIDEBAR_EXPANDED_GLYPH = "▾";
313
+
314
+ // Cache rate is a share of the three buckets a cache hit can be served from, so
315
+ // it is meaningless when all of them are zero — and a zero denominator is the
316
+ // normal state of a session that never hit a cache. A dash, not "0.0%".
317
+ export const CACHE_RATE_DECIMALS = 1;
318
+ export const CACHE_RATE_EMPTY = "-";
319
+
320
+ // A model name in opencode's narrower sidebar: 12 cells, where Kilo's table
321
+ // gives 19.
322
+ export const OPENCODE_MODEL_NAME_MAX_CHARS = 12;
323
+ // A percent in its own fixed-width cell, so "0%", "42%" and "100%" end on the
324
+ // same column. A text node's `width` reserves cells but does NOT right-align the
325
+ // text inside them, so the padding has to be in the string.
326
+ export const PERCENT_CELL_WIDTH = 5;
327
+
328
+ // The collapsed mix line: how many models it lists, and how many cells it may
329
+ // spend. The budget is why the mix line is built cell by cell (see
330
+ // `buildModelMixSummary`) instead of joined from a fixed list.
331
+ export const TOP_GO_MODELS_LIMIT = 3;
332
+ export const GO_MODEL_MIX_NAME_MAX_CHARS = 6;
333
+ export const GO_MODEL_MIX_BUDGET = 28;
334
+ // No spaces around the separator: the same three entries cost 29 cells with
335
+ // them and 25 without, and 25 is what fits beside the section's indent in a
336
+ // ~30-cell sidebar. A cut-off third entry is a worse summary than tight dots.
337
+ export const GO_MODEL_MIX_SEPARATOR = "·";
338
+
339
+ // The section title says "Go models" on purpose: the weight of a model is its
340
+ // share of the Go tokens, so a model from another provider has no weight to
341
+ // show and is not counted here.
342
+ export const TOP_GO_MODELS_LABEL = "Top Go models";
343
+
344
+ // Section titles of the integrated panel. The token section is deliberately NOT
345
+ // "Token Usage": integrated mode retires the host panel of that name, and
346
+ // re-printing the title would be indistinguishable from the panel that was
347
+ // supposed to go.
348
+ export const INTEGRATED_TOKENS_SECTION_LABEL = "Session Tokens";
349
+ export const INTEGRATED_MODELS_SECTION_LABEL = "Models";
350
+ export const GO_PLAN_HEADING = "Go Plan";
351
+ export const INTEGRATED_GO_SHARE_LABEL = "Go share";
352
+
353
+ // Wording for a model section with nothing to show. Both hosts use it (opencode's
354
+ // mix folds its own message store, Kilo's integrated panel the host endpoint),
355
+ // and Kilo's own load/failure wording is borrowed too, so the section reads the
356
+ // same on either host whether the host panel or ours is on screen.
357
+ export const MODELS_EMPTY_LABEL = "No model usage yet";
358
+ export const INTEGRATED_LOADING_LABEL = "Loading usage...";
359
+ export const INTEGRATED_UNAVAILABLE_LABEL = "Usage unavailable";
360
+
361
+ // Two most significant units, always: `4h57m` under an hour, `2d 6h` under a
362
+ // week, `1w 1d` above it. A 30-day window resets ~720h out, and "resets in
363
+ // 720h00m" is a number nobody can read at a glance -- the countdown exists to
364
+ // say "you do not have to think about this yet", and a week count says that in
365
+ // five characters. Below an hour the minutes (and then seconds) still matter, so
366
+ // they are what is shown.
367
+ const SECONDS_PER_MINUTE = 60;
368
+ const SECONDS_PER_HOUR = 3600;
369
+ const SECONDS_PER_DAY = 86400;
370
+ const SECONDS_PER_WEEK = 604800;
371
+
372
+ export function formatResetDuration(totalSec: number | null): string | null {
373
+ if (totalSec === null || !Number.isFinite(totalSec) || totalSec < 0) return null;
374
+ const sec = Math.floor(totalSec);
375
+ if (sec >= SECONDS_PER_WEEK) {
376
+ const weeks = Math.floor(sec / SECONDS_PER_WEEK);
377
+ const days = Math.floor((sec % SECONDS_PER_WEEK) / SECONDS_PER_DAY);
378
+ return `${weeks}w ${days}d`;
379
+ }
380
+ if (sec >= SECONDS_PER_DAY) {
381
+ const days = Math.floor(sec / SECONDS_PER_DAY);
382
+ const hours = Math.floor((sec % SECONDS_PER_DAY) / SECONDS_PER_HOUR);
383
+ return `${days}d ${hours}h`;
384
+ }
385
+ const hours = Math.floor(sec / SECONDS_PER_HOUR);
386
+ const minutes = Math.floor((sec % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE);
387
+ if (hours > 0) return `${hours}h${minutes}m`;
388
+ if (minutes > 0) return `${minutes}m`;
389
+ return `${sec}s`;
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // Credentials boundary (auth.json; secrets never logged)
394
+ // ---------------------------------------------------------------------------
395
+
396
+ export function readAuthJsonApiKey(
397
+ host: UsageHost = "opencode",
398
+ env: NodeJS.ProcessEnv = process.env,
399
+ homedir: () => string = resolveHomedir,
400
+ ): string | null {
401
+ for (const authPath of authJsonPaths(host, env, homedir)) {
402
+ let raw: string;
403
+ try {
404
+ raw = fs.readFileSync(authPath, "utf8");
405
+ } catch {
406
+ continue;
407
+ }
408
+ let parsed: unknown;
409
+ try {
410
+ parsed = JSON.parse(raw);
411
+ } catch {
412
+ continue;
413
+ }
414
+ if (!isRecord(parsed)) continue;
415
+ const goEntry = parsed["opencode-go"];
416
+ const fallbackEntry = parsed["opencode"];
417
+ const goKey = isRecord(goEntry) ? toNonEmptyString(goEntry.key) : null;
418
+ if (goKey) return goKey;
419
+ const fallbackKey = isRecord(fallbackEntry) ? toNonEmptyString(fallbackEntry.key) : null;
420
+ if (fallbackKey) return fallbackKey;
421
+ }
422
+ return null;
423
+ }
424
+
425
+ // ---------------------------------------------------------------------------
426
+ // API payload boundary (tolerant JSON parsing; shape may evolve)
427
+ // ---------------------------------------------------------------------------
428
+
429
+ // A countdown is only usable when it is a finite, non-negative number of
430
+ // seconds. One guard covers every failure mode, so no consumer re-checks before
431
+ // rendering and no `NaN`, `Invalid Date` or negative span can reach a
432
+ // statusline — an elapsed reset (stale payload, clock skew, a reset that fired
433
+ // mid-flight) is as unusable as an unreadable one and degrades the same way.
434
+ function usableCountdown(seconds: unknown): number | null {
435
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) return null;
436
+ return seconds;
437
+ }
438
+
439
+ // The live usage API reports the reset as an absolute ISO 8601 instant
440
+ // (`resetsAt`), while every consumer wants the RELATIVE count of seconds left
441
+ // (`resetInSec`). Convert here, at the parse boundary, so the instant is read
442
+ // once against a single clock reading.
443
+ function secondsUntilReset(resetsAt: unknown, now: number): number | null {
444
+ const instant = toNonEmptyString(resetsAt);
445
+ if (instant === null) return null;
446
+ return usableCountdown(Math.round((Date.parse(instant) - now) / 1000));
447
+ }
448
+
449
+ // Statuses that mean "this window is capped". Matching is on whole normalized
450
+ // tokens, never substrings: "unlimited" contains "limited" and would flip a
451
+ // healthy window. Every unrecognized value (including the scrape path's
452
+ // historical "active" and the API's "ok") stays false, so a status this build
453
+ // has never seen can never paint a window as limited.
454
+ const LIMITED_STATUSES = new Set(["rate-limited", "limited", "exhausted", "capped"]);
455
+
456
+ // Exported for the cookie-scrape parsers in `src/index.ts`, which build windows
457
+ // from scraped markup instead of the JSON API and must apply the same mapping.
458
+ export function isLimitedStatus(status: string | null): boolean {
459
+ if (status === null) return false;
460
+ const normalized = status.trim().toLowerCase().replace(/[\s_]+/g, "-");
461
+ return LIMITED_STATUSES.has(normalized);
462
+ }
463
+
464
+ // `now` is injectable so the absolute-to-relative conversion is unit-testable
465
+ // without sleeping; callers always pass the snapshot's single clock reading.
466
+ export function extractWindow(candidate: unknown, now: number = Date.now()): UsageWindow | null {
467
+ if (!isRecord(candidate)) return null;
468
+ const percent = toFiniteNumber(
469
+ candidate.percent ??
470
+ candidate.usagePercent ??
471
+ candidate.usedPercent ??
472
+ candidate.value ??
473
+ candidate.usage,
474
+ );
475
+ if (percent === null) return null;
476
+ const status = toNonEmptyString(candidate.status);
477
+ // The relative spellings win when present because they are already a
478
+ // countdown; `resetsAt` is the live field and is derived from the instant.
479
+ const resetInSec =
480
+ usableCountdown(candidate.resetInSec ?? candidate.resetInSeconds) ??
481
+ secondsUntilReset(candidate.resetsAt, now);
482
+ const resetText = toNonEmptyString(candidate.resetText ?? candidate.reset ?? null);
483
+ return { percent: Math.round(percent), status, limited: isLimitedStatus(status), resetInSec, resetText };
484
+ }
485
+
486
+ export function extractSnapshotFromApiPayload(
487
+ payload: unknown,
488
+ now: number = Date.now(),
489
+ ): UsageSnapshot | null {
490
+ if (!isRecord(payload)) return null;
491
+ const containers: unknown[] = [payload];
492
+ for (const key of ["usage", "data", "go"]) {
493
+ if (isRecord(payload[key])) containers.push(payload[key]);
494
+ }
495
+ for (const container of containers) {
496
+ if (!isRecord(container)) continue;
497
+ const rolling = extractWindow(container.rolling ?? container.rollingUsage ?? container["5h"], now);
498
+ const weekly = extractWindow(container.weekly ?? container.weeklyUsage ?? container["7d"], now);
499
+ const monthly = extractWindow(container.monthly ?? container.monthlyUsage ?? container["30d"], now);
500
+ if (rolling !== null || weekly !== null || monthly !== null) {
501
+ return {
502
+ rolling,
503
+ weekly,
504
+ monthly,
505
+ source: "api",
506
+ fetchedAt: now,
507
+ };
508
+ }
509
+ }
510
+ return null;
511
+ }
512
+
513
+ // ---------------------------------------------------------------------------
514
+ // Snapshot builders
515
+ // ---------------------------------------------------------------------------
516
+
517
+ // `"active"` is the status this mock (and the cookie scrape) has always
518
+ // reported, and it must map to `limited: false` exactly like the parser does —
519
+ // the unit tier pins that agreement so the mock cannot drift from live shape.
520
+ // `resetInSec` stays a literal (not derived from `resetsAt`) so the mock reset
521
+ // suffix is deterministic for the TUI display tests.
522
+ export function mockSnapshot(): UsageSnapshot {
523
+ return {
524
+ rolling: { percent: 42, status: "active", limited: false, resetInSec: 7543, resetText: null },
525
+ weekly: { percent: 15, status: "active", limited: false, resetInSec: null, resetText: null },
526
+ monthly: { percent: 61, status: "active", limited: false, resetInSec: null, resetText: null },
527
+ source: "mock",
528
+ fetchedAt: Date.now(),
529
+ };
530
+ }
531
+
532
+ export function unavailableSnapshot(error: string): UsageSnapshot {
533
+ return {
534
+ rolling: null,
535
+ weekly: null,
536
+ monthly: null,
537
+ source: "unavailable",
538
+ fetchedAt: Date.now(),
539
+ apiUnavailable: true,
540
+ apiError: error,
541
+ };
542
+ }
543
+
544
+ // ---------------------------------------------------------------------------
545
+ // Session model usage boundary (Kilo's per-session token/cost split)
546
+ // ---------------------------------------------------------------------------
547
+ //
548
+ // `GET /session/{sessionID}/model-usage` on the host API, which reports the
549
+ // whole top-level session tree. `sessionCost` also exists in 7.8.x and is absent
550
+ // in 7.7.5, so it is deliberately not part of the trusted shape: nothing here may
551
+ // depend on it. The path is a PATH param and is pinned against the real host by
552
+ // test/e2e/kilo-contract.test.js — the generated SDK types are the only place a
553
+ // route typo could otherwise hide.
554
+
555
+ export type UsageTokens = {
556
+ input: number;
557
+ output: number;
558
+ reasoning: number;
559
+ cache: { read: number; write: number };
560
+ };
561
+
562
+ export type UsageTotals = {
563
+ steps: number;
564
+ cost: number;
565
+ tokens: UsageTokens;
566
+ };
567
+
568
+ export type ModelUsage = UsageTotals & {
569
+ providerID: string;
570
+ modelID: string;
571
+ };
572
+
573
+ export type SessionModelUsage = {
574
+ sessionIDs: string[];
575
+ totals: UsageTotals;
576
+ models: ModelUsage[];
577
+ };
578
+
579
+ const EMPTY_TOKENS: UsageTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } };
580
+
581
+ // Tolerant because the payload is host-owned and can be renamed without breaking
582
+ // our build: a missing bucket becomes 0 rather than `undefined`, and a missing
583
+ // envelope is a null so the caller can show the unavailable state instead of
584
+ // rendering a column of zeros it cannot vouch for.
585
+ function parseUsageTokens(candidate: unknown): UsageTokens {
586
+ if (!isRecord(candidate)) return { ...EMPTY_TOKENS, cache: { ...EMPTY_TOKENS.cache } };
587
+ const cache = isRecord(candidate.cache) ? candidate.cache : {};
588
+ return {
589
+ input: toUsageCount(candidate.input),
590
+ output: toUsageCount(candidate.output),
591
+ reasoning: toUsageCount(candidate.reasoning),
592
+ cache: { read: toUsageCount(cache.read), write: toUsageCount(cache.write) },
593
+ };
594
+ }
595
+
596
+ function parseUsageTotals(candidate: unknown): UsageTotals {
597
+ if (!isRecord(candidate)) return { steps: 0, cost: 0, tokens: { ...EMPTY_TOKENS, cache: { ...EMPTY_TOKENS.cache } } };
598
+ return {
599
+ steps: toUsageCount(candidate.steps),
600
+ cost: toUsageCount(candidate.cost),
601
+ tokens: parseUsageTokens(candidate.tokens),
602
+ };
603
+ }
604
+
605
+ export function parseSessionModelUsage(payload: unknown): SessionModelUsage | null {
606
+ if (!isRecord(payload)) return null;
607
+ const models: ModelUsage[] = [];
608
+ if (Array.isArray(payload.models)) {
609
+ for (const entry of payload.models) {
610
+ if (!isRecord(entry)) continue;
611
+ const providerID = toNonEmptyString(entry.providerID);
612
+ const modelID = toNonEmptyString(entry.modelID);
613
+ // A row without both keys cannot be grouped or labelled, so it is dropped
614
+ // rather than rendered as an unlabelled model.
615
+ if (providerID === null || modelID === null) continue;
616
+ models.push({ providerID, modelID, ...parseUsageTotals(entry) });
617
+ }
618
+ }
619
+ return {
620
+ sessionIDs: Array.isArray(payload.sessionIDs) ? payload.sessionIDs.filter((id): id is string => typeof id === "string") : [],
621
+ totals: parseUsageTotals(payload.totals),
622
+ models,
623
+ };
624
+ }
625
+
626
+ // ---------------------------------------------------------------------------
627
+ // The same split, derived from opencode's own message store
628
+ // ---------------------------------------------------------------------------
629
+ //
630
+ // opencode has no `model-usage` endpoint to call, and it does not need one: every
631
+ // assistant message already carries the provider, the model, the cost and the
632
+ // five token buckets, so the per-model table is a fold over
633
+ // `api.state.session.messages(sessionID)` instead of a request. The result is
634
+ // the SAME `SessionModelUsage` shape Kilo's endpoint returns, which is what lets
635
+ // both hosts share one renderer.
636
+ //
637
+ // SCOPE: the rendered session only, not the session tree. That is the scope
638
+ // opencode's own Context panel reports (`Session.tokens` / `Session.cost`), so
639
+ // the mix is measured exactly like the number the user already sees next to it,
640
+ // and this plugin measures nothing the host does not. Kilo differs here only
641
+ // because its endpoint sums the tree and a client cannot ask for less.
642
+ //
643
+ // Tolerant for the same reason `parseSessionModelUsage` is: a message without a
644
+ // provider or a model cannot be weighted, so it is skipped rather than folded
645
+ // into an unnamed bucket that would silently take share from real models.
646
+
647
+ function addUsageTokens(left: UsageTokens, right: UsageTokens): UsageTokens {
648
+ return {
649
+ input: left.input + right.input,
650
+ output: left.output + right.output,
651
+ reasoning: left.reasoning + right.reasoning,
652
+ cache: { read: left.cache.read + right.cache.read, write: left.cache.write + right.cache.write },
653
+ };
654
+ }
655
+
656
+ export function aggregateModelUsageFromMessages(messages: readonly unknown[]): SessionModelUsage {
657
+ const byModel = new Map<string, ModelUsage>();
658
+ const sessionIDs: string[] = [];
659
+ for (const entry of messages) {
660
+ if (!isRecord(entry) || entry.role !== "assistant") continue;
661
+ const providerID = toNonEmptyString(entry.providerID);
662
+ const modelID = toNonEmptyString(entry.modelID);
663
+ if (providerID === null || modelID === null) continue;
664
+ const parsed = parseUsageTotals(entry);
665
+ const key = `${providerID}/${modelID}`;
666
+ const existing = byModel.get(key);
667
+ if (existing === undefined) {
668
+ byModel.set(key, {
669
+ providerID,
670
+ modelID,
671
+ steps: 1,
672
+ cost: parsed.cost,
673
+ tokens: { ...parsed.tokens, cache: { ...parsed.tokens.cache } },
674
+ });
675
+ } else {
676
+ // In place: the map owns the row, and a fresh object per message would
677
+ // make a long session allocate one row per step.
678
+ existing.steps += 1;
679
+ existing.cost += parsed.cost;
680
+ existing.tokens = addUsageTokens(existing.tokens, parsed.tokens);
681
+ }
682
+ const sessionID = toNonEmptyString(entry.sessionID);
683
+ if (sessionID !== null && !sessionIDs.includes(sessionID)) sessionIDs.push(sessionID);
684
+ }
685
+
686
+ const models = [...byModel.values()];
687
+ const totals = models.reduce<UsageTotals>(
688
+ (sum, model) => ({
689
+ steps: sum.steps + model.steps,
690
+ cost: sum.cost + model.cost,
691
+ tokens: addUsageTokens(sum.tokens, model.tokens),
692
+ }),
693
+ { steps: 0, cost: 0, tokens: { ...EMPTY_TOKENS, cache: { ...EMPTY_TOKENS.cache } } },
694
+ );
695
+ return { sessionIDs, totals, models };
696
+ }