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 +27 -6
- package/src/collector.ts +6 -0
- package/src/tui-v2.tsx +299 -0
- package/src/tui.tsx +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oc-metricboard",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|