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.
- package/README.md +137 -0
- package/package.json +92 -0
- package/src/assistant-progress.ts +99 -0
- package/src/badge-contrast.ts +62 -0
- package/src/collector-state.ts +36 -0
- package/src/collector.ts +615 -0
- package/src/components/SidebarMetrics.tsx +331 -0
- package/src/components/StatRow.tsx +83 -0
- package/src/config.ts +59 -0
- package/src/event-bus.ts +61 -0
- package/src/event-handlers.ts +345 -0
- package/src/event-shapes.ts +198 -0
- package/src/live-speed.ts +79 -0
- package/src/logger.ts +36 -0
- package/src/metrics.ts +243 -0
- package/src/request-state.ts +100 -0
- package/src/request-updates.ts +40 -0
- package/src/scripts/init-tui-preferences.ts +107 -0
- package/src/session-hydration.ts +278 -0
- package/src/session-timing.ts +78 -0
- package/src/session-tree.ts +43 -0
- package/src/tui-preferences.ts +203 -0
- package/src/tui-prefs-io.ts +159 -0
- package/src/tui.tsx +60 -0
- package/src/turn-state.ts +85 -0
- package/src/types.ts +138 -0
|
@@ -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
|
+
}
|
package/src/event-bus.ts
ADDED
|
@@ -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
|
+
}
|