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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-metricboard",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
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",
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(input: { sessionID: string }): Promise<unknown>
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 treeHydrationApi.client.session.children({ sessionID: parentID }),
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(ids: readonly string[], now: number, scope: MetricsScope): ModelMetrics[] {
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>{collapsed() ? "▶ " : "▼ "}{headerLabel()}</b>
200
+ <b>▼ {headerLabel()}</b>
153
201
  </text>
154
202
  </box>
155
203
 
@@ -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
- messages(options: { readonly id: string }): Promise<unknown>
12
- status(options: { readonly id: string }): Promise<unknown>
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
- readonly parts?(options: { readonly id: string }): Promise<unknown>
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 parts({ id: messageID }))
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 input.api.client.session.messages({ id: input.sessionID }))
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
- if (sessionStatus === "idle") completeTurn(turn, completedTime ?? input.now)
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