oc-metricboard 0.1.4

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.
@@ -0,0 +1,331 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ /** @jsxRuntime automatic */
3
+ import { createMemo, createSignal, onCleanup } from "solid-js"
4
+ import type { BoxRenderable } from "@opentui/core"
5
+ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
6
+ import type { BarConfig, MetricsAggregate, ModelMetrics } from "../types"
7
+ import type { MetricsCollector } from "../collector"
8
+ import {
9
+ formatTokens,
10
+ formatDuration,
11
+ formatElapsed,
12
+ formatCacheRead,
13
+ } from "../metrics"
14
+ import { StatRow } from "./StatRow"
15
+ import type { MetricsSidebarController } from "../tui-preferences"
16
+
17
+ // Upper bound on model-breakdown rows rendered in the sidebar. This runtime's
18
+ // JSX tree is not reactive, so rows are pre-rendered once and toggled via
19
+ // registerSync; more sub-agents than this simply scroll off.
20
+ const MAX_MODEL_ROWS = 8
21
+
22
+ interface SidebarMetricsProps {
23
+ sessionID: string
24
+ collector: MetricsCollector
25
+ refreshIntervalMs: number
26
+ barConfig: BarConfig
27
+ theme: TuiThemeCurrent
28
+ controller: MetricsSidebarController
29
+ requestRender?: () => void
30
+ }
31
+
32
+ export function SidebarMetrics(props: SidebarMetricsProps) {
33
+ let disposed = false
34
+ let refreshQueued = false
35
+ let interval: ReturnType<typeof setInterval> | undefined
36
+ let unsub = () => {}
37
+ let unsubController = () => {}
38
+ const rowSyncs = new Set<() => void>()
39
+ const registerRowSync = (sync: () => void) => {
40
+ if (disposed) return () => {}
41
+ rowSyncs.add(sync)
42
+ return () => rowSyncs.delete(sync)
43
+ }
44
+ const syncRows = () => {
45
+ if (disposed) return
46
+ for (const sync of rowSyncs) sync()
47
+ }
48
+ const [tick, setTick] = createSignal(0)
49
+ const bump = () => {
50
+ if (disposed) return
51
+ setTick((t) => t + 1)
52
+ syncRows()
53
+ if (refreshQueued) return
54
+ refreshQueued = true
55
+ queueMicrotask(() => {
56
+ refreshQueued = false
57
+ if (disposed) return
58
+ syncRows()
59
+ props.requestRender?.()
60
+ })
61
+ }
62
+
63
+ onCleanup(() => {
64
+ disposed = true
65
+ refreshQueued = false
66
+ rowSyncs.clear()
67
+ if (interval !== undefined) clearInterval(interval)
68
+ unsub()
69
+ unsubController()
70
+ })
71
+
72
+ interval = setInterval(bump, props.refreshIntervalMs)
73
+ unsub = props.collector.subscribe(bump)
74
+ unsubController = props.controller.subscribe(bump)
75
+
76
+ const sectionEnabled = createMemo(() => {
77
+ tick()
78
+ return props.controller.prefs().section.enabled
79
+ })
80
+ const requestNow = (m: MetricsAggregate): number => {
81
+ const live = performance.now()
82
+ return m.isComplete && m.completeTime !== null ? m.completeTime : live
83
+ }
84
+
85
+ const rowVisible = (key: keyof BarConfig["visible"]): boolean => {
86
+ const barVis = props.barConfig.visible
87
+ const rowPrefs = props.controller.prefs().rows
88
+ return barVis[key] !== false && rowPrefs[key as keyof typeof rowPrefs] !== false
89
+ }
90
+
91
+ const collapsed = createMemo(() => {
92
+ tick()
93
+ return props.controller.collapsed()
94
+ })
95
+ const headerLabel = () => props.controller.prefs().section.label
96
+ const toggleCollapsed = () => props.controller.toggleCollapsed()
97
+ const attachBoxToggle = (node: BoxRenderable) => {
98
+ node.onMouseDown = toggleCollapsed
99
+ }
100
+ const currentScope = () => props.controller.prefs().scope
101
+ const currentAggregate = () => props.collector.getAggregate(props.sessionID, currentScope())
102
+ const hasAggregate = () => currentAggregate() !== null
103
+ const expandedActive = () => !collapsed() && hasAggregate()
104
+ const expandedIdle = () => !collapsed() && !hasAggregate()
105
+ const collapsedActive = () => collapsed() && hasAggregate()
106
+ const frozenNow = (): number => {
107
+ const m = currentAggregate()
108
+ if (!m) return performance.now()
109
+ return requestNow(m)
110
+ }
111
+ const speedValue = () => {
112
+ const m = currentAggregate()
113
+ return m?.liveTps === null || m?.liveTps === undefined ? "—" : `${m.liveTps.toFixed(1)} t/s`
114
+ }
115
+ const elapsedValue = () => {
116
+ const m = currentAggregate()
117
+ return formatElapsed(m ? frozenNow() - m.requestStartTime : 0)
118
+ }
119
+ const ttftValue = () => {
120
+ const ttft = currentAggregate()?.ttft ?? null
121
+ return ttft !== null ? formatDuration(ttft) : "--"
122
+ }
123
+ const tokenValue = () => {
124
+ const m = currentAggregate()
125
+ const inputTokens = m?.inputTokens ?? 0
126
+ const outputTokens = m?.outputTokens ?? 0
127
+ return `${rowVisible("input") ? `↓ ${formatTokens(inputTokens)} in` : ""}${rowVisible("input") && rowVisible("output") ? " " : ""}${rowVisible("output") ? `↑ ${formatTokens(outputTokens)} out` : ""}`
128
+ }
129
+ const cacheValue = () => {
130
+ const m = currentAggregate()
131
+ return formatCacheRead(m?.cacheReadTokens ?? 0, m?.cacheReadCompleteness ?? "unknown")
132
+ }
133
+ const sessionValue = () => {
134
+ return formatElapsed(props.collector.getSessionElapsedMs(props.sessionID, currentScope(), performance.now()))
135
+ }
136
+
137
+ return (
138
+ <box
139
+ width="100%"
140
+ flexDirection="column"
141
+ height={sectionEnabled() ? "auto" : 0}
142
+ >
143
+ <box
144
+ width="100%"
145
+ flexDirection="row"
146
+ alignItems="center"
147
+ ref={attachBoxToggle}
148
+ >
149
+ <text
150
+ fg={props.theme.text}
151
+ >
152
+ <b>{collapsed() ? "▶ " : "▼ "}{headerLabel()}</b>
153
+ </text>
154
+ </box>
155
+
156
+ <box width="100%" flexDirection="column">
157
+ <StatRow
158
+ theme={props.theme}
159
+ label="Status"
160
+ value="No active request"
161
+ dim
162
+ icon="○"
163
+ visible={expandedIdle}
164
+ registerSync={registerRowSync}
165
+ />
166
+ {rowVisible("speed") && (
167
+ <StatRow
168
+ theme={props.theme}
169
+ label="TPS"
170
+ value={speedValue}
171
+ accent
172
+ icon="⚡"
173
+ registerSync={registerRowSync}
174
+ visible={expandedActive}
175
+ />
176
+ )}
177
+ {rowVisible("elapsed") && (
178
+ <StatRow
179
+ theme={props.theme}
180
+ label="Elapsed"
181
+ value={elapsedValue}
182
+ icon="▹"
183
+ registerSync={registerRowSync}
184
+ visible={expandedActive}
185
+ />
186
+ )}
187
+ {rowVisible("ttft") && (
188
+ <StatRow
189
+ theme={props.theme}
190
+ label="TTFT"
191
+ value={ttftValue}
192
+ icon="⏱"
193
+ registerSync={registerRowSync}
194
+ visible={expandedActive}
195
+ />
196
+ )}
197
+ {(rowVisible("input") || rowVisible("output")) && (
198
+ <StatRow
199
+ theme={props.theme}
200
+ label="Tokens"
201
+ value={tokenValue}
202
+ registerSync={registerRowSync}
203
+ visible={expandedActive}
204
+ />
205
+ )}
206
+ {rowVisible("cache") && (
207
+ <StatRow
208
+ theme={props.theme}
209
+ label="Cache"
210
+ value={cacheValue}
211
+ dim
212
+ icon="○"
213
+ registerSync={registerRowSync}
214
+ visible={expandedActive}
215
+ />
216
+ )}
217
+ {rowVisible("session") && (
218
+ <StatRow
219
+ theme={props.theme}
220
+ label="Session"
221
+ value={sessionValue}
222
+ icon="◷"
223
+ registerSync={registerRowSync}
224
+ visible={expandedActive}
225
+ />
226
+ )}
227
+
228
+ {/* NEW: Model breakdown rows for tree scope.
229
+ NOTE: this runtime's JSX tree is NOT reactive — the tree
230
+ renders once at mount. Inline .map() evaluates once, so rows
231
+ for sub-agents spawned later never appear. Fix: pre-render a
232
+ fixed number of rows that pull their data live from
233
+ currentAggregate() on every registerSync tick, exactly like
234
+ the static Speed/TTFT rows. */}
235
+ {rowVisible("modelBreakdown") && (
236
+ <>
237
+ {Array.from({ length: MAX_MODEL_ROWS }, (_, i) => {
238
+ // Line 1: short model name + ×N session count.
239
+ const modelLabel = () => {
240
+ const m = currentAggregate()?.modelBreakdown[i]
241
+ if (!m) return ""
242
+ const count = m.sessionCount > 1 ? ` ×${m.sessionCount}` : ""
243
+ return `${m.modelID}${count}`
244
+ }
245
+ // Line 2: metrics for that model group. Compact
246
+ // format (no spaces after icons, no unit suffixes,
247
+ // single-space separators) so the data line fits the
248
+ // narrow sidebar on ONE line without right-edge
249
+ // truncation.
250
+ const modelValue = () => {
251
+ const m = currentAggregate()?.modelBreakdown[i]
252
+ if (!m) return ""
253
+ const parts: string[] = []
254
+ if (rowVisible("speed")) parts.push(`⚡${m.liveTps !== null ? m.liveTps.toFixed(1) : "—"}`)
255
+ if (rowVisible("ttft")) parts.push(`⏱${m.ttft !== null ? formatDuration(m.ttft) : "--"}`)
256
+ if (rowVisible("input")) parts.push(`↓${formatTokens(m.inputTokens)}`)
257
+ if (rowVisible("output")) parts.push(`↑${formatTokens(m.outputTokens)}`)
258
+ return parts.join(" ")
259
+ }
260
+ const visible = () => expandedActive() && (currentAggregate()?.modelBreakdown.length ?? 0) > i
261
+ // The wrapper box reserves marginTop even when its
262
+ // children collapse to height 0, leaving blank rows
263
+ // while collapsed. Collapse height AND margins here.
264
+ let wrapperNode: BoxRenderable | undefined
265
+ const syncWrapper = () => {
266
+ if (disposed) return
267
+ if (!wrapperNode || wrapperNode.isDestroyed) return
268
+ const v = visible()
269
+ try {
270
+ wrapperNode.height = v ? "auto" : 0
271
+ wrapperNode.marginTop = v ? 1 : 0
272
+ wrapperNode.marginLeft = v ? 1 : 0
273
+ } catch { /* ignore */ }
274
+ }
275
+ const attachWrapper = (node: BoxRenderable) => {
276
+ wrapperNode = node
277
+ syncWrapper()
278
+ }
279
+ const unregisterWrapperSync = registerRowSync(syncWrapper)
280
+ onCleanup(() => {
281
+ unregisterWrapperSync()
282
+ })
283
+ return (
284
+ <box key={i} ref={attachWrapper} width="100%" flexDirection="column" marginLeft={1} marginTop={1}>
285
+ <StatRow
286
+ theme={props.theme}
287
+ label={modelLabel}
288
+ value={() => ""}
289
+ dim
290
+ registerSync={registerRowSync}
291
+ visible={visible}
292
+ />
293
+ <StatRow
294
+ theme={props.theme}
295
+ label={() => ""}
296
+ value={modelValue}
297
+ dim
298
+ registerSync={registerRowSync}
299
+ visible={visible}
300
+ />
301
+ </box>
302
+ )
303
+ })}
304
+ </>
305
+ )}
306
+
307
+ {rowVisible("speed") && (
308
+ <StatRow
309
+ theme={props.theme}
310
+ label="TPS"
311
+ value={speedValue}
312
+ accent
313
+ icon="⚡"
314
+ registerSync={registerRowSync}
315
+ visible={collapsedActive}
316
+ />
317
+ )}
318
+ {rowVisible("session") && (
319
+ <StatRow
320
+ theme={props.theme}
321
+ label="Session"
322
+ value={sessionValue}
323
+ icon="◷"
324
+ registerSync={registerRowSync}
325
+ visible={collapsedActive}
326
+ />
327
+ )}
328
+ </box>
329
+ </box>
330
+ )
331
+ }
@@ -0,0 +1,83 @@
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
+ }
package/src/config.ts ADDED
@@ -0,0 +1,59 @@
1
+ // src/config.ts
2
+ import { readFileSync, existsSync } from "node:fs"
3
+ import { join } from "node:path"
4
+ import { homedir } from "node:os"
5
+ import type { BarConfig } from "./types"
6
+ import { DEFAULT_CONFIG } from "./types"
7
+
8
+ function getConfigPaths(): string[] {
9
+ return [
10
+ join(homedir(), ".config", "opencode", "opencode-metrics.json"),
11
+ join(homedir(), ".config", "opencode", "opencode-bar.json"),
12
+ join(process.cwd(), ".opencode", "opencode-metrics.json"),
13
+ join(process.cwd(), ".opencode", "opencode-bar.json"),
14
+ ]
15
+ }
16
+
17
+ let cachedConfig: BarConfig | null = null
18
+
19
+ export function getConfig(): BarConfig {
20
+ if (cachedConfig) return cachedConfig
21
+ const merged = { ...DEFAULT_CONFIG }
22
+ for (const path of getConfigPaths()) {
23
+ if (!existsSync(path)) continue
24
+ try {
25
+ const raw = readFileSync(path, "utf-8")
26
+ const parsed = JSON.parse(raw)
27
+ if (typeof parsed.refreshIntervalMs === "number" && parsed.refreshIntervalMs >= 100) {
28
+ merged.refreshIntervalMs = parsed.refreshIntervalMs
29
+ }
30
+ if (typeof parsed.holdDurationMs === "number" && (parsed.holdDurationMs === 0 || parsed.holdDurationMs >= 1000)) {
31
+ merged.holdDurationMs = parsed.holdDurationMs
32
+ }
33
+ if (typeof parsed.estimationRatio === "number" && parsed.estimationRatio > 0) {
34
+ merged.estimationRatio = parsed.estimationRatio
35
+ }
36
+ if (typeof parsed.enableLogging === "boolean") {
37
+ merged.enableLogging = parsed.enableLogging
38
+ }
39
+ if (parsed.visible && typeof parsed.visible === "object") {
40
+ for (const key of Object.keys(merged.visible)) {
41
+ if (typeof (parsed.visible as Record<string, unknown>)[key] === "boolean") {
42
+ (merged.visible as Record<string, unknown>)[key] = (parsed.visible as Record<string, unknown>)[key]
43
+ }
44
+ }
45
+ }
46
+ } catch (error) {
47
+ if (process.env.OPENCODE_METRICS_DEBUG === "1") {
48
+ console.warn(`opencode-metrics: failed to read config ${path}`, error)
49
+ }
50
+ }
51
+ }
52
+ cachedConfig = merged
53
+ return merged
54
+ }
55
+
56
+ /** Reset cache (for testing) */
57
+ export function resetConfig(): void {
58
+ cachedConfig = null
59
+ }
@@ -0,0 +1,61 @@
1
+ export interface MetricsEventApi {
2
+ readonly event: {
3
+ on(type: string, handler: (event: unknown) => void): () => void
4
+ }
5
+ }
6
+
7
+ function isRecord(value: unknown): value is Record<string, unknown> {
8
+ return typeof value === "object" && value !== null && !Array.isArray(value)
9
+ }
10
+
11
+ function syncEvent(event: unknown): Record<string, unknown> | null {
12
+ if (!isRecord(event)) return null
13
+ const sync = event.syncEvent
14
+ return isRecord(sync) ? sync : null
15
+ }
16
+
17
+ export function eventProperties(event: unknown): unknown {
18
+ if (!isRecord(event)) return null
19
+ if (Object.prototype.hasOwnProperty.call(event, "properties")) return event.properties
20
+ if (Object.prototype.hasOwnProperty.call(event, "data")) return event.data
21
+ const sync = syncEvent(event)
22
+ if (sync && Object.prototype.hasOwnProperty.call(sync, "data")) return sync.data
23
+ return null
24
+ }
25
+
26
+ export function eventID(event: unknown): string {
27
+ if (!isRecord(event)) return ""
28
+ const sync = syncEvent(event)
29
+ if (sync && typeof sync.id === "string") return sync.id
30
+ return typeof event.id === "string" ? event.id : ""
31
+ }
32
+
33
+ export function eventAggregateID(event: unknown): string {
34
+ const sync = syncEvent(event)
35
+ if (sync && typeof sync.aggregateID === "string") return sync.aggregateID
36
+ if (!isRecord(event) || !isRecord(event.durable)) return ""
37
+ return typeof event.durable.aggregateID === "string" ? event.durable.aggregateID : ""
38
+ }
39
+
40
+ export function eventProperty(event: unknown, key: string): unknown {
41
+ const properties = eventProperties(event)
42
+ if (!isRecord(properties) || !Object.prototype.hasOwnProperty.call(properties, key)) return null
43
+ return properties[key]
44
+ }
45
+
46
+ export function stringEventProperty(event: unknown, key: string): string {
47
+ const value = eventProperty(event, key)
48
+ return typeof value === "string" ? value : ""
49
+ }
50
+
51
+ export function statusType(event: unknown): string {
52
+ const status = eventProperty(event, "status")
53
+ if (!isRecord(status)) return ""
54
+ return typeof status.type === "string" ? status.type : ""
55
+ }
56
+
57
+ export function isMetricsEventApi(value: unknown): value is MetricsEventApi {
58
+ if (!isRecord(value)) return false
59
+ const event = value.event
60
+ return isRecord(event) && typeof event.on === "function"
61
+ }