oc-metricboard 0.1.7 → 0.1.9

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.7",
3
+ "version": "0.1.9",
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
@@ -1,14 +1,14 @@
1
1
  import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
2
2
  import type { BarConfig, CacheReadCompleteness, MetricsAggregate, MetricsScope, ModelMetrics, RequestMetrics } from "./types"
3
- import { getDisplayInputTokens, getDisplayOutputTokens, gateTtft, getTtft } from "./metrics"
3
+ import { getDisplayInputTokens, getDisplayOutputTokens, getTtft } from "./metrics"
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[] = []
@@ -352,7 +356,15 @@ export function createCollector(
352
356
 
353
357
  const cacheReadCompleteness: CacheReadCompleteness =
354
358
  exactCacheCount === 0 ? "unknown" : exactCacheCount === metrics.length ? "exact" : "partial"
355
- const ttft = firstTokenTime === null ? null : gateTtft(firstTokenTime - requestStartTime)
359
+ // Model rows use the same turn-level TTFT as the main aggregate: the
360
+ // earliest gated turn TTFT across the group's sessions, falling back to
361
+ // per-request TTFT when no turn timing exists (e.g. sub-agent sessions
362
+ // whose turn start coincides with their first request).
363
+ let ttft: number | null = null
364
+ for (const m of metrics) {
365
+ const candidate = getTurnTtft(state.turns.get(m.sessionID), getTtft(m))
366
+ if (candidate !== null && (ttft === null || candidate < ttft)) ttft = candidate
367
+ }
356
368
 
357
369
  // Live TPS for this model group
358
370
  let liveTps = 0
@@ -551,10 +563,7 @@ export function createCollector(
551
563
  // collapse to ~0). Anchor TTFT to the turn's user message instead —
552
564
  // stable across all steps within a turn, refreshed on each new turn.
553
565
  // Falls back to the per-request measurement when no turn timing exists.
554
- const turnTtft = foregroundTurn?.firstTokenTime != null
555
- ? gateTtft(foregroundTurn.firstTokenTime - foregroundTurn.turnStartTime)
556
- : null
557
- const ttft = turnTtft ?? (foregroundRequest ? getTtft(foregroundRequest) : null)
566
+ const ttft = getTurnTtft(foregroundTurn, foregroundRequest ? getTtft(foregroundRequest) : null)
558
567
 
559
568
  const result: MetricsAggregate = {
560
569
  sessionIDs: contributingSessionIDs.length > 0 ? contributingSessionIDs : [rootID],
@@ -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)
package/src/turn-state.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { getDisplayInputTokens, getDisplayOutputTokens } from "./metrics"
1
+ import { gateTtft, getDisplayInputTokens, getDisplayOutputTokens } from "./metrics"
2
2
  import type { RequestMetrics, TurnMetrics } from "./types"
3
3
 
4
4
  export function createTurnMetrics(sessionID: string, now: number): TurnMetrics {
@@ -94,3 +94,17 @@ export function completeTurn(turn: TurnMetrics, now: number): void {
94
94
  export function recordTurnFirstToken(turn: TurnMetrics, now: number): void {
95
95
  if (turn.firstTokenTime === null) turn.firstTokenTime = now
96
96
  }
97
+
98
+ /**
99
+ * Turn-level TTFT (user message → first token), gated. Falls back to the
100
+ * supplied per-request TTFT when the turn has no first-token timing. This is
101
+ * the ONLY meaningful TTFT observable from opencode's event stream for
102
+ * intra-turn steps, so every TTFT display path should prefer it.
103
+ */
104
+ export function getTurnTtft(turn: TurnMetrics | undefined, fallback: number | null = null): number | null {
105
+ if (turn?.firstTokenTime != null) {
106
+ const gated = gateTtft(turn.firstTokenTime - turn.turnStartTime)
107
+ if (gated !== null) return gated
108
+ }
109
+ return fallback
110
+ }