oc-metricboard 0.1.8 → 0.1.10
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 +1 -1
- package/src/collector.ts +25 -7
- package/src/components/SidebarMetrics.tsx +50 -2
- package/src/session-hydration.ts +48 -6
package/package.json
CHANGED
package/src/collector.ts
CHANGED
|
@@ -4,11 +4,11 @@ import { getDisplayInputTokens, getDisplayOutputTokens, getTtft } from "./metric
|
|
|
4
4
|
import { registerEventHandlers } from "./event-handlers"
|
|
5
5
|
import type { CollectorState } from "./collector-state"
|
|
6
6
|
import type { MetricsEventApi } from "./event-bus"
|
|
7
|
-
import { hydrateSession, isHydrationApi, type HydrationApi } from "./session-hydration"
|
|
7
|
+
import { hydrateSession, isHydrationApi, callWithFallback, type HydrationApi } from "./session-hydration"
|
|
8
8
|
import { createSessionTree } from "./session-tree"
|
|
9
9
|
import { getScopeElapsedMs, getSessionElapsedMs, startSessionTiming, stopSessionTiming } from "./session-timing"
|
|
10
10
|
import { clearLiveSpeed, getLiveTps } from "./live-speed"
|
|
11
|
-
import { liveRequestOutput, turnInputTokens } from "./turn-state"
|
|
11
|
+
import { getTurnTtft, liveRequestOutput, turnInputTokens } from "./turn-state"
|
|
12
12
|
|
|
13
13
|
export type MetricsListener = () => void
|
|
14
14
|
type MetricsHydrationApi = MetricsEventApi & HydrationApi
|
|
@@ -16,7 +16,7 @@ type MetricsHydrationApi = MetricsEventApi & HydrationApi
|
|
|
16
16
|
interface TreeHydrationApi {
|
|
17
17
|
readonly client: {
|
|
18
18
|
readonly session: {
|
|
19
|
-
children(
|
|
19
|
+
children(options: { path?: { sessionID?: string; id?: string }; sessionID?: string }): Promise<unknown>
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
}
|
|
@@ -195,7 +195,11 @@ export function createCollector(
|
|
|
195
195
|
while (parents.length > 0) {
|
|
196
196
|
const responses = await Promise.all(parents.map(async (parentID) => ({
|
|
197
197
|
parentID,
|
|
198
|
-
response: await
|
|
198
|
+
response: await callWithFallback([
|
|
199
|
+
() => treeHydrationApi.client.session.children({ sessionID: parentID }),
|
|
200
|
+
() => treeHydrationApi.client.session.children({ path: { sessionID: parentID } }),
|
|
201
|
+
() => treeHydrationApi.client.session.children({ path: { id: parentID } }),
|
|
202
|
+
]),
|
|
199
203
|
})))
|
|
200
204
|
if (disposed) return
|
|
201
205
|
const next: string[] = []
|
|
@@ -299,14 +303,28 @@ export function createCollector(
|
|
|
299
303
|
return { rootID: requestedSessionID, ids: requestedIDs }
|
|
300
304
|
}
|
|
301
305
|
|
|
302
|
-
function aggregateByModel(
|
|
306
|
+
function aggregateByModel(
|
|
307
|
+
ids: readonly string[],
|
|
308
|
+
now: number,
|
|
309
|
+
scope: MetricsScope,
|
|
310
|
+
rootID: string,
|
|
311
|
+
foregroundTurnStart: number,
|
|
312
|
+
): ModelMetrics[] {
|
|
303
313
|
// Group requests by (modelID, providerID) — one row per distinct model
|
|
304
|
-
// across all sub-agent sessions, with a ×N session count.
|
|
314
|
+
// across all sub-agent sessions, with a ×N session count. Apply the same
|
|
315
|
+
// foreground-turn membership filter as the token totals: a session whose
|
|
316
|
+
// last turn predates the foreground turn (e.g. a model the user switched
|
|
317
|
+
// away from hours ago) would otherwise linger as a stale zombie row that
|
|
318
|
+
// is absent from the aggregate totals.
|
|
305
319
|
const modelGroups = new Map<string, RequestMetrics[]>()
|
|
306
320
|
|
|
307
321
|
for (const id of ids) {
|
|
308
322
|
const request = state.requests.get(id)
|
|
309
323
|
if (!request) continue
|
|
324
|
+
const turn = state.turns.get(id)
|
|
325
|
+
const belongsToForegroundTurn = id === rootID
|
|
326
|
+
|| Boolean(turn && (turn.turnStartTime >= foregroundTurnStart || !turn.isComplete))
|
|
327
|
+
if (!belongsToForegroundTurn) continue
|
|
310
328
|
|
|
311
329
|
// Key: modelID|providerID (model-grouped, not per-session)
|
|
312
330
|
const key = `${request.modelID}|${request.providerID}`
|
|
@@ -553,7 +571,7 @@ export function createCollector(
|
|
|
553
571
|
}
|
|
554
572
|
|
|
555
573
|
// NEW: Build per-model breakdown for tree scope
|
|
556
|
-
const modelBreakdown = scope === "tree" ? aggregateByModel(ids, now, scope) : []
|
|
574
|
+
const modelBreakdown = scope === "tree" ? aggregateByModel(ids, now, scope, rootID, foregroundTurnStart) : []
|
|
557
575
|
// Turn-level TTFT: opencode stamps step.started at the first token, so
|
|
558
576
|
// intra-turn steps cannot measure their own request start (they would
|
|
559
577
|
// collapse to ~0). Anchor TTFT to the turn's user message instead —
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
/** @jsxRuntime automatic */
|
|
3
3
|
import { createMemo, createSignal, onCleanup } from "solid-js"
|
|
4
|
-
import type { BoxRenderable } from "@opentui/core"
|
|
4
|
+
import type { BoxRenderable, TextRenderable } from "@opentui/core"
|
|
5
5
|
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
|
6
6
|
import type { BarConfig, MetricsAggregate, ModelMetrics } from "../types"
|
|
7
7
|
import type { MetricsCollector } from "../collector"
|
|
@@ -93,6 +93,46 @@ export function SidebarMetrics(props: SidebarMetricsProps) {
|
|
|
93
93
|
return props.controller.collapsed()
|
|
94
94
|
})
|
|
95
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
|
+
}
|
|
96
136
|
const toggleCollapsed = () => props.controller.toggleCollapsed()
|
|
97
137
|
const attachBoxToggle = (node: BoxRenderable) => {
|
|
98
138
|
node.onMouseDown = toggleCollapsed
|
|
@@ -147,9 +187,17 @@ export function SidebarMetrics(props: SidebarMetricsProps) {
|
|
|
147
187
|
ref={attachBoxToggle}
|
|
148
188
|
>
|
|
149
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}
|
|
150
198
|
fg={props.theme.text}
|
|
151
199
|
>
|
|
152
|
-
<b
|
|
200
|
+
<b>▼ {headerLabel()}</b>
|
|
153
201
|
</text>
|
|
154
202
|
</box>
|
|
155
203
|
|
package/src/session-hydration.ts
CHANGED
|
@@ -4,19 +4,46 @@ import { applyAssistantTokens, hasPositiveAssistantTokens } from "./request-upda
|
|
|
4
4
|
import type { RequestMetrics, TurnMetrics } from "./types"
|
|
5
5
|
import type { SessionTiming } from "./session-timing"
|
|
6
6
|
import { completeTurn, createTurnMetrics, retireRequestIntoTurn } from "./turn-state"
|
|
7
|
+
import { log } from "./logger"
|
|
7
8
|
|
|
8
9
|
export interface HydrationApi {
|
|
9
10
|
readonly client: {
|
|
10
11
|
readonly session: {
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
// Shape-agnostic: the v1 SDK client routes use /session/{sessionID}/…,
|
|
13
|
+
// v2 uses /session/{id}/…, and very old runtimes accepted flat keys.
|
|
14
|
+
messages(options: { path?: { sessionID?: string; id?: string }; id?: string; sessionID?: string }): Promise<unknown>
|
|
15
|
+
status(options: { path?: { sessionID?: string; id?: string }; id?: string; sessionID?: string }): Promise<unknown>
|
|
13
16
|
}
|
|
14
17
|
readonly message?: {
|
|
15
|
-
|
|
18
|
+
parts?(options: { path?: { messageID?: string; id?: string }; id?: string }): Promise<unknown>
|
|
16
19
|
}
|
|
17
20
|
}
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Try SDK arg shapes in order until one resolves WITHOUT an error field. The
|
|
25
|
+
* host TUI client (confirmed at runtime on opencode 1.18.26) speaks the flat
|
|
26
|
+
* original-SDK dialect: path params are TOP-LEVEL keys substituted into the
|
|
27
|
+
* URL template — `session.messages/children({ sessionID })`,
|
|
28
|
+
* `message.parts({ id })`. hey-api nested shapes (`{ path: { … } }`, v1/v2)
|
|
29
|
+
* are kept as fallbacks so the plugin survives opencode SDK upgrades. A wrong
|
|
30
|
+
* shape leaves the URL template unsubstituted (literal `{sessionID}`) and the
|
|
31
|
+
* server rejects it, surfacing only as `response.error` — never an exception.
|
|
32
|
+
*/
|
|
33
|
+
export async function callWithFallback(attempts: Array<() => Promise<unknown>>): Promise<unknown> {
|
|
34
|
+
let lastResponse: unknown = null
|
|
35
|
+
for (let i = 0; i < attempts.length; i++) {
|
|
36
|
+
const response = await attempts[i]()
|
|
37
|
+
if (!isRecord(response) || response.error == null) {
|
|
38
|
+
if (i > 0) log(`sdk arg shapes: attempt ${i + 1}/${attempts.length} succeeded`)
|
|
39
|
+
return response
|
|
40
|
+
}
|
|
41
|
+
lastResponse = response
|
|
42
|
+
}
|
|
43
|
+
log(`sdk arg shapes: all ${attempts.length} failed (response.error)`)
|
|
44
|
+
return lastResponse
|
|
45
|
+
}
|
|
46
|
+
|
|
20
47
|
export interface HydrationState {
|
|
21
48
|
readonly requests: Map<string, RequestMetrics>
|
|
22
49
|
readonly turns: Map<string, TurnMetrics>
|
|
@@ -89,7 +116,11 @@ async function observablePartTimes(api: HydrationApi, messageID: string, now: nu
|
|
|
89
116
|
if (typeof parts !== "function") return { first: null, last: null }
|
|
90
117
|
let values: readonly unknown[]
|
|
91
118
|
try {
|
|
92
|
-
values = unwrapMessageList(await
|
|
119
|
+
values = unwrapMessageList(await callWithFallback([
|
|
120
|
+
() => parts({ id: messageID }),
|
|
121
|
+
() => parts({ path: { messageID: messageID } }),
|
|
122
|
+
() => parts({ path: { id: messageID } }),
|
|
123
|
+
]))
|
|
93
124
|
} catch {
|
|
94
125
|
// Part timing is best-effort; unavailable/failed lookups yield null times.
|
|
95
126
|
return { first: null, last: null }
|
|
@@ -166,7 +197,12 @@ export interface HydrateSessionInput {
|
|
|
166
197
|
}
|
|
167
198
|
|
|
168
199
|
export async function hydrateSession(input: HydrateSessionInput): Promise<boolean> {
|
|
169
|
-
const rawMessages = unwrapMessageList(await
|
|
200
|
+
const rawMessages = unwrapMessageList(await callWithFallback([
|
|
201
|
+
() => input.api.client.session.messages({ sessionID: input.sessionID }),
|
|
202
|
+
() => input.api.client.session.messages({ id: input.sessionID }),
|
|
203
|
+
() => input.api.client.session.messages({ path: { sessionID: input.sessionID } }),
|
|
204
|
+
() => input.api.client.session.messages({ path: { id: input.sessionID } }),
|
|
205
|
+
]))
|
|
170
206
|
const infos = rawMessages.map(messageInfo)
|
|
171
207
|
const assistants = infos
|
|
172
208
|
.map(parseAssistantMessage)
|
|
@@ -199,6 +235,7 @@ export async function hydrateSession(input: HydrateSessionInput): Promise<boolea
|
|
|
199
235
|
await input.api.client.session.status({ id: input.sessionID }),
|
|
200
236
|
input.sessionID,
|
|
201
237
|
))
|
|
238
|
+
log(`hydrate session=${input.sessionID.slice(0, 8)} status='${sessionStatus}'`)
|
|
202
239
|
const shouldReuseExisting = existing?.messageID === assistant.messageID
|
|
203
240
|
|
|
204
241
|
const current = shouldReuseExisting ? existing : createFreshMetrics(
|
|
@@ -251,7 +288,12 @@ export async function hydrateSession(input: HydrateSessionInput): Promise<boolea
|
|
|
251
288
|
applyAssistantTokens(stepMetrics, step.tokens)
|
|
252
289
|
retireRequestIntoTurn(turn, stepMetrics)
|
|
253
290
|
}
|
|
254
|
-
|
|
291
|
+
// Turn completion: the session-status lookup is unreliable across host SDK
|
|
292
|
+
// dialects (may resolve to an unrecognizable shape → status ''). The last
|
|
293
|
+
// assistant message's own completed flag is authoritative: if it finished,
|
|
294
|
+
// its turn is over. An in-flight resumed message (completed=false) keeps
|
|
295
|
+
// the turn open.
|
|
296
|
+
if (sessionStatus === "idle" || current.isComplete) completeTurn(turn, completedTime ?? input.now)
|
|
255
297
|
input.state.turns.set(input.sessionID, turn)
|
|
256
298
|
|
|
257
299
|
// Write-point race guard: the checks above ran before several awaits. By
|