oc-metricboard 0.1.8 → 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.8",
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
@@ -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[] = []
@@ -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)