oc-metricboard 0.1.21 → 0.1.22

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,379 +1,396 @@
1
- /** @jsxImportSource @opentui/solid */
2
- /** @jsxRuntime automatic */
3
- import { createMemo, createSignal, onCleanup } from "solid-js"
4
- import type { BoxRenderable, TextRenderable } 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
- // This runtime's JSX is non-reactive: the header <text> renders once at
97
- // mount, so the ▶/▼ arrow would freeze on its initial value. Two static
98
- // bold nodes (collapsed/expanded) are pre-rendered and toggled
99
- // imperatively through registerSync — the same mechanism as the rows.
100
- // Hidden node must be zero-sized in BOTH axes: visible=false only
101
- // suppresses painting, while yoga still reserves its box, which would
102
- // offset the visible label. No value imports from @opentui/core beyond
103
- // what mount already exercised, so version drift can't break the plugin.
104
- let collapsedHeaderNode: TextRenderable | undefined
105
- let expandedHeaderNode: TextRenderable | undefined
106
- const syncHeader = () => {
107
- if (disposed) return
108
- const isCollapsed = collapsed()
109
- for (const [node, visible] of [
110
- [collapsedHeaderNode, isCollapsed],
111
- [expandedHeaderNode, !isCollapsed],
112
- ] as const) {
113
- if (!node || node.isDestroyed) continue
114
- try {
115
- node.visible = visible
116
- if (visible) {
117
- node.width = "auto"
118
- node.height = "auto"
119
- } else {
120
- node.width = 0
121
- node.height = 0
122
- }
123
- } catch { /* cosmetic — ignore layout failures */ }
124
- }
125
- }
126
- const unregisterHeaderSync = registerRowSync(syncHeader)
127
- onCleanup(() => unregisterHeaderSync())
128
- const attachCollapsedHeaderNode = (node: TextRenderable) => {
129
- collapsedHeaderNode = node
130
- syncHeader()
131
- }
132
- const attachExpandedHeaderNode = (node: TextRenderable) => {
133
- expandedHeaderNode = node
134
- syncHeader()
135
- }
136
- const toggleCollapsed = () => props.controller.toggleCollapsed()
137
- const attachBoxToggle = (node: BoxRenderable) => {
138
- node.onMouseDown = toggleCollapsed
139
- }
140
- const currentScope = () => props.controller.prefs().scope
141
- const currentAggregate = () => props.collector.getAggregate(props.sessionID, currentScope())
142
- const hasAggregate = () => currentAggregate() !== null
143
- const expandedActive = () => !collapsed() && hasAggregate()
144
- const expandedIdle = () => !collapsed() && !hasAggregate()
145
- const collapsedActive = () => collapsed() && hasAggregate()
146
- const frozenNow = (): number => {
147
- const m = currentAggregate()
148
- if (!m) return performance.now()
149
- return requestNow(m)
150
- }
151
- const speedValue = () => {
152
- const m = currentAggregate()
153
- return m?.liveTps === null || m?.liveTps === undefined ? "—" : `${m.liveTps.toFixed(1)} t/s`
154
- }
155
- const elapsedValue = () => {
156
- const m = currentAggregate()
157
- return formatElapsed(m ? frozenNow() - m.requestStartTime : 0)
158
- }
159
- const ttftValue = () => {
160
- const ttft = currentAggregate()?.ttft ?? null
161
- return ttft !== null ? formatDuration(ttft) : "--"
162
- }
163
- const tokenValue = () => {
164
- const m = currentAggregate()
165
- const inputTokens = m?.inputTokens ?? 0
166
- const outputTokens = m?.outputTokens ?? 0
167
- return `${rowVisible("input") ? `↓ ${formatTokens(inputTokens)} in` : ""}${rowVisible("input") && rowVisible("output") ? " " : ""}${rowVisible("output") ? `↑ ${formatTokens(outputTokens)} out` : ""}`
168
- }
169
- const cacheValue = () => {
170
- const m = currentAggregate()
171
- return formatCacheRead(m?.cacheReadTokens ?? 0, m?.cacheReadCompleteness ?? "unknown")
172
- }
173
- const sessionValue = () => {
174
- return formatElapsed(props.collector.getSessionElapsedMs(props.sessionID, currentScope(), performance.now()))
175
- }
176
-
177
- return (
178
- <box
179
- width="100%"
180
- flexDirection="column"
181
- height={sectionEnabled() ? "auto" : 0}
182
- >
183
- <box
184
- width="100%"
185
- flexDirection="row"
186
- alignItems="center"
187
- ref={attachBoxToggle}
188
- >
189
- <text
190
- ref={attachCollapsedHeaderNode}
191
- fg={props.theme.text}
192
- visible={false}
193
- >
194
- <b>▶ {headerLabel()}</b>
195
- </text>
196
- <text
197
- ref={attachExpandedHeaderNode}
198
- fg={props.theme.text}
199
- >
200
- <b>▼ {headerLabel()}</b>
201
- </text>
202
- </box>
203
-
204
- <box width="100%" flexDirection="column">
205
- <StatRow
206
- theme={props.theme}
207
- label="Status"
208
- value="No active request"
209
- dim
210
- icon="○"
211
- visible={expandedIdle}
212
- registerSync={registerRowSync}
213
- />
214
- {rowVisible("speed") && (
215
- <StatRow
216
- theme={props.theme}
217
- label="TPS"
218
- value={speedValue}
219
- accent
220
- icon="⚡"
221
- registerSync={registerRowSync}
222
- visible={expandedActive}
223
- />
224
- )}
225
- {rowVisible("elapsed") && (
226
- <StatRow
227
- theme={props.theme}
228
- label="Elapsed"
229
- value={elapsedValue}
230
- icon="▹"
231
- registerSync={registerRowSync}
232
- visible={expandedActive}
233
- />
234
- )}
235
- {rowVisible("ttft") && (
236
- <StatRow
237
- theme={props.theme}
238
- label="TTFT"
239
- value={ttftValue}
240
- icon="⏱"
241
- registerSync={registerRowSync}
242
- visible={expandedActive}
243
- />
244
- )}
245
- {(rowVisible("input") || rowVisible("output")) && (
246
- <StatRow
247
- theme={props.theme}
248
- label="Tokens"
249
- value={tokenValue}
250
- registerSync={registerRowSync}
251
- visible={expandedActive}
252
- />
253
- )}
254
- {rowVisible("cache") && (
255
- <StatRow
256
- theme={props.theme}
257
- label="Cache"
258
- value={cacheValue}
259
- dim
260
- icon="○"
261
- registerSync={registerRowSync}
262
- visible={expandedActive}
263
- />
264
- )}
265
- {rowVisible("session") && (
266
- <StatRow
267
- theme={props.theme}
268
- label="Session"
269
- value={sessionValue}
270
- icon="◷"
271
- registerSync={registerRowSync}
272
- visible={expandedActive}
273
- />
274
- )}
275
-
276
- {/* NEW: Model breakdown rows for tree scope.
277
- NOTE: this runtime's JSX tree is NOT reactive — the tree
278
- renders once at mount. Inline .map() evaluates once, so rows
279
- for sub-agents spawned later never appear. Fix: pre-render a
280
- fixed number of rows that pull their data live from
281
- currentAggregate() on every registerSync tick, exactly like
282
- the static Speed/TTFT rows. */}
283
- {rowVisible("modelBreakdown") && (
284
- <>
285
- {Array.from({ length: MAX_MODEL_ROWS }, (_, i) => {
286
- // Line 1: short model name + ×N session count.
287
- const modelLabel = () => {
288
- const m = currentAggregate()?.modelBreakdown[i]
289
- if (!m) return ""
290
- const count = m.sessionCount > 1 ? ` ×${m.sessionCount}` : ""
291
- return `${m.modelID}${count}`
292
- }
293
- // Line 2: metrics for that model group. Compact
294
- // format (no spaces after icons, no unit suffixes,
295
- // single-space separators) so the data line fits the
296
- // narrow sidebar on ONE line without right-edge
297
- // truncation.
298
- const modelValue = () => {
299
- const m = currentAggregate()?.modelBreakdown[i]
300
- if (!m) return ""
301
- const parts: string[] = []
302
- if (rowVisible("speed")) parts.push(`⚡${m.liveTps !== null ? m.liveTps.toFixed(1) : "—"}`)
303
- if (rowVisible("ttft")) parts.push(`⏱${m.ttft !== null ? " " + formatDuration(m.ttft) : " --"}`)
304
- if (rowVisible("input")) parts.push(`↓${formatTokens(m.inputTokens)}`)
305
- if (rowVisible("output")) parts.push(`↑${formatTokens(m.outputTokens)}`)
306
- return parts.join(" ")
307
- }
308
- const visible = () => expandedActive() && (currentAggregate()?.modelBreakdown.length ?? 0) > i
309
- // The wrapper box reserves marginTop even when its
310
- // children collapse to height 0, leaving blank rows
311
- // while collapsed. Collapse height AND margins here.
312
- let wrapperNode: BoxRenderable | undefined
313
- const syncWrapper = () => {
314
- if (disposed) return
315
- if (!wrapperNode || wrapperNode.isDestroyed) return
316
- const v = visible()
317
- try {
318
- wrapperNode.height = v ? "auto" : 0
319
- wrapperNode.marginTop = v ? 1 : 0
320
- wrapperNode.marginLeft = v ? 1 : 0
321
- } catch { /* ignore */ }
322
- }
323
- const attachWrapper = (node: BoxRenderable) => {
324
- wrapperNode = node
325
- syncWrapper()
326
- }
327
- const unregisterWrapperSync = registerRowSync(syncWrapper)
328
- onCleanup(() => {
329
- unregisterWrapperSync()
330
- })
331
- return (
332
- <box ref={attachWrapper} width="100%" flexDirection="column" marginLeft={1} marginTop={1}>
333
- <StatRow
334
- theme={props.theme}
335
- label={modelLabel}
336
- value={() => ""}
337
- dim
338
- registerSync={registerRowSync}
339
- visible={visible}
340
- />
341
- <StatRow
342
- theme={props.theme}
343
- label={() => ""}
344
- value={modelValue}
345
- dim
346
- registerSync={registerRowSync}
347
- visible={visible}
348
- />
349
- </box>
350
- )
351
- })}
352
- </>
353
- )}
354
-
355
- {rowVisible("speed") && (
356
- <StatRow
357
- theme={props.theme}
358
- label="TPS"
359
- value={speedValue}
360
- accent
361
- icon="⚡"
362
- registerSync={registerRowSync}
363
- visible={collapsedActive}
364
- />
365
- )}
366
- {rowVisible("session") && (
367
- <StatRow
368
- theme={props.theme}
369
- label="Session"
370
- value={sessionValue}
371
- icon="◷"
372
- registerSync={registerRowSync}
373
- visible={collapsedActive}
374
- />
375
- )}
376
- </box>
377
- </box>
378
- )
379
- }
1
+ import { createMemo, createSignal, type JSX } from "solid-js"
2
+ import type { BoxRenderable, TextRenderable } from "@opentui/core"
3
+ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
4
+ import type { BarConfig, MetricsAggregate, ModelMetrics } from "../types"
5
+ import type { MetricsCollector } from "../collector"
6
+ import {
7
+ formatTokens,
8
+ formatDuration,
9
+ formatElapsed,
10
+ formatCacheRead,
11
+ } from "../metrics"
12
+ import { StatRow } from "./StatRow"
13
+ import { element } from "./ui-element"
14
+ import type { MetricsSidebarController } from "../tui-preferences"
15
+
16
+ // Upper bound on model-breakdown rows rendered in the sidebar. This runtime's
17
+ // tree is not reactive, so rows are pre-rendered once and toggled via
18
+ // registerSync; more sub-agents than this simply scroll off.
19
+ const MAX_MODEL_ROWS = 8
20
+
21
+ interface SidebarMetricsProps {
22
+ sessionID: string
23
+ collector: MetricsCollector
24
+ refreshIntervalMs: number
25
+ barConfig: BarConfig
26
+ theme: TuiThemeCurrent
27
+ controller: MetricsSidebarController
28
+ requestRender?: () => void
29
+ }
30
+
31
+ export interface SidebarMetricsInstance {
32
+ /** Root renderable for the host slot. Treated as the host's `JSX.Element` (same lie the old jsx-runtime types told). */
33
+ node: JSX.Element
34
+ /** Clears timers/subscriptions/syncs. Call from the plugin setup cleanup path. */
35
+ dispose: () => void
36
+ }
37
+
38
+ export function SidebarMetrics(props: SidebarMetricsProps): SidebarMetricsInstance {
39
+ let disposed = false
40
+ let refreshQueued = false
41
+ let interval: ReturnType<typeof setInterval> | undefined
42
+ let unsub = () => {}
43
+ let unsubController = () => {}
44
+ const rowSyncs = new Set<() => void>()
45
+ const registerRowSync = (sync: () => void) => {
46
+ if (disposed) return () => {}
47
+ rowSyncs.add(sync)
48
+ return () => rowSyncs.delete(sync)
49
+ }
50
+ const syncRows = () => {
51
+ if (disposed) return
52
+ for (const sync of rowSyncs) sync()
53
+ }
54
+ const [tick, setTick] = createSignal(0)
55
+ const bump = () => {
56
+ if (disposed) return
57
+ setTick((t) => t + 1)
58
+ syncRows()
59
+ if (refreshQueued) return
60
+ refreshQueued = true
61
+ queueMicrotask(() => {
62
+ refreshQueued = false
63
+ if (disposed) return
64
+ syncRows()
65
+ props.requestRender?.()
66
+ })
67
+ }
68
+
69
+ // Explicit disposal replacing the old onCleanup: the V2 Node loader builds
70
+ // this tree outside a dispose-owned JSX scope, so onCleanup was wired to a
71
+ // scope we do not control. The setup cleanup path (tui.tsx onDispose /
72
+ // tui-v2.tsx setup return) calls this instead.
73
+ const dispose = () => {
74
+ if (disposed) return
75
+ disposed = true
76
+ refreshQueued = false
77
+ rowSyncs.clear()
78
+ if (interval !== undefined) clearInterval(interval)
79
+ unsub()
80
+ unsubController()
81
+ }
82
+
83
+ interval = setInterval(bump, props.refreshIntervalMs)
84
+ unsub = props.collector.subscribe(bump)
85
+ unsubController = props.controller.subscribe(bump)
86
+
87
+ const sectionEnabled = createMemo(() => {
88
+ tick()
89
+ return props.controller.prefs().section.enabled
90
+ })
91
+ const requestNow = (m: MetricsAggregate): number => {
92
+ const live = performance.now()
93
+ return m.isComplete && m.completeTime !== null ? m.completeTime : live
94
+ }
95
+
96
+ const rowVisible = (key: keyof BarConfig["visible"]): boolean => {
97
+ const barVis = props.barConfig.visible
98
+ const rowPrefs = props.controller.prefs().rows
99
+ return barVis[key] !== false && rowPrefs[key as keyof typeof rowPrefs] !== false
100
+ }
101
+
102
+ const collapsed = createMemo(() => {
103
+ tick()
104
+ return props.controller.collapsed()
105
+ })
106
+ const headerLabel = () => props.controller.prefs().section.label
107
+ // This runtime's tree is non-reactive: the header <text> nodes are built
108
+ // once at mount, so the ▶/▼ arrow would freeze on its initial value. Two
109
+ // static bold nodes (collapsed/expanded) are pre-rendered and toggled
110
+ // imperatively through registerSync — the same mechanism as the rows.
111
+ // Hidden node must be zero-sized in BOTH axes: visible=false only
112
+ // suppresses painting, while yoga still reserves its box, which would
113
+ // offset the visible label. No value imports from @opentui/core beyond
114
+ // what mount already exercised, so version drift can't break the plugin.
115
+ let collapsedHeaderNode: TextRenderable | undefined
116
+ let expandedHeaderNode: TextRenderable | undefined
117
+ const syncHeader = () => {
118
+ if (disposed) return
119
+ const isCollapsed = collapsed()
120
+ for (const [node, visible] of [
121
+ [collapsedHeaderNode, isCollapsed],
122
+ [expandedHeaderNode, !isCollapsed],
123
+ ] as const) {
124
+ if (!node || node.isDestroyed) continue
125
+ try {
126
+ node.visible = visible
127
+ if (visible) {
128
+ node.width = "auto"
129
+ node.height = "auto"
130
+ } else {
131
+ node.width = 0
132
+ node.height = 0
133
+ }
134
+ } catch { /* cosmetic — ignore layout failures */ }
135
+ }
136
+ }
137
+ // Unregistered wholesale by dispose() via rowSyncs.clear() (the old
138
+ // per-sync onCleanup unregister went with the JSX scope).
139
+ registerRowSync(syncHeader)
140
+ const attachCollapsedHeaderNode = (node: TextRenderable) => {
141
+ collapsedHeaderNode = node
142
+ syncHeader()
143
+ }
144
+ const attachExpandedHeaderNode = (node: TextRenderable) => {
145
+ expandedHeaderNode = node
146
+ syncHeader()
147
+ }
148
+ const toggleCollapsed = () => props.controller.toggleCollapsed()
149
+ const attachBoxToggle = (node: BoxRenderable) => {
150
+ node.onMouseDown = toggleCollapsed
151
+ }
152
+ const currentScope = () => props.controller.prefs().scope
153
+ const currentAggregate = () => props.collector.getAggregate(props.sessionID, currentScope())
154
+ const hasAggregate = () => currentAggregate() !== null
155
+ const expandedActive = () => !collapsed() && hasAggregate()
156
+ const expandedIdle = () => !collapsed() && !hasAggregate()
157
+ const collapsedActive = () => collapsed() && hasAggregate()
158
+ const frozenNow = (): number => {
159
+ const m = currentAggregate()
160
+ if (!m) return performance.now()
161
+ return requestNow(m)
162
+ }
163
+ const speedValue = () => {
164
+ const m = currentAggregate()
165
+ return m?.liveTps === null || m?.liveTps === undefined ? "—" : `${m.liveTps.toFixed(1)} t/s`
166
+ }
167
+ const elapsedValue = () => {
168
+ const m = currentAggregate()
169
+ return formatElapsed(m ? frozenNow() - m.requestStartTime : 0)
170
+ }
171
+ const ttftValue = () => {
172
+ const ttft = currentAggregate()?.ttft ?? null
173
+ return ttft !== null ? formatDuration(ttft) : "--"
174
+ }
175
+ const tokenValue = () => {
176
+ const m = currentAggregate()
177
+ const inputTokens = m?.inputTokens ?? 0
178
+ const outputTokens = m?.outputTokens ?? 0
179
+ return `${rowVisible("input") ? `↓ ${formatTokens(inputTokens)} in` : ""}${rowVisible("input") && rowVisible("output") ? " " : ""}${rowVisible("output") ? `↑ ${formatTokens(outputTokens)} out` : ""}`
180
+ }
181
+ const cacheValue = () => {
182
+ const m = currentAggregate()
183
+ return formatCacheRead(m?.cacheReadTokens ?? 0, m?.cacheReadCompleteness ?? "unknown")
184
+ }
185
+ const sessionValue = () => {
186
+ return formatElapsed(props.collector.getSessionElapsedMs(props.sessionID, currentScope(), performance.now()))
187
+ }
188
+
189
+ // --- tree (same hierarchy/spacing/order as the old JSX) ------------------
190
+ const headerBox = element("box", {
191
+ width: "100%",
192
+ flexDirection: "row",
193
+ alignItems: "center",
194
+ ref: attachBoxToggle,
195
+ }, [
196
+ element("text", {
197
+ ref: attachCollapsedHeaderNode,
198
+ fg: props.theme.text,
199
+ visible: false,
200
+ }, [
201
+ element("b", {}, ["▶ ", headerLabel()]),
202
+ ]),
203
+ element("text", {
204
+ ref: attachExpandedHeaderNode,
205
+ fg: props.theme.text,
206
+ }, [
207
+ element("b", {}, ["▼ ", headerLabel()]),
208
+ ]),
209
+ ])
210
+
211
+ const rows: unknown[] = []
212
+ rows.push(StatRow({
213
+ theme: props.theme,
214
+ label: "Status",
215
+ value: "No active request",
216
+ dim: true,
217
+ icon: "○",
218
+ visible: expandedIdle,
219
+ registerSync: registerRowSync,
220
+ }))
221
+ if (rowVisible("speed")) {
222
+ rows.push(StatRow({
223
+ theme: props.theme,
224
+ label: "TPS",
225
+ value: speedValue,
226
+ accent: true,
227
+ icon: "⚡",
228
+ registerSync: registerRowSync,
229
+ visible: expandedActive,
230
+ }))
231
+ }
232
+ if (rowVisible("elapsed")) {
233
+ rows.push(StatRow({
234
+ theme: props.theme,
235
+ label: "Elapsed",
236
+ value: elapsedValue,
237
+ icon: "▹",
238
+ registerSync: registerRowSync,
239
+ visible: expandedActive,
240
+ }))
241
+ }
242
+ if (rowVisible("ttft")) {
243
+ rows.push(StatRow({
244
+ theme: props.theme,
245
+ label: "TTFT",
246
+ value: ttftValue,
247
+ icon: "⏱",
248
+ registerSync: registerRowSync,
249
+ visible: expandedActive,
250
+ }))
251
+ }
252
+ if (rowVisible("input") || rowVisible("output")) {
253
+ rows.push(StatRow({
254
+ theme: props.theme,
255
+ label: "Tokens",
256
+ value: tokenValue,
257
+ registerSync: registerRowSync,
258
+ visible: expandedActive,
259
+ }))
260
+ }
261
+ if (rowVisible("cache")) {
262
+ rows.push(StatRow({
263
+ theme: props.theme,
264
+ label: "Cache",
265
+ value: cacheValue,
266
+ dim: true,
267
+ icon: "○",
268
+ registerSync: registerRowSync,
269
+ visible: expandedActive,
270
+ }))
271
+ }
272
+ if (rowVisible("session")) {
273
+ rows.push(StatRow({
274
+ theme: props.theme,
275
+ label: "Session",
276
+ value: sessionValue,
277
+ icon: "◷",
278
+ registerSync: registerRowSync,
279
+ visible: expandedActive,
280
+ }))
281
+ }
282
+
283
+ // Model breakdown rows for tree scope. This runtime's tree is NOT
284
+ // reactive — it is built once at mount, so inline .map() evaluates once:
285
+ // rows for sub-agents spawned later would never appear. Fix: pre-render a
286
+ // fixed number of rows that pull their data live from currentAggregate()
287
+ // on every registerSync tick, exactly like the static Speed/TTFT rows.
288
+ if (rowVisible("modelBreakdown")) {
289
+ for (let i = 0; i < MAX_MODEL_ROWS; i++) {
290
+ // Line 1: short model name + ×N session count.
291
+ const modelLabel = () => {
292
+ const m = currentAggregate()?.modelBreakdown[i]
293
+ if (!m) return ""
294
+ const count = m.sessionCount > 1 ? ` ×${m.sessionCount}` : ""
295
+ return `${m.modelID}${count}`
296
+ }
297
+ // Line 2: metrics for that model group. Compact
298
+ // format (no spaces after icons, no unit suffixes,
299
+ // single-space separators) so the data line fits the
300
+ // narrow sidebar on ONE line without right-edge
301
+ // truncation.
302
+ const modelValue = () => {
303
+ const m = currentAggregate()?.modelBreakdown[i]
304
+ if (!m) return ""
305
+ const parts: string[] = []
306
+ if (rowVisible("speed")) parts.push(`⚡${m.liveTps !== null ? m.liveTps.toFixed(1) : "—"}`)
307
+ if (rowVisible("ttft")) parts.push(`⏱${m.ttft !== null ? " " + formatDuration(m.ttft) : " --"}`)
308
+ if (rowVisible("input")) parts.push(`↓${formatTokens(m.inputTokens)}`)
309
+ if (rowVisible("output")) parts.push(`↑${formatTokens(m.outputTokens)}`)
310
+ return parts.join(" ")
311
+ }
312
+ const visible = () => expandedActive() && (currentAggregate()?.modelBreakdown.length ?? 0) > i
313
+ // The wrapper box reserves marginTop even when its
314
+ // children collapse to height 0, leaving blank rows
315
+ // while collapsed. Collapse height AND margins here.
316
+ let wrapperNode: BoxRenderable | undefined
317
+ const syncWrapper = () => {
318
+ if (disposed) return
319
+ if (!wrapperNode || wrapperNode.isDestroyed) return
320
+ const v = visible()
321
+ try {
322
+ wrapperNode.height = v ? "auto" : 0
323
+ wrapperNode.marginTop = v ? 1 : 0
324
+ wrapperNode.marginLeft = v ? 1 : 0
325
+ } catch { /* ignore */ }
326
+ }
327
+ const attachWrapper = (node: BoxRenderable) => {
328
+ wrapperNode = node
329
+ syncWrapper()
330
+ }
331
+ // Wrapper syncs are unregistered wholesale by dispose() via
332
+ // rowSyncs.clear() (the old per-row onCleanup unregister went
333
+ // with the JSX scope).
334
+ registerRowSync(syncWrapper)
335
+ rows.push(element<BoxRenderable>("box", {
336
+ ref: attachWrapper,
337
+ width: "100%",
338
+ flexDirection: "column",
339
+ marginLeft: 1,
340
+ marginTop: 1,
341
+ }, [
342
+ StatRow({
343
+ theme: props.theme,
344
+ label: modelLabel,
345
+ value: () => "",
346
+ dim: true,
347
+ registerSync: registerRowSync,
348
+ visible,
349
+ }),
350
+ StatRow({
351
+ theme: props.theme,
352
+ label: () => "",
353
+ value: modelValue,
354
+ dim: true,
355
+ registerSync: registerRowSync,
356
+ visible,
357
+ }),
358
+ ]))
359
+ }
360
+ }
361
+
362
+ if (rowVisible("speed")) {
363
+ rows.push(StatRow({
364
+ theme: props.theme,
365
+ label: "TPS",
366
+ value: speedValue,
367
+ accent: true,
368
+ icon: "⚡",
369
+ registerSync: registerRowSync,
370
+ visible: collapsedActive,
371
+ }))
372
+ }
373
+ if (rowVisible("session")) {
374
+ rows.push(StatRow({
375
+ theme: props.theme,
376
+ label: "Session",
377
+ value: sessionValue,
378
+ icon: "◷",
379
+ registerSync: registerRowSync,
380
+ visible: collapsedActive,
381
+ }))
382
+ }
383
+
384
+ const contentBox = element("box", {
385
+ width: "100%",
386
+ flexDirection: "column",
387
+ }, rows)
388
+
389
+ const root = element("box", {
390
+ width: "100%",
391
+ flexDirection: "column",
392
+ height: sectionEnabled() ? "auto" : 0,
393
+ }, [headerBox, contentBox])
394
+
395
+ return { node: root as unknown as JSX.Element, dispose }
396
+ }