oc-metricboard 0.1.20 → 0.1.22

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.
@@ -1,11 +1,9 @@
1
- import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "@opentui/solid/jsx-runtime";
2
- /** @jsxImportSource @opentui/solid */
3
- /** @jsxRuntime automatic */
4
- import { createMemo, createSignal, onCleanup } from "solid-js";
1
+ import { createMemo, createSignal } from "solid-js";
5
2
  import { formatTokens, formatDuration, formatElapsed, formatCacheRead, } from "../metrics";
6
3
  import { StatRow } from "./StatRow";
4
+ import { element } from "./ui-element";
7
5
  // Upper bound on model-breakdown rows rendered in the sidebar. This runtime's
8
- // JSX tree is not reactive, so rows are pre-rendered once and toggled via
6
+ // tree is not reactive, so rows are pre-rendered once and toggled via
9
7
  // registerSync; more sub-agents than this simply scroll off.
10
8
  const MAX_MODEL_ROWS = 8;
11
9
  export function SidebarMetrics(props) {
@@ -44,7 +42,13 @@ export function SidebarMetrics(props) {
44
42
  props.requestRender?.();
45
43
  });
46
44
  };
47
- onCleanup(() => {
45
+ // Explicit disposal replacing the old onCleanup: the V2 Node loader builds
46
+ // this tree outside a dispose-owned JSX scope, so onCleanup was wired to a
47
+ // scope we do not control. The setup cleanup path (tui.tsx onDispose /
48
+ // tui-v2.tsx setup return) calls this instead.
49
+ const dispose = () => {
50
+ if (disposed)
51
+ return;
48
52
  disposed = true;
49
53
  refreshQueued = false;
50
54
  rowSyncs.clear();
@@ -52,7 +56,7 @@ export function SidebarMetrics(props) {
52
56
  clearInterval(interval);
53
57
  unsub();
54
58
  unsubController();
55
- });
59
+ };
56
60
  interval = setInterval(bump, props.refreshIntervalMs);
57
61
  unsub = props.collector.subscribe(bump);
58
62
  unsubController = props.controller.subscribe(bump);
@@ -74,9 +78,9 @@ export function SidebarMetrics(props) {
74
78
  return props.controller.collapsed();
75
79
  });
76
80
  const headerLabel = () => props.controller.prefs().section.label;
77
- // This runtime's JSX is non-reactive: the header <text> renders once at
78
- // mount, so the ▶/▼ arrow would freeze on its initial value. Two static
79
- // bold nodes (collapsed/expanded) are pre-rendered and toggled
81
+ // This runtime's tree is non-reactive: the header <text> nodes are built
82
+ // once at mount, so the ▶/▼ arrow would freeze on its initial value. Two
83
+ // static bold nodes (collapsed/expanded) are pre-rendered and toggled
80
84
  // imperatively through registerSync — the same mechanism as the rows.
81
85
  // Hidden node must be zero-sized in BOTH axes: visible=false only
82
86
  // suppresses painting, while yoga still reserves its box, which would
@@ -108,8 +112,9 @@ export function SidebarMetrics(props) {
108
112
  catch { /* cosmetic — ignore layout failures */ }
109
113
  }
110
114
  };
111
- const unregisterHeaderSync = registerRowSync(syncHeader);
112
- onCleanup(() => unregisterHeaderSync());
115
+ // Unregistered wholesale by dispose() via rowSyncs.clear() (the old
116
+ // per-sync onCleanup unregister went with the JSX scope).
117
+ registerRowSync(syncHeader);
113
118
  const attachCollapsedHeaderNode = (node) => {
114
119
  collapsedHeaderNode = node;
115
120
  syncHeader();
@@ -159,61 +164,214 @@ export function SidebarMetrics(props) {
159
164
  const sessionValue = () => {
160
165
  return formatElapsed(props.collector.getSessionElapsedMs(props.sessionID, currentScope(), performance.now()));
161
166
  };
162
- return (_jsxs("box", { width: "100%", flexDirection: "column", height: sectionEnabled() ? "auto" : 0, children: [_jsxs("box", { width: "100%", flexDirection: "row", alignItems: "center", ref: attachBoxToggle, children: [_jsx("text", { ref: attachCollapsedHeaderNode, fg: props.theme.text, visible: false, children: _jsxs("b", { children: ["\u25B6 ", headerLabel()] }) }), _jsx("text", { ref: attachExpandedHeaderNode, fg: props.theme.text, children: _jsxs("b", { children: ["\u25BC ", headerLabel()] }) })] }), _jsxs("box", { width: "100%", flexDirection: "column", children: [_jsx(StatRow, { theme: props.theme, label: "Status", value: "No active request", dim: true, icon: "\u25CB", visible: expandedIdle, registerSync: registerRowSync }), rowVisible("speed") && (_jsx(StatRow, { theme: props.theme, label: "TPS", value: speedValue, accent: true, icon: "\u26A1", registerSync: registerRowSync, visible: expandedActive })), rowVisible("elapsed") && (_jsx(StatRow, { theme: props.theme, label: "Elapsed", value: elapsedValue, icon: "\u25B9", registerSync: registerRowSync, visible: expandedActive })), rowVisible("ttft") && (_jsx(StatRow, { theme: props.theme, label: "TTFT", value: ttftValue, icon: "\u23F1", registerSync: registerRowSync, visible: expandedActive })), (rowVisible("input") || rowVisible("output")) && (_jsx(StatRow, { theme: props.theme, label: "Tokens", value: tokenValue, registerSync: registerRowSync, visible: expandedActive })), rowVisible("cache") && (_jsx(StatRow, { theme: props.theme, label: "Cache", value: cacheValue, dim: true, icon: "\u25CB", registerSync: registerRowSync, visible: expandedActive })), rowVisible("session") && (_jsx(StatRow, { theme: props.theme, label: "Session", value: sessionValue, icon: "\u25F7", registerSync: registerRowSync, visible: expandedActive })), rowVisible("modelBreakdown") && (_jsx(_Fragment, { children: Array.from({ length: MAX_MODEL_ROWS }, (_, i) => {
163
- // Line 1: short model name + ×N session count.
164
- const modelLabel = () => {
165
- const m = currentAggregate()?.modelBreakdown[i];
166
- if (!m)
167
- return "";
168
- const count = m.sessionCount > 1 ? ` ×${m.sessionCount}` : "";
169
- return `${m.modelID}${count}`;
170
- };
171
- // Line 2: metrics for that model group. Compact
172
- // format (no spaces after icons, no unit suffixes,
173
- // single-space separators) so the data line fits the
174
- // narrow sidebar on ONE line without right-edge
175
- // truncation.
176
- const modelValue = () => {
177
- const m = currentAggregate()?.modelBreakdown[i];
178
- if (!m)
179
- return "";
180
- const parts = [];
181
- if (rowVisible("speed"))
182
- parts.push(`⚡${m.liveTps !== null ? m.liveTps.toFixed(1) : "—"}`);
183
- if (rowVisible("ttft"))
184
- parts.push(`⏱${m.ttft !== null ? " " + formatDuration(m.ttft) : " --"}`);
185
- if (rowVisible("input"))
186
- parts.push(`↓${formatTokens(m.inputTokens)}`);
187
- if (rowVisible("output"))
188
- parts.push(`↑${formatTokens(m.outputTokens)}`);
189
- return parts.join(" ");
190
- };
191
- const visible = () => expandedActive() && (currentAggregate()?.modelBreakdown.length ?? 0) > i;
192
- // The wrapper box reserves marginTop even when its
193
- // children collapse to height 0, leaving blank rows
194
- // while collapsed. Collapse height AND margins here.
195
- let wrapperNode;
196
- const syncWrapper = () => {
197
- if (disposed)
198
- return;
199
- if (!wrapperNode || wrapperNode.isDestroyed)
200
- return;
201
- const v = visible();
202
- try {
203
- wrapperNode.height = v ? "auto" : 0;
204
- wrapperNode.marginTop = v ? 1 : 0;
205
- wrapperNode.marginLeft = v ? 1 : 0;
206
- }
207
- catch { /* ignore */ }
208
- };
209
- const attachWrapper = (node) => {
210
- wrapperNode = node;
211
- syncWrapper();
212
- };
213
- const unregisterWrapperSync = registerRowSync(syncWrapper);
214
- onCleanup(() => {
215
- unregisterWrapperSync();
216
- });
217
- return (_jsxs("box", { ref: attachWrapper, width: "100%", flexDirection: "column", marginLeft: 1, marginTop: 1, children: [_jsx(StatRow, { theme: props.theme, label: modelLabel, value: () => "", dim: true, registerSync: registerRowSync, visible: visible }), _jsx(StatRow, { theme: props.theme, label: () => "", value: modelValue, dim: true, registerSync: registerRowSync, visible: visible })] }));
218
- }) })), rowVisible("speed") && (_jsx(StatRow, { theme: props.theme, label: "TPS", value: speedValue, accent: true, icon: "\u26A1", registerSync: registerRowSync, visible: collapsedActive })), rowVisible("session") && (_jsx(StatRow, { theme: props.theme, label: "Session", value: sessionValue, icon: "\u25F7", registerSync: registerRowSync, visible: collapsedActive }))] })] }));
167
+ // --- tree (same hierarchy/spacing/order as the old JSX) ------------------
168
+ const headerBox = element("box", {
169
+ width: "100%",
170
+ flexDirection: "row",
171
+ alignItems: "center",
172
+ ref: attachBoxToggle,
173
+ }, [
174
+ element("text", {
175
+ ref: attachCollapsedHeaderNode,
176
+ fg: props.theme.text,
177
+ visible: false,
178
+ }, [
179
+ element("b", {}, ["▶ ", headerLabel()]),
180
+ ]),
181
+ element("text", {
182
+ ref: attachExpandedHeaderNode,
183
+ fg: props.theme.text,
184
+ }, [
185
+ element("b", {}, ["▼ ", headerLabel()]),
186
+ ]),
187
+ ]);
188
+ const rows = [];
189
+ rows.push(StatRow({
190
+ theme: props.theme,
191
+ label: "Status",
192
+ value: "No active request",
193
+ dim: true,
194
+ icon: "○",
195
+ visible: expandedIdle,
196
+ registerSync: registerRowSync,
197
+ }));
198
+ if (rowVisible("speed")) {
199
+ rows.push(StatRow({
200
+ theme: props.theme,
201
+ label: "TPS",
202
+ value: speedValue,
203
+ accent: true,
204
+ icon: "⚡",
205
+ registerSync: registerRowSync,
206
+ visible: expandedActive,
207
+ }));
208
+ }
209
+ if (rowVisible("elapsed")) {
210
+ rows.push(StatRow({
211
+ theme: props.theme,
212
+ label: "Elapsed",
213
+ value: elapsedValue,
214
+ icon: "▹",
215
+ registerSync: registerRowSync,
216
+ visible: expandedActive,
217
+ }));
218
+ }
219
+ if (rowVisible("ttft")) {
220
+ rows.push(StatRow({
221
+ theme: props.theme,
222
+ label: "TTFT",
223
+ value: ttftValue,
224
+ icon: "⏱",
225
+ registerSync: registerRowSync,
226
+ visible: expandedActive,
227
+ }));
228
+ }
229
+ if (rowVisible("input") || rowVisible("output")) {
230
+ rows.push(StatRow({
231
+ theme: props.theme,
232
+ label: "Tokens",
233
+ value: tokenValue,
234
+ registerSync: registerRowSync,
235
+ visible: expandedActive,
236
+ }));
237
+ }
238
+ if (rowVisible("cache")) {
239
+ rows.push(StatRow({
240
+ theme: props.theme,
241
+ label: "Cache",
242
+ value: cacheValue,
243
+ dim: true,
244
+ icon: "○",
245
+ registerSync: registerRowSync,
246
+ visible: expandedActive,
247
+ }));
248
+ }
249
+ if (rowVisible("session")) {
250
+ rows.push(StatRow({
251
+ theme: props.theme,
252
+ label: "Session",
253
+ value: sessionValue,
254
+ icon: "◷",
255
+ registerSync: registerRowSync,
256
+ visible: expandedActive,
257
+ }));
258
+ }
259
+ // Model breakdown rows for tree scope. This runtime's tree is NOT
260
+ // reactive — it is built once at mount, so inline .map() evaluates once:
261
+ // rows for sub-agents spawned later would never appear. Fix: pre-render a
262
+ // fixed number of rows that pull their data live from currentAggregate()
263
+ // on every registerSync tick, exactly like the static Speed/TTFT rows.
264
+ if (rowVisible("modelBreakdown")) {
265
+ for (let i = 0; i < MAX_MODEL_ROWS; i++) {
266
+ // Line 1: short model name + ×N session count.
267
+ const modelLabel = () => {
268
+ const m = currentAggregate()?.modelBreakdown[i];
269
+ if (!m)
270
+ return "";
271
+ const count = m.sessionCount > 1 ? ` ×${m.sessionCount}` : "";
272
+ return `${m.modelID}${count}`;
273
+ };
274
+ // Line 2: metrics for that model group. Compact
275
+ // format (no spaces after icons, no unit suffixes,
276
+ // single-space separators) so the data line fits the
277
+ // narrow sidebar on ONE line without right-edge
278
+ // truncation.
279
+ const modelValue = () => {
280
+ const m = currentAggregate()?.modelBreakdown[i];
281
+ if (!m)
282
+ return "";
283
+ const parts = [];
284
+ if (rowVisible("speed"))
285
+ parts.push(`⚡${m.liveTps !== null ? m.liveTps.toFixed(1) : "—"}`);
286
+ if (rowVisible("ttft"))
287
+ parts.push(`⏱${m.ttft !== null ? " " + formatDuration(m.ttft) : " --"}`);
288
+ if (rowVisible("input"))
289
+ parts.push(`↓${formatTokens(m.inputTokens)}`);
290
+ if (rowVisible("output"))
291
+ parts.push(`↑${formatTokens(m.outputTokens)}`);
292
+ return parts.join(" ");
293
+ };
294
+ const visible = () => expandedActive() && (currentAggregate()?.modelBreakdown.length ?? 0) > i;
295
+ // The wrapper box reserves marginTop even when its
296
+ // children collapse to height 0, leaving blank rows
297
+ // while collapsed. Collapse height AND margins here.
298
+ let wrapperNode;
299
+ const syncWrapper = () => {
300
+ if (disposed)
301
+ return;
302
+ if (!wrapperNode || wrapperNode.isDestroyed)
303
+ return;
304
+ const v = visible();
305
+ try {
306
+ wrapperNode.height = v ? "auto" : 0;
307
+ wrapperNode.marginTop = v ? 1 : 0;
308
+ wrapperNode.marginLeft = v ? 1 : 0;
309
+ }
310
+ catch { /* ignore */ }
311
+ };
312
+ const attachWrapper = (node) => {
313
+ wrapperNode = node;
314
+ syncWrapper();
315
+ };
316
+ // Wrapper syncs are unregistered wholesale by dispose() via
317
+ // rowSyncs.clear() (the old per-row onCleanup unregister went
318
+ // with the JSX scope).
319
+ registerRowSync(syncWrapper);
320
+ rows.push(element("box", {
321
+ ref: attachWrapper,
322
+ width: "100%",
323
+ flexDirection: "column",
324
+ marginLeft: 1,
325
+ marginTop: 1,
326
+ }, [
327
+ StatRow({
328
+ theme: props.theme,
329
+ label: modelLabel,
330
+ value: () => "",
331
+ dim: true,
332
+ registerSync: registerRowSync,
333
+ visible,
334
+ }),
335
+ StatRow({
336
+ theme: props.theme,
337
+ label: () => "",
338
+ value: modelValue,
339
+ dim: true,
340
+ registerSync: registerRowSync,
341
+ visible,
342
+ }),
343
+ ]));
344
+ }
345
+ }
346
+ if (rowVisible("speed")) {
347
+ rows.push(StatRow({
348
+ theme: props.theme,
349
+ label: "TPS",
350
+ value: speedValue,
351
+ accent: true,
352
+ icon: "⚡",
353
+ registerSync: registerRowSync,
354
+ visible: collapsedActive,
355
+ }));
356
+ }
357
+ if (rowVisible("session")) {
358
+ rows.push(StatRow({
359
+ theme: props.theme,
360
+ label: "Session",
361
+ value: sessionValue,
362
+ icon: "◷",
363
+ registerSync: registerRowSync,
364
+ visible: collapsedActive,
365
+ }));
366
+ }
367
+ const contentBox = element("box", {
368
+ width: "100%",
369
+ flexDirection: "column",
370
+ }, rows);
371
+ const root = element("box", {
372
+ width: "100%",
373
+ flexDirection: "column",
374
+ height: sectionEnabled() ? "auto" : 0,
375
+ }, [headerBox, contentBox]);
376
+ return { node: root, dispose };
219
377
  }
@@ -1,13 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "@opentui/solid/jsx-runtime";
2
- /** @jsxImportSource @opentui/solid */
3
- /** @jsxRuntime automatic */
4
- import { onCleanup } from "solid-js";
1
+ import { element } from "./ui-element";
5
2
  export function StatRow(props) {
6
- let disposed = false;
7
3
  let rowNode;
8
4
  let labelNode;
9
5
  let valueNode;
10
- let unregisterSync;
11
6
  const fg = () => {
12
7
  if (props.warning)
13
8
  return props.theme.warning;
@@ -24,8 +19,6 @@ export function StatRow(props) {
24
19
  const visible = () => typeof props.visible === "function" ? props.visible() : props.visible !== false;
25
20
  const content = () => `${props.icon ? `${props.icon} ` : ""}${value()}`;
26
21
  const syncContent = () => {
27
- if (disposed)
28
- return;
29
22
  const isVisible = visible();
30
23
  if (rowNode && !rowNode.isDestroyed) {
31
24
  rowNode.visible = isVisible;
@@ -45,7 +38,12 @@ export function StatRow(props) {
45
38
  valueNode.content = isVisible ? content() : "";
46
39
  valueNode.requestRender();
47
40
  };
48
- unregisterSync = props.registerSync?.(syncContent);
41
+ // No onCleanup here: the tree is built outside a dispose-owned JSX scope
42
+ // on the Node loader path. Row syncs stop when the parent
43
+ // SidebarMetrics.dispose() clears its rowSyncs set, and the renderables
44
+ // themselves are destroyed with the host slot tree (isDestroyed guards
45
+ // above cover the not-yet-disposed window).
46
+ props.registerSync?.(syncContent);
49
47
  const attachRowNode = (node) => {
50
48
  rowNode = node;
51
49
  syncContent();
@@ -58,13 +56,22 @@ export function StatRow(props) {
58
56
  valueNode = node;
59
57
  syncContent();
60
58
  };
61
- onCleanup(() => {
62
- disposed = true;
63
- unregisterSync?.();
64
- unregisterSync = undefined;
65
- rowNode = undefined;
66
- labelNode = undefined;
67
- valueNode = undefined;
68
- });
69
- return (_jsxs("box", { ref: attachRowNode, width: "100%", flexDirection: "row", justifyContent: "space-between", visible: visible(), children: [_jsx("text", { ref: attachLabelNode, fg: props.theme.textMuted, content: visible() ? label() : "" }), _jsx("text", { ref: attachValueNode, fg: fg(), content: content() })] }));
59
+ return element("box", {
60
+ ref: attachRowNode,
61
+ width: "100%",
62
+ flexDirection: "row",
63
+ justifyContent: "space-between",
64
+ visible: visible(),
65
+ }, [
66
+ element("text", {
67
+ ref: attachLabelNode,
68
+ fg: props.theme.textMuted,
69
+ content: visible() ? label() : "",
70
+ }),
71
+ element("text", {
72
+ ref: attachValueNode,
73
+ fg: fg(),
74
+ content: content(),
75
+ }),
76
+ ]);
70
77
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Direct element factory (mirrors oh-my-opencode-slim's `element()` helper).
3
+ *
4
+ * The opencode v2.0.26 Node loader cannot resolve the automatic-JSX entry of
5
+ * `@opentui/solid` from plugin dist, so no JSX may remain in the sources:
6
+ * trees are built by hand with the same call shapes OMO uses —
7
+ * `createElement` + `setProp` + `insert` from the `@opentui/solid` package
8
+ * root.
9
+ *
10
+ * Semantics that MUST stay identical to the old JSX emit:
11
+ * - props are applied, children are inserted, then an optional `ref` callback
12
+ * runs (JSX `spread` ran children -> ref -> props; running the ref AFTER the
13
+ * props lets the imperative registerSync pass win at mount — on every mount
14
+ * state the prop and the sync compute the same values, except where the sync
15
+ * is the one that knows the truth, e.g. collapse state / hidden model rows).
16
+ * - children are handed to `insert` with ONE array call, matching the JSX
17
+ * runtime's single-array `insertExpression`. Inserting string siblings
18
+ * one-by-one would wipe the previously inserted text node.
19
+ * - children must be pre-evaluated values (no accessor functions): this
20
+ * runtime's tree is non-reactive by design; dynamic values flow through the
21
+ * components' registerSync -> requestRender path instead.
22
+ */
23
+ import { createElement, insert, setProp } from "@opentui/solid";
24
+ export function element(tag, props = {}, children = []) {
25
+ const node = createElement(tag);
26
+ let ref;
27
+ for (const [key, value] of Object.entries(props)) {
28
+ if (value === undefined)
29
+ continue;
30
+ if (key === "ref") {
31
+ ref = value;
32
+ continue;
33
+ }
34
+ setProp(node, key, value);
35
+ }
36
+ if (children.length > 0)
37
+ insert(node, children);
38
+ ref?.(node);
39
+ return node;
40
+ }
package/dist/tui-v2.js CHANGED
@@ -1,4 +1,3 @@
1
- import { jsx as _jsx } from "@opentui/solid/jsx-runtime";
2
1
  /**
3
2
  * OpenCode V2 TUI entry (dual-host compatibility).
4
3
  *
@@ -39,8 +38,7 @@ import { jsx as _jsx } from "@opentui/solid/jsx-runtime";
39
38
  * - message.updated passthrough (user-message turn anchor) — turn start is
40
39
  * covered by session.execution.started / step.started
41
40
  */
42
- /** @jsxImportSource @opentui/solid */
43
- /** @jsxRuntime automatic */
41
+ import { createElement } from "@opentui/solid";
44
42
  import { createCollector } from "./collector";
45
43
  import { getConfig } from "./config";
46
44
  import { log } from "./logger";
@@ -271,8 +269,20 @@ export function setupTuiV2(ctx) {
271
269
  render: (input) => {
272
270
  const sessionID = input?.sessionID ?? "";
273
271
  if (!sessionID)
274
- return _jsx("box", {});
275
- return (_jsx(SidebarMetrics, { sessionID: sessionID, collector: collector, refreshIntervalMs: config.refreshIntervalMs, barConfig: config, theme: theme, controller: controller, requestRender: requestRender }));
272
+ return createElement("box");
273
+ const sidebar = SidebarMetrics({
274
+ sessionID,
275
+ collector,
276
+ refreshIntervalMs: config.refreshIntervalMs,
277
+ barConfig: config,
278
+ theme: theme,
279
+ controller,
280
+ requestRender,
281
+ });
282
+ // Component-owned timers/subscriptions are torn down from the
283
+ // setup cleanup path (the disposers array returned below).
284
+ disposers.push(sidebar.dispose);
285
+ return sidebar.node;
276
286
  },
277
287
  }));
278
288
  return () => {
package/dist/tui.js CHANGED
@@ -1,4 +1,3 @@
1
- import { jsx as _jsx } from "@opentui/solid/jsx-runtime";
2
1
  import { createCollector } from "./collector";
3
2
  import { getConfig } from "./config";
4
3
  import { log } from "./logger";
@@ -15,15 +14,35 @@ const plugin = async (api, _options, _meta) => {
15
14
  const effectiveOrder = computeEffectiveOrder(seedRoot, PLUGIN_KEY, DEFAULT_SLOT_ORDER);
16
15
  const prefs = resolveMetricsPrefs(seedRoot);
17
16
  const controller = createMetricsSidebarController(prefs, () => api.renderer.requestRender());
17
+ // Explicit teardown for component-owned timers/subscriptions (replaces
18
+ // the components' old onCleanup, which is not reliable on the V2 Node
19
+ // loader path). Drained from the plugin dispose hook below.
20
+ const sidebarDisposers = [];
18
21
  api.slots.register({
19
22
  order: effectiveOrder,
20
23
  slots: {
21
24
  sidebar_content(ctx, props) {
22
- return (_jsx(SidebarMetrics, { sessionID: props.session_id, collector: collector, refreshIntervalMs: config.refreshIntervalMs, barConfig: config, theme: ctx.theme.current, controller: controller, requestRender: () => api.renderer.requestRender() }));
25
+ const sidebar = SidebarMetrics({
26
+ sessionID: props.session_id,
27
+ collector,
28
+ refreshIntervalMs: config.refreshIntervalMs,
29
+ barConfig: config,
30
+ theme: ctx.theme.current,
31
+ controller: controller,
32
+ requestRender: () => api.renderer.requestRender(),
33
+ });
34
+ sidebarDisposers.push(sidebar.dispose);
35
+ return sidebar.node;
23
36
  },
24
37
  },
25
38
  });
26
39
  api.lifecycle.onDispose(() => {
40
+ for (const dispose of sidebarDisposers.splice(0)) {
41
+ try {
42
+ dispose();
43
+ }
44
+ catch { /* ignore */ }
45
+ }
27
46
  collector.dispose();
28
47
  });
29
48
  log("opencode-metrics sidebar initialized");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-metricboard",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "OpenCode TUI sidebar plugin showing real-time LLM metrics (TPS, TTFT, tokens) with sub-agent × model breakdown",
5
5
  "keywords": [
6
6
  "opencode",
@@ -66,6 +66,7 @@
66
66
  "src/event-shapes.ts",
67
67
  "src/components/SidebarMetrics.tsx",
68
68
  "src/components/StatRow.tsx",
69
+ "src/components/ui-element.ts",
69
70
  "src/scripts/init-tui-preferences.ts",
70
71
  "README.md"
71
72
  ],
@@ -86,9 +87,10 @@
86
87
  "typescript": "^5.0.0"
87
88
  },
88
89
  "dependencies": {
89
- "@opentui/core": "0.5.17",
90
+ "comment-json": "^5.0.0"
91
+ },
92
+ "optionalDependencies": {
90
93
  "@opentui/solid": "0.5.17",
91
- "comment-json": "^5.0.0",
92
94
  "solid-js": "1.9.12"
93
95
  },
94
96
  "peerDependencies": {