oc-go-usage-display 1.1.0 → 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 (42) hide show
  1. package/README.md +245 -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 +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 -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 +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 +75 -7
  21. package/dist/shared.d.ts.map +1 -1
  22. package/dist/shared.js +448 -25
  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 +372 -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 +46 -14
  37. package/src/helpers.ts +733 -0
  38. package/src/index.ts +316 -175
  39. package/src/shared.ts +545 -28
  40. package/src/tui-shared.tsx +705 -0
  41. package/src/tui.kilo.tsx +816 -0
  42. package/src/tui.tsx +352 -366
package/src/shared.ts CHANGED
@@ -14,16 +14,167 @@ import * as path from "node:path";
14
14
  // Constants
15
15
  // ---------------------------------------------------------------------------
16
16
 
17
- export const CONFIG_DIR = path.join(os.homedir(), ".config", "opencode");
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;
18
140
 
19
- export function dataShareAuthPath(): string {
20
- const xdgDataHome = toNonEmptyString(process.env.XDG_DATA_HOME);
21
- if (xdgDataHome) return path.join(xdgDataHome, "opencode", "auth.json");
22
- return path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
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));
23
161
  }
24
162
 
25
- export function authJsonPaths(): string[] {
26
- return [dataShareAuthPath(), path.join(CONFIG_DIR, "auth.json")];
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")];
27
178
  }
28
179
 
29
180
  // ---------------------------------------------------------------------------
@@ -32,8 +183,9 @@ export function authJsonPaths(): string[] {
32
183
 
33
184
  export type UsageWindow = {
34
185
  percent: number;
35
- resetInSec: number | null;
36
186
  status: string | null;
187
+ limited: boolean;
188
+ resetInSec: number | null;
37
189
  resetText: string | null;
38
190
  };
39
191
 
@@ -60,17 +212,178 @@ export function toFiniteNumber(value: unknown): number | null {
60
212
  return value;
61
213
  }
62
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
+
63
223
  export function toNonEmptyString(value: unknown): string | null {
64
224
  if (typeof value !== "string") return null;
65
225
  const trimmed = value.trim();
66
226
  return trimmed.length > 0 ? trimmed : null;
67
227
  }
68
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
+
69
372
  export function formatResetDuration(totalSec: number | null): string | null {
70
373
  if (totalSec === null || !Number.isFinite(totalSec) || totalSec < 0) return null;
71
374
  const sec = Math.floor(totalSec);
72
- const hours = Math.floor(sec / 3600);
73
- const minutes = Math.floor((sec % 3600) / 60);
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);
74
387
  if (hours > 0) return `${hours}h${minutes}m`;
75
388
  if (minutes > 0) return `${minutes}m`;
76
389
  return `${sec}s`;
@@ -80,8 +393,12 @@ export function formatResetDuration(totalSec: number | null): string | null {
80
393
  // Credentials boundary (auth.json; secrets never logged)
81
394
  // ---------------------------------------------------------------------------
82
395
 
83
- export function readAuthJsonApiKey(): string | null {
84
- for (const authPath of authJsonPaths()) {
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)) {
85
402
  let raw: string;
86
403
  try {
87
404
  raw = fs.readFileSync(authPath, "utf8");
@@ -109,7 +426,44 @@ export function readAuthJsonApiKey(): string | null {
109
426
  // API payload boundary (tolerant JSON parsing; shape may evolve)
110
427
  // ---------------------------------------------------------------------------
111
428
 
112
- export function extractWindow(candidate: unknown): UsageWindow | null {
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 {
113
467
  if (!isRecord(candidate)) return null;
114
468
  const percent = toFiniteNumber(
115
469
  candidate.percent ??
@@ -119,13 +473,20 @@ export function extractWindow(candidate: unknown): UsageWindow | null {
119
473
  candidate.usage,
120
474
  );
121
475
  if (percent === null) return null;
122
- const resetInSec = toFiniteNumber(candidate.resetInSec ?? candidate.resetInSeconds ?? null);
123
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);
124
482
  const resetText = toNonEmptyString(candidate.resetText ?? candidate.reset ?? null);
125
- return { percent: Math.round(percent), resetInSec, status, resetText };
483
+ return { percent: Math.round(percent), status, limited: isLimitedStatus(status), resetInSec, resetText };
126
484
  }
127
485
 
128
- export function extractSnapshotFromApiPayload(payload: unknown): UsageSnapshot | null {
486
+ export function extractSnapshotFromApiPayload(
487
+ payload: unknown,
488
+ now: number = Date.now(),
489
+ ): UsageSnapshot | null {
129
490
  if (!isRecord(payload)) return null;
130
491
  const containers: unknown[] = [payload];
131
492
  for (const key of ["usage", "data", "go"]) {
@@ -133,16 +494,16 @@ export function extractSnapshotFromApiPayload(payload: unknown): UsageSnapshot |
133
494
  }
134
495
  for (const container of containers) {
135
496
  if (!isRecord(container)) continue;
136
- const rolling = extractWindow(container.rolling ?? container.rollingUsage ?? container["5h"]);
137
- const weekly = extractWindow(container.weekly ?? container.weeklyUsage ?? container["7d"]);
138
- const monthly = extractWindow(container.monthly ?? container.monthlyUsage ?? container["30d"]);
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);
139
500
  if (rolling !== null || weekly !== null || monthly !== null) {
140
501
  return {
141
502
  rolling,
142
503
  weekly,
143
504
  monthly,
144
505
  source: "api",
145
- fetchedAt: Date.now(),
506
+ fetchedAt: now,
146
507
  };
147
508
  }
148
509
  }
@@ -153,27 +514,183 @@ export function extractSnapshotFromApiPayload(payload: unknown): UsageSnapshot |
153
514
  // Snapshot builders
154
515
  // ---------------------------------------------------------------------------
155
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.
156
522
  export function mockSnapshot(): UsageSnapshot {
157
523
  return {
158
- rolling: { percent: 42, resetInSec: 7543, status: "active", resetText: null },
159
- weekly: { percent: 15, resetInSec: null, status: "active", resetText: null },
160
- monthly: { percent: 61, resetInSec: null, status: "active", resetText: null },
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 },
161
527
  source: "mock",
162
528
  fetchedAt: Date.now(),
163
529
  };
164
530
  }
165
531
 
166
- export function unavailableSnapshot(
167
- error: string,
168
- source: UsageSnapshot["source"] = "unavailable",
169
- ): UsageSnapshot {
532
+ export function unavailableSnapshot(error: string): UsageSnapshot {
170
533
  return {
171
534
  rolling: null,
172
535
  weekly: null,
173
536
  monthly: null,
174
- source,
537
+ source: "unavailable",
175
538
  fetchedAt: Date.now(),
176
539
  apiUnavailable: true,
177
540
  apiError: error,
178
541
  };
179
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
+ }