oc-go-usage-display 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +245 -93
  2. package/bin/lib.js +395 -37
  3. package/bin/oc-go-usage-display-init.js +42 -13
  4. package/bin/oc-go-usage-display-remove.js +22 -17
  5. package/bin/oc-go-usage-display-show.js +47 -39
  6. package/bin/oc-go-usage-display-status.js +31 -52
  7. package/bin/oc-go-usage-display-update.js +27 -14
  8. package/dist/helpers.d.ts +116 -0
  9. package/dist/helpers.d.ts.map +1 -0
  10. package/dist/helpers.js +568 -0
  11. package/dist/helpers.js.map +1 -0
  12. package/dist/index.d.ts +5 -13
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +249 -159
  15. package/dist/index.js.map +1 -1
  16. package/dist/plugins/oc-go-usage-display.kilo.ts +678 -0
  17. package/dist/plugins/oc-go-usage-display.kilo.tsx +1272 -0
  18. package/dist/plugins/oc-go-usage-display.ts +678 -0
  19. package/dist/plugins/oc-go-usage-display.tsx +1018 -0
  20. package/dist/shared.d.ts +75 -7
  21. package/dist/shared.d.ts.map +1 -1
  22. package/dist/shared.js +448 -25
  23. package/dist/shared.js.map +1 -1
  24. package/dist/tui-shared.d.ts +109 -0
  25. package/dist/tui-shared.d.ts.map +1 -0
  26. package/dist/tui-shared.js +372 -0
  27. package/dist/tui-shared.js.map +1 -0
  28. package/dist/tui.d.ts +0 -17
  29. package/dist/tui.d.ts.map +1 -1
  30. package/dist/tui.js +229 -282
  31. package/dist/tui.js.map +1 -1
  32. package/dist/tui.kilo.d.ts +8 -0
  33. package/dist/tui.kilo.d.ts.map +1 -0
  34. package/dist/tui.kilo.js +502 -0
  35. package/dist/tui.kilo.js.map +1 -0
  36. package/package.json +46 -14
  37. package/src/helpers.ts +733 -0
  38. package/src/index.ts +316 -175
  39. package/src/shared.ts +545 -28
  40. package/src/tui-shared.tsx +705 -0
  41. package/src/tui.kilo.tsx +816 -0
  42. package/src/tui.tsx +352 -366
package/src/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% | 30d 61%`), 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,315 +60,216 @@ import type {
58
60
  TuiSlotContext,
59
61
  TuiTheme,
60
62
  } from "@opencode-ai/plugin/tui";
61
- import { For, Show, createEffect, createSignal } from "solid-js";
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";
62
75
  import {
63
- extractSnapshotFromApiPayload,
64
- formatResetDuration,
65
- isRecord,
66
- mockSnapshot,
67
- readAuthJsonApiKey,
68
- toNonEmptyString,
69
- unavailableSnapshot,
76
+ aggregateModelUsageFromMessages,
77
+ errorMessage,
78
+ MODELS_EMPTY_LABEL,
79
+ OPENCODE_MODEL_NAME_MAX_CHARS,
80
+ TOP_GO_MODELS_LABEL,
70
81
  } 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 };
75
-
76
- // ---------------------------------------------------------------------------
77
- // Constants
78
- // ---------------------------------------------------------------------------
79
-
80
- const API_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
81
- const POLL_INTERVAL_MS = 60_000;
82
- const FETCH_TIMEOUT_MS = 10_000;
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;
83
103
  const SLOT_ORDER = 50;
84
- const KV_DISPLAY_KEY = "display";
85
- const KV_COLLAPSED_SIDEBAR_KEY = "collapsed_sidebar";
86
- const KV_COLLAPSED_STATUSLINE_KEY = "collapsed_statusline";
87
- const KV_COLLAPSED_LEGACY_KEY = "collapsed";
88
- const LOG_SERVICE = "oc-go-usage-display";
89
-
90
- // ---------------------------------------------------------------------------
91
- // Trusted types (parsed at the boundary, trusted internally; usage shapes
92
- // live in `./shared.js` so server and TUI parse identically)
93
- // ---------------------------------------------------------------------------
94
-
95
- type DisplayMode = "sidebar" | "statusline" | "both";
96
-
97
- type SurfaceSelection = {
98
- sidebar: boolean;
99
- statusline: boolean;
100
- };
101
-
102
- type UsageRow = {
103
- label: string;
104
- value: string;
105
- };
106
-
107
- // ---------------------------------------------------------------------------
108
- // Small pure helpers
109
- // ---------------------------------------------------------------------------
110
-
111
- function isDisplayMode(value: unknown): value is DisplayMode {
112
- return value === "sidebar" || value === "statusline" || value === "both";
113
- }
114
-
115
- export function surfaceSelectionFromDisplayMode(mode: DisplayMode): SurfaceSelection {
116
- return { sidebar: mode !== "statusline", statusline: mode !== "sidebar" };
117
- }
118
-
119
- export function parseBooleanFlag(value: unknown): boolean | null {
120
- if (typeof value === "boolean") return value;
121
- if (typeof value === "number") {
122
- if (value === 1) return true;
123
- if (value === 0) return false;
124
- return null;
125
- }
126
- if (typeof value !== "string") return null;
127
- const normalized = value.trim().toLowerCase();
128
- if (normalized === "1" || normalized === "true") return true;
129
- if (normalized === "0" || normalized === "false") return false;
130
- return null;
131
- }
132
-
133
- export function isSnapshotEmpty(snapshot: UsageSnapshot): boolean {
134
- return snapshot.rolling === null && snapshot.weekly === null && snapshot.monthly === null;
135
- }
136
-
137
- export function formatCompactLine(snapshot: UsageSnapshot): string {
138
- const rolling = snapshot.rolling === null ? "5h n/a" : `5h ${snapshot.rolling.percent}%`;
139
- const weekly = snapshot.weekly === null ? "7d n/a" : `7d ${snapshot.weekly.percent}%`;
140
- const monthly = snapshot.monthly === null ? "30d n/a" : `30d ${snapshot.monthly.percent}%`;
141
- return `Go ${rolling} | ${weekly} | ${monthly}`;
142
- }
143
-
144
- export function buildUsageRows(snapshot: UsageSnapshot): UsageRow[] {
145
- const rows: UsageRow[] = [];
146
- if (snapshot.rolling !== null) {
147
- const reset = formatResetDuration(snapshot.rolling.resetInSec) ?? snapshot.rolling.resetText;
148
- rows.push({ label: "5h", value: `${snapshot.rolling.percent}%${reset ? ` · resets ${reset}` : ""}` });
149
- }
150
- if (snapshot.weekly !== null) rows.push({ label: "7d", value: `${snapshot.weekly.percent}%` });
151
- if (snapshot.monthly !== null) rows.push({ label: "30d", value: `${snapshot.monthly.percent}%` });
152
- return rows;
153
- }
154
-
155
- // ---------------------------------------------------------------------------
156
- // Settings boundary (new toggles > legacy display; tui.json options > env >
157
- // api.kv > default both on)
158
- // ---------------------------------------------------------------------------
159
-
160
- function resolveSurfaceSelection(options: PluginOptions | undefined, api: TuiPluginApi): SurfaceSelection {
161
- if (options !== undefined) {
162
- const sidebarOption = parseBooleanFlag(options.sidebar);
163
- const statuslineOption = parseBooleanFlag(options.statusline);
164
- if (sidebarOption !== null || statuslineOption !== null) {
165
- return { sidebar: sidebarOption ?? true, statusline: statuslineOption ?? true };
166
- }
167
- if (isDisplayMode(options.display)) return surfaceSelectionFromDisplayMode(options.display);
168
- }
169
-
170
- const sidebarEnv = parseBooleanFlag(process.env.OPENCODE_GO_SIDEBAR);
171
- const statuslineEnv = parseBooleanFlag(process.env.OPENCODE_GO_STATUSLINE);
172
- if (sidebarEnv !== null || statuslineEnv !== null) {
173
- return { sidebar: sidebarEnv ?? true, statusline: statuslineEnv ?? true };
174
- }
175
-
176
- const displayEnv = toNonEmptyString(process.env.OPENCODE_GO_DISPLAY);
177
- if (displayEnv !== null && isDisplayMode(displayEnv)) {
178
- return surfaceSelectionFromDisplayMode(displayEnv);
179
- }
180
-
181
- try {
182
- const stored = api.kv.get(KV_DISPLAY_KEY, "both");
183
- if (isDisplayMode(stored)) return surfaceSelectionFromDisplayMode(stored);
184
- } catch {
185
- // Persisted settings are best-effort; fall through to the default.
186
- }
187
- return { sidebar: true, statusline: true };
188
- }
189
104
 
190
- function readCollapsedFlag(api: TuiPluginApi, key: string): boolean {
191
- try {
192
- return api.kv.get<boolean>(key, false) === true;
193
- } catch {
194
- return false;
195
- }
196
- }
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);
197
112
 
198
- function migrateLegacyCollapsedFlag(api: TuiPluginApi): void {
199
- let legacyCollapsed = false;
113
+ const [activeProviderId, setActiveProviderId] = createSignal<string | undefined>(undefined);
200
114
  try {
201
- legacyCollapsed = api.kv.get<boolean>(KV_COLLAPSED_LEGACY_KEY, false) === true;
202
- } catch {
203
- return;
204
- }
205
- if (!legacyCollapsed) return;
206
- try {
207
- api.kv.set(KV_COLLAPSED_SIDEBAR_KEY, true);
208
- api.kv.set(KV_COLLAPSED_STATUSLINE_KEY, true);
209
- api.kv.set(KV_COLLAPSED_LEGACY_KEY, false);
210
- } catch {
211
- // Collapse state is best-effort persistence only.
212
- }
213
- }
214
-
215
- // ---------------------------------------------------------------------------
216
- // Data boundary (auth.json -> Bearer usage fetch; throws nothing)
217
- // ---------------------------------------------------------------------------
218
-
219
- async function fetchJsonWithTimeout(url: string, apiKey: string): Promise<unknown | null> {
220
- const controller = new AbortController();
221
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
222
- try {
223
- const response = await fetch(url, {
224
- signal: controller.signal,
225
- headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
226
- });
227
- if (response.status === 401 || response.status === 403) return { __rejected: true };
228
- if (!response.ok) return null;
229
- try {
230
- return (await response.json()) as unknown;
231
- } catch {
232
- return null;
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);
233
119
  }
234
120
  } catch {
235
- return null;
236
- } finally {
237
- clearTimeout(timer);
238
- }
239
- }
240
-
241
- async function loadUsageSnapshot(): Promise<UsageSnapshot | null> {
242
- if (process.env.OPENCODE_GO_MOCK === "1") return mockSnapshot();
243
-
244
- const apiKey = toNonEmptyString(process.env.OPENCODE_GO_API_KEY) ?? readAuthJsonApiKey();
245
- if (apiKey === null) return null;
246
-
247
- const payload = await fetchJsonWithTimeout(API_USAGE_URL, apiKey);
248
- if (payload === null) 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
- }
254
- return extractSnapshotFromApiPayload(payload);
255
- }
256
-
257
- async function logUsageError(api: TuiPluginApi, message: string): Promise<void> {
258
- try {
259
- await api.client.app.log({ service: LOG_SERVICE, level: "error", message });
260
- } catch {
261
- // Logging is best-effort; the usage display must never break the host.
121
+ // State may not be ready; fall through to the event signal.
262
122
  }
263
- }
123
+ const resolveActiveProviderId = makeProviderResolver(api.state, activeProviderId);
264
124
 
265
- // ---------------------------------------------------------------------------
266
- // TUI plugin
267
- // ---------------------------------------------------------------------------
268
-
269
- const goUsageTui: TuiPlugin = async (api, options) => {
270
- const surfaces = resolveSurfaceSelection(options, api);
271
- migrateLegacyCollapsedFlag(api);
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);
272
131
 
273
- const [usageSnapshot, setUsageSnapshot] = createSignal<UsageSnapshot | null>(null);
274
- const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal<boolean>(
275
- readCollapsedFlag(api, KV_COLLAPSED_SIDEBAR_KEY),
276
- );
277
- const [isStatuslineCollapsed, setIsStatuslineCollapsed] = createSignal<boolean>(
278
- readCollapsedFlag(api, KV_COLLAPSED_STATUSLINE_KEY),
279
- );
280
-
281
- let cachedAt = 0;
282
- let refreshInFlight = false;
283
-
284
- async function refreshUsage(): Promise<void> {
285
- if (refreshInFlight) return;
286
- if (Date.now() - cachedAt < POLL_INTERVAL_MS && usageSnapshot() !== null) return;
287
- refreshInFlight = true;
132
+ function readSessionMessages(sessionId: string): readonly unknown[] {
133
+ if (sessionId.length === 0) return [];
288
134
  try {
289
- const snapshot = await loadUsageSnapshot();
290
- if (snapshot === null) {
291
- if (usageSnapshot() === null) {
292
- await logUsageError(api, "Go usage unavailable (not configured or request failed)");
293
- }
294
- return;
295
- }
296
- cachedAt = Date.now();
297
- setUsageSnapshot(snapshot);
135
+ return api.state.session.messages(sessionId);
298
136
  } catch {
299
- // Keep stale data; the panel simply shows the last known snapshot.
300
- } finally {
301
- refreshInFlight = false;
137
+ return [];
302
138
  }
303
139
  }
304
140
 
305
- function toggleSidebarCollapsed(): void {
306
- const next = !isSidebarCollapsed();
307
- setIsSidebarCollapsed(next);
308
- try {
309
- api.kv.set(KV_COLLAPSED_SIDEBAR_KEY, next);
310
- } catch {
311
- // Collapse state is best-effort persistence only.
312
- }
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
+ );
313
167
  }
314
168
 
315
- function toggleStatuslineCollapsed(): void {
316
- const next = !isStatuslineCollapsed();
317
- setIsStatuslineCollapsed(next);
318
- try {
319
- api.kv.set(KV_COLLAPSED_STATUSLINE_KEY, next);
320
- } catch {
321
- // Collapse state is best-effort persistence only.
322
- }
323
- }
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
+ );
324
193
 
325
- function GoSidebarPanel(props: { theme: TuiTheme }) {
326
- createEffect(() => {
327
- const snapshot = usageSnapshot();
328
- if (snapshot !== null && snapshot.source === "unavailable") {
329
- void logUsageError(
330
- api,
331
- snapshot.apiError ? `Go usage unavailable (${snapshot.apiError})` : "Go usage snapshot unavailable",
332
- );
333
- }
334
- });
335
- return (
336
- <box flexDirection="column">
337
- <text fg={props.theme.current.text}>
338
- <b>Go Usage</b>
339
- </text>
340
- <Show
341
- when={usageSnapshot()}
342
- fallback={
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) => (
343
199
  <text fg={props.theme.current.textMuted} wrapMode="none">
344
- Go loading…
200
+ {line}
345
201
  </text>
346
- }
347
- >
348
- {(snapshot) => (
349
- <For each={buildUsageRows(snapshot())}>
350
- {(row) => (
351
- <text fg={props.theme.current.textMuted} wrapMode="none">
352
- {row.label} {row.value}
353
- </text>
354
- )}
355
- </For>
356
202
  )}
357
- </Show>
203
+ </For>
358
204
  </box>
359
- );
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
+ });
249
+
250
+ return <box flexDirection="column">{reactiveChild(section)}</box>;
360
251
  }
361
252
 
362
- function GoStatusline() {
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
+ });
363
263
  return (
364
- <Show when={usageSnapshot()} fallback={null}>
365
- {(snapshot) => {
366
- if (isSnapshotEmpty(snapshot())) return null;
367
- return <text>{formatCompactLine(snapshot())}</text>;
368
- }}
369
- </Show>
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>
370
273
  );
371
274
  }
372
275
 
@@ -375,69 +278,152 @@ const goUsageTui: TuiPlugin = async (api, options) => {
375
278
  // unregister API, so there is nothing to dispose here (the slot dies
376
279
  // with the host). Interval/event/command teardown below is the full
377
280
  // dispose path.
378
- api.slots.register({
379
- order: SLOT_ORDER,
380
- slots: {
381
- sidebar_content(ctx: TuiSlotContext, props: { session_id: string }) {
382
- if (props.session_id.length === 0) return null;
383
- if (api.route.current.name !== "session") return null;
384
- if (isSidebarCollapsed()) return null;
385
- return <GoSidebarPanel theme={ctx.theme} />;
281
+ try {
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
+ },
386
297
  },
387
- },
388
- });
298
+ });
299
+ } catch {
300
+ // Additive slot: a registration failure must not abort the plugin.
301
+ }
389
302
  }
390
303
 
391
304
  if (surfaces.statusline) {
392
305
  // Host-owned slot (see sidebar note above): no unregister API exists,
393
306
  // so disposal is a no-op for slots.
394
- api.slots.register({
395
- order: SLOT_ORDER,
396
- slots: {
397
- session_prompt_right(_ctx: TuiSlotContext, props: { session_id: string }) {
398
- if (props.session_id.length === 0) return null;
399
- if (isStatuslineCollapsed()) return null;
400
- return <GoStatusline />;
307
+ try {
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
+ },
401
322
  },
402
- },
403
- });
323
+ });
324
+ } catch {
325
+ // Additive slot: a registration failure must not abort the plugin.
326
+ }
404
327
  }
405
328
 
406
329
  // `api.command` is a deprecated legacy shim that hosts may omit; guard so
407
330
  // the plugin still initializes and disposes safely without it.
408
- const unregisterToggleCommand = api.command?.register(() => [
409
- {
410
- title: "Go usage: toggle sidebar",
411
- value: "oc-go-usage-display.toggle-sidebar",
412
- category: "Go",
413
- onSelect: () => toggleSidebarCollapsed(),
414
- },
415
- {
416
- title: "Go usage: toggle statusline",
417
- value: "oc-go-usage-display.toggle-statusline",
418
- category: "Go",
419
- onSelect: () => toggleStatuslineCollapsed(),
420
- },
421
- ]) ?? (() => {});
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.
350
+ }
422
351
 
423
- const unsubscribeSession = api.event.on("session.updated", () => {
424
- void refreshUsage();
425
- });
426
- const unsubscribeMessage = api.event.on("message.updated", () => {
427
- void refreshUsage();
428
- });
429
- const pollTimer = setInterval(() => {
430
- void refreshUsage();
431
- }, POLL_INTERVAL_MS);
352
+ let unsubscribeSession: () => void = () => {};
353
+ let unsubscribeMessage: () => void = () => {};
354
+ try {
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);
359
+ });
360
+ if (typeof unsubscribe === "function") unsubscribeSession = unsubscribe;
361
+ } catch {
362
+ // Event subscription is additive; a failure must not abort the plugin.
363
+ }
364
+ try {
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;
372
+ } catch {
373
+ // Event subscription is additive; a failure must not abort the plugin.
374
+ }
432
375
 
433
- api.lifecycle.onDispose(() => {
434
- clearInterval(pollTimer);
435
- unsubscribeSession();
436
- unsubscribeMessage();
437
- unregisterToggleCommand();
438
- });
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
+ }
382
+
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
392
+ }
393
+ }
394
+ for (const teardown of [unsubscribeSession, unsubscribeMessage, unregisterToggleCommand]) {
395
+ try {
396
+ teardown();
397
+ } catch {
398
+ // noop
399
+ }
400
+ }
401
+ });
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
+ }
411
+ }
412
+
413
+ await usageStore.refresh();
414
+ }
439
415
 
440
- await refreshUsage();
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
+ );
426
+ }
441
427
  };
442
428
 
443
429
  export default { id: "oc-go-usage-display", tui: goUsageTui } satisfies TuiPluginModule;