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
@@ -0,0 +1,816 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ //
3
+ // OpenCode Go usage TUI plugin (dual-surface display) for Kilo Code.
4
+ //
5
+ // Everything the two hosts share -- the refresh policy, the settings ladder,
6
+ // the plan meters, the row grammar, the foldable sections, the failure
7
+ // boundaries -- lives in `./tui-shared.jsx` and is inlined into this bundle.
8
+ // What is left here is what is genuinely Kilo-specific:
9
+ //
10
+ // 1. the host it reads (`KILO_OC_GO_*`, Kilo's own auth store) and the band it
11
+ // registers in. `SLOT_ORDER` (KILO_SLOT_ORDER) is a free band in Kilo's own
12
+ // `sidebar_content` ladder, so the block is placed deterministically instead
13
+ // of tied with a host panel:
14
+ // 50 kilo-sidebar-pr | 100 sidebar-context | 150 kilo-sidebar-usage
15
+ // | 200 sidebar-mcp | 225 kilo-sidebar-indexing
16
+ // | 250 kilo-sidebar-background-processes | 300 sidebar-lsp
17
+ // | 400 sidebar-todo | 500 sidebar-files | 1000 kilo-sidebar-memory
18
+ // 100 < 125 < 150 therefore renders directly below Kilo's `Context` panel and
19
+ // above its `Token Usage` block, keeping both usage readouts adjacent at the
20
+ // top of the sidebar; worktrunk renders elsewhere so multi-render stacking is
21
+ // unaffected. Kilo's own `session_prompt_right` panels sit at 50/51, so the
22
+ // same order puts this statusline rightmost.
23
+ // 2. the `sidebar_mode` axis (default `integrated`), resolved exactly like the
24
+ // surface toggles -- TUI plugin option in tui.json -> `KILO_OC_GO_SIDEBAR_MODE`
25
+ // -> `api.kv` `sidebar_mode` -> default. (Kilo's tui.json schema rejects the
26
+ // option, so on this host the option path is only reachable in-process, the
27
+ // same situation the `sidebar`/`statusline` options are in.)
28
+ // - `standalone` -> register at KILO_SLOT_ORDER (125), Kilo's own
29
+ // `Token Usage` panel stays where it is.
30
+ // - `integrated` -> register at 150 and switch `internal:kilo-sidebar-usage`
31
+ // off, so one band carries the Go readout instead of two competing ones.
32
+ // Kilo's panel cannot be extended (the real slot registry is unreachable
33
+ // from a plugin), so integrated mode retires it through
34
+ // `api.plugins.deactivate` -- the runtime form of the `plugin_enabled`
35
+ // map in tui.json, and reversible via `activate`. Retiring it also means
36
+ // replacing what it drew, so the 150 band in integrated mode renders
37
+ // three sections instead of one block:
38
+ // Go Usage the compact plan readout, as in standalone
39
+ // Session Tokens Kilo's `Token Usage` rows, from the host endpoint.
40
+ // Deliberately not titled `Token Usage`: that is the
41
+ // name of the panel integrated mode retired, and a
42
+ // second header with it would be indistinguishable
43
+ // from the panel that was supposed to go.
44
+ // Models (N) Kilo's per-model table, models grouped by provider.
45
+ // Inside the `OpenCode Go` group the `Go Plan` meters
46
+ // sit between the provider header and the model rows,
47
+ // and each Go model row carries a `Go share` line --
48
+ // its share of the Go tokens in this session tree,
49
+ // never of the plan, and never a price.
50
+ // The two sections collapse like Kilo's own (local state, both expanded
51
+ // by default), and model rows fold per model.
52
+ // The mode is switched live by `oc-go-usage-display.toggle-sidebar-mode`
53
+ // (title `Go usage: toggle sidebar mode`); the host panel follows
54
+ // immediately, the slot ORDER is bound at registration and moves on the
55
+ // next TUI start (the SDK exposes no slot unregister).
56
+ // 3. where the per-session model split comes from: the host's own
57
+ // `client.kilocode.sessionModelUsage` (GET /session/{sessionID}/model-usage),
58
+ // refreshed on the same events Kilo's panel uses and never on a timer. The
59
+ // opencode bundle reads the opencode stores and opencode's own message store
60
+ // instead; each host reads only its own. Failures keep stale data and never
61
+ // break the host; errors go to api.client.app.log (never console). Secrets
62
+ // are never logged.
63
+ //
64
+ // Coexistence: the server plugin `src/index.ts` (`go_usage` tool only)
65
+ // stays as the headless/Desktop fallback. This module exports
66
+ // only `tui` (never `server`) under id `oc-go-usage-display`.
67
+
68
+ import type { PluginOptions } from "@kilocode/plugin";
69
+ import type {
70
+ TuiPlugin,
71
+ TuiPluginApi,
72
+ TuiPluginModule,
73
+ TuiSlotContext,
74
+ TuiTheme,
75
+ } from "@kilocode/plugin/tui";
76
+ import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
77
+ import {
78
+ buildModelTokenRows,
79
+ buildPlanRows,
80
+ buildTokenUsageRows,
81
+ relevantReset,
82
+ formatUsageCost,
83
+ formatUsageCount,
84
+ goSharePercent,
85
+ groupModelsByProvider,
86
+ modelDisplayName,
87
+ parseSidebarMode,
88
+ totalGoTokens,
89
+ usageTokenCount,
90
+ DEFAULT_SIDEBAR_MODE,
91
+ } from "./helpers.js";
92
+ import type { ModelProviderGroup, PlanRow, ResetCountdown, SidebarMode } from "./helpers.js";
93
+ import {
94
+ hostEnv,
95
+ parseSessionModelUsage,
96
+ errorMessage,
97
+ GO_PROVIDER_ID,
98
+ MODELS_EMPTY_LABEL,
99
+ INTEGRATED_LOADING_LABEL,
100
+ INTEGRATED_MODELS_SECTION_LABEL,
101
+ INTEGRATED_TOKENS_SECTION_LABEL,
102
+ INTEGRATED_UNAVAILABLE_LABEL,
103
+ SIDEBAR_COLLAPSED_GLYPH,
104
+ SIDEBAR_EXPANDED_GLYPH,
105
+ KILO_COST_COLUMN_WIDTH,
106
+ KILO_INTEGRATED_SLOT_ORDER,
107
+ KILO_SLOT_ORDER,
108
+ KILO_STEPS_COLUMN_WIDTH,
109
+ KILO_USAGE_PANEL_PLUGIN_ID,
110
+ } from "./shared.js";
111
+ import type { ModelUsage, SessionModelUsage } from "./shared.js";
112
+ import {
113
+ CollapsibleSection,
114
+ GoPlanSection,
115
+ GoShareRow,
116
+ GoStatusline,
117
+ GoUsageBlock,
118
+ LabeledValueRow,
119
+ createCollapseState,
120
+ createUsageStore,
121
+ isGoUsageProvider,
122
+ logUsageError,
123
+ makeProviderResolver,
124
+ readModelDisplayName,
125
+ readProviderDisplayNames,
126
+ reactiveChild,
127
+ resolveSurfaceSelection,
128
+ EVENT_TTL_MS,
129
+ POLL_INTERVAL_MS,
130
+ } from "./tui-shared.js";
131
+ import type { DisplayOptions } from "./tui-shared.js";
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Constants
135
+ // ---------------------------------------------------------------------------
136
+
137
+ // The endpoint reports the whole top-level session tree, so a change in a child
138
+ // session still changes our numbers. The walk is bounded because a cycle in the
139
+ // host's parent chain must not hang a render.
140
+ const SESSION_TREE_WALK_LIMIT = 32;
141
+
142
+ // This entry is the Kilo build, so it resolves `KILO_OC_GO_*` and never reads
143
+ // an opencode-prefixed name.
144
+ const HOST = "kilo" as const;
145
+ const SLOT_ORDER = KILO_SLOT_ORDER;
146
+ const INTEGRATED_SLOT_ORDER = KILO_INTEGRATED_SLOT_ORDER;
147
+ const KV_SIDEBAR_MODE_KEY = "sidebar_mode";
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Settings: sidebar placement
151
+ // ---------------------------------------------------------------------------
152
+
153
+ // Sidebar placement uses the same precedence ladder as the surface selection,
154
+ // including the Kilo-hostile case of an option its tui.json schema cannot
155
+ // carry: the plugin cannot fix the host's schema, and it must not skip the
156
+ // option because of it (that would silently diverge from the surface ladder
157
+ // and make the two axes disagree about which source won).
158
+ function resolveSidebarMode(options: PluginOptions | undefined, api: TuiPluginApi): SidebarMode {
159
+ if (options !== undefined) {
160
+ const option = parseSidebarMode(options.sidebar_mode);
161
+ if (option !== null) return option;
162
+ }
163
+
164
+ const env = parseSidebarMode(hostEnv(HOST, "SIDEBAR_MODE"));
165
+ if (env !== null) return env;
166
+
167
+ try {
168
+ const stored = parseSidebarMode(api.kv.get<unknown>(KV_SIDEBAR_MODE_KEY, null));
169
+ if (stored !== null) return stored;
170
+ } catch {
171
+ // Persisted settings are best-effort; fall through to the default.
172
+ }
173
+ return DEFAULT_SIDEBAR_MODE;
174
+ }
175
+
176
+ // Whether the host's own token-usage panel is currently switched on, or null
177
+ // when the host does not report it. Reading the state first keeps every launch
178
+ // from writing an unchanged `plugin_enabled` entry.
179
+ function hostUsagePanelEnabled(api: TuiPluginApi): boolean | null {
180
+ try {
181
+ const entry = api.plugins?.list?.().find((status) => status.id === KILO_USAGE_PANEL_PLUGIN_ID);
182
+ if (entry === undefined) return null;
183
+ return entry.active || entry.enabled;
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ // Integrated mode replaces Kilo's `Token Usage` band, so the host panel has to
190
+ // go; standalone mode puts it back. The SDK's `deactivate`/`activate` are the
191
+ // runtime form of the `plugin_enabled` map in tui.json and persist to KV, which
192
+ // is what makes the switch survive a restart. Kilo's panel is not reachable
193
+ // through `api.slots` (that facade only exposes `register`), so this is the
194
+ // only supported way to take its band. Every step is best-effort: a failure
195
+ // leaves both panels on screen and is logged, never thrown.
196
+ async function applyHostUsagePanel(api: TuiPluginApi, mode: SidebarMode): Promise<void> {
197
+ const wantEnabled = mode === "standalone";
198
+ try {
199
+ const current = hostUsagePanelEnabled(api);
200
+ if (current === wantEnabled) return;
201
+ const applied = await (wantEnabled
202
+ ? api.plugins.activate(KILO_USAGE_PANEL_PLUGIN_ID)
203
+ : api.plugins.deactivate(KILO_USAGE_PANEL_PLUGIN_ID));
204
+ if (applied === true) return;
205
+ await logUsageError(
206
+ api,
207
+ `Could not ${wantEnabled ? "enable" : "disable"} Kilo's ${KILO_USAGE_PANEL_PLUGIN_ID} panel`,
208
+ );
209
+ } catch (error) {
210
+ await logUsageError(
211
+ api,
212
+ `Kilo ${KILO_USAGE_PANEL_PLUGIN_ID} panel switch failed: ${errorMessage(error)}`,
213
+ );
214
+ }
215
+ }
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // Session model usage (the integrated panel's replacement for Kilo's own
219
+ // token-usage band)
220
+ // ---------------------------------------------------------------------------
221
+
222
+ // This is the same typed call Kilo's own panel makes
223
+ // (`client.kilocode.sessionModelUsage`), which the SDK resolves to
224
+ // `GET /session/{sessionID}/model-usage`; that path is a PATH param and is
225
+ // pinned against the real host by test/e2e/kilo-contract.test.js, because
226
+ // `/kilocode/sessionModelUsage` 404s and `/session/model-usage` collides with
227
+ // `/session/:id`. No timer: the panel refreshes on host events instead.
228
+ async function fetchSessionModelUsage(
229
+ api: TuiPluginApi,
230
+ sessionId: string,
231
+ ): Promise<SessionModelUsage | null> {
232
+ if (sessionId.length === 0) return null;
233
+ try {
234
+ const result = await api.client.kilocode.sessionModelUsage({ sessionID: sessionId });
235
+ return parseSessionModelUsage(result.data);
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ // Whether `sessionId` is the rendered session or one of its descendants: the
242
+ // endpoint sums the tree, so a step finished in a child session moves our
243
+ // numbers too. Mirrors the check Kilo makes before it refetches.
244
+ function isInSessionTree(api: TuiPluginApi, sessionId: string, rootId: string): boolean {
245
+ if (sessionId === rootId) return true;
246
+ let current = sessionId;
247
+ for (let depth = 0; depth < SESSION_TREE_WALK_LIMIT; depth += 1) {
248
+ let parent: string | undefined;
249
+ try {
250
+ parent = api.state?.session.get(current)?.parentID;
251
+ } catch {
252
+ return false;
253
+ }
254
+ if (parent === undefined || parent.length === 0) return false;
255
+ if (parent === rootId) return true;
256
+ current = parent;
257
+ }
258
+ return false;
259
+ }
260
+
261
+ function trackSubscription(unsubscribes: Array<() => void>, subscribe: () => (() => void) | undefined): void {
262
+ try {
263
+ const unsubscribe = subscribe();
264
+ if (typeof unsubscribe === "function") unsubscribes.push(unsubscribe);
265
+ } catch {
266
+ // Event subscription is additive; a failure must not abort the panel.
267
+ }
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // TUI plugin
272
+ // ---------------------------------------------------------------------------
273
+
274
+ // The factory body lives here so the exported `goUsageTui` can wrap the whole
275
+ // initialization in a single fail-safe boundary. A throwing factory would
276
+ // destabilize the host's plugin load, so it must never reject.
277
+ async function initializeTui(api: TuiPluginApi, options: PluginOptions | undefined): Promise<void> {
278
+ const surfaces = resolveSurfaceSelection(options as DisplayOptions | undefined, api, HOST);
279
+ const usageStore = createUsageStore(api, HOST);
280
+ const collapse = createCollapseState(api);
281
+ // Live mode: the host panel below follows it immediately. The sidebar slot's
282
+ // ORDER is read once at registration, because the SDK exposes no slot
283
+ // unregister -- so a toggle moves the block on the next TUI start.
284
+ let sidebarMode = resolveSidebarMode(options, api);
285
+ void applyHostUsagePanel(api, sidebarMode);
286
+
287
+ const [activeProviderId, setActiveProviderId] = createSignal<string | undefined>(undefined);
288
+ try {
289
+ const modelString = api.state?.config?.model;
290
+ if (typeof modelString === "string" && modelString.length > 0) {
291
+ const providerPart = modelString.split("/")[0];
292
+ if (providerPart !== undefined && providerPart.length > 0) setActiveProviderId(providerPart);
293
+ }
294
+ } catch {
295
+ // State may not be ready; fall through to the event signal.
296
+ }
297
+ const resolveActiveProviderId = makeProviderResolver(api.state, activeProviderId);
298
+
299
+ function toggleSidebarMode(): void {
300
+ const next: SidebarMode = sidebarMode === "integrated" ? "standalone" : "integrated";
301
+ sidebarMode = next;
302
+ // Fire-and-forget: the host panel switch is async and may fail, and the
303
+ // command must return either way.
304
+ void applyHostUsagePanel(api, next);
305
+ try {
306
+ api.kv.set(KV_SIDEBAR_MODE_KEY, next);
307
+ } catch {
308
+ // Mode persistence is best-effort; the panel switch above still happened.
309
+ }
310
+ }
311
+
312
+ // The countdown the plan is waiting on, on one line under the block header.
313
+ function planResetLine(): ResetCountdown | null {
314
+ const snapshot = usageStore.snapshot();
315
+ if (snapshot === null || snapshot.source === "unavailable") return null;
316
+ return relevantReset(snapshot);
317
+ }
318
+
319
+ // The plan meters, or nothing at all: an unavailable snapshot has no windows,
320
+ // and a missing one is still loading.
321
+ function currentPlanRows(): PlanRow[] {
322
+ const snapshot = usageStore.snapshot();
323
+ if (snapshot === null || snapshot.source === "unavailable") return [];
324
+ return buildPlanRows(snapshot);
325
+ }
326
+
327
+ function ProviderUsageGroup(props: {
328
+ theme: TuiTheme;
329
+ group: ModelProviderGroup;
330
+ planRows: PlanRow[];
331
+ goTotal: number;
332
+ expandedModels: () => ReadonlySet<string>;
333
+ onToggleModel: (key: string) => void;
334
+ }) {
335
+ const isGo = props.group.providerID === GO_PROVIDER_ID;
336
+ return (
337
+ <box flexDirection="column" gap={1}>
338
+ <text fg={props.theme.current.text} wrapMode="none">
339
+ {props.group.providerName}
340
+ </text>
341
+ <Show when={isGo && props.planRows.length > 0}>
342
+ <GoPlanSection theme={props.theme} rows={props.planRows} />
343
+ </Show>
344
+ <box flexDirection="row" gap={1}>
345
+ <box width={1} flexShrink={0} />
346
+ <text flexGrow={1} minWidth={0} fg={props.theme.current.textMuted} wrapMode="none">
347
+ Model
348
+ </text>
349
+ <box
350
+ width={KILO_STEPS_COLUMN_WIDTH}
351
+ flexDirection="row"
352
+ flexShrink={0}
353
+ justifyContent="flex-end"
354
+ >
355
+ <text fg={props.theme.current.textMuted} wrapMode="none">
356
+ Steps
357
+ </text>
358
+ </box>
359
+ <box
360
+ width={KILO_COST_COLUMN_WIDTH}
361
+ flexDirection="row"
362
+ flexShrink={0}
363
+ justifyContent="flex-end"
364
+ >
365
+ <text fg={props.theme.current.textMuted} wrapMode="none">
366
+ Cost
367
+ </text>
368
+ </box>
369
+ </box>
370
+ <For each={props.group.models}>
371
+ {(model) => {
372
+ const key = `${model.providerID}/${model.modelID}`;
373
+ return (
374
+ <ModelUsageRow
375
+ theme={props.theme}
376
+ model={model}
377
+ isGo={isGo}
378
+ expanded={() => props.expandedModels().has(key)}
379
+ goShare={() => goSharePercent(usageTokenCount(model.tokens), props.goTotal)}
380
+ onToggle={() => props.onToggleModel(key)}
381
+ />
382
+ );
383
+ }}
384
+ </For>
385
+ </box>
386
+ );
387
+ }
388
+
389
+ function ModelUsageRow(props: {
390
+ theme: TuiTheme;
391
+ model: ModelUsage;
392
+ isGo: boolean;
393
+ expanded: () => boolean;
394
+ goShare: () => number;
395
+ onToggle: () => void;
396
+ }) {
397
+ const detail = createMemo(() =>
398
+ props.expanded() ? (
399
+ <box flexDirection="column" paddingLeft={2}>
400
+ <For each={buildModelTokenRows(props.model.tokens)}>
401
+ {(row) => <LabeledValueRow theme={props.theme} row={row} />}
402
+ </For>
403
+ </box>
404
+ ) : null,
405
+ );
406
+ return (
407
+ <box flexDirection="column" gap={1}>
408
+ <box flexDirection="row" gap={1} flexShrink={0} onMouseDown={props.onToggle}>
409
+ <text fg={props.theme.current.text} wrapMode="none" flexShrink={0}>
410
+ {reactiveChild(() => (props.expanded() ? SIDEBAR_EXPANDED_GLYPH : SIDEBAR_COLLAPSED_GLYPH))}
411
+ </text>
412
+ <box flexGrow={1} minWidth={0} overflow="hidden">
413
+ <text fg={props.theme.current.text} wrapMode="none">
414
+ <b>
415
+ {modelDisplayName(
416
+ readModelDisplayName(api, props.model.providerID, props.model.modelID),
417
+ props.model.modelID,
418
+ )}
419
+ </b>
420
+ </text>
421
+ </box>
422
+ <box
423
+ width={KILO_STEPS_COLUMN_WIDTH}
424
+ flexDirection="row"
425
+ flexShrink={0}
426
+ justifyContent="flex-end"
427
+ >
428
+ <text fg={props.theme.current.textMuted} wrapMode="none">
429
+ {formatUsageCount(props.model.steps)}
430
+ </text>
431
+ </box>
432
+ <box
433
+ width={KILO_COST_COLUMN_WIDTH}
434
+ flexDirection="row"
435
+ flexShrink={0}
436
+ justifyContent="flex-end"
437
+ >
438
+ <text fg={props.theme.current.textMuted} wrapMode="none">
439
+ {formatUsageCost(props.model.cost)}
440
+ </text>
441
+ </box>
442
+ </box>
443
+ <Show when={props.isGo}>
444
+ <box paddingLeft={2}>
445
+ <GoShareRow theme={props.theme} percent={props.goShare()} />
446
+ </box>
447
+ </Show>
448
+ {reactiveChild(detail)}
449
+ </box>
450
+ );
451
+ }
452
+
453
+ function ModelsSection(props: {
454
+ theme: TuiTheme;
455
+ usage: () => SessionModelUsage | null;
456
+ planRows: () => PlanRow[];
457
+ }) {
458
+ const [isExpanded, setIsExpanded] = createSignal<boolean>(true);
459
+ const [expandedModels, setExpandedModels] = createSignal<ReadonlySet<string>>(new Set());
460
+
461
+ // Fold state is local, like Kilo's: it resets on remount rather than
462
+ // outliving the session it described.
463
+ function toggleModel(key: string): void {
464
+ setExpandedModels((current) => {
465
+ const next = new Set(current);
466
+ if (!next.delete(key)) next.add(key);
467
+ return next;
468
+ });
469
+ }
470
+
471
+ const modelCount = createMemo(() => props.usage()?.models.length ?? 0);
472
+
473
+ const body = createMemo(() => {
474
+ const usage = props.usage();
475
+ const models = usage?.models ?? [];
476
+ const providerNames = readProviderDisplayNames(api);
477
+ const grouped = groupModelsByProvider(models, providerNames);
478
+ // A failed or still-loading request leaves no model list, and the Go plan
479
+ // would disappear with it. A model-less Go group keeps the plan on screen:
480
+ // it comes from a different endpoint, and those numbers stay ours to show
481
+ // regardless of what the host's per-model request did.
482
+ const groups =
483
+ grouped.length === 0 && props.planRows().length > 0
484
+ ? [
485
+ {
486
+ providerID: GO_PROVIDER_ID,
487
+ providerName: providerNames.get(GO_PROVIDER_ID) ?? GO_PROVIDER_ID,
488
+ models: [],
489
+ },
490
+ ]
491
+ : grouped;
492
+ const goTotal = totalGoTokens(models);
493
+ return (
494
+ <box flexDirection="column" gap={1} paddingTop={1}>
495
+ <Show when={models.length === 0}>
496
+ <text fg={props.theme.current.textMuted} wrapMode="none">
497
+ {MODELS_EMPTY_LABEL}
498
+ </text>
499
+ </Show>
500
+ <For each={groups}>
501
+ {(group) => (
502
+ <ProviderUsageGroup
503
+ theme={props.theme}
504
+ group={group}
505
+ planRows={props.planRows()}
506
+ goTotal={goTotal}
507
+ expandedModels={expandedModels}
508
+ onToggleModel={toggleModel}
509
+ />
510
+ )}
511
+ </For>
512
+ </box>
513
+ );
514
+ });
515
+
516
+ return (
517
+ <CollapsibleSection
518
+ theme={props.theme}
519
+ label={INTEGRATED_MODELS_SECTION_LABEL}
520
+ count={modelCount}
521
+ expanded={isExpanded}
522
+ onToggle={() => setIsExpanded((current) => !current)}
523
+ body={body}
524
+ />
525
+ );
526
+ }
527
+
528
+ // Integrated mode's replacement for Kilo's own 150 band: the compact Go
529
+ // readout, Kilo's session token totals, and Kilo's per-model table with the
530
+ // plan nested inside the OpenCode Go group.
531
+ function GoIntegratedPanel(props: { theme: TuiTheme; sessionId: string }) {
532
+ const [usage, setUsage] = createSignal<SessionModelUsage | null>(null);
533
+ const [loadFailed, setLoadFailed] = createSignal<boolean>(false);
534
+ const [refetchToken, setRefetchToken] = createSignal<number>(0);
535
+ const [isTokensExpanded, setIsTokensExpanded] = createSignal<boolean>(true);
536
+
537
+ createEffect(() => {
538
+ const sessionId = props.sessionId;
539
+ refetchToken();
540
+ if (sessionId.length === 0) return;
541
+ let cancelled = false;
542
+ void fetchSessionModelUsage(api, sessionId).then((result) => {
543
+ if (cancelled) return;
544
+ setUsage(result);
545
+ // Log the transition, not every event: a step-finish storm must not
546
+ // fill the host log with the same line.
547
+ if (result === null && !loadFailed()) {
548
+ void logUsageError(api, "Session model usage unavailable (request failed)");
549
+ }
550
+ setLoadFailed(result === null);
551
+ });
552
+ onCleanup(() => {
553
+ cancelled = true;
554
+ });
555
+ });
556
+
557
+ createEffect(() => {
558
+ const sessionId = props.sessionId;
559
+ const unsubscribes: Array<() => void> = [];
560
+ const inTree = (id: string): boolean => isInSessionTree(api, id, sessionId);
561
+ const refetch = (): void => {
562
+ setRefetchToken((current) => current + 1);
563
+ };
564
+
565
+ trackSubscription(unsubscribes, () =>
566
+ api.event.on("message.part.updated", (event) => {
567
+ if (event.properties.part.type !== "step-finish") return;
568
+ if (inTree(event.properties.sessionID)) refetch();
569
+ }),
570
+ );
571
+ trackSubscription(unsubscribes, () =>
572
+ api.event.on("message.part.removed", (event) => {
573
+ if (inTree(event.properties.sessionID)) refetch();
574
+ }),
575
+ );
576
+ trackSubscription(unsubscribes, () =>
577
+ api.event.on("message.removed", (event) => {
578
+ if (inTree(event.properties.sessionID)) refetch();
579
+ }),
580
+ );
581
+ trackSubscription(unsubscribes, () =>
582
+ api.event.on("session.created", (event) => {
583
+ if (inTree(event.properties.sessionID)) refetch();
584
+ }),
585
+ );
586
+ trackSubscription(unsubscribes, () =>
587
+ api.event.on("session.deleted", (event) => {
588
+ if (inTree(event.properties.sessionID)) refetch();
589
+ }),
590
+ );
591
+ trackSubscription(unsubscribes, () => api.event.on("server.connected", refetch));
592
+
593
+ onCleanup(() => {
594
+ for (const unsubscribe of unsubscribes) {
595
+ try {
596
+ unsubscribe();
597
+ } catch {
598
+ // A throwing unsubscribe must not stop the rest of the teardown.
599
+ }
600
+ }
601
+ });
602
+ });
603
+
604
+ const tokenBody = createMemo(() => {
605
+ const data = usage();
606
+ if (data === null) {
607
+ return (
608
+ <text fg={props.theme.current.textMuted} wrapMode="none">
609
+ {loadFailed() ? INTEGRATED_UNAVAILABLE_LABEL : INTEGRATED_LOADING_LABEL}
610
+ </text>
611
+ );
612
+ }
613
+ return (
614
+ <box flexDirection="column">
615
+ <For each={buildTokenUsageRows(data.totals)}>
616
+ {(row) => <LabeledValueRow theme={props.theme} row={row} />}
617
+ </For>
618
+ </box>
619
+ );
620
+ });
621
+
622
+ return (
623
+ // Integrated mode draws the plan ONCE, as the `Go Plan` meters inside the
624
+ // OpenCode Go group of the Models table. A `Go Usage` block above it (as
625
+ // there was) said the same three numbers twice, in two different
626
+ // layouts, on the same screen.
627
+ <box flexDirection="column" gap={1}>
628
+ <CollapsibleSection
629
+ theme={props.theme}
630
+ label={INTEGRATED_TOKENS_SECTION_LABEL}
631
+ count={() => null}
632
+ expanded={isTokensExpanded}
633
+ onToggle={() => setIsTokensExpanded((current) => !current)}
634
+ body={tokenBody}
635
+ />
636
+ <ModelsSection theme={props.theme} usage={usage} planRows={currentPlanRows} />
637
+ </box>
638
+ );
639
+ }
640
+
641
+ // The `Go Usage` block: the plan as meters, the next reset on one line --
642
+ // the same block opencode's sidebar leads with, so the plan looks identical
643
+ // whichever host draws it. Kilo's sidebar is wider, so its meters keep the
644
+ // full 16 cells.
645
+ function GoSidebarPanel(props: { theme: TuiTheme }) {
646
+ return (
647
+ <GoUsageBlock
648
+ api={api}
649
+ theme={props.theme}
650
+ snapshot={usageStore.snapshot}
651
+ resetLine={planResetLine}
652
+ />
653
+ );
654
+ }
655
+
656
+ if (surfaces.sidebar) {
657
+ // Host-owned slot: `register` returns an id but the SDK exposes no
658
+ // unregister API, so there is nothing to dispose here (the slot dies
659
+ // with the host). Interval/event/command teardown below is the full
660
+ // dispose path.
661
+ try {
662
+ api.slots.register({
663
+ order: sidebarMode === "integrated" ? INTEGRATED_SLOT_ORDER : SLOT_ORDER,
664
+ slots: {
665
+ sidebar_content(ctx: TuiSlotContext, props: { session_id: string }) {
666
+ // Individually guarded: a later render must never throw into the host.
667
+ try {
668
+ if (props.session_id.length === 0) return null;
669
+ if (!isGoUsageProvider(resolveActiveProviderId(props.session_id))) return null;
670
+ if (api.route.current.name !== "session") return null;
671
+ if (collapse.isSidebarCollapsed()) return null;
672
+ // Read live so the mode command takes effect at once, the same way
673
+ // the host panel follows it; only the ORDER stays bound to
674
+ // registration.
675
+ if (sidebarMode === "integrated") {
676
+ return <GoIntegratedPanel theme={ctx.theme} sessionId={props.session_id} />;
677
+ }
678
+ return <GoSidebarPanel theme={ctx.theme} />;
679
+ } catch {
680
+ return null;
681
+ }
682
+ },
683
+ },
684
+ });
685
+ } catch {
686
+ // Additive slot: a registration failure must not abort the plugin.
687
+ }
688
+ }
689
+
690
+ if (surfaces.statusline) {
691
+ // Host-owned slot (see sidebar note above): no unregister API exists,
692
+ // so disposal is a no-op for slots.
693
+ try {
694
+ api.slots.register({
695
+ order: SLOT_ORDER,
696
+ slots: {
697
+ session_prompt_right(_ctx: TuiSlotContext, props: { session_id: string }) {
698
+ // Individually guarded: a later render must never throw into the host.
699
+ try {
700
+ if (props.session_id.length === 0) return null;
701
+ if (!isGoUsageProvider(resolveActiveProviderId(props.session_id))) return null;
702
+ if (collapse.isStatuslineCollapsed()) return null;
703
+ return <GoStatusline snapshot={usageStore.snapshot} />;
704
+ } catch {
705
+ return null;
706
+ }
707
+ },
708
+ },
709
+ });
710
+ } catch {
711
+ // Additive slot: a registration failure must not abort the plugin.
712
+ }
713
+ }
714
+
715
+ // `api.command` is a deprecated legacy shim that hosts may omit; guard so
716
+ // the plugin still initializes and disposes safely without it.
717
+ let unregisterToggleCommand: () => void = () => {};
718
+ try {
719
+ const unregister = api.command?.register(() => [
720
+ {
721
+ title: "Go usage: toggle sidebar",
722
+ value: "oc-go-usage-display.toggle-sidebar",
723
+ category: "Go",
724
+ onSelect: () => collapse.toggleSidebar(),
725
+ },
726
+ {
727
+ title: "Go usage: toggle statusline",
728
+ value: "oc-go-usage-display.toggle-statusline",
729
+ category: "Go",
730
+ onSelect: () => collapse.toggleStatusline(),
731
+ },
732
+ {
733
+ title: "Go usage: toggle sidebar mode",
734
+ value: "oc-go-usage-display.toggle-sidebar-mode",
735
+ category: "Go",
736
+ onSelect: () => toggleSidebarMode(),
737
+ },
738
+ ]);
739
+ if (typeof unregister === "function") unregisterToggleCommand = unregister;
740
+ } catch {
741
+ // Legacy command registration is optional; ignore failures.
742
+ }
743
+
744
+ let unsubscribeSession: () => void = () => {};
745
+ let unsubscribeMessage: () => void = () => {};
746
+ try {
747
+ const unsubscribe = api.event.on("session.updated", (event) => {
748
+ const providerId = event?.properties?.info?.model?.providerID;
749
+ if (providerId !== undefined) setActiveProviderId(providerId);
750
+ usageStore.refreshSafely(0);
751
+ });
752
+ if (typeof unsubscribe === "function") unsubscribeSession = unsubscribe;
753
+ } catch {
754
+ // Event subscription is additive; a failure must not abort the plugin.
755
+ }
756
+ try {
757
+ const unsubscribe = api.event.on("message.updated", () => usageStore.refreshSafely(EVENT_TTL_MS));
758
+ if (typeof unsubscribe === "function") unsubscribeMessage = unsubscribe;
759
+ } catch {
760
+ // Event subscription is additive; a failure must not abort the plugin.
761
+ }
762
+
763
+ let pollTimer: ReturnType<typeof setInterval> | null = null;
764
+ try {
765
+ pollTimer = setInterval(() => usageStore.refreshSafely(), POLL_INTERVAL_MS);
766
+ } catch {
767
+ // No poll timer: the on-demand refresh below still runs.
768
+ }
769
+
770
+ try {
771
+ api.lifecycle.onDispose(() => {
772
+ // Each teardown step is isolated: one throwing unsubscribe must not
773
+ // prevent the rest (or leak into the host's dispose pass).
774
+ if (pollTimer !== null) {
775
+ try {
776
+ clearInterval(pollTimer);
777
+ } catch {
778
+ // noop
779
+ }
780
+ }
781
+ for (const teardown of [unsubscribeSession, unsubscribeMessage, unregisterToggleCommand]) {
782
+ try {
783
+ teardown();
784
+ } catch {
785
+ // noop
786
+ }
787
+ }
788
+ });
789
+ } catch {
790
+ // Lifecycle registration is best-effort; never leak a timer into the host.
791
+ if (pollTimer !== null) {
792
+ try {
793
+ clearInterval(pollTimer);
794
+ } catch {
795
+ // noop
796
+ }
797
+ }
798
+ }
799
+
800
+ await usageStore.refresh();
801
+ }
802
+
803
+ const goUsageTui: TuiPlugin = async (api, options) => {
804
+ try {
805
+ await initializeTui(api, options);
806
+ } catch (error) {
807
+ // Best-effort and non-blocking: never delay plugin resolution on the host
808
+ // log and never let an initialization error reject into the host.
809
+ void logUsageError(
810
+ api,
811
+ `Go usage TUI failed to initialize: ${errorMessage(error)}; continuing without display`,
812
+ );
813
+ }
814
+ };
815
+
816
+ export default { id: "oc-go-usage-display", tui: goUsageTui } satisfies TuiPluginModule;