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/tui.tsx CHANGED
@@ -2,42 +2,44 @@
2
2
  //
3
3
  // OpenCode Go usage TUI plugin (dual-surface display).
4
4
  //
5
- // Renders subscription usage in two additive multi-render slots (sidebar order
6
- // 50 sits above the context panel at 100 and below model-sidebar at 20;
7
- // worktrunk renders elsewhere so multi-render stacking is unaffected):
8
- // - `sidebar_content` -> titled block, e.g. `Go Usage` header plus one
9
- // muted row per window (`5h 42%`, `7d 15%`, `30d 61%`). The header box
10
- // carries no paddingLeft/gap so `Go Usage` aligns flush left like the
11
- // `Context` header.
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
14
- // lives. Additive multi-render only; `sidebar_footer` (single_winner,
15
- // replaces name/version) is never used.
5
+ // Everything shared with the Kilo entry -- the refresh policy, the settings
6
+ // ladder, the plan meters, the row grammar, the failure boundaries -- lives in
7
+ // `./tui-shared.jsx` and is inlined into this bundle. What is left here is what
8
+ // is genuinely opencode-specific:
16
9
  //
17
- // Display surface is user-configurable (default both on; static selection
18
- // still requires restart):
19
- // 1. TUI plugin options in tui.json: `[..., {"sidebar": true, "statusline": true}]`
20
- // 2. `OPENCODE_GO_SIDEBAR` / `OPENCODE_GO_STATUSLINE` env vars (0/1/false/true)
21
- // 3. Legacy `display` option (`"sidebar"|"statusline"|"both"`) in tui.json,
22
- // `OPENCODE_GO_DISPLAY` env var, or persisted `api.kv` key `display`
23
- // (checked in that order when the new toggles are absent)
24
- // Only the selected surface(s) register a slot. Both slots are additive
25
- // multi-render (`sidebar_content` + `session_prompt_right`), so no
26
- // `single_winner` slot is ever used; each slot returns null when its own
27
- // collapse flag is set or when empty so it collapses instead of reserving
28
- // space.
29
- // (`session_prompt_right` is the primary statusline slot; if a future host
30
- // drops it, the additive fallbacks would be `home_footer` / `home_bottom`.)
10
+ // - `SLOT_ORDER` (50) sits in the free band above every host panel, so the
11
+ // block renders at the top of the sidebar. OpenCode's own `sidebar_content`
12
+ // ladder starts at 100 (`sidebar-context`) and continues 200 mcp / 300 lsp /
13
+ // 400 todo / 500 files, and it registers no `session_prompt_right` panel at
14
+ // all, so the statusline has nothing to stack against. Worktrunk renders
15
+ // elsewhere so multi-render stacking is unaffected.
16
+ // - both surfaces are additive multi-render (`sidebar_content` +
17
+ // `session_prompt_right`); no `single_winner` slot is ever used.
18
+ // - this host has no usage panel of its own to retire, so there is no
19
+ // sidebar_mode axis: the block is one thing, at one order.
31
20
  //
32
- // Collapse is independent per surface and persisted in `api.kv`:
33
- // - `collapsed_sidebar` toggled by `oc-go-usage-display.toggle-sidebar`
34
- // (title `Go usage: toggle sidebar`), checked only by `sidebar_content`.
35
- // - `collapsed_statusline` toggled by `oc-go-usage-display.toggle-statusline`
36
- // (title `Go usage: toggle statusline`), checked only by
37
- // `session_prompt_right`.
38
- // The legacy single `collapsed` key is migrated once on startup (when true,
39
- // both new keys are set true, then the legacy key is cleared) and ignored
40
- // afterwards.
21
+ // WHAT THE BLOCK RENDERS
22
+ // ---------------------
23
+ // The sidebar is ~30 cells wide here (Kilo's is ~40), which decides the whole
24
+ // layout: the plan is the meters themselves rather than label/value lines, and
25
+ // the per-model section is a foldable block that starts collapsed.
26
+ //
27
+ // Go Usage
28
+ // 5h resets in 2h5m the next plan window, labelled, one line
29
+ // 5h [#######.....] 42% the three windows, 10-cell meters
30
+ // 7d [#...........] 15%
31
+ // 30d [#########..] 61%
32
+ // > Top Go models (7) one line: the mix, cheapest thing to keep
33
+ // mimo 59% · qwen 25% ...
34
+ // (expanded: one row per model with a 6-cell
35
+ // weight bar, then what the rows cover and the
36
+ // session's Go totals)
37
+ //
38
+ // The weight of a model is its share of the Go tokens spent in THIS session --
39
+ // measured the way the host's own Context panel measures the number next to it,
40
+ // i.e. over the rendered session's assistant messages, not the session tree.
41
+ // See `aggregateModelUsageFromMessages` for why opencode needs no endpoint for
42
+ // this and what "the same shape as Kilo's" buys.
41
43
  //
42
44
  // Data: auth.json (dataShare ~/.local/share/opencode/auth.json, then legacy
43
45
  // ~/.config/opencode/auth.json, `opencode-go` key else `opencode` key) as
@@ -58,481 +60,370 @@ import type {
58
60
  TuiSlotContext,
59
61
  TuiTheme,
60
62
  } from "@opencode-ai/plugin/tui";
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";
65
-
66
- // ---------------------------------------------------------------------------
67
- // Constants
68
- // ---------------------------------------------------------------------------
69
-
70
- const API_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
71
- const POLL_INTERVAL_MS = 60_000;
72
- const FETCH_TIMEOUT_MS = 10_000;
63
+ import { For, Show, createMemo, createSignal } from "solid-js";
64
+ import {
65
+ buildGoModelFooters,
66
+ buildModelMixSummary,
67
+ relevantReset,
68
+ formatPercentCell,
69
+ meterSeverityForPercent,
70
+ modelDisplayName,
71
+ shortModelName,
72
+ weightGoModels,
73
+ } from "./helpers.js";
74
+ import type { GoModelWeight } from "./helpers.js";
75
+ import {
76
+ aggregateModelUsageFromMessages,
77
+ errorMessage,
78
+ MODELS_EMPTY_LABEL,
79
+ OPENCODE_MODEL_NAME_MAX_CHARS,
80
+ TOP_GO_MODELS_LABEL,
81
+ } from "./shared.js";
82
+ import type { SessionModelUsage } from "./shared.js";
83
+ import {
84
+ CollapsibleSection,
85
+ GoStatusline,
86
+ GoUsageBlock,
87
+ MeterBar,
88
+ createCollapseState,
89
+ createUsageStore,
90
+ isGoUsageProvider,
91
+ logUsageError,
92
+ makeProviderResolver,
93
+ readModelDisplayName,
94
+ reactiveChild,
95
+ resolveSurfaceSelection,
96
+ } from "./tui-shared.js";
97
+ import { EVENT_TTL_MS, POLL_INTERVAL_MS } from "./tui-shared.js";
98
+ import type { DisplayOptions } from "./tui-shared.js";
99
+
100
+ // This entry is the opencode build, so it resolves `OPENCODE_OC_GO_*` and never
101
+ // reads a Kilo-prefixed name.
102
+ const HOST = "opencode" as const;
73
103
  const SLOT_ORDER = 50;
74
- const KV_DISPLAY_KEY = "display";
75
- const KV_COLLAPSED_SIDEBAR_KEY = "collapsed_sidebar";
76
- const KV_COLLAPSED_STATUSLINE_KEY = "collapsed_statusline";
77
- const KV_COLLAPSED_LEGACY_KEY = "collapsed";
78
- const LOG_SERVICE = "oc-go-usage-display";
79
-
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
- // ---------------------------------------------------------------------------
93
- // Trusted types (parsed at the boundary, trusted internally)
94
- // ---------------------------------------------------------------------------
95
-
96
- type DisplayMode = "sidebar" | "statusline" | "both";
97
-
98
- type SurfaceSelection = {
99
- sidebar: boolean;
100
- statusline: boolean;
101
- };
102
-
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
- type UsageRow = {
119
- label: string;
120
- value: string;
121
- };
122
-
123
- // ---------------------------------------------------------------------------
124
- // Small pure helpers
125
- // ---------------------------------------------------------------------------
126
-
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
104
 
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
- function isDisplayMode(value: unknown): value is DisplayMode {
143
- return value === "sidebar" || value === "statusline" || value === "both";
144
- }
145
-
146
- function surfaceSelectionFromDisplayMode(mode: DisplayMode): SurfaceSelection {
147
- return { sidebar: mode !== "statusline", statusline: mode !== "sidebar" };
148
- }
149
-
150
- function parseBooleanFlag(value: unknown): boolean | null {
151
- if (typeof value === "boolean") return value;
152
- if (typeof value === "number") {
153
- if (value === 1) return true;
154
- if (value === 0) return false;
155
- return null;
156
- }
157
- if (typeof value !== "string") return null;
158
- const normalized = value.trim().toLowerCase();
159
- if (normalized === "1" || normalized === "true") return true;
160
- if (normalized === "0" || normalized === "false") return false;
161
- return null;
162
- }
105
+ // The factory body lives here so the exported `goUsageTui` can wrap the whole
106
+ // initialization in a single fail-safe boundary. A throwing factory would
107
+ // destabilize the host's plugin load, so it must never reject.
108
+ async function initializeTui(api: TuiPluginApi, options: PluginOptions | undefined): Promise<void> {
109
+ const surfaces = resolveSurfaceSelection(options as DisplayOptions | undefined, api, HOST);
110
+ const usageStore = createUsageStore(api, HOST);
111
+ const collapse = createCollapseState(api);
163
112
 
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 {
175
- return snapshot.rolling === null && snapshot.weekly === null && snapshot.monthly === null;
176
- }
177
-
178
- function formatCompactLine(snapshot: UsageSnapshot): string {
179
- const rolling = snapshot.rolling === null ? "5h n/a" : `5h ${snapshot.rolling.percent}%`;
180
- const weekly = snapshot.weekly === null ? "7d n/a" : `7d ${snapshot.weekly.percent}%`;
181
- return `Go ${rolling} | ${weekly}`;
182
- }
183
-
184
- function buildUsageRows(snapshot: UsageSnapshot): UsageRow[] {
185
- const rows: UsageRow[] = [];
186
- if (snapshot.rolling !== null) {
187
- const reset = formatResetDuration(snapshot.rolling.resetInSec) ?? snapshot.rolling.resetText;
188
- rows.push({ label: "5h", value: `${snapshot.rolling.percent}%${reset ? ` · resets ${reset}` : ""}` });
113
+ const [activeProviderId, setActiveProviderId] = createSignal<string | undefined>(undefined);
114
+ try {
115
+ const modelString = api.state?.config?.model;
116
+ if (typeof modelString === "string" && modelString.length > 0) {
117
+ const providerPart = modelString.split("/")[0];
118
+ if (providerPart !== undefined && providerPart.length > 0) setActiveProviderId(providerPart);
119
+ }
120
+ } catch {
121
+ // State may not be ready; fall through to the event signal.
189
122
  }
190
- if (snapshot.weekly !== null) rows.push({ label: "7d", value: `${snapshot.weekly.percent}%` });
191
- if (snapshot.monthly !== null) rows.push({ label: "30d", value: `${snapshot.monthly.percent}%` });
192
- return rows;
193
- }
123
+ const resolveActiveProviderId = makeProviderResolver(api.state, activeProviderId);
194
124
 
195
- // ---------------------------------------------------------------------------
196
- // Settings boundary (new toggles > legacy display; tui.json options > env >
197
- // api.kv > default both on)
198
- // ---------------------------------------------------------------------------
125
+ // The host's message store is where the per-model mix comes from, and it
126
+ // changes without the plan changing. Bumping this token on the same events
127
+ // that refresh the plan keeps the mix from going stale behind a host that
128
+ // reuses the element it was handed instead of re-invoking this subtree (see
129
+ // the reactivity note in `tui-shared`).
130
+ const [stateToken, setStateToken] = createSignal(0);
199
131
 
200
- function resolveSurfaceSelection(options: PluginOptions | undefined, api: TuiPluginApi): SurfaceSelection {
201
- if (options !== undefined) {
202
- const sidebarOption = parseBooleanFlag(options.sidebar);
203
- const statuslineOption = parseBooleanFlag(options.statusline);
204
- if (sidebarOption !== null || statuslineOption !== null) {
205
- return { sidebar: sidebarOption ?? true, statusline: statuslineOption ?? true };
132
+ function readSessionMessages(sessionId: string): readonly unknown[] {
133
+ if (sessionId.length === 0) return [];
134
+ try {
135
+ return api.state.session.messages(sessionId);
136
+ } catch {
137
+ return [];
206
138
  }
207
- if (isDisplayMode(options.display)) return surfaceSelectionFromDisplayMode(options.display);
208
139
  }
209
140
 
210
- const sidebarEnv = parseBooleanFlag(process.env.OPENCODE_GO_SIDEBAR);
211
- const statuslineEnv = parseBooleanFlag(process.env.OPENCODE_GO_STATUSLINE);
212
- if (sidebarEnv !== null || statuslineEnv !== null) {
213
- return { sidebar: sidebarEnv ?? true, statusline: statuslineEnv ?? true };
141
+ // One model row: the display name the catalog gives it, then its weight bar
142
+ // and percent pushed to the right edge so the bars line up down the block.
143
+ function WeightedModelRow(props: { theme: TuiTheme; model: GoModelWeight }) {
144
+ const rounded = createMemo(() => Math.round(props.model.share));
145
+ const name = createMemo(() =>
146
+ modelDisplayName(
147
+ readModelDisplayName(api, props.model.providerID, props.model.modelID),
148
+ props.model.modelID,
149
+ OPENCODE_MODEL_NAME_MAX_CHARS,
150
+ ),
151
+ );
152
+ return (
153
+ <box flexDirection="row" gap={1} flexShrink={0}>
154
+ <text fg={props.theme.current.text} wrapMode="none" flexShrink={0}>
155
+ {name()}
156
+ </text>
157
+ <MeterBar
158
+ theme={props.theme}
159
+ percent={props.model.share}
160
+ severity={meterSeverityForPercent(rounded())}
161
+ />
162
+ <text fg={props.theme.current.textMuted} wrapMode="none" flexShrink={0}>
163
+ {formatPercentCell(rounded())}
164
+ </text>
165
+ </box>
166
+ );
214
167
  }
215
168
 
216
- const displayEnv = toNonEmptyString(process.env.OPENCODE_GO_DISPLAY);
217
- if (displayEnv !== null && isDisplayMode(displayEnv)) {
218
- return surfaceSelectionFromDisplayMode(displayEnv);
219
- }
169
+ // The model mix. Collapsed by default: the one-line summary is the cheap
170
+ // answer, and this block already leads a sidebar that the host's own Context
171
+ // panel follows. The count in the header is every Go model the session used,
172
+ // not the three rows below it, so a truncated ranking says so.
173
+ function TopGoModelsSection(props: { theme: TuiTheme; usage: () => SessionModelUsage | null }) {
174
+ const [isExpanded, setIsExpanded] = createSignal<boolean>(false);
175
+ const weights = createMemo(() => weightGoModels(props.usage()?.models ?? []));
176
+ const rows = (): GoModelWeight[] => weights().models;
177
+ // The collapsed line names the same models the expanded rows do, in as few
178
+ // cells as the sidebar has.
179
+ const mix = createMemo(() =>
180
+ buildModelMixSummary(
181
+ rows().map((model) => ({
182
+ name: shortModelName(
183
+ modelDisplayName(
184
+ readModelDisplayName(api, model.providerID, model.modelID),
185
+ model.modelID,
186
+ OPENCODE_MODEL_NAME_MAX_CHARS,
187
+ ),
188
+ ),
189
+ share: model.share,
190
+ })),
191
+ ),
192
+ );
220
193
 
221
- try {
222
- const stored = api.kv.get(KV_DISPLAY_KEY, "both");
223
- if (isDisplayMode(stored)) return surfaceSelectionFromDisplayMode(stored);
224
- } catch {
225
- // Persisted settings are best-effort; fall through to the default.
226
- }
227
- return { sidebar: true, statusline: true };
228
- }
194
+ const body = createMemo(() => (
195
+ <box flexDirection="column" gap={1} paddingTop={1} paddingLeft={2}>
196
+ <For each={rows()}>{(model) => <WeightedModelRow theme={props.theme} model={model} />}</For>
197
+ <For each={buildGoModelFooters(weights())}>
198
+ {(line) => (
199
+ <text fg={props.theme.current.textMuted} wrapMode="none">
200
+ {line}
201
+ </text>
202
+ )}
203
+ </For>
204
+ </box>
205
+ ));
206
+
207
+ // The whole section is one function child, because two rules apply here and
208
+ // both are about the same thing: the host reuses the element it was handed,
209
+ // so a plain value is read once at mount. That is why the conditions below
210
+ // are read INSIDE this memo (re-running it is what re-reads them) and why
211
+ // `Show` is given a value rather than an accessor -- solid's `Show` memoizes
212
+ // `when` and never calls it, so an accessor here is a function object, and a
213
+ // function object is always truthy. The real host caught exactly that: a
214
+ // session with no assistant messages rendered `Top Go models (0)` with a
215
+ // caret that opened onto nothing.
216
+ const section = createMemo(() => {
217
+ // No models yet is not a fold: there is nothing to fold, and a caret that
218
+ // opens onto an empty box reads as a broken section.
219
+ if (weights().goModels === 0) {
220
+ return (
221
+ <box flexDirection="column">
222
+ <text fg={props.theme.current.text} wrapMode="none">
223
+ <b>{TOP_GO_MODELS_LABEL}</b>
224
+ </text>
225
+ <text fg={props.theme.current.textMuted} wrapMode="none">
226
+ {MODELS_EMPTY_LABEL}
227
+ </text>
228
+ </box>
229
+ );
230
+ }
231
+ return (
232
+ <box flexDirection="column">
233
+ <CollapsibleSection
234
+ theme={props.theme}
235
+ label={TOP_GO_MODELS_LABEL}
236
+ count={() => weights().goModels}
237
+ expanded={isExpanded}
238
+ onToggle={() => setIsExpanded((current) => !current)}
239
+ body={body}
240
+ />
241
+ <Show when={!isExpanded()}>
242
+ <text fg={props.theme.current.textMuted} wrapMode="none" paddingLeft={2}>
243
+ {reactiveChild(mix)}
244
+ </text>
245
+ </Show>
246
+ </box>
247
+ );
248
+ });
229
249
 
230
- function readCollapsedFlag(api: TuiPluginApi, key: string): boolean {
231
- try {
232
- return api.kv.get<boolean>(key, false) === true;
233
- } catch {
234
- return false;
250
+ return <box flexDirection="column">{reactiveChild(section)}</box>;
235
251
  }
236
- }
237
252
 
238
- function migrateLegacyCollapsedFlag(api: TuiPluginApi): void {
239
- let legacyCollapsed = false;
240
- try {
241
- legacyCollapsed = api.kv.get<boolean>(KV_COLLAPSED_LEGACY_KEY, false) === true;
242
- } catch {
243
- return;
244
- }
245
- if (!legacyCollapsed) return;
246
- try {
247
- api.kv.set(KV_COLLAPSED_SIDEBAR_KEY, true);
248
- api.kv.set(KV_COLLAPSED_STATUSLINE_KEY, true);
249
- api.kv.set(KV_COLLAPSED_LEGACY_KEY, false);
250
- } catch {
251
- // Collapse state is best-effort persistence only.
253
+ function GoSidebarPanel(props: { theme: TuiTheme; sessionId: string }) {
254
+ const planResetLine = createMemo(() => {
255
+ const snapshot = usageStore.snapshot();
256
+ if (snapshot === null || snapshot.source === "unavailable") return null;
257
+ return relevantReset(snapshot);
258
+ });
259
+ const sessionUsage = createMemo(() => {
260
+ stateToken();
261
+ return aggregateModelUsageFromMessages(readSessionMessages(props.sessionId));
262
+ });
263
+ return (
264
+ <box flexDirection="column" gap={1}>
265
+ <GoUsageBlock
266
+ api={api}
267
+ theme={props.theme}
268
+ snapshot={usageStore.snapshot}
269
+ resetLine={planResetLine}
270
+ />
271
+ <TopGoModelsSection theme={props.theme} usage={sessionUsage} />
272
+ </box>
273
+ );
252
274
  }
253
- }
254
-
255
- // ---------------------------------------------------------------------------
256
- // Data boundary (auth.json -> Bearer usage fetch; throws nothing)
257
- // ---------------------------------------------------------------------------
258
275
 
259
- function readAuthJsonApiKey(): string | null {
260
- for (const authPath of authJsonPaths()) {
261
- let raw: string;
276
+ if (surfaces.sidebar) {
277
+ // Host-owned slot: `register` returns an id but the SDK exposes no
278
+ // unregister API, so there is nothing to dispose here (the slot dies
279
+ // with the host). Interval/event/command teardown below is the full
280
+ // dispose path.
262
281
  try {
263
- raw = fs.readFileSync(authPath, "utf8");
282
+ api.slots.register({
283
+ order: SLOT_ORDER,
284
+ slots: {
285
+ sidebar_content(ctx: TuiSlotContext, props: { session_id: string }) {
286
+ // Individually guarded: a later render must never throw into the host.
287
+ try {
288
+ if (props.session_id.length === 0) return null;
289
+ if (!isGoUsageProvider(resolveActiveProviderId(props.session_id))) return null;
290
+ if (api.route.current.name !== "session") return null;
291
+ if (collapse.isSidebarCollapsed()) return null;
292
+ return <GoSidebarPanel theme={ctx.theme} sessionId={props.session_id} />;
293
+ } catch {
294
+ return null;
295
+ }
296
+ },
297
+ },
298
+ });
264
299
  } catch {
265
- continue;
300
+ // Additive slot: a registration failure must not abort the plugin.
266
301
  }
267
- let parsed: unknown;
302
+ }
303
+
304
+ if (surfaces.statusline) {
305
+ // Host-owned slot (see sidebar note above): no unregister API exists,
306
+ // so disposal is a no-op for slots.
268
307
  try {
269
- parsed = JSON.parse(raw);
308
+ api.slots.register({
309
+ order: SLOT_ORDER,
310
+ slots: {
311
+ session_prompt_right(_ctx: TuiSlotContext, props: { session_id: string }) {
312
+ // Individually guarded: a later render must never throw into the host.
313
+ try {
314
+ if (props.session_id.length === 0) return null;
315
+ if (!isGoUsageProvider(resolveActiveProviderId(props.session_id))) return null;
316
+ if (collapse.isStatuslineCollapsed()) return null;
317
+ return <GoStatusline snapshot={usageStore.snapshot} />;
318
+ } catch {
319
+ return null;
320
+ }
321
+ },
322
+ },
323
+ });
270
324
  } catch {
271
- continue;
325
+ // Additive slot: a registration failure must not abort the plugin.
272
326
  }
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
327
  }
281
- return null;
282
- }
283
328
 
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
- }
329
+ // `api.command` is a deprecated legacy shim that hosts may omit; guard so
330
+ // the plugin still initializes and disposes safely without it.
331
+ let unregisterToggleCommand: () => void = () => {};
332
+ try {
333
+ const unregister = api.command?.register(() => [
334
+ {
335
+ title: "Go usage: toggle sidebar",
336
+ value: "oc-go-usage-display.toggle-sidebar",
337
+ category: "Go",
338
+ onSelect: () => collapse.toggleSidebar(),
339
+ },
340
+ {
341
+ title: "Go usage: toggle statusline",
342
+ value: "oc-go-usage-display.toggle-statusline",
343
+ category: "Go",
344
+ onSelect: () => collapse.toggleStatusline(),
345
+ },
346
+ ]);
347
+ if (typeof unregister === "function") unregisterToggleCommand = unregister;
348
+ } catch {
349
+ // Legacy command registration is optional; ignore failures.
315
350
  }
316
- return null;
317
- }
318
351
 
319
- async function fetchJsonWithTimeout(url: string, apiKey: string): Promise<unknown | null> {
320
- const controller = new AbortController();
321
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
352
+ let unsubscribeSession: () => void = () => {};
353
+ let unsubscribeMessage: () => void = () => {};
322
354
  try {
323
- const response = await fetch(url, {
324
- signal: controller.signal,
325
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
355
+ const unsubscribe = api.event.on("session.updated", (event) => {
356
+ const providerId = event?.properties?.info?.model?.providerID;
357
+ if (providerId !== undefined) setActiveProviderId(providerId);
358
+ usageStore.refreshSafely(0);
326
359
  });
327
- if (response.status === 401 || response.status === 403) return { __rejected: true };
328
- if (!response.ok) return null;
329
- try {
330
- return (await response.json()) as unknown;
331
- } catch {
332
- return null;
333
- }
360
+ if (typeof unsubscribe === "function") unsubscribeSession = unsubscribe;
334
361
  } catch {
335
- return null;
336
- } finally {
337
- clearTimeout(timer);
362
+ // Event subscription is additive; a failure must not abort the plugin.
338
363
  }
339
- }
340
-
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
- async function loadUsageSnapshot(): Promise<UsageSnapshot | null> {
352
- if (process.env.OPENCODE_GO_MOCK === "1") return mockSnapshot();
353
-
354
- const apiKey = toNonEmptyString(process.env.OPENCODE_GO_API_KEY) ?? readAuthJsonApiKey();
355
- if (apiKey === null) return null;
356
-
357
- const payload = await fetchJsonWithTimeout(API_USAGE_URL, apiKey);
358
- if (payload === null) return null;
359
- if (isRecord(payload) && payload.__rejected === true) return null;
360
- return extractSnapshotFromApiPayload(payload);
361
- }
362
-
363
- async function logUsageError(api: TuiPluginApi, message: string): Promise<void> {
364
364
  try {
365
- await api.client.app.log({ service: LOG_SERVICE, level: "error", message });
365
+ const unsubscribe = api.event.on("message.updated", () => {
366
+ usageStore.refreshSafely(EVENT_TTL_MS);
367
+ // The model's share of the session changes with every finished message,
368
+ // not only when the plan does.
369
+ setStateToken((current) => current + 1);
370
+ });
371
+ if (typeof unsubscribe === "function") unsubscribeMessage = unsubscribe;
366
372
  } catch {
367
- // Logging is best-effort; the usage display must never break the host.
373
+ // Event subscription is additive; a failure must not abort the plugin.
368
374
  }
369
- }
370
-
371
- // ---------------------------------------------------------------------------
372
- // TUI plugin
373
- // ---------------------------------------------------------------------------
374
-
375
- const goUsageTui: TuiPlugin = async (api, options) => {
376
- const surfaces = resolveSurfaceSelection(options, api);
377
- migrateLegacyCollapsedFlag(api);
378
-
379
- const [usageSnapshot, setUsageSnapshot] = createSignal<UsageSnapshot | null>(null);
380
- const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal<boolean>(
381
- readCollapsedFlag(api, KV_COLLAPSED_SIDEBAR_KEY),
382
- );
383
- const [isStatuslineCollapsed, setIsStatuslineCollapsed] = createSignal<boolean>(
384
- readCollapsedFlag(api, KV_COLLAPSED_STATUSLINE_KEY),
385
- );
386
375
 
387
- let cachedAt = 0;
388
- let refreshInFlight = false;
376
+ let pollTimer: ReturnType<typeof setInterval> | null = null;
377
+ try {
378
+ pollTimer = setInterval(() => usageStore.refreshSafely(), POLL_INTERVAL_MS);
379
+ } catch {
380
+ // No poll timer: the on-demand refresh below still runs.
381
+ }
389
382
 
390
- async function refreshUsage(): Promise<void> {
391
- if (refreshInFlight) return;
392
- if (Date.now() - cachedAt < POLL_INTERVAL_MS && usageSnapshot() !== null) return;
393
- refreshInFlight = true;
394
- try {
395
- const snapshot = await loadUsageSnapshot();
396
- if (snapshot === null) {
397
- if (usageSnapshot() === null) {
398
- await logUsageError(api, "Go usage unavailable (not configured or request failed)");
383
+ try {
384
+ api.lifecycle.onDispose(() => {
385
+ // Each teardown step is isolated: one throwing unsubscribe must not
386
+ // prevent the rest (or leak into the host's dispose pass).
387
+ if (pollTimer !== null) {
388
+ try {
389
+ clearInterval(pollTimer);
390
+ } catch {
391
+ // noop
399
392
  }
400
- return;
401
393
  }
402
- cachedAt = Date.now();
403
- setUsageSnapshot(snapshot);
404
- } catch {
405
- // Keep stale data; the panel simply shows the last known snapshot.
406
- } finally {
407
- refreshInFlight = false;
408
- }
409
- }
410
-
411
- function toggleSidebarCollapsed(): void {
412
- const next = !isSidebarCollapsed();
413
- setIsSidebarCollapsed(next);
414
- try {
415
- api.kv.set(KV_COLLAPSED_SIDEBAR_KEY, next);
416
- } catch {
417
- // Collapse state is best-effort persistence only.
418
- }
419
- }
420
-
421
- function toggleStatuslineCollapsed(): void {
422
- const next = !isStatuslineCollapsed();
423
- setIsStatuslineCollapsed(next);
424
- try {
425
- api.kv.set(KV_COLLAPSED_STATUSLINE_KEY, next);
426
- } catch {
427
- // Collapse state is best-effort persistence only.
428
- }
429
- }
430
-
431
- function GoSidebarPanel(props: { theme: TuiTheme }) {
432
- createEffect(() => {
433
- const snapshot = usageSnapshot();
434
- if (snapshot !== null && snapshot.source === "unavailable") {
435
- void logUsageError(api, "Go usage snapshot unavailable");
394
+ for (const teardown of [unsubscribeSession, unsubscribeMessage, unregisterToggleCommand]) {
395
+ try {
396
+ teardown();
397
+ } catch {
398
+ // noop
399
+ }
436
400
  }
437
401
  });
438
- return (
439
- <box flexDirection="column">
440
- <text fg={props.theme.current.text}>
441
- <b>Go Usage</b>
442
- </text>
443
- <Show
444
- when={usageSnapshot()}
445
- fallback={
446
- <text fg={props.theme.current.textMuted} wrapMode="none">
447
- Go loading…
448
- </text>
449
- }
450
- >
451
- {(snapshot) => (
452
- <For each={buildUsageRows(snapshot())}>
453
- {(row) => (
454
- <text fg={props.theme.current.textMuted} wrapMode="none">
455
- {row.label} {row.value}
456
- </text>
457
- )}
458
- </For>
459
- )}
460
- </Show>
461
- </box>
462
- );
463
- }
464
-
465
- function GoStatusline() {
466
- return (
467
- <Show when={usageSnapshot()} fallback={null}>
468
- {(snapshot) => {
469
- if (isSnapshotEmpty(snapshot())) return null;
470
- return <text>{formatCompactLine(snapshot())}</text>;
471
- }}
472
- </Show>
473
- );
402
+ } catch {
403
+ // Lifecycle registration is best-effort; never leak a timer into the host.
404
+ if (pollTimer !== null) {
405
+ try {
406
+ clearInterval(pollTimer);
407
+ } catch {
408
+ // noop
409
+ }
410
+ }
474
411
  }
475
412
 
476
- if (surfaces.sidebar) {
477
- api.slots.register({
478
- order: SLOT_ORDER,
479
- slots: {
480
- sidebar_content(ctx: TuiSlotContext, props: { session_id: string }) {
481
- if (props.session_id.length === 0) return null;
482
- if (api.route.current.name !== "session") return null;
483
- if (isSidebarCollapsed()) return null;
484
- return <GoSidebarPanel theme={ctx.theme} />;
485
- },
486
- },
487
- });
488
- }
413
+ await usageStore.refresh();
414
+ }
489
415
 
490
- if (surfaces.statusline) {
491
- api.slots.register({
492
- order: SLOT_ORDER,
493
- slots: {
494
- session_prompt_right(_ctx: TuiSlotContext, props: { session_id: string }) {
495
- if (props.session_id.length === 0) return null;
496
- if (isStatuslineCollapsed()) return null;
497
- return <GoStatusline />;
498
- },
499
- },
500
- });
416
+ const goUsageTui: TuiPlugin = async (api, options) => {
417
+ try {
418
+ await initializeTui(api, options);
419
+ } catch (error) {
420
+ // Best-effort and non-blocking: never delay plugin resolution on the host
421
+ // log and never let an initialization error reject into the host.
422
+ void logUsageError(
423
+ api,
424
+ `Go usage TUI failed to initialize: ${errorMessage(error)}; continuing without display`,
425
+ );
501
426
  }
502
-
503
- const unregisterToggleCommand = api.command.register(() => [
504
- {
505
- title: "Go usage: toggle sidebar",
506
- value: "oc-go-usage-display.toggle-sidebar",
507
- category: "Go",
508
- onSelect: () => toggleSidebarCollapsed(),
509
- },
510
- {
511
- title: "Go usage: toggle statusline",
512
- value: "oc-go-usage-display.toggle-statusline",
513
- category: "Go",
514
- onSelect: () => toggleStatuslineCollapsed(),
515
- },
516
- ]);
517
-
518
- const unsubscribeSession = api.event.on("session.updated", () => {
519
- void refreshUsage();
520
- });
521
- const unsubscribeMessage = api.event.on("message.updated", () => {
522
- void refreshUsage();
523
- });
524
- const pollTimer = setInterval(() => {
525
- void refreshUsage();
526
- }, POLL_INTERVAL_MS);
527
-
528
- api.lifecycle.onDispose(() => {
529
- clearInterval(pollTimer);
530
- unsubscribeSession();
531
- unsubscribeMessage();
532
- unregisterToggleCommand();
533
- });
534
-
535
- await refreshUsage();
536
427
  };
537
428
 
538
429
  export default { id: "oc-go-usage-display", tui: goUsageTui } satisfies TuiPluginModule;