oc-metricboard 0.1.11 → 0.1.13

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-metricboard",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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",
@@ -42,6 +42,7 @@
42
42
  ],
43
43
  "files": [
44
44
  "src/tui.tsx",
45
+ "src/tui-v2.tsx",
45
46
  "src/tui-preferences.ts",
46
47
  "src/tui-prefs-io.ts",
47
48
  "src/badge-contrast.ts",
@@ -75,18 +76,38 @@
75
76
  "devDependencies": {
76
77
  "@opencode-ai/plugin": "1.18.4",
77
78
  "@opencode-ai/sdk": "1.18.4",
79
+ "@opentui/core": "^0.4.5",
80
+ "@opentui/solid": "^0.4.5",
81
+ "solid-js": "^1.9.12",
78
82
  "@types/bun": "latest",
79
83
  "tsup": "^8.0.0",
80
84
  "typescript": "^5.0.0"
81
85
  },
82
86
  "dependencies": {
83
- "@opentui/core": "0.4.5",
84
- "@opentui/solid": "0.4.5",
85
- "comment-json": "^5.0.0",
86
- "solid-js": "^1.9.12"
87
+ "comment-json": "^5.0.0"
87
88
  },
88
89
  "peerDependencies": {
89
90
  "@opencode-ai/plugin": "*",
90
- "@opencode-ai/sdk": "*"
91
+ "@opencode-ai/sdk": "*",
92
+ "@opentui/core": "*",
93
+ "@opentui/solid": "*",
94
+ "solid-js": ">=1.9.0"
95
+ },
96
+ "peerDependenciesMeta": {
97
+ "@opencode-ai/plugin": {
98
+ "optional": true
99
+ },
100
+ "@opencode-ai/sdk": {
101
+ "optional": true
102
+ },
103
+ "@opentui/core": {
104
+ "optional": true
105
+ },
106
+ "@opentui/solid": {
107
+ "optional": true
108
+ },
109
+ "solid-js": {
110
+ "optional": true
111
+ }
91
112
  }
92
113
  }
package/src/collector.ts CHANGED
@@ -49,6 +49,8 @@ export interface MetricsCollector {
49
49
  getAggregate(sessionID: string, scope: MetricsScope, now?: number): MetricsAggregate | null
50
50
  getSessionElapsedMs(sessionID: string, scope?: MetricsScope, now?: number): number
51
51
  getChildSessionCount(sessionID: string): number
52
+ /** Registers a parent/child relation for the session tree (V2 host bridge). */
53
+ setSessionParent(childID: string, parentID: string | null): void
52
54
  subscribe(listener: MetricsListener): () => void
53
55
  dispose(): void
54
56
  }
@@ -611,6 +613,10 @@ export function createCollector(
611
613
  getChildSessionCount(sessionID: string): number {
612
614
  return state.sessionTree.getChildSessionCount(sessionID)
613
615
  },
616
+ setSessionParent(childID: string, parentID: string | null): void {
617
+ if (!childID) return
618
+ state.sessionTree.setParent(childID, parentID)
619
+ },
614
620
  subscribe(listener: MetricsListener): () => void {
615
621
  listeners.add(listener)
616
622
  return () => { listeners.delete(listener) }
package/src/tui-v2.tsx ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * OpenCode V2 TUI entry (dual-host compatibility).
3
+ *
4
+ * OpenCode V2 (2.0.x) rejects V1-only `{ id, tui }` modules: its TUI loader
5
+ * validates `{ id, setup }`. One `./tui` module exporting BOTH keys satisfies
6
+ * both hosts — each reads its own key and ignores the other (the pattern
7
+ * proven by oh-my-opencode-slim and opencode-tps-meter).
8
+ *
9
+ * V2 surface mapping used here (vs the V1 `tui(api, options, meta)` entry in
10
+ * src/tui.tsx):
11
+ *
12
+ * api.event.on(type, handler) -> ctx.data.on(v2Type, handler)
13
+ * api.slots.register({ slots }) -> ctx.ui.slot({ append, render })
14
+ * slot props.session_id (required) -> render input.sessionID (optional)
15
+ * api.renderer.requestRender() -> ctx.renderer?.requestRender?.()
16
+ * api.lifecycle.onDispose(fn) -> cleanup returned from setup
17
+ *
18
+ * Event vocabulary (V2 -> the V1 names the collector understands):
19
+ *
20
+ * session.text.delta -> session.next.text.delta
21
+ * session.reasoning.delta -> session.next.reasoning.delta
22
+ * session.step.started -> session.next.step.started
23
+ * session.step.ended -> session.next.step.ended
24
+ * session.idle -> session.idle (unchanged)
25
+ * session.execution.started -> session.status (busy) (turn start)
26
+ *
27
+ * The V2 payloads are field-compatible with the V1 parsers
28
+ * (`assistantMessageID`, `tokens: { input, output, reasoning,
29
+ * cache: { read, write } }`), so events are forwarded as-is; only
30
+ * execution.started needs a real translation. The event shim hides all of
31
+ * this behind the V1 `api.event.on` contract, so the collector and every
32
+ * event handler run unmodified on both hosts.
33
+ *
34
+ * Deliberately absent in the V2 port (degrades gracefully):
35
+ * - session hydration / historical token restore (the V2 client dialect
36
+ * differs; live tracking is unaffected) — the collector's hydration API
37
+ * check simply fails, so no failing requests are fired
38
+ * - message.updated passthrough (user-message turn anchor) — turn start is
39
+ * covered by session.execution.started / step.started
40
+ */
41
+ /** @jsxImportSource @opentui/solid */
42
+ /** @jsxRuntime automatic */
43
+ import { createCollector, type MetricsCollector } from "./collector"
44
+ import type { MetricsEventApi } from "./event-bus"
45
+ import { getConfig } from "./config"
46
+ import { log } from "./logger"
47
+ import { SidebarMetrics } from "./components/SidebarMetrics"
48
+ import {
49
+ computeEffectiveOrder,
50
+ createMetricsSidebarController,
51
+ DEFAULT_SLOT_ORDER,
52
+ PLUGIN_KEY,
53
+ resolveMetricsPrefs,
54
+ } from "./tui-preferences"
55
+ import { readTuiPreferencesFileSync } from "./tui-prefs-io"
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Minimal structural types for the V2 TUI context (mirrors @opencode/plugin/tui
59
+ // beta; declared locally so the package keeps zero hard deps on the V2 scope).
60
+ // ---------------------------------------------------------------------------
61
+
62
+ interface V2Theme {
63
+ readonly text?: {
64
+ readonly default?: unknown
65
+ readonly subdued?: unknown
66
+ readonly feedback?: Readonly<Record<string, { readonly default?: unknown } | undefined>>
67
+ }
68
+ readonly [key: string]: unknown
69
+ }
70
+
71
+ interface V2EventLike {
72
+ readonly type?: unknown
73
+ readonly data?: unknown
74
+ readonly [key: string]: unknown
75
+ }
76
+
77
+ interface V2Context {
78
+ readonly options?: Readonly<Record<string, unknown>>
79
+ readonly theme?: V2Theme
80
+ readonly renderer?: { readonly requestRender?: () => void }
81
+ readonly data?: {
82
+ readonly on: (type: string, handler: (event: unknown) => void) => () => void
83
+ readonly session?: {
84
+ readonly root: (sessionID: string) => string
85
+ readonly family: (sessionID: string) => string[]
86
+ }
87
+ }
88
+ readonly ui: {
89
+ readonly slot: (claim: {
90
+ readonly render: (input: { readonly sessionID?: string; readonly mode?: string }) => unknown
91
+ readonly append?: string
92
+ readonly after?: string
93
+ }) => () => void
94
+ }
95
+ }
96
+
97
+ type V2Cleanup = () => void
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Event bridge: V1 api.event.on contract over V2 ctx.data.on.
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /** V1 event name -> V2 event name. `null` = no V2 equivalent (no-op). */
104
+ const V2_EVENT_MAP: Readonly<Record<string, string | null>> = {
105
+ "message.part.delta": null,
106
+ "message.part.updated": null,
107
+ "message.updated": null,
108
+ "session.created": "session.created",
109
+ "session.updated": "session.updated",
110
+ "session.deleted": "session.deleted",
111
+ "session.status": null,
112
+ "session.idle": "session.idle",
113
+ "session.next.text.delta": "session.text.delta",
114
+ "session.next.reasoning.delta": "session.reasoning.delta",
115
+ "session.next.step.started": "session.step.started",
116
+ "session.next.step.ended": "session.step.ended",
117
+ }
118
+
119
+ function isRecord(value: unknown): value is Record<string, unknown> {
120
+ return typeof value === "object" && value !== null && !Array.isArray(value)
121
+ }
122
+
123
+ function str(value: unknown): string {
124
+ return typeof value === "string" ? value : ""
125
+ }
126
+
127
+ /**
128
+ * `session.execution.started` = "the user's prompt was admitted and work
129
+ * started" — the V2 equivalent of V1's `session.status -> busy`, which drives
130
+ * turn start and session timing.
131
+ */
132
+ function translateExecutionStarted(event: V2EventLike): unknown {
133
+ const data = isRecord(event.data) ? event.data : {}
134
+ const sessionID = str(data.sessionID)
135
+ if (!sessionID) return null
136
+ return { type: "session.status", properties: { sessionID, status: { type: "busy" } } }
137
+ }
138
+
139
+ function eventSessionID(event: unknown): string {
140
+ if (!isRecord(event)) return ""
141
+ return str(isRecord(event.data) ? event.data.sessionID : undefined)
142
+ }
143
+
144
+ interface CollectorHolder {
145
+ collector: MetricsCollector | null
146
+ }
147
+
148
+ function createV1EventShim(ctx: V2Context, holder: CollectorHolder): MetricsEventApi {
149
+ let lastFamilySyncAt = -Infinity
150
+ const syncFamily = (sessionID: string): void => {
151
+ const data = ctx.data
152
+ try {
153
+ if (!holder.collector || !data?.session || !sessionID) return
154
+ const now = Date.now()
155
+ if (now - lastFamilySyncAt < 2_000) return
156
+ lastFamilySyncAt = now
157
+ const rootID = data.session.root(sessionID)
158
+ const ids = data.session.family(rootID) ?? []
159
+ for (const id of ids) {
160
+ if (id !== rootID) holder.collector.setSessionParent(id, rootID)
161
+ }
162
+ } catch {
163
+ // Family attribution is best-effort; the root session works without it.
164
+ }
165
+ }
166
+
167
+ return {
168
+ event: {
169
+ on(type: string, handler: (event: unknown) => void): () => void {
170
+ // V1 hosts subscribe `.1`/`.2` delivery variants; V2 has none.
171
+ if (/\.1$|\.2$/.test(type)) return () => {}
172
+ const v2Type = V2_EVENT_MAP[type]
173
+ if (!v2Type) return () => {}
174
+ let unsub: (() => void) | undefined
175
+ try {
176
+ unsub = ctx.data.on(v2Type, (event: unknown) => {
177
+ try {
178
+ if (v2Type === "session.execution.started") {
179
+ const translated = translateExecutionStarted(event as V2EventLike)
180
+ if (translated !== null) handler(translated)
181
+ return
182
+ }
183
+ syncFamily(eventSessionID(event))
184
+ handler(event)
185
+ } catch (error) {
186
+ // One malformed event must never tear down the handler chain.
187
+ log(`v2 event dispatch failed: ${String(error)}`)
188
+ }
189
+ })
190
+ } catch (error) {
191
+ log(`v2 event subscribe failed (${type}): ${String(error)}`)
192
+ return () => {}
193
+ }
194
+ return typeof unsub === "function" ? unsub : () => {}
195
+ },
196
+ },
197
+ }
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Theme adapter: components read V1 flat tokens.
202
+ // NOTE: the theme shape is declared locally on purpose — tui-v2 must not
203
+ // import anything from the `@opencode-ai/*` (V1) scope, not even types: the
204
+ // V2 host installs every `@opencode-ai/*` specifier it sees into the
205
+ // plugin sandbox, pulling a second plugin runtime into the process.
206
+ // ---------------------------------------------------------------------------
207
+
208
+ /** Flat theme tokens the shared components read (V1 shape, structural). */
209
+ interface V1FlatTheme {
210
+ readonly text?: unknown
211
+ readonly textMuted?: unknown
212
+ readonly accent?: unknown
213
+ readonly warning?: unknown
214
+ readonly success?: unknown
215
+ readonly [key: string]: unknown
216
+ }
217
+
218
+ function pickThemeToken(theme: V2Theme | undefined, paths: ReadonlyArray<ReadonlyArray<string>>): unknown {
219
+ for (const path of paths) {
220
+ let value: unknown = theme
221
+ for (const key of path) {
222
+ if (!isRecord(value)) {
223
+ value = undefined
224
+ break
225
+ }
226
+ value = value[key]
227
+ }
228
+ if (value !== undefined && value !== null) return value
229
+ }
230
+ return undefined
231
+ }
232
+
233
+ function v1ThemeFromV2(theme: V2Theme | undefined): V1FlatTheme {
234
+ return {
235
+ text: pickThemeToken(theme, [["text", "default"]]),
236
+ textMuted: pickThemeToken(theme, [["text", "subdued"]]),
237
+ accent: pickThemeToken(theme, [["text", "feedback", "info", "default"], ["text", "default"]]),
238
+ warning: pickThemeToken(theme, [["text", "feedback", "warning", "default"]]),
239
+ success: pickThemeToken(theme, [["text", "feedback", "success", "default"]]),
240
+ }
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // V2 setup
245
+ // ---------------------------------------------------------------------------
246
+
247
+ export function setupTuiV2(ctx: V2Context): V2Cleanup {
248
+ const config = getConfig()
249
+ log("opencode-metrics v2 entry initialized")
250
+
251
+ const seedRoot = readTuiPreferencesFileSync()
252
+ const effectiveOrder = computeEffectiveOrder(seedRoot, PLUGIN_KEY, DEFAULT_SLOT_ORDER)
253
+ const prefs = resolveMetricsPrefs(seedRoot)
254
+ const requestRender = (): void => {
255
+ try {
256
+ ctx.renderer?.requestRender?.()
257
+ } catch { /* rendering is host-driven in v2; ignore failures */ }
258
+ }
259
+ const controller = createMetricsSidebarController(prefs, requestRender)
260
+
261
+ const holder: CollectorHolder = { collector: null }
262
+ const collector = createCollector(createV1EventShim(ctx, holder), config, log)
263
+ holder.collector = collector
264
+
265
+ const theme = v1ThemeFromV2(ctx.theme)
266
+ const disposers: Array<() => void> = []
267
+
268
+ // The sidebar is the ONE surface that must exist; claim it outside any guard.
269
+ disposers.push(
270
+ ctx.ui.slot({
271
+ append: "sidebar.content",
272
+ render: (input) => {
273
+ const sessionID = input?.sessionID ?? ""
274
+ if (!sessionID) return <box />
275
+ return (
276
+ <SidebarMetrics
277
+ sessionID={sessionID}
278
+ collector={collector}
279
+ refreshIntervalMs={config.refreshIntervalMs}
280
+ barConfig={config}
281
+ theme={theme as never}
282
+ controller={controller}
283
+ requestRender={requestRender}
284
+ />
285
+ )
286
+ },
287
+ })
288
+ )
289
+
290
+ return () => {
291
+ for (const dispose of disposers) {
292
+ try {
293
+ dispose()
294
+ } catch { /* ignore */ }
295
+ }
296
+ disposers.length = 0
297
+ collector.dispose()
298
+ }
299
+ }
package/src/tui.tsx CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  resolveMetricsPrefs,
15
15
  } from "./tui-preferences"
16
16
  import { readTuiPreferencesFileSync } from "./tui-prefs-io"
17
+ import { setupTuiV2 } from "./tui-v2"
17
18
 
18
19
  const plugin: TuiPlugin = async (api: TuiPluginApi, _options: PluginOptions | undefined, _meta: TuiPluginMeta) => {
19
20
  const config = getConfig()
@@ -52,9 +53,16 @@ const plugin: TuiPlugin = async (api: TuiPluginApi, _options: PluginOptions | un
52
53
  log("opencode-metrics sidebar initialized")
53
54
  }
54
55
 
55
- const pluginModule: { id: string; tui: TuiPlugin } = {
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 } = {
56
63
  id: "oc-metricboard",
57
64
  tui: plugin,
65
+ setup: setupTuiV2,
58
66
  }
59
67
 
60
68
  export default pluginModule