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/dist/tui.js CHANGED
@@ -1,419 +1,268 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "@opentui/solid/jsx-runtime";
2
- import { For, Show, createEffect, createSignal } from "solid-js";
3
- import * as fs from "node:fs";
4
- import * as os from "node:os";
5
- import * as path from "node:path";
6
- // ---------------------------------------------------------------------------
7
- // Constants
8
- // ---------------------------------------------------------------------------
9
- const API_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
10
- const POLL_INTERVAL_MS = 60_000;
11
- const FETCH_TIMEOUT_MS = 10_000;
2
+ import { For, Show, createMemo, createSignal } from "solid-js";
3
+ import { buildGoModelFooters, buildModelMixSummary, relevantReset, formatPercentCell, meterSeverityForPercent, modelDisplayName, shortModelName, weightGoModels, } from "./helpers.js";
4
+ import { aggregateModelUsageFromMessages, errorMessage, MODELS_EMPTY_LABEL, OPENCODE_MODEL_NAME_MAX_CHARS, TOP_GO_MODELS_LABEL, } from "./shared.js";
5
+ import { CollapsibleSection, GoStatusline, GoUsageBlock, MeterBar, createCollapseState, createUsageStore, isGoUsageProvider, logUsageError, makeProviderResolver, readModelDisplayName, reactiveChild, resolveSurfaceSelection, } from "./tui-shared.js";
6
+ import { EVENT_TTL_MS, POLL_INTERVAL_MS } from "./tui-shared.js";
7
+ // This entry is the opencode build, so it resolves `OPENCODE_OC_GO_*` and never
8
+ // reads a Kilo-prefixed name.
9
+ const HOST = "opencode";
12
10
  const SLOT_ORDER = 50;
13
- const KV_DISPLAY_KEY = "display";
14
- const KV_COLLAPSED_SIDEBAR_KEY = "collapsed_sidebar";
15
- const KV_COLLAPSED_STATUSLINE_KEY = "collapsed_statusline";
16
- const KV_COLLAPSED_LEGACY_KEY = "collapsed";
17
- const LOG_SERVICE = "oc-go-usage-display";
18
- const CONFIG_DIR = path.join(os.homedir(), ".config", "opencode");
19
- function dataShareAuthPath() {
20
- const xdgDataHome = toNonEmptyString(process.env.XDG_DATA_HOME);
21
- if (xdgDataHome)
22
- return path.join(xdgDataHome, "opencode", "auth.json");
23
- return path.join(os.homedir(), ".local", "share", "opencode", "auth.json");
24
- }
25
- function authJsonPaths() {
26
- return [dataShareAuthPath(), path.join(CONFIG_DIR, "auth.json")];
27
- }
28
- // ---------------------------------------------------------------------------
29
- // Small pure helpers
30
- // ---------------------------------------------------------------------------
31
- function isRecord(value) {
32
- return typeof value === "object" && value !== null && !Array.isArray(value);
33
- }
34
- function toFiniteNumber(value) {
35
- if (typeof value !== "number" || !Number.isFinite(value))
36
- return null;
37
- return value;
38
- }
39
- function toNonEmptyString(value) {
40
- if (typeof value !== "string")
41
- return null;
42
- const trimmed = value.trim();
43
- return trimmed.length > 0 ? trimmed : null;
44
- }
45
- function isDisplayMode(value) {
46
- return value === "sidebar" || value === "statusline" || value === "both";
47
- }
48
- function surfaceSelectionFromDisplayMode(mode) {
49
- return { sidebar: mode !== "statusline", statusline: mode !== "sidebar" };
50
- }
51
- function parseBooleanFlag(value) {
52
- if (typeof value === "boolean")
53
- return value;
54
- if (typeof value === "number") {
55
- if (value === 1)
56
- return true;
57
- if (value === 0)
58
- return false;
59
- return null;
60
- }
61
- if (typeof value !== "string")
62
- return null;
63
- const normalized = value.trim().toLowerCase();
64
- if (normalized === "1" || normalized === "true")
65
- return true;
66
- if (normalized === "0" || normalized === "false")
67
- return false;
68
- return null;
69
- }
70
- function formatResetDuration(totalSec) {
71
- if (totalSec === null || !Number.isFinite(totalSec) || totalSec < 0)
72
- return null;
73
- const sec = Math.floor(totalSec);
74
- const hours = Math.floor(sec / 3600);
75
- const minutes = Math.floor((sec % 3600) / 60);
76
- if (hours > 0)
77
- return `${hours}h${minutes}m`;
78
- if (minutes > 0)
79
- return `${minutes}m`;
80
- return `${sec}s`;
81
- }
82
- function isSnapshotEmpty(snapshot) {
83
- return snapshot.rolling === null && snapshot.weekly === null && snapshot.monthly === null;
84
- }
85
- function formatCompactLine(snapshot) {
86
- const rolling = snapshot.rolling === null ? "5h n/a" : `5h ${snapshot.rolling.percent}%`;
87
- const weekly = snapshot.weekly === null ? "7d n/a" : `7d ${snapshot.weekly.percent}%`;
88
- return `Go ${rolling} | ${weekly}`;
89
- }
90
- function buildUsageRows(snapshot) {
91
- const rows = [];
92
- if (snapshot.rolling !== null) {
93
- const reset = formatResetDuration(snapshot.rolling.resetInSec) ?? snapshot.rolling.resetText;
94
- rows.push({ label: "5h", value: `${snapshot.rolling.percent}%${reset ? ` · resets ${reset}` : ""}` });
95
- }
96
- if (snapshot.weekly !== null)
97
- rows.push({ label: "7d", value: `${snapshot.weekly.percent}%` });
98
- if (snapshot.monthly !== null)
99
- rows.push({ label: "30d", value: `${snapshot.monthly.percent}%` });
100
- return rows;
101
- }
102
- // ---------------------------------------------------------------------------
103
- // Settings boundary (new toggles > legacy display; tui.json options > env >
104
- // api.kv > default both on)
105
- // ---------------------------------------------------------------------------
106
- function resolveSurfaceSelection(options, api) {
107
- if (options !== undefined) {
108
- const sidebarOption = parseBooleanFlag(options.sidebar);
109
- const statuslineOption = parseBooleanFlag(options.statusline);
110
- if (sidebarOption !== null || statuslineOption !== null) {
111
- return { sidebar: sidebarOption ?? true, statusline: statuslineOption ?? true };
112
- }
113
- if (isDisplayMode(options.display))
114
- return surfaceSelectionFromDisplayMode(options.display);
115
- }
116
- const sidebarEnv = parseBooleanFlag(process.env.OPENCODE_GO_SIDEBAR);
117
- const statuslineEnv = parseBooleanFlag(process.env.OPENCODE_GO_STATUSLINE);
118
- if (sidebarEnv !== null || statuslineEnv !== null) {
119
- return { sidebar: sidebarEnv ?? true, statusline: statuslineEnv ?? true };
120
- }
121
- const displayEnv = toNonEmptyString(process.env.OPENCODE_GO_DISPLAY);
122
- if (displayEnv !== null && isDisplayMode(displayEnv)) {
123
- return surfaceSelectionFromDisplayMode(displayEnv);
124
- }
11
+ // The factory body lives here so the exported `goUsageTui` can wrap the whole
12
+ // initialization in a single fail-safe boundary. A throwing factory would
13
+ // destabilize the host's plugin load, so it must never reject.
14
+ async function initializeTui(api, options) {
15
+ const surfaces = resolveSurfaceSelection(options, api, HOST);
16
+ const usageStore = createUsageStore(api, HOST);
17
+ const collapse = createCollapseState(api);
18
+ const [activeProviderId, setActiveProviderId] = createSignal(undefined);
125
19
  try {
126
- const stored = api.kv.get(KV_DISPLAY_KEY, "both");
127
- if (isDisplayMode(stored))
128
- return surfaceSelectionFromDisplayMode(stored);
129
- }
130
- catch {
131
- // Persisted settings are best-effort; fall through to the default.
132
- }
133
- return { sidebar: true, statusline: true };
134
- }
135
- function readCollapsedFlag(api, key) {
136
- try {
137
- return api.kv.get(key, false) === true;
20
+ const modelString = api.state?.config?.model;
21
+ if (typeof modelString === "string" && modelString.length > 0) {
22
+ const providerPart = modelString.split("/")[0];
23
+ if (providerPart !== undefined && providerPart.length > 0)
24
+ setActiveProviderId(providerPart);
25
+ }
138
26
  }
139
27
  catch {
140
- return false;
28
+ // State may not be ready; fall through to the event signal.
141
29
  }
142
- }
143
- function migrateLegacyCollapsedFlag(api) {
144
- let legacyCollapsed = false;
145
- try {
146
- legacyCollapsed = api.kv.get(KV_COLLAPSED_LEGACY_KEY, false) === true;
30
+ const resolveActiveProviderId = makeProviderResolver(api.state, activeProviderId);
31
+ // The host's message store is where the per-model mix comes from, and it
32
+ // changes without the plan changing. Bumping this token on the same events
33
+ // that refresh the plan keeps the mix from going stale behind a host that
34
+ // reuses the element it was handed instead of re-invoking this subtree (see
35
+ // the reactivity note in `tui-shared`).
36
+ const [stateToken, setStateToken] = createSignal(0);
37
+ function readSessionMessages(sessionId) {
38
+ if (sessionId.length === 0)
39
+ return [];
40
+ try {
41
+ return api.state.session.messages(sessionId);
42
+ }
43
+ catch {
44
+ return [];
45
+ }
147
46
  }
148
- catch {
149
- return;
47
+ // One model row: the display name the catalog gives it, then its weight bar
48
+ // and percent pushed to the right edge so the bars line up down the block.
49
+ function WeightedModelRow(props) {
50
+ const rounded = createMemo(() => Math.round(props.model.share));
51
+ const name = createMemo(() => modelDisplayName(readModelDisplayName(api, props.model.providerID, props.model.modelID), props.model.modelID, OPENCODE_MODEL_NAME_MAX_CHARS));
52
+ return (_jsxs("box", { flexDirection: "row", gap: 1, flexShrink: 0, children: [_jsx("text", { fg: props.theme.current.text, wrapMode: "none", flexShrink: 0, children: name() }), _jsx(MeterBar, { theme: props.theme, percent: props.model.share, severity: meterSeverityForPercent(rounded()) }), _jsx("text", { fg: props.theme.current.textMuted, wrapMode: "none", flexShrink: 0, children: formatPercentCell(rounded()) })] }));
150
53
  }
151
- if (!legacyCollapsed)
152
- return;
153
- try {
154
- api.kv.set(KV_COLLAPSED_SIDEBAR_KEY, true);
155
- api.kv.set(KV_COLLAPSED_STATUSLINE_KEY, true);
156
- api.kv.set(KV_COLLAPSED_LEGACY_KEY, false);
54
+ // The model mix. Collapsed by default: the one-line summary is the cheap
55
+ // answer, and this block already leads a sidebar that the host's own Context
56
+ // panel follows. The count in the header is every Go model the session used,
57
+ // not the three rows below it, so a truncated ranking says so.
58
+ function TopGoModelsSection(props) {
59
+ const [isExpanded, setIsExpanded] = createSignal(false);
60
+ const weights = createMemo(() => weightGoModels(props.usage()?.models ?? []));
61
+ const rows = () => weights().models;
62
+ // The collapsed line names the same models the expanded rows do, in as few
63
+ // cells as the sidebar has.
64
+ const mix = createMemo(() => buildModelMixSummary(rows().map((model) => ({
65
+ name: shortModelName(modelDisplayName(readModelDisplayName(api, model.providerID, model.modelID), model.modelID, OPENCODE_MODEL_NAME_MAX_CHARS)),
66
+ share: model.share,
67
+ }))));
68
+ const body = createMemo(() => (_jsxs("box", { flexDirection: "column", gap: 1, paddingTop: 1, paddingLeft: 2, children: [_jsx(For, { each: rows(), children: (model) => _jsx(WeightedModelRow, { theme: props.theme, model: model }) }), _jsx(For, { each: buildGoModelFooters(weights()), children: (line) => (_jsx("text", { fg: props.theme.current.textMuted, wrapMode: "none", children: line })) })] })));
69
+ // The whole section is one function child, because two rules apply here and
70
+ // both are about the same thing: the host reuses the element it was handed,
71
+ // so a plain value is read once at mount. That is why the conditions below
72
+ // are read INSIDE this memo (re-running it is what re-reads them) and why
73
+ // `Show` is given a value rather than an accessor -- solid's `Show` memoizes
74
+ // `when` and never calls it, so an accessor here is a function object, and a
75
+ // function object is always truthy. The real host caught exactly that: a
76
+ // session with no assistant messages rendered `Top Go models (0)` with a
77
+ // caret that opened onto nothing.
78
+ const section = createMemo(() => {
79
+ // No models yet is not a fold: there is nothing to fold, and a caret that
80
+ // opens onto an empty box reads as a broken section.
81
+ if (weights().goModels === 0) {
82
+ return (_jsxs("box", { flexDirection: "column", children: [_jsx("text", { fg: props.theme.current.text, wrapMode: "none", children: _jsx("b", { children: TOP_GO_MODELS_LABEL }) }), _jsx("text", { fg: props.theme.current.textMuted, wrapMode: "none", children: MODELS_EMPTY_LABEL })] }));
83
+ }
84
+ return (_jsxs("box", { flexDirection: "column", children: [_jsx(CollapsibleSection, { theme: props.theme, label: TOP_GO_MODELS_LABEL, count: () => weights().goModels, expanded: isExpanded, onToggle: () => setIsExpanded((current) => !current), body: body }), _jsx(Show, { when: !isExpanded(), children: _jsx("text", { fg: props.theme.current.textMuted, wrapMode: "none", paddingLeft: 2, children: reactiveChild(mix) }) })] }));
85
+ });
86
+ return _jsx("box", { flexDirection: "column", children: reactiveChild(section) });
157
87
  }
158
- catch {
159
- // Collapse state is best-effort persistence only.
88
+ function GoSidebarPanel(props) {
89
+ const planResetLine = createMemo(() => {
90
+ const snapshot = usageStore.snapshot();
91
+ if (snapshot === null || snapshot.source === "unavailable")
92
+ return null;
93
+ return relevantReset(snapshot);
94
+ });
95
+ const sessionUsage = createMemo(() => {
96
+ stateToken();
97
+ return aggregateModelUsageFromMessages(readSessionMessages(props.sessionId));
98
+ });
99
+ return (_jsxs("box", { flexDirection: "column", gap: 1, children: [_jsx(GoUsageBlock, { api: api, theme: props.theme, snapshot: usageStore.snapshot, resetLine: planResetLine }), _jsx(TopGoModelsSection, { theme: props.theme, usage: sessionUsage })] }));
160
100
  }
161
- }
162
- // ---------------------------------------------------------------------------
163
- // Data boundary (auth.json -> Bearer usage fetch; throws nothing)
164
- // ---------------------------------------------------------------------------
165
- function readAuthJsonApiKey() {
166
- for (const authPath of authJsonPaths()) {
167
- let raw;
101
+ if (surfaces.sidebar) {
102
+ // Host-owned slot: `register` returns an id but the SDK exposes no
103
+ // unregister API, so there is nothing to dispose here (the slot dies
104
+ // with the host). Interval/event/command teardown below is the full
105
+ // dispose path.
168
106
  try {
169
- raw = fs.readFileSync(authPath, "utf8");
107
+ api.slots.register({
108
+ order: SLOT_ORDER,
109
+ slots: {
110
+ sidebar_content(ctx, props) {
111
+ // Individually guarded: a later render must never throw into the host.
112
+ try {
113
+ if (props.session_id.length === 0)
114
+ return null;
115
+ if (!isGoUsageProvider(resolveActiveProviderId(props.session_id)))
116
+ return null;
117
+ if (api.route.current.name !== "session")
118
+ return null;
119
+ if (collapse.isSidebarCollapsed())
120
+ return null;
121
+ return _jsx(GoSidebarPanel, { theme: ctx.theme, sessionId: props.session_id });
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ },
127
+ },
128
+ });
170
129
  }
171
130
  catch {
172
- continue;
131
+ // Additive slot: a registration failure must not abort the plugin.
173
132
  }
174
- let parsed;
133
+ }
134
+ if (surfaces.statusline) {
135
+ // Host-owned slot (see sidebar note above): no unregister API exists,
136
+ // so disposal is a no-op for slots.
175
137
  try {
176
- parsed = JSON.parse(raw);
138
+ api.slots.register({
139
+ order: SLOT_ORDER,
140
+ slots: {
141
+ session_prompt_right(_ctx, props) {
142
+ // Individually guarded: a later render must never throw into the host.
143
+ try {
144
+ if (props.session_id.length === 0)
145
+ return null;
146
+ if (!isGoUsageProvider(resolveActiveProviderId(props.session_id)))
147
+ return null;
148
+ if (collapse.isStatuslineCollapsed())
149
+ return null;
150
+ return _jsx(GoStatusline, { snapshot: usageStore.snapshot });
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ },
156
+ },
157
+ });
177
158
  }
178
159
  catch {
179
- continue;
160
+ // Additive slot: a registration failure must not abort the plugin.
180
161
  }
181
- if (!isRecord(parsed))
182
- continue;
183
- const goEntry = parsed["opencode-go"];
184
- const fallbackEntry = parsed["opencode"];
185
- const goKey = isRecord(goEntry) ? toNonEmptyString(goEntry.key) : null;
186
- if (goKey)
187
- return goKey;
188
- const fallbackKey = isRecord(fallbackEntry) ? toNonEmptyString(fallbackEntry.key) : null;
189
- if (fallbackKey)
190
- return fallbackKey;
191
162
  }
192
- return null;
193
- }
194
- function extractWindow(candidate) {
195
- if (!isRecord(candidate))
196
- return null;
197
- const percent = toFiniteNumber(candidate.percent ??
198
- candidate.usagePercent ??
199
- candidate.usedPercent ??
200
- candidate.value ??
201
- candidate.usage);
202
- if (percent === null)
203
- return null;
204
- return {
205
- percent: Math.round(percent),
206
- resetInSec: toFiniteNumber(candidate.resetInSec ?? candidate.resetInSeconds ?? null),
207
- resetText: toNonEmptyString(candidate.resetText ?? candidate.reset ?? null),
208
- };
209
- }
210
- function extractSnapshotFromApiPayload(payload) {
211
- if (!isRecord(payload))
212
- return null;
213
- const containers = [payload];
214
- for (const key of ["usage", "data", "go"]) {
215
- if (isRecord(payload[key]))
216
- containers.push(payload[key]);
163
+ // `api.command` is a deprecated legacy shim that hosts may omit; guard so
164
+ // the plugin still initializes and disposes safely without it.
165
+ let unregisterToggleCommand = () => { };
166
+ try {
167
+ const unregister = api.command?.register(() => [
168
+ {
169
+ title: "Go usage: toggle sidebar",
170
+ value: "oc-go-usage-display.toggle-sidebar",
171
+ category: "Go",
172
+ onSelect: () => collapse.toggleSidebar(),
173
+ },
174
+ {
175
+ title: "Go usage: toggle statusline",
176
+ value: "oc-go-usage-display.toggle-statusline",
177
+ category: "Go",
178
+ onSelect: () => collapse.toggleStatusline(),
179
+ },
180
+ ]);
181
+ if (typeof unregister === "function")
182
+ unregisterToggleCommand = unregister;
217
183
  }
218
- for (const container of containers) {
219
- if (!isRecord(container))
220
- continue;
221
- const rolling = extractWindow(container.rolling ?? container.rollingUsage ?? container["5h"]);
222
- const weekly = extractWindow(container.weekly ?? container.weeklyUsage ?? container["7d"]);
223
- const monthly = extractWindow(container.monthly ?? container.monthlyUsage ?? container["30d"]);
224
- if (rolling !== null || weekly !== null || monthly !== null) {
225
- return { rolling, weekly, monthly, source: "api", fetchedAt: Date.now() };
226
- }
184
+ catch {
185
+ // Legacy command registration is optional; ignore failures.
227
186
  }
228
- return null;
229
- }
230
- async function fetchJsonWithTimeout(url, apiKey) {
231
- const controller = new AbortController();
232
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
187
+ let unsubscribeSession = () => { };
188
+ let unsubscribeMessage = () => { };
233
189
  try {
234
- const response = await fetch(url, {
235
- signal: controller.signal,
236
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
190
+ const unsubscribe = api.event.on("session.updated", (event) => {
191
+ const providerId = event?.properties?.info?.model?.providerID;
192
+ if (providerId !== undefined)
193
+ setActiveProviderId(providerId);
194
+ usageStore.refreshSafely(0);
237
195
  });
238
- if (response.status === 401 || response.status === 403)
239
- return { __rejected: true };
240
- if (!response.ok)
241
- return null;
242
- try {
243
- return (await response.json());
244
- }
245
- catch {
246
- return null;
247
- }
196
+ if (typeof unsubscribe === "function")
197
+ unsubscribeSession = unsubscribe;
248
198
  }
249
199
  catch {
250
- return null;
200
+ // Event subscription is additive; a failure must not abort the plugin.
251
201
  }
252
- finally {
253
- clearTimeout(timer);
202
+ try {
203
+ const unsubscribe = api.event.on("message.updated", () => {
204
+ usageStore.refreshSafely(EVENT_TTL_MS);
205
+ // The model's share of the session changes with every finished message,
206
+ // not only when the plan does.
207
+ setStateToken((current) => current + 1);
208
+ });
209
+ if (typeof unsubscribe === "function")
210
+ unsubscribeMessage = unsubscribe;
254
211
  }
255
- }
256
- function mockSnapshot() {
257
- return {
258
- rolling: { percent: 42, resetInSec: 7543, resetText: null },
259
- weekly: { percent: 15, resetInSec: null, resetText: null },
260
- monthly: { percent: 61, resetInSec: null, resetText: null },
261
- source: "mock",
262
- fetchedAt: Date.now(),
263
- };
264
- }
265
- async function loadUsageSnapshot() {
266
- if (process.env.OPENCODE_GO_MOCK === "1")
267
- return mockSnapshot();
268
- const apiKey = toNonEmptyString(process.env.OPENCODE_GO_API_KEY) ?? readAuthJsonApiKey();
269
- if (apiKey === null)
270
- return null;
271
- const payload = await fetchJsonWithTimeout(API_USAGE_URL, apiKey);
272
- if (payload === null)
273
- return null;
274
- if (isRecord(payload) && payload.__rejected === true)
275
- return null;
276
- return extractSnapshotFromApiPayload(payload);
277
- }
278
- async function logUsageError(api, message) {
212
+ catch {
213
+ // Event subscription is additive; a failure must not abort the plugin.
214
+ }
215
+ let pollTimer = null;
279
216
  try {
280
- await api.client.app.log({ service: LOG_SERVICE, level: "error", message });
217
+ pollTimer = setInterval(() => usageStore.refreshSafely(), POLL_INTERVAL_MS);
281
218
  }
282
219
  catch {
283
- // Logging is best-effort; the usage display must never break the host.
220
+ // No poll timer: the on-demand refresh below still runs.
284
221
  }
285
- }
286
- // ---------------------------------------------------------------------------
287
- // TUI plugin
288
- // ---------------------------------------------------------------------------
289
- const goUsageTui = async (api, options) => {
290
- const surfaces = resolveSurfaceSelection(options, api);
291
- migrateLegacyCollapsedFlag(api);
292
- const [usageSnapshot, setUsageSnapshot] = createSignal(null);
293
- const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(readCollapsedFlag(api, KV_COLLAPSED_SIDEBAR_KEY));
294
- const [isStatuslineCollapsed, setIsStatuslineCollapsed] = createSignal(readCollapsedFlag(api, KV_COLLAPSED_STATUSLINE_KEY));
295
- let cachedAt = 0;
296
- let refreshInFlight = false;
297
- async function refreshUsage() {
298
- if (refreshInFlight)
299
- return;
300
- if (Date.now() - cachedAt < POLL_INTERVAL_MS && usageSnapshot() !== null)
301
- return;
302
- refreshInFlight = true;
303
- try {
304
- const snapshot = await loadUsageSnapshot();
305
- if (snapshot === null) {
306
- if (usageSnapshot() === null) {
307
- await logUsageError(api, "Go usage unavailable (not configured or request failed)");
222
+ try {
223
+ api.lifecycle.onDispose(() => {
224
+ // Each teardown step is isolated: one throwing unsubscribe must not
225
+ // prevent the rest (or leak into the host's dispose pass).
226
+ if (pollTimer !== null) {
227
+ try {
228
+ clearInterval(pollTimer);
229
+ }
230
+ catch {
231
+ // noop
308
232
  }
309
- return;
310
233
  }
311
- cachedAt = Date.now();
312
- setUsageSnapshot(snapshot);
313
- }
314
- catch {
315
- // Keep stale data; the panel simply shows the last known snapshot.
316
- }
317
- finally {
318
- refreshInFlight = false;
319
- }
320
- }
321
- function toggleSidebarCollapsed() {
322
- const next = !isSidebarCollapsed();
323
- setIsSidebarCollapsed(next);
324
- try {
325
- api.kv.set(KV_COLLAPSED_SIDEBAR_KEY, next);
326
- }
327
- catch {
328
- // Collapse state is best-effort persistence only.
329
- }
330
- }
331
- function toggleStatuslineCollapsed() {
332
- const next = !isStatuslineCollapsed();
333
- setIsStatuslineCollapsed(next);
334
- try {
335
- api.kv.set(KV_COLLAPSED_STATUSLINE_KEY, next);
336
- }
337
- catch {
338
- // Collapse state is best-effort persistence only.
339
- }
340
- }
341
- function GoSidebarPanel(props) {
342
- createEffect(() => {
343
- const snapshot = usageSnapshot();
344
- if (snapshot !== null && snapshot.source === "unavailable") {
345
- void logUsageError(api, "Go usage snapshot unavailable");
234
+ for (const teardown of [unsubscribeSession, unsubscribeMessage, unregisterToggleCommand]) {
235
+ try {
236
+ teardown();
237
+ }
238
+ catch {
239
+ // noop
240
+ }
346
241
  }
347
242
  });
348
- return (_jsxs("box", { flexDirection: "column", children: [_jsx("text", { fg: props.theme.current.text, children: _jsx("b", { children: "Go Usage" }) }), _jsx(Show, { when: usageSnapshot(), fallback: _jsx("text", { fg: props.theme.current.textMuted, wrapMode: "none", children: "Go loading\u2026" }), children: (snapshot) => (_jsx(For, { each: buildUsageRows(snapshot()), children: (row) => (_jsxs("text", { fg: props.theme.current.textMuted, wrapMode: "none", children: [row.label, " ", row.value] })) })) })] }));
349
243
  }
350
- function GoStatusline() {
351
- return (_jsx(Show, { when: usageSnapshot(), fallback: null, children: (snapshot) => {
352
- if (isSnapshotEmpty(snapshot()))
353
- return null;
354
- return _jsx("text", { children: formatCompactLine(snapshot()) });
355
- } }));
244
+ catch {
245
+ // Lifecycle registration is best-effort; never leak a timer into the host.
246
+ if (pollTimer !== null) {
247
+ try {
248
+ clearInterval(pollTimer);
249
+ }
250
+ catch {
251
+ // noop
252
+ }
253
+ }
356
254
  }
357
- if (surfaces.sidebar) {
358
- api.slots.register({
359
- order: SLOT_ORDER,
360
- slots: {
361
- sidebar_content(ctx, props) {
362
- if (props.session_id.length === 0)
363
- return null;
364
- if (api.route.current.name !== "session")
365
- return null;
366
- if (isSidebarCollapsed())
367
- return null;
368
- return _jsx(GoSidebarPanel, { theme: ctx.theme });
369
- },
370
- },
371
- });
255
+ await usageStore.refresh();
256
+ }
257
+ const goUsageTui = async (api, options) => {
258
+ try {
259
+ await initializeTui(api, options);
372
260
  }
373
- if (surfaces.statusline) {
374
- api.slots.register({
375
- order: SLOT_ORDER,
376
- slots: {
377
- session_prompt_right(_ctx, props) {
378
- if (props.session_id.length === 0)
379
- return null;
380
- if (isStatuslineCollapsed())
381
- return null;
382
- return _jsx(GoStatusline, {});
383
- },
384
- },
385
- });
261
+ catch (error) {
262
+ // Best-effort and non-blocking: never delay plugin resolution on the host
263
+ // log and never let an initialization error reject into the host.
264
+ void logUsageError(api, `Go usage TUI failed to initialize: ${errorMessage(error)}; continuing without display`);
386
265
  }
387
- const unregisterToggleCommand = api.command.register(() => [
388
- {
389
- title: "Go usage: toggle sidebar",
390
- value: "oc-go-usage-display.toggle-sidebar",
391
- category: "Go",
392
- onSelect: () => toggleSidebarCollapsed(),
393
- },
394
- {
395
- title: "Go usage: toggle statusline",
396
- value: "oc-go-usage-display.toggle-statusline",
397
- category: "Go",
398
- onSelect: () => toggleStatuslineCollapsed(),
399
- },
400
- ]);
401
- const unsubscribeSession = api.event.on("session.updated", () => {
402
- void refreshUsage();
403
- });
404
- const unsubscribeMessage = api.event.on("message.updated", () => {
405
- void refreshUsage();
406
- });
407
- const pollTimer = setInterval(() => {
408
- void refreshUsage();
409
- }, POLL_INTERVAL_MS);
410
- api.lifecycle.onDispose(() => {
411
- clearInterval(pollTimer);
412
- unsubscribeSession();
413
- unsubscribeMessage();
414
- unregisterToggleCommand();
415
- });
416
- await refreshUsage();
417
266
  };
418
267
  export default { id: "oc-go-usage-display", tui: goUsageTui };
419
268
  //# sourceMappingURL=tui.js.map