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