oc-go-usage-display 1.0.2 → 1.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.
package/src/tui.tsx CHANGED
@@ -10,7 +10,7 @@
10
10
  // carries no paddingLeft/gap so `Go Usage` aligns flush left like the
11
11
  // `Context` header.
12
12
  // - `session_prompt_right` -> compact single line next to the context status
13
- // info (e.g. `Go 5h 42% | 7d 15%`), where the `80.6K (8%) · $0.09` readout
13
+ // info (e.g. `Go 5h 42% | 7d 15% | 30d 61%`), where the `80.6K (8%) · $0.09` readout
14
14
  // lives. Additive multi-render only; `sidebar_footer` (single_winner,
15
15
  // replaces name/version) is never used.
16
16
  //
@@ -59,9 +59,19 @@ import type {
59
59
  TuiTheme,
60
60
  } from "@opencode-ai/plugin/tui";
61
61
  import { For, Show, createEffect, createSignal } from "solid-js";
62
- import * as fs from "node:fs";
63
- import * as os from "node:os";
64
- import * as path from "node:path";
62
+ import {
63
+ extractSnapshotFromApiPayload,
64
+ formatResetDuration,
65
+ isRecord,
66
+ mockSnapshot,
67
+ readAuthJsonApiKey,
68
+ toNonEmptyString,
69
+ unavailableSnapshot,
70
+ } from "./shared.js";
71
+ import type { UsageSnapshot } from "./shared.js";
72
+
73
+ // Re-exported so `dist/tui.js` keeps the helper surface used by tests.
74
+ export { formatResetDuration };
65
75
 
66
76
  // ---------------------------------------------------------------------------
67
77
  // Constants
@@ -77,20 +87,9 @@ const KV_COLLAPSED_STATUSLINE_KEY = "collapsed_statusline";
77
87
  const KV_COLLAPSED_LEGACY_KEY = "collapsed";
78
88
  const LOG_SERVICE = "oc-go-usage-display";
79
89
 
80
- const CONFIG_DIR = path.join(os.homedir(), ".config", "opencode");
81
-
82
- function dataShareAuthPath(): string {
83
- const xdgDataHome = toNonEmptyString(process.env.XDG_DATA_HOME);
84
- if (xdgDataHome) return path.join(xdgDataHome, "opencode", "auth.json");
85
- return path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
86
- }
87
-
88
- function authJsonPaths(): string[] {
89
- return [dataShareAuthPath(), path.join(CONFIG_DIR, "auth.json")];
90
- }
91
-
92
90
  // ---------------------------------------------------------------------------
93
- // Trusted types (parsed at the boundary, trusted internally)
91
+ // Trusted types (parsed at the boundary, trusted internally; usage shapes
92
+ // live in `./shared.js` so server and TUI parse identically)
94
93
  // ---------------------------------------------------------------------------
95
94
 
96
95
  type DisplayMode = "sidebar" | "statusline" | "both";
@@ -100,21 +99,6 @@ type SurfaceSelection = {
100
99
  statusline: boolean;
101
100
  };
102
101
 
103
- type UsageWindow = {
104
- percent: number;
105
- resetInSec: number | null;
106
- resetText: string | null;
107
- };
108
-
109
- type UsageSnapshot = {
110
- rolling: UsageWindow | null;
111
- weekly: UsageWindow | null;
112
- monthly: UsageWindow | null;
113
- source: "api" | "mock" | "unavailable";
114
- fetchedAt: number;
115
- apiError?: string;
116
- };
117
-
118
102
  type UsageRow = {
119
103
  label: string;
120
104
  value: string;
@@ -124,30 +108,15 @@ type UsageRow = {
124
108
  // Small pure helpers
125
109
  // ---------------------------------------------------------------------------
126
110
 
127
- function isRecord(value: unknown): value is Record<string, unknown> {
128
- return typeof value === "object" && value !== null && !Array.isArray(value);
129
- }
130
-
131
- function toFiniteNumber(value: unknown): number | null {
132
- if (typeof value !== "number" || !Number.isFinite(value)) return null;
133
- return value;
134
- }
135
-
136
- function toNonEmptyString(value: unknown): string | null {
137
- if (typeof value !== "string") return null;
138
- const trimmed = value.trim();
139
- return trimmed.length > 0 ? trimmed : null;
140
- }
141
-
142
111
  function isDisplayMode(value: unknown): value is DisplayMode {
143
112
  return value === "sidebar" || value === "statusline" || value === "both";
144
113
  }
145
114
 
146
- function surfaceSelectionFromDisplayMode(mode: DisplayMode): SurfaceSelection {
115
+ export function surfaceSelectionFromDisplayMode(mode: DisplayMode): SurfaceSelection {
147
116
  return { sidebar: mode !== "statusline", statusline: mode !== "sidebar" };
148
117
  }
149
118
 
150
- function parseBooleanFlag(value: unknown): boolean | null {
119
+ export function parseBooleanFlag(value: unknown): boolean | null {
151
120
  if (typeof value === "boolean") return value;
152
121
  if (typeof value === "number") {
153
122
  if (value === 1) return true;
@@ -161,27 +130,18 @@ function parseBooleanFlag(value: unknown): boolean | null {
161
130
  return null;
162
131
  }
163
132
 
164
- function formatResetDuration(totalSec: number | null): string | null {
165
- if (totalSec === null || !Number.isFinite(totalSec) || totalSec < 0) return null;
166
- const sec = Math.floor(totalSec);
167
- const hours = Math.floor(sec / 3600);
168
- const minutes = Math.floor((sec % 3600) / 60);
169
- if (hours > 0) return `${hours}h${minutes}m`;
170
- if (minutes > 0) return `${minutes}m`;
171
- return `${sec}s`;
172
- }
173
-
174
- function isSnapshotEmpty(snapshot: UsageSnapshot): boolean {
133
+ export function isSnapshotEmpty(snapshot: UsageSnapshot): boolean {
175
134
  return snapshot.rolling === null && snapshot.weekly === null && snapshot.monthly === null;
176
135
  }
177
136
 
178
- function formatCompactLine(snapshot: UsageSnapshot): string {
137
+ export function formatCompactLine(snapshot: UsageSnapshot): string {
179
138
  const rolling = snapshot.rolling === null ? "5h n/a" : `5h ${snapshot.rolling.percent}%`;
180
139
  const weekly = snapshot.weekly === null ? "7d n/a" : `7d ${snapshot.weekly.percent}%`;
181
- return `Go ${rolling} | ${weekly}`;
140
+ const monthly = snapshot.monthly === null ? "30d n/a" : `30d ${snapshot.monthly.percent}%`;
141
+ return `Go ${rolling} | ${weekly} | ${monthly}`;
182
142
  }
183
143
 
184
- function buildUsageRows(snapshot: UsageSnapshot): UsageRow[] {
144
+ export function buildUsageRows(snapshot: UsageSnapshot): UsageRow[] {
185
145
  const rows: UsageRow[] = [];
186
146
  if (snapshot.rolling !== null) {
187
147
  const reset = formatResetDuration(snapshot.rolling.resetInSec) ?? snapshot.rolling.resetText;
@@ -256,66 +216,6 @@ function migrateLegacyCollapsedFlag(api: TuiPluginApi): void {
256
216
  // Data boundary (auth.json -> Bearer usage fetch; throws nothing)
257
217
  // ---------------------------------------------------------------------------
258
218
 
259
- function readAuthJsonApiKey(): string | null {
260
- for (const authPath of authJsonPaths()) {
261
- let raw: string;
262
- try {
263
- raw = fs.readFileSync(authPath, "utf8");
264
- } catch {
265
- continue;
266
- }
267
- let parsed: unknown;
268
- try {
269
- parsed = JSON.parse(raw);
270
- } catch {
271
- continue;
272
- }
273
- if (!isRecord(parsed)) continue;
274
- const goEntry = parsed["opencode-go"];
275
- const fallbackEntry = parsed["opencode"];
276
- const goKey = isRecord(goEntry) ? toNonEmptyString(goEntry.key) : null;
277
- if (goKey) return goKey;
278
- const fallbackKey = isRecord(fallbackEntry) ? toNonEmptyString(fallbackEntry.key) : null;
279
- if (fallbackKey) return fallbackKey;
280
- }
281
- return null;
282
- }
283
-
284
- function extractWindow(candidate: unknown): UsageWindow | null {
285
- if (!isRecord(candidate)) return null;
286
- const percent = toFiniteNumber(
287
- candidate.percent ??
288
- candidate.usagePercent ??
289
- candidate.usedPercent ??
290
- candidate.value ??
291
- candidate.usage,
292
- );
293
- if (percent === null) return null;
294
- return {
295
- percent: Math.round(percent),
296
- resetInSec: toFiniteNumber(candidate.resetInSec ?? candidate.resetInSeconds ?? null),
297
- resetText: toNonEmptyString(candidate.resetText ?? candidate.reset ?? null),
298
- };
299
- }
300
-
301
- function extractSnapshotFromApiPayload(payload: unknown): UsageSnapshot | null {
302
- if (!isRecord(payload)) return null;
303
- const containers: unknown[] = [payload];
304
- for (const key of ["usage", "data", "go"]) {
305
- if (isRecord(payload[key])) containers.push(payload[key]);
306
- }
307
- for (const container of containers) {
308
- if (!isRecord(container)) continue;
309
- const rolling = extractWindow(container.rolling ?? container.rollingUsage ?? container["5h"]);
310
- const weekly = extractWindow(container.weekly ?? container.weeklyUsage ?? container["7d"]);
311
- const monthly = extractWindow(container.monthly ?? container.monthlyUsage ?? container["30d"]);
312
- if (rolling !== null || weekly !== null || monthly !== null) {
313
- return { rolling, weekly, monthly, source: "api", fetchedAt: Date.now() };
314
- }
315
- }
316
- return null;
317
- }
318
-
319
219
  async function fetchJsonWithTimeout(url: string, apiKey: string): Promise<unknown | null> {
320
220
  const controller = new AbortController();
321
221
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
@@ -338,16 +238,6 @@ async function fetchJsonWithTimeout(url: string, apiKey: string): Promise<unknow
338
238
  }
339
239
  }
340
240
 
341
- function mockSnapshot(): UsageSnapshot {
342
- return {
343
- rolling: { percent: 42, resetInSec: 7543, resetText: null },
344
- weekly: { percent: 15, resetInSec: null, resetText: null },
345
- monthly: { percent: 61, resetInSec: null, resetText: null },
346
- source: "mock",
347
- fetchedAt: Date.now(),
348
- };
349
- }
350
-
351
241
  async function loadUsageSnapshot(): Promise<UsageSnapshot | null> {
352
242
  if (process.env.OPENCODE_GO_MOCK === "1") return mockSnapshot();
353
243
 
@@ -356,7 +246,11 @@ async function loadUsageSnapshot(): Promise<UsageSnapshot | null> {
356
246
 
357
247
  const payload = await fetchJsonWithTimeout(API_USAGE_URL, apiKey);
358
248
  if (payload === null) return null;
359
- if (isRecord(payload) && payload.__rejected === true) return null;
249
+ // Mirror the server: a rejected key is a distinct unavailable snapshot
250
+ // (surfaced via `apiError`) rather than a silent null.
251
+ if (isRecord(payload) && payload.__rejected === true) {
252
+ return unavailableSnapshot("API key rejected (401/403)");
253
+ }
360
254
  return extractSnapshotFromApiPayload(payload);
361
255
  }
362
256
 
@@ -432,7 +326,10 @@ const goUsageTui: TuiPlugin = async (api, options) => {
432
326
  createEffect(() => {
433
327
  const snapshot = usageSnapshot();
434
328
  if (snapshot !== null && snapshot.source === "unavailable") {
435
- void logUsageError(api, "Go usage snapshot unavailable");
329
+ void logUsageError(
330
+ api,
331
+ snapshot.apiError ? `Go usage unavailable (${snapshot.apiError})` : "Go usage snapshot unavailable",
332
+ );
436
333
  }
437
334
  });
438
335
  return (
@@ -474,6 +371,10 @@ const goUsageTui: TuiPlugin = async (api, options) => {
474
371
  }
475
372
 
476
373
  if (surfaces.sidebar) {
374
+ // Host-owned slot: `register` returns an id but the SDK exposes no
375
+ // unregister API, so there is nothing to dispose here (the slot dies
376
+ // with the host). Interval/event/command teardown below is the full
377
+ // dispose path.
477
378
  api.slots.register({
478
379
  order: SLOT_ORDER,
479
380
  slots: {
@@ -488,6 +389,8 @@ const goUsageTui: TuiPlugin = async (api, options) => {
488
389
  }
489
390
 
490
391
  if (surfaces.statusline) {
392
+ // Host-owned slot (see sidebar note above): no unregister API exists,
393
+ // so disposal is a no-op for slots.
491
394
  api.slots.register({
492
395
  order: SLOT_ORDER,
493
396
  slots: {
@@ -500,7 +403,9 @@ const goUsageTui: TuiPlugin = async (api, options) => {
500
403
  });
501
404
  }
502
405
 
503
- const unregisterToggleCommand = api.command.register(() => [
406
+ // `api.command` is a deprecated legacy shim that hosts may omit; guard so
407
+ // the plugin still initializes and disposes safely without it.
408
+ const unregisterToggleCommand = api.command?.register(() => [
504
409
  {
505
410
  title: "Go usage: toggle sidebar",
506
411
  value: "oc-go-usage-display.toggle-sidebar",
@@ -513,7 +418,7 @@ const goUsageTui: TuiPlugin = async (api, options) => {
513
418
  category: "Go",
514
419
  onSelect: () => toggleStatuslineCollapsed(),
515
420
  },
516
- ]);
421
+ ]) ?? (() => {});
517
422
 
518
423
  const unsubscribeSession = api.event.on("session.updated", () => {
519
424
  void refreshUsage();