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,705 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ //
3
+ // The host-agnostic TUI layer, inlined into BOTH TUI bundles (esbuild bundles
4
+ // every relative import, so the deployed `dist/plugins/*` files stay
5
+ // self-contained and default-export-only).
6
+ //
7
+ // `src/tui.tsx` (opencode) and `src/tui.kilo.tsx` (Kilo) differ in three places
8
+ // and nowhere else:
9
+ //
10
+ // 1. the host they read — `OPENCODE_OC_GO_*` vs `KILO_OC_GO_*`, and each
11
+ // host's own auth store (see `hostEnv` / `readAuthJsonApiKey`);
12
+ // 2. the sidebar band they register in, and whether they retire a host panel
13
+ // to take it (only Kilo has a token-usage panel to retire);
14
+ // 3. where per-session model usage comes from — opencode's own message store
15
+ // vs Kilo's `client.kilocode.sessionModelUsage`.
16
+ //
17
+ // Everything else is shared verbatim from here: the plan meters, the row
18
+ // grammar, the foldable sections, the refresh policy, the settings ladder and
19
+ // every failure boundary. The two hosts therefore cannot drift on the parts a
20
+ // user actually reads, and a fix in one is the fix in both.
21
+ //
22
+ // HOST BOUNDARY
23
+ // -------------
24
+ // The types below are structural on purpose. `TuiTheme` is imported from
25
+ // `@opencode-ai/plugin/tui` even though this file is inlined into the Kilo
26
+ // bundle too: it is an `import type`, so it is erased at build time and the
27
+ // Kilo bundle never requires the opencode package, and both hosts' `TuiTheme`
28
+ // is the same declaration (both re-export `RGBA` from the same `@opentui/core`),
29
+ // so the assignment is structural. `UsagePanelApi` is the smallest surface the
30
+ // shared layer needs; a host `TuiPluginApi` satisfies it structurally, which is
31
+ // what lets one implementation serve both without a cast or an `any`.
32
+ //
33
+ // REACTIVITY
34
+ // ----------
35
+ // Reactivity in this layer is hand-rolled on purpose. The deployed bundle is
36
+ // compiled by esbuild's automatic JSX rather than Solid's compiler, so a child
37
+ // written as a plain value is an ordinary evaluated expression: the host is
38
+ // free to reuse the element it was handed instead of re-invoking this subtree
39
+ // when something changes. A FUNCTION child is the one form the renderer is
40
+ // documented to re-evaluate (`insertExpression` wraps it in a render effect),
41
+ // so every region whose contents can change after mount is written as a
42
+ // function child and every prop carrying a changing value is passed as an
43
+ // accessor. Wrapping a region that happens to be static costs nothing and
44
+ // removes the question.
45
+ //
46
+ // This was verified against the real host in both directions rather than
47
+ // assumed: the collapse toggle and an async snapshot arriving after mount both
48
+ // re-render correctly, which is the property this pattern exists to guarantee.
49
+
50
+ import type { JSX } from "@opentui/solid/jsx-runtime";
51
+ import type { TuiTheme } from "@opencode-ai/plugin/tui";
52
+ import { For, Show, createEffect, createMemo, createSignal } from "solid-js";
53
+ import {
54
+ PLAN_LABEL_WIDTH,
55
+ buildPlanRows,
56
+ formatPercentCell,
57
+ formatStatusline,
58
+ isDisplayMode,
59
+ isSnapshotEmpty,
60
+ meterFillPercent,
61
+ meterSeverityForPercent,
62
+ parseBooleanFlag,
63
+ resolveProviderId,
64
+ surfaceSelectionFromDisplayMode,
65
+ } from "./helpers.js";
66
+ import type {
67
+ PlanRow,
68
+ ProviderSource,
69
+ ResetCountdown,
70
+ SurfaceSelection,
71
+ UsageMeterSeverity,
72
+ UsageRow,
73
+ } from "./helpers.js";
74
+ import {
75
+ extractSnapshotFromApiPayload,
76
+ hostEnv,
77
+ isRecord,
78
+ mockSnapshot,
79
+ readAuthJsonApiKey,
80
+ toNonEmptyString,
81
+ unavailableSnapshot,
82
+ GO_PLAN_HEADING,
83
+ GO_PROVIDER_ID,
84
+ INTEGRATED_GO_SHARE_LABEL,
85
+ SIDEBAR_COLLAPSED_GLYPH,
86
+ SIDEBAR_EXPANDED_GLYPH,
87
+ } from "./shared.js";
88
+ import type { UsageHost, UsageSnapshot } from "./shared.js";
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Constants
92
+ // ---------------------------------------------------------------------------
93
+
94
+ const API_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
95
+
96
+ // One refresh policy for both hosts, so the numbers on a host's sidebar and its
97
+ // statusline can never come from two different cadences: the background poll,
98
+ // the event TTL and the debounce below are the same numbers as the ones each
99
+ // entry passes to `refreshSafely`.
100
+ export const POLL_INTERVAL_MS = 60_000;
101
+ export const EVENT_TTL_MS = 15_000;
102
+ const DEBOUNCE_MS = 5_000;
103
+ const FETCH_TIMEOUT_MS = 10_000;
104
+ const LOG_SERVICE = "oc-go-usage-display";
105
+
106
+ // Persisted `api.kv` keys. Each host keeps its own store, so the same names
107
+ // never collide across hosts.
108
+ export const KV_DISPLAY_KEY = "display";
109
+ export const KV_COLLAPSED_SIDEBAR_KEY = "collapsed_sidebar";
110
+ export const KV_COLLAPSED_STATUSLINE_KEY = "collapsed_statusline";
111
+ const KV_COLLAPSED_LEGACY_KEY = "collapsed";
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Host surface
115
+ // ---------------------------------------------------------------------------
116
+
117
+ // The smallest `TuiPluginApi` the shared layer touches. Declared with method
118
+ // syntax so TypeScript compares these members bivariantly: a host whose
119
+ // `client.app.log` types `level` as a union still satisfies it.
120
+ export type UsagePanelApi = {
121
+ kv: {
122
+ get<Value = unknown>(key: string, fallback?: Value): Value;
123
+ set(key: string, value: unknown): void;
124
+ };
125
+ client: {
126
+ app: {
127
+ log(entry: { service: string; level: "error"; message: string }): Promise<unknown>;
128
+ };
129
+ };
130
+ };
131
+
132
+ export function reactiveChild(accessor: () => unknown): JSX.Element {
133
+ return accessor as unknown as JSX.Element;
134
+ }
135
+
136
+ export function meterColor(theme: TuiTheme, severity: UsageMeterSeverity) {
137
+ if (severity === "error") return theme.current.error;
138
+ if (severity === "warning") return theme.current.warning;
139
+ return theme.current.textMuted;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Failure boundary (api.client.app.log, never console; secrets never logged)
144
+ // ---------------------------------------------------------------------------
145
+
146
+ export async function logUsageError(api: UsagePanelApi, message: string): Promise<void> {
147
+ try {
148
+ await api.client.app.log({ service: LOG_SERVICE, level: "error", message });
149
+ } catch {
150
+ // Logging is best-effort; the usage display must never break the host.
151
+ }
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Data boundary (auth.json -> Bearer usage fetch; throws nothing)
156
+ // ---------------------------------------------------------------------------
157
+
158
+ async function fetchJsonWithTimeout(url: string, apiKey: string): Promise<unknown | null> {
159
+ const controller = new AbortController();
160
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
161
+ try {
162
+ const response = await fetch(url, {
163
+ signal: controller.signal,
164
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
165
+ });
166
+ if (response.status === 401 || response.status === 403) return { __rejected: true };
167
+ if (!response.ok) return null;
168
+ try {
169
+ return (await response.json()) as unknown;
170
+ } catch {
171
+ return null;
172
+ }
173
+ } catch {
174
+ return null;
175
+ } finally {
176
+ clearTimeout(timer);
177
+ }
178
+ }
179
+
180
+ async function loadUsageSnapshot(host: UsageHost): Promise<UsageSnapshot | null> {
181
+ if (hostEnv(host, "MOCK") === "1") return mockSnapshot();
182
+
183
+ const apiKey = toNonEmptyString(hostEnv(host, "API_KEY")) ?? readAuthJsonApiKey(host);
184
+ if (apiKey === null) return null;
185
+
186
+ const payload = await fetchJsonWithTimeout(API_USAGE_URL, apiKey);
187
+ if (payload === null) return null;
188
+ // Mirror the server: a rejected key is a distinct unavailable snapshot
189
+ // (surfaced via `apiError`) rather than a silent null.
190
+ if (isRecord(payload) && payload.__rejected === true) {
191
+ return unavailableSnapshot("API key rejected (401/403)");
192
+ }
193
+ return extractSnapshotFromApiPayload(payload);
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Usage store: the one refresh policy both hosts run
198
+ // ---------------------------------------------------------------------------
199
+
200
+ export type UsageStore = {
201
+ snapshot: () => UsageSnapshot | null;
202
+ // Awaited once at the end of initialization so the first render has data.
203
+ refresh: () => Promise<void>;
204
+ // Fire-and-forget and cannot surface an unhandled rejection. The background
205
+ // poll (no argument) keeps the 60s TTL guard; `session.updated` passes 0 to
206
+ // bypass the TTL entirely (debounce only); `message.updated` passes
207
+ // EVENT_TTL_MS for a 15s effective window.
208
+ refreshSafely: (ttlOverride?: number) => void;
209
+ };
210
+
211
+ export function createUsageStore(api: UsagePanelApi, host: UsageHost): UsageStore {
212
+ const [snapshot, setSnapshot] = createSignal<UsageSnapshot | null>(null);
213
+ let cachedAt = 0;
214
+ let lastFetchAt = 0;
215
+ let refreshInFlight = false;
216
+
217
+ async function refresh(ttlOverride?: number): Promise<void> {
218
+ if (refreshInFlight) return;
219
+ const effectiveTtl = ttlOverride ?? POLL_INTERVAL_MS;
220
+ if (ttlOverride !== undefined) {
221
+ if (Date.now() - lastFetchAt < DEBOUNCE_MS) return;
222
+ if (Date.now() - cachedAt < effectiveTtl && snapshot() !== null) return;
223
+ } else if (Date.now() - cachedAt < POLL_INTERVAL_MS && snapshot() !== null) {
224
+ return;
225
+ }
226
+ refreshInFlight = true;
227
+ try {
228
+ const next = await loadUsageSnapshot(host);
229
+ if (next === null) {
230
+ if (snapshot() === null) {
231
+ await logUsageError(api, "Go usage unavailable (not configured or request failed)");
232
+ }
233
+ return;
234
+ }
235
+ cachedAt = Date.now();
236
+ setSnapshot(next);
237
+ } catch {
238
+ // Keep stale data; the panel simply shows the last known snapshot.
239
+ } finally {
240
+ lastFetchAt = Date.now();
241
+ refreshInFlight = false;
242
+ }
243
+ }
244
+
245
+ return {
246
+ snapshot,
247
+ refresh: () => refresh(),
248
+ refreshSafely: (ttlOverride?: number) => {
249
+ void refresh(ttlOverride).catch(() => {
250
+ // refresh already swallows failures; this guards a regression.
251
+ });
252
+ },
253
+ };
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Settings boundary (new toggles > legacy display; tui.json options > env >
258
+ // api.kv > default both on)
259
+ // ---------------------------------------------------------------------------
260
+
261
+ // Structurally the host's `PluginOptions` (`Record<string, unknown>`) on both
262
+ // hosts; named so the shared ladder is not typed against either package.
263
+ export type DisplayOptions = {
264
+ sidebar?: unknown;
265
+ statusline?: unknown;
266
+ display?: unknown;
267
+ };
268
+
269
+ export function resolveSurfaceSelection(
270
+ options: DisplayOptions | undefined,
271
+ api: UsagePanelApi,
272
+ host: UsageHost,
273
+ ): SurfaceSelection {
274
+ if (options !== undefined) {
275
+ const sidebarOption = parseBooleanFlag(options.sidebar);
276
+ const statuslineOption = parseBooleanFlag(options.statusline);
277
+ if (sidebarOption !== null || statuslineOption !== null) {
278
+ return { sidebar: sidebarOption ?? true, statusline: statuslineOption ?? true };
279
+ }
280
+ if (isDisplayMode(options.display)) return surfaceSelectionFromDisplayMode(options.display);
281
+ }
282
+
283
+ const sidebarEnv = parseBooleanFlag(hostEnv(host, "SIDEBAR"));
284
+ const statuslineEnv = parseBooleanFlag(hostEnv(host, "STATUSLINE"));
285
+ if (sidebarEnv !== null || statuslineEnv !== null) {
286
+ return { sidebar: sidebarEnv ?? true, statusline: statuslineEnv ?? true };
287
+ }
288
+
289
+ const displayEnv = toNonEmptyString(hostEnv(host, "DISPLAY"));
290
+ if (displayEnv !== null && isDisplayMode(displayEnv)) {
291
+ return surfaceSelectionFromDisplayMode(displayEnv);
292
+ }
293
+
294
+ try {
295
+ const stored = api.kv.get(KV_DISPLAY_KEY, "both");
296
+ if (isDisplayMode(stored)) return surfaceSelectionFromDisplayMode(stored);
297
+ } catch {
298
+ // Persisted settings are best-effort; fall through to the default.
299
+ }
300
+ return { sidebar: true, statusline: true };
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Collapse (independent per surface, persisted in `api.kv`)
305
+ // ---------------------------------------------------------------------------
306
+
307
+ // The legacy single `collapsed` key is migrated once (when true, both new keys
308
+ // are set true, then the legacy key is cleared) and ignored afterwards.
309
+ export type CollapseState = {
310
+ isSidebarCollapsed: () => boolean;
311
+ isStatuslineCollapsed: () => boolean;
312
+ toggleSidebar: () => void;
313
+ toggleStatusline: () => void;
314
+ };
315
+
316
+ function readCollapsedFlag(api: UsagePanelApi, key: string): boolean {
317
+ try {
318
+ return api.kv.get<boolean>(key, false) === true;
319
+ } catch {
320
+ return false;
321
+ }
322
+ }
323
+
324
+ function migrateLegacyCollapsedFlag(api: UsagePanelApi): void {
325
+ let legacyCollapsed = false;
326
+ try {
327
+ legacyCollapsed = api.kv.get<boolean>(KV_COLLAPSED_LEGACY_KEY, false) === true;
328
+ } catch {
329
+ return;
330
+ }
331
+ if (!legacyCollapsed) return;
332
+ try {
333
+ api.kv.set(KV_COLLAPSED_SIDEBAR_KEY, true);
334
+ api.kv.set(KV_COLLAPSED_STATUSLINE_KEY, true);
335
+ api.kv.set(KV_COLLAPSED_LEGACY_KEY, false);
336
+ } catch {
337
+ // Collapse state is best-effort persistence only.
338
+ }
339
+ }
340
+
341
+ export function createCollapseState(api: UsagePanelApi): CollapseState {
342
+ migrateLegacyCollapsedFlag(api);
343
+ const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal<boolean>(
344
+ readCollapsedFlag(api, KV_COLLAPSED_SIDEBAR_KEY),
345
+ );
346
+ const [isStatuslineCollapsed, setIsStatuslineCollapsed] = createSignal<boolean>(
347
+ readCollapsedFlag(api, KV_COLLAPSED_STATUSLINE_KEY),
348
+ );
349
+ return {
350
+ isSidebarCollapsed,
351
+ isStatuslineCollapsed,
352
+ toggleSidebar() {
353
+ try {
354
+ const next = !isSidebarCollapsed();
355
+ setIsSidebarCollapsed(next);
356
+ api.kv.set(KV_COLLAPSED_SIDEBAR_KEY, next);
357
+ } catch {
358
+ // Collapse state is best-effort persistence only.
359
+ }
360
+ },
361
+ toggleStatusline() {
362
+ try {
363
+ const next = !isStatuslineCollapsed();
364
+ setIsStatuslineCollapsed(next);
365
+ api.kv.set(KV_COLLAPSED_STATUSLINE_KEY, next);
366
+ } catch {
367
+ // Collapse state is best-effort persistence only.
368
+ }
369
+ },
370
+ };
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Provider gate
375
+ // ---------------------------------------------------------------------------
376
+
377
+ export function isGoUsageProvider(providerId: string | undefined): boolean {
378
+ return providerId === GO_PROVIDER_ID;
379
+ }
380
+
381
+ // Which provider is in use, read live on every render rather than latched at
382
+ // init. Latching left the gate closed for anyone without a config-level model,
383
+ // and `session.updated` only fires on a server-side change, so opening a
384
+ // session or switching model in the picker never re-armed it -- the plugin
385
+ // loaded, showed as `active` in the Plugins menu, and rendered nothing.
386
+ export function makeProviderResolver(
387
+ state: ProviderSource | undefined,
388
+ fallback: () => string | undefined,
389
+ ): (sessionId: string) => string | undefined {
390
+ return (sessionId: string) => resolveProviderId(state, sessionId, fallback());
391
+ }
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // Provider / model display names
395
+ // ---------------------------------------------------------------------------
396
+
397
+ // The provider catalog as far as the panels need it. Both hosts expose the same
398
+ // shape (`state.provider[].id|name|models[modelID].name`); the fields are read
399
+ // through `toNonEmptyString`, so a host that renames or drops one degrades to
400
+ // the raw id rather than rendering `undefined`.
401
+ export type ProviderCatalogSource = {
402
+ state?: {
403
+ provider?: ReadonlyArray<{
404
+ id?: unknown;
405
+ name?: unknown;
406
+ models?: Readonly<Record<string, { name?: unknown } | undefined>>;
407
+ }>;
408
+ };
409
+ };
410
+
411
+ export function readProviderDisplayNames(source: ProviderCatalogSource): ReadonlyMap<string, string> {
412
+ const names = new Map<string, string>();
413
+ try {
414
+ for (const provider of source.state?.provider ?? []) {
415
+ const id = toNonEmptyString(provider.id);
416
+ if (id === null) continue;
417
+ names.set(id, toNonEmptyString(provider.name) ?? id);
418
+ }
419
+ } catch {
420
+ // The catalog is optional: a group falls back to its raw provider id.
421
+ }
422
+ return names;
423
+ }
424
+
425
+ export function readModelDisplayName(
426
+ source: ProviderCatalogSource,
427
+ providerID: string,
428
+ modelID: string,
429
+ ): string | null {
430
+ try {
431
+ const provider = source.state?.provider?.find((entry) => entry.id === providerID);
432
+ return toNonEmptyString(provider?.models?.[modelID]?.name);
433
+ } catch {
434
+ return null;
435
+ }
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Row grammar
440
+ // ---------------------------------------------------------------------------
441
+
442
+ // A `space-between` label/value row, the grammar the hosts' own panels use.
443
+ export function LabeledValueRow(props: { theme: TuiTheme; row: UsageRow }) {
444
+ return (
445
+ <box flexDirection="row" justifyContent="space-between">
446
+ <text fg={props.theme.current.textMuted} wrapMode="none">
447
+ {props.row.label}
448
+ </text>
449
+ <text fg={props.theme.current.textMuted} wrapMode="none">
450
+ {props.row.value}
451
+ </text>
452
+ </box>
453
+ );
454
+ }
455
+
456
+ // The narrowest a meter may become before it stops being a meter: enough to read
457
+ // a filled-vs-empty split at a glance.
458
+ export const MIN_METER_WIDTH = 6;
459
+
460
+ // A meter as two boxes: the filled part at the window's percentage, the track for
461
+ // the rest. Both fill whatever the row has, so the bar reaches the sidebar's
462
+ // right edge on any host, at any sidebar width, with nothing to measure and
463
+ // nothing to guess -- the failure modes a fixed-width glyph string has (too short
464
+ // on a wide sidebar, clipped on a narrow one) cannot happen.
465
+ //
466
+ // The track is a surface tone rather than the severity colour, so an empty meter
467
+ // reads as an empty track instead of blank space.
468
+ export function MeterBar(props: { theme: TuiTheme; percent: number; severity: UsageMeterSeverity }) {
469
+ const fill = createMemo(() => meterFillPercent(props.percent));
470
+ return (
471
+ <box flexDirection="row" flexGrow={1} flexShrink={1} minWidth={MIN_METER_WIDTH}>
472
+ <box width={`${fill()}%`} backgroundColor={meterColor(props.theme, props.severity)} />
473
+ <box flexGrow={1} backgroundColor={props.theme.current.backgroundElement} />
474
+ </box>
475
+ );
476
+ }
477
+
478
+ // One plan window as a row of three FIXED-WIDTH columns: label, bar, percent.
479
+ //
480
+ // Not `justifyContent="space-between"` on a label plus a bar: that lets the bar
481
+ // start wherever the label happened to end, so the three meters only look
482
+ // stacked when every label is the same width -- and a terminal font that draws
483
+ // the empty meter glyph narrower than the full one shifts the bar of a 0% row
484
+ // against a 100% one (seen at 30d 100% next to 5h 0%). Fixed columns make the
485
+ // meters stack whatever the font does, and the percentages right-align on one
486
+ // edge.
487
+ export function GoPlanRow(props: { theme: TuiTheme; row: PlanRow }) {
488
+ return (
489
+ <box flexDirection="column">
490
+ <box flexDirection="row">
491
+ {/* The label sits in a fixed-width BOX, not a fixed-width text: opentui
492
+ treats a text node's `width` as a wrapping bound, so the box after it
493
+ still starts wherever the label ended -- "5h" and "30d" then draw
494
+ their meters a cell apart. A box is a real layout box, which is why
495
+ Kilo's Steps/Cost columns (boxes) line up and these did not. */}
496
+ {/* The label cell is exactly `30d` wide and carries a one-cell margin:
497
+ without it the longest label butts straight into the meter, while the
498
+ shorter `5h`/`7d` happen to leave one behind -- the meter has to start
499
+ the same column on every row, so the separation is a layout fact and
500
+ not something the label's own length may decide. */}
501
+ <box width={PLAN_LABEL_WIDTH} marginRight={1} flexShrink={0} flexDirection="row">
502
+ <text fg={props.theme.current.textMuted} wrapMode="none">
503
+ {props.row.label}
504
+ </text>
505
+ </box>
506
+ <MeterBar theme={props.theme} percent={props.row.percent} severity={props.row.severity} />
507
+ <text fg={props.theme.current.textMuted} wrapMode="none" flexShrink={0}>
508
+ {formatPercentCell(props.row.percent)}
509
+ </text>
510
+ </box>
511
+ <Show when={props.row.reset}>
512
+ {(reset) => (
513
+ <text fg={props.theme.current.textMuted} wrapMode="none">
514
+ resets in {reset()}
515
+ </text>
516
+ )}
517
+ </Show>
518
+ </box>
519
+ );
520
+ }
521
+
522
+ export function GoPlanSection(props: { theme: TuiTheme; rows: PlanRow[] }) {
523
+ return (
524
+ <box flexDirection="column">
525
+ <text fg={props.theme.current.text} wrapMode="none">
526
+ <b>{GO_PLAN_HEADING}</b>
527
+ </text>
528
+ <box flexDirection="column" paddingLeft={1}>
529
+ <For each={props.rows}>
530
+ {(row) => <GoPlanRow theme={props.theme} row={row} />}
531
+ </For>
532
+ </box>
533
+ </box>
534
+ );
535
+ }
536
+
537
+ export function SectionHeader(props: {
538
+ theme: TuiTheme;
539
+ label: string;
540
+ expanded: () => boolean;
541
+ count: () => number | null;
542
+ onToggle: () => void;
543
+ }) {
544
+ return (
545
+ <box flexDirection="row" gap={1} flexShrink={0} onMouseDown={props.onToggle}>
546
+ <text fg={props.theme.current.text} wrapMode="none" flexShrink={0}>
547
+ {reactiveChild(() => (props.expanded() ? SIDEBAR_EXPANDED_GLYPH : SIDEBAR_COLLAPSED_GLYPH))}
548
+ </text>
549
+ <text fg={props.theme.current.text} wrapMode="none">
550
+ <b>
551
+ {reactiveChild(() => {
552
+ const count = props.count();
553
+ return count === null ? props.label : `${props.label} (${count})`;
554
+ })}
555
+ </b>
556
+ </text>
557
+ </box>
558
+ );
559
+ }
560
+
561
+ // A collapsible region: the header reacts to its own state, and the body is a
562
+ // memo so an unchanged subtree keeps the very same element instead of being
563
+ // rebuilt on every unrelated signal write.
564
+ export function CollapsibleSection(props: {
565
+ theme: TuiTheme;
566
+ label: string;
567
+ count: () => number | null;
568
+ expanded: () => boolean;
569
+ onToggle: () => void;
570
+ body: () => JSX.Element;
571
+ }) {
572
+ return (
573
+ <box flexDirection="column">
574
+ <SectionHeader
575
+ theme={props.theme}
576
+ label={props.label}
577
+ count={props.count}
578
+ expanded={props.expanded}
579
+ onToggle={props.onToggle}
580
+ />
581
+ <box flexDirection="column" gap={1}>
582
+ {reactiveChild(() => (props.expanded() ? props.body() : null))}
583
+ </box>
584
+ </box>
585
+ );
586
+ }
587
+
588
+ // A model's share of the Go tokens spent in this session tree, drawn with the
589
+ // same block bar and the same threshold coloring as the plan meters. It is a
590
+ // share of tokens and nothing else: not of the plan, not a quota, not a price.
591
+ // A model's Go share: the bar and the percent pinned to the RIGHT edge of the
592
+ // row, so they line up with the Steps/Cost columns of the table above instead of
593
+ // trailing the label wherever it happens to end. The label takes the slack
594
+ // (`flexGrow`), the meter group never shrinks.
595
+ export function GoShareRow(props: { theme: TuiTheme; percent: number }) {
596
+ const rounded = Math.round(props.percent);
597
+ return (
598
+ <box flexDirection="row" gap={1}>
599
+ <text fg={props.theme.current.textMuted} wrapMode="none" flexShrink={0} flexGrow={1}>
600
+ {INTEGRATED_GO_SHARE_LABEL}
601
+ </text>
602
+ <MeterBar
603
+ theme={props.theme}
604
+ percent={rounded}
605
+ severity={meterSeverityForPercent(rounded)}
606
+ />
607
+ <text fg={props.theme.current.textMuted} wrapMode="none" flexShrink={0}>
608
+ {formatPercentCell(rounded)}
609
+ </text>
610
+ </box>
611
+ );
612
+ }
613
+
614
+ // ---------------------------------------------------------------------------
615
+ // The compact `Go Usage` block both hosts lead the sidebar with
616
+ // ---------------------------------------------------------------------------
617
+
618
+ // The `Go Usage` block both hosts lead their sidebar with: the next reset on one
619
+ // line, then the plan as the meters themselves, one row per window, stacked in
620
+ // fixed-width columns.
621
+ //
622
+ // One shape for both hosts, on purpose. Kilo's standalone band and opencode's
623
+ // whole sidebar used to be different layouts (label/value rows with a separate
624
+ // `Go Plan` heading, versus meters inline), which meant the plan looked like a
625
+ // different feature depending on where it was drawn. Kilo's integrated mode does
626
+ // not use this block at all -- the plan is drawn once, inside its Models table
627
+ // where it belongs to the provider it meters.
628
+ //
629
+ // There is no meter width in here to tune: `MeterBar` fills the row.
630
+ export function GoUsageBlock(props: {
631
+ api: UsagePanelApi;
632
+ theme: TuiTheme;
633
+ snapshot: () => UsageSnapshot | null;
634
+ resetLine?: () => ResetCountdown | null;
635
+ }) {
636
+ createEffect(() => {
637
+ const snapshot = props.snapshot();
638
+ if (snapshot !== null && snapshot.source === "unavailable") {
639
+ void logUsageError(
640
+ props.api,
641
+ snapshot.apiError ? `Go usage unavailable (${snapshot.apiError})` : "Go usage snapshot unavailable",
642
+ );
643
+ }
644
+ });
645
+
646
+ const body = createMemo(() => {
647
+ const snapshot = props.snapshot();
648
+ if (snapshot === null) {
649
+ return (
650
+ <text fg={props.theme.current.textMuted} wrapMode="none">
651
+ Go loading…
652
+ </text>
653
+ );
654
+ }
655
+ // Rejected keys (and other unavailable snapshots) must surface a row
656
+ // instead of a bare header with zero rows. The statusline stays hidden for
657
+ // unavailable (isSnapshotEmpty -> null).
658
+ if (snapshot.source === "unavailable") {
659
+ return (
660
+ <text fg={props.theme.current.textMuted} wrapMode="none">
661
+ Go n/a ({snapshot.apiError ?? "unavailable"})
662
+ </text>
663
+ );
664
+ }
665
+ // The per-row `resets in` suffix is dropped here: `resetLine` already prints
666
+ // the plan's countdown under the header, and printing one countdown twice in
667
+ // a single block is noise rather than emphasis. Both that line and the
668
+ // statusline read `relevantReset`, so the block and the statusline can only
669
+ // ever be counting down to the same window.
670
+ const planRows = buildPlanRows(snapshot).map((row) => ({ ...row, reset: null }));
671
+ return (
672
+ <box flexDirection="column">
673
+ <Show when={props.resetLine === undefined ? null : props.resetLine()}>
674
+ {(reset) => (
675
+ <text fg={props.theme.current.textMuted} wrapMode="none">
676
+ {reset().label} resets in {reset().text}
677
+ </text>
678
+ )}
679
+ </Show>
680
+ <For each={planRows}>{(row) => <GoPlanRow theme={props.theme} row={row} />}</For>
681
+ </box>
682
+ );
683
+ });
684
+
685
+ return (
686
+ <box flexDirection="column">
687
+ <text fg={props.theme.current.text}>
688
+ <b>Go Usage</b>
689
+ </text>
690
+ {reactiveChild(body)}
691
+ </box>
692
+ );
693
+ }
694
+
695
+ // The compact statusline: one muted line next to the host's context readout.
696
+ export function GoStatusline(props: { snapshot: () => UsageSnapshot | null }) {
697
+ return (
698
+ <Show when={props.snapshot()} fallback={null}>
699
+ {(snapshot) => {
700
+ if (isSnapshotEmpty(snapshot())) return null;
701
+ return <text>{formatStatusline(snapshot())}</text>;
702
+ }}
703
+ </Show>
704
+ );
705
+ }