oc-metricboard 0.1.21 → 0.1.23

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,83 +1,87 @@
1
- /** @jsxImportSource @opentui/solid */
2
- /** @jsxRuntime automatic */
3
- import { onCleanup } from "solid-js"
4
- import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
5
- import type { BoxRenderable, TextRenderable } from "@opentui/core"
6
-
7
- interface StatRowProps {
8
- theme: TuiThemeCurrent
9
- label: string | (() => string)
10
- value: string | (() => string)
11
- accent?: boolean
12
- warning?: boolean
13
- success?: boolean
14
- dim?: boolean
15
- icon?: string
16
- visible?: boolean | (() => boolean)
17
- registerSync?: (sync: () => void) => () => void
18
- }
19
-
20
- export function StatRow(props: StatRowProps) {
21
- let disposed = false
22
- let rowNode: BoxRenderable | undefined
23
- let labelNode: TextRenderable | undefined
24
- let valueNode: TextRenderable | undefined
25
- let unregisterSync: (() => void) | undefined
26
- const fg = () => {
27
- if (props.warning) return props.theme.warning
28
- if (props.success) return props.theme.success ?? props.theme.accent
29
- if (props.accent) return props.theme.accent
30
- if (props.dim) return props.theme.textMuted
31
- return props.theme.text
32
- }
33
- const label = () => typeof props.label === "function" ? props.label() : props.label
34
- const value = () => typeof props.value === "function" ? props.value() : props.value
35
- const visible = () => typeof props.visible === "function" ? props.visible() : props.visible !== false
36
- const content = () => `${props.icon ? `${props.icon} ` : ""}${value()}`
37
- const syncContent = () => {
38
- if (disposed) return
39
- const isVisible = visible()
40
- if (rowNode && !rowNode.isDestroyed) {
41
- rowNode.visible = isVisible
42
- // visible=false only suppresses rendering — the yoga layout node
43
- // still reserves height, leaving blank gaps when a row is hidden
44
- // (e.g. expanded rows while the panel is collapsed). Collapse the
45
- // height so hidden rows truly take no space.
46
- try {
47
- rowNode.height = isVisible ? "auto" : 0
48
- } catch { /* not all renderables accept height */ }
49
- }
50
- if (labelNode && !labelNode.isDestroyed) labelNode.content = isVisible ? label() : ""
51
- if (!valueNode || valueNode.isDestroyed) return
52
- valueNode.content = isVisible ? content() : ""
53
- valueNode.requestRender()
54
- }
55
- unregisterSync = props.registerSync?.(syncContent)
56
- const attachRowNode = (node: BoxRenderable) => {
57
- rowNode = node
58
- syncContent()
59
- }
60
- const attachLabelNode = (node: TextRenderable) => {
61
- labelNode = node
62
- syncContent()
63
- }
64
- const attachValueNode = (node: TextRenderable) => {
65
- valueNode = node
66
- syncContent()
67
- }
68
- onCleanup(() => {
69
- disposed = true
70
- unregisterSync?.()
71
- unregisterSync = undefined
72
- rowNode = undefined
73
- labelNode = undefined
74
- valueNode = undefined
75
- })
76
-
77
- return (
78
- <box ref={attachRowNode} width="100%" flexDirection="row" justifyContent="space-between" visible={visible()}>
79
- <text ref={attachLabelNode} fg={props.theme.textMuted} content={visible() ? label() : ""} />
80
- <text ref={attachValueNode} fg={fg()} content={content()} />
81
- </box>
82
- )
83
- }
1
+ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
2
+ import type { BoxRenderable, TextRenderable } from "@opentui/core"
3
+ import { element } from "./ui-element"
4
+
5
+ interface StatRowProps {
6
+ theme: TuiThemeCurrent
7
+ label: string | (() => string)
8
+ value: string | (() => string)
9
+ accent?: boolean
10
+ warning?: boolean
11
+ success?: boolean
12
+ dim?: boolean
13
+ icon?: string
14
+ visible?: boolean | (() => boolean)
15
+ registerSync?: (sync: () => void) => () => void
16
+ }
17
+
18
+ export function StatRow(props: StatRowProps) {
19
+ let rowNode: BoxRenderable | undefined
20
+ let labelNode: TextRenderable | undefined
21
+ let valueNode: TextRenderable | undefined
22
+ const fg = () => {
23
+ if (props.warning) return props.theme.warning
24
+ if (props.success) return props.theme.success ?? props.theme.accent
25
+ if (props.accent) return props.theme.accent
26
+ if (props.dim) return props.theme.textMuted
27
+ return props.theme.text
28
+ }
29
+ const label = () => typeof props.label === "function" ? props.label() : props.label
30
+ const value = () => typeof props.value === "function" ? props.value() : props.value
31
+ const visible = () => typeof props.visible === "function" ? props.visible() : props.visible !== false
32
+ const content = () => `${props.icon ? `${props.icon} ` : ""}${value()}`
33
+ const syncContent = () => {
34
+ const isVisible = visible()
35
+ if (rowNode && !rowNode.isDestroyed) {
36
+ rowNode.visible = isVisible
37
+ // visible=false only suppresses rendering — the yoga layout node
38
+ // still reserves height, leaving blank gaps when a row is hidden
39
+ // (e.g. expanded rows while the panel is collapsed). Collapse the
40
+ // height so hidden rows truly take no space.
41
+ try {
42
+ rowNode.height = isVisible ? "auto" : 0
43
+ } catch { /* not all renderables accept height */ }
44
+ }
45
+ if (labelNode && !labelNode.isDestroyed) labelNode.content = isVisible ? label() : ""
46
+ if (!valueNode || valueNode.isDestroyed) return
47
+ valueNode.content = isVisible ? content() : ""
48
+ valueNode.requestRender()
49
+ }
50
+ // No onCleanup here: the tree is built outside a dispose-owned JSX scope
51
+ // on the Node loader path. Row syncs stop when the parent
52
+ // SidebarMetrics.dispose() clears its rowSyncs set, and the renderables
53
+ // themselves are destroyed with the host slot tree (isDestroyed guards
54
+ // above cover the not-yet-disposed window).
55
+ props.registerSync?.(syncContent)
56
+ const attachRowNode = (node: BoxRenderable) => {
57
+ rowNode = node
58
+ syncContent()
59
+ }
60
+ const attachLabelNode = (node: TextRenderable) => {
61
+ labelNode = node
62
+ syncContent()
63
+ }
64
+ const attachValueNode = (node: TextRenderable) => {
65
+ valueNode = node
66
+ syncContent()
67
+ }
68
+
69
+ return element<BoxRenderable>("box", {
70
+ ref: attachRowNode,
71
+ width: "100%",
72
+ flexDirection: "row",
73
+ justifyContent: "space-between",
74
+ visible: visible(),
75
+ }, [
76
+ element<TextRenderable>("text", {
77
+ ref: attachLabelNode,
78
+ fg: props.theme.textMuted,
79
+ content: visible() ? label() : "",
80
+ }),
81
+ element<TextRenderable>("text", {
82
+ ref: attachValueNode,
83
+ fg: fg(),
84
+ content: content(),
85
+ }),
86
+ ])
87
+ }
@@ -0,0 +1,44 @@
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
+ import type { BaseRenderable } from "@opentui/core"
25
+
26
+ export function element<T extends BaseRenderable = BaseRenderable>(
27
+ tag: string,
28
+ props: Record<string, unknown> = {},
29
+ children: unknown[] = [],
30
+ ): T {
31
+ const node = createElement(tag)
32
+ let ref: ((node: BaseRenderable) => void) | undefined
33
+ for (const [key, value] of Object.entries(props)) {
34
+ if (value === undefined) continue
35
+ if (key === "ref") {
36
+ ref = value as typeof ref
37
+ continue
38
+ }
39
+ setProp(node, key, value)
40
+ }
41
+ if (children.length > 0) insert(node, children)
42
+ ref?.(node)
43
+ return node as T
44
+ }
package/src/tui-v2.tsx CHANGED
@@ -38,8 +38,7 @@
38
38
  * - message.updated passthrough (user-message turn anchor) — turn start is
39
39
  * covered by session.execution.started / step.started
40
40
  */
41
- /** @jsxImportSource @opentui/solid */
42
- /** @jsxRuntime automatic */
41
+ import { createElement } from "@opentui/solid"
43
42
  import { createCollector, type MetricsCollector } from "./collector"
44
43
  import type { MetricsEventApi } from "./event-bus"
45
44
  import { getConfig } from "./config"
@@ -346,18 +345,20 @@ export function setupTuiV2(ctx: V2Context): V2Cleanup {
346
345
  append: "sidebar.content",
347
346
  render: (input) => {
348
347
  const sessionID = input?.sessionID ?? ""
349
- if (!sessionID) return <box />
350
- return (
351
- <SidebarMetrics
352
- sessionID={sessionID}
353
- collector={collector}
354
- refreshIntervalMs={config.refreshIntervalMs}
355
- barConfig={config}
356
- theme={theme as never}
357
- controller={controller}
358
- requestRender={requestRender}
359
- />
360
- )
348
+ if (!sessionID) return createElement("box")
349
+ const sidebar = SidebarMetrics({
350
+ sessionID,
351
+ collector,
352
+ refreshIntervalMs: config.refreshIntervalMs,
353
+ barConfig: config,
354
+ theme: theme as never,
355
+ controller,
356
+ requestRender,
357
+ })
358
+ // Component-owned timers/subscriptions are torn down from the
359
+ // setup cleanup path (the disposers array returned below).
360
+ disposers.push(sidebar.dispose)
361
+ return sidebar.node
361
362
  },
362
363
  })
363
364
  )
package/src/tui.tsx CHANGED
@@ -1,68 +1,76 @@
1
- /** @jsxImportSource @opentui/solid */
2
- /** @jsxRuntime automatic */
3
- import type { TuiPlugin, TuiPluginApi, TuiPluginMeta } from "@opencode-ai/plugin/tui"
4
- import type { PluginOptions } from "@opencode-ai/plugin"
5
- import { createCollector } from "./collector"
6
- import { getConfig } from "./config"
7
- import { log } from "./logger"
8
- import { SidebarMetrics } from "./components/SidebarMetrics"
9
- import {
10
- computeEffectiveOrder,
11
- createMetricsSidebarController,
12
- DEFAULT_SLOT_ORDER,
13
- PLUGIN_KEY,
14
- resolveMetricsPrefs,
15
- } from "./tui-preferences"
16
- import { readTuiPreferencesFileSync } from "./tui-prefs-io"
17
- import { setupTuiV2 } from "./tui-v2"
18
-
19
- const plugin: TuiPlugin = async (api: TuiPluginApi, _options: PluginOptions | undefined, _meta: TuiPluginMeta) => {
20
- const config = getConfig()
21
- const collector = createCollector(api, config, log)
22
-
23
- // Sync-read preferences at slot mount so the sidebar renders at its final
24
- // order and collapse state on the first paint (no async flicker).
25
- const seedRoot = readTuiPreferencesFileSync()
26
- const effectiveOrder = computeEffectiveOrder(seedRoot, PLUGIN_KEY, DEFAULT_SLOT_ORDER)
27
- const prefs = resolveMetricsPrefs(seedRoot)
28
- const controller = createMetricsSidebarController(prefs, () => api.renderer.requestRender())
29
-
30
- api.slots.register({
31
- order: effectiveOrder,
32
- slots: {
33
- sidebar_content(ctx, props) {
34
- return (
35
- <SidebarMetrics
36
- sessionID={props.session_id}
37
- collector={collector}
38
- refreshIntervalMs={config.refreshIntervalMs}
39
- barConfig={config}
40
- theme={ctx.theme.current}
41
- controller={controller}
42
- requestRender={() => api.renderer.requestRender()}
43
- />
44
- )
45
- },
46
- },
47
- })
48
-
49
- api.lifecycle.onDispose(() => {
50
- collector.dispose()
51
- })
52
-
53
- log("opencode-metrics sidebar initialized")
54
- }
55
-
56
- /**
57
- * Dual-host TUI module:
58
- * - OpenCode V1 (1.18.x) validates `{ id, tui }` and ignores extra keys.
59
- * - OpenCode V2 (2.0.x) validates `{ id, setup }` and rejects V1-only modules.
60
- * Exporting both keys from one `./tui` module satisfies both hosts.
61
- */
62
- const pluginModule: { id: string; tui: TuiPlugin; setup: typeof setupTuiV2 } = {
63
- id: "oc-metricboard",
64
- tui: plugin,
65
- setup: setupTuiV2,
66
- }
67
-
68
- export default pluginModule
1
+ import type { TuiPlugin, TuiPluginApi, TuiPluginMeta } from "@opencode-ai/plugin/tui"
2
+ import type { PluginOptions } from "@opencode-ai/plugin"
3
+ import { createCollector } from "./collector"
4
+ import { getConfig } from "./config"
5
+ import { log } from "./logger"
6
+ import { SidebarMetrics } from "./components/SidebarMetrics"
7
+ import {
8
+ computeEffectiveOrder,
9
+ createMetricsSidebarController,
10
+ DEFAULT_SLOT_ORDER,
11
+ PLUGIN_KEY,
12
+ resolveMetricsPrefs,
13
+ } from "./tui-preferences"
14
+ import { readTuiPreferencesFileSync } from "./tui-prefs-io"
15
+ import { setupTuiV2 } from "./tui-v2"
16
+
17
+ const plugin: TuiPlugin = async (api: TuiPluginApi, _options: PluginOptions | undefined, _meta: TuiPluginMeta) => {
18
+ const config = getConfig()
19
+ const collector = createCollector(api, config, log)
20
+
21
+ // Sync-read preferences at slot mount so the sidebar renders at its final
22
+ // order and collapse state on the first paint (no async flicker).
23
+ const seedRoot = readTuiPreferencesFileSync()
24
+ const effectiveOrder = computeEffectiveOrder(seedRoot, PLUGIN_KEY, DEFAULT_SLOT_ORDER)
25
+ const prefs = resolveMetricsPrefs(seedRoot)
26
+ const controller = createMetricsSidebarController(prefs, () => api.renderer.requestRender())
27
+
28
+ // Explicit teardown for component-owned timers/subscriptions (replaces
29
+ // the components' old onCleanup, which is not reliable on the V2 Node
30
+ // loader path). Drained from the plugin dispose hook below.
31
+ const sidebarDisposers: Array<() => void> = []
32
+
33
+ api.slots.register({
34
+ order: effectiveOrder,
35
+ slots: {
36
+ sidebar_content(ctx, props) {
37
+ const sidebar = SidebarMetrics({
38
+ sessionID: props.session_id,
39
+ collector,
40
+ refreshIntervalMs: config.refreshIntervalMs,
41
+ barConfig: config,
42
+ theme: ctx.theme.current,
43
+ controller: controller,
44
+ requestRender: () => api.renderer.requestRender(),
45
+ })
46
+ sidebarDisposers.push(sidebar.dispose)
47
+ return sidebar.node
48
+ },
49
+ },
50
+ })
51
+
52
+ api.lifecycle.onDispose(() => {
53
+ for (const dispose of sidebarDisposers.splice(0)) {
54
+ try {
55
+ dispose()
56
+ } catch { /* ignore */ }
57
+ }
58
+ collector.dispose()
59
+ })
60
+
61
+ log("opencode-metrics sidebar initialized")
62
+ }
63
+
64
+ /**
65
+ * Dual-host TUI module:
66
+ * - OpenCode V1 (1.18.x) validates `{ id, tui }` and ignores extra keys.
67
+ * - OpenCode V2 (2.0.x) validates `{ id, setup }` and rejects V1-only modules.
68
+ * Exporting both keys from one `./tui` module satisfies both hosts.
69
+ */
70
+ const pluginModule: { id: string; tui: TuiPlugin; setup: typeof setupTuiV2 } = {
71
+ id: "oc-metricboard",
72
+ tui: plugin,
73
+ setup: setupTuiV2,
74
+ }
75
+
76
+ export default pluginModule