oc-metricboard 0.1.6 → 0.1.8

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.6",
3
+ "version": "0.1.8",
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",
@@ -4,6 +4,7 @@ import { currentRequest } from "./request-state"
4
4
  import { eventAggregateID, eventID, eventProperties, stringEventProperty } from "./event-bus"
5
5
  import type { EventHandlerContext } from "./collector-state"
6
6
  import { recordLiveTokens } from "./live-speed"
7
+ import { ensureTurn, recordTurnFirstToken } from "./turn-state"
7
8
 
8
9
  export interface AssistantProgressPart {
9
10
  readonly partID: string
@@ -26,6 +27,7 @@ function applyTokenDelta(
26
27
  if (state.turns.get(sessionID)?.finalizedSteps.has(messageID)) return
27
28
  const current = currentRequest({ state, actions, sessionID, messageID, now })
28
29
  if (current.firstTokenTime === null) current.firstTokenTime = now
30
+ recordTurnFirstToken(ensureTurn(state.turns, sessionID, now), now)
29
31
  if (deltaTokens > 0) {
30
32
  current.estimatedOutputTokens += deltaTokens
31
33
  current.lastDeltaTime = now
package/src/collector.ts CHANGED
@@ -1,6 +1,6 @@
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"
@@ -352,7 +352,15 @@ export function createCollector(
352
352
 
353
353
  const cacheReadCompleteness: CacheReadCompleteness =
354
354
  exactCacheCount === 0 ? "unknown" : exactCacheCount === metrics.length ? "exact" : "partial"
355
- const ttft = firstTokenTime === null ? null : gateTtft(firstTokenTime - requestStartTime)
355
+ // Model rows use the same turn-level TTFT as the main aggregate: the
356
+ // earliest gated turn TTFT across the group's sessions, falling back to
357
+ // per-request TTFT when no turn timing exists (e.g. sub-agent sessions
358
+ // whose turn start coincides with their first request).
359
+ let ttft: number | null = null
360
+ for (const m of metrics) {
361
+ const candidate = getTurnTtft(state.turns.get(m.sessionID), getTtft(m))
362
+ if (candidate !== null && (ttft === null || candidate < ttft)) ttft = candidate
363
+ }
356
364
 
357
365
  // Live TPS for this model group
358
366
  let liveTps = 0
@@ -546,7 +554,12 @@ export function createCollector(
546
554
 
547
555
  // NEW: Build per-model breakdown for tree scope
548
556
  const modelBreakdown = scope === "tree" ? aggregateByModel(ids, now, scope) : []
549
- const ttft = foregroundRequest ? getTtft(foregroundRequest) : null
557
+ // Turn-level TTFT: opencode stamps step.started at the first token, so
558
+ // intra-turn steps cannot measure their own request start (they would
559
+ // collapse to ~0). Anchor TTFT to the turn's user message instead —
560
+ // stable across all steps within a turn, refreshed on each new turn.
561
+ // Falls back to the per-request measurement when no turn timing exists.
562
+ const ttft = getTurnTtft(foregroundTurn, foregroundRequest ? getTtft(foregroundRequest) : null)
550
563
 
551
564
  const result: MetricsAggregate = {
552
565
  sessionIDs: contributingSessionIDs.length > 0 ? contributingSessionIDs : [rootID],
@@ -233,6 +233,11 @@ export async function hydrateSession(input: HydrateSessionInput): Promise<boolea
233
233
  input.now,
234
234
  ) ?? requestStartTime
235
235
  const turn = createTurnMetrics(input.sessionID, turnStartTime)
236
+ // Turn-level TTFT anchor: the first trailing assistant's createdTime is
237
+ // stamped at its first token, so turnStart → that instant is the hydrated
238
+ // turn's time-to-first-token. Fall back to the live request's first token.
239
+ turn.firstTokenTime = toPerformanceTime(trailingAssistants[0]?.createdTime, input.now)
240
+ ?? current.firstTokenTime
236
241
  for (const step of trailingAssistants) {
237
242
  if (!step.tokens || !hasPositiveAssistantTokens(step.tokens)) continue
238
243
  const stepStart = toPerformanceTime(step.createdTime, input.now) ?? turnStartTime
package/src/turn-state.ts CHANGED
@@ -1,10 +1,11 @@
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 {
5
5
  return {
6
6
  sessionID,
7
7
  turnStartTime: now,
8
+ firstTokenTime: null,
8
9
  completeTime: null,
9
10
  finalizedOutputTokens: 0,
10
11
  finalizedSteps: new Map(),
@@ -83,3 +84,27 @@ export function completeTurn(turn: TurnMetrics, now: number): void {
83
84
  turn.isComplete = true
84
85
  turn.completeTime = now
85
86
  }
87
+
88
+ /**
89
+ * Record the turn's first token instant (once). opencode stamps step.started
90
+ * at the first token, so intra-turn steps cannot measure their own request
91
+ * start; the turn-level anchor (user message → first delta) is the only
92
+ * meaningful TTFT observable from the event stream.
93
+ */
94
+ export function recordTurnFirstToken(turn: TurnMetrics, now: number): void {
95
+ if (turn.firstTokenTime === null) turn.firstTokenTime = now
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
+ }
package/src/types.ts CHANGED
@@ -37,6 +37,7 @@ export interface FinalizedStepUsage {
37
37
  export interface TurnMetrics {
38
38
  sessionID: string
39
39
  turnStartTime: number
40
+ firstTokenTime: number | null // First token of the turn (user message → first delta)
40
41
  completeTime: number | null
41
42
  finalizedOutputTokens: number
42
43
  finalizedSteps: Map<string, FinalizedStepUsage>