opencode-tui-stats 0.3.0 → 0.4.1

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/tracker.ts +172 -15
  3. package/tui.tsx +63 -14
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "opencode-tui-stats",
4
- "version": "0.3.0",
4
+ "version": "0.4.1",
5
5
  "description": "OpenCode 1 TUI plugin that shows live TPS, average TPS, and average time to first token in the session prompt.",
6
6
  "type": "module",
7
7
  "exports": {
package/tracker.ts CHANGED
@@ -41,6 +41,8 @@ export type ModelAggregate = {
41
41
  totalDurationMs: number
42
42
  totalTtftMs: number
43
43
  stepCount: number
44
+ /** Last time this model was observed in this session (merged with `max` across a subtree). */
45
+ lastAt: number
44
46
  }
45
47
 
46
48
  export type SessionModels = {
@@ -60,6 +62,8 @@ export type Tracker = {
60
62
  stats: Record<string, SessionStats>
61
63
  dedup: Record<string, Set<string>>
62
64
  models: Record<string, SessionModels>
65
+ /** `sessionID -> parentID`; a key that is present with an `undefined` value is a confirmed root. */
66
+ parentOf: Record<string, string | undefined>
63
67
  }
64
68
 
65
69
  const STREAM_WINDOW_MS = 5_000
@@ -72,6 +76,8 @@ const DEDUP_WINDOW_LIMIT = 64
72
76
  /** Cap on `messageID -> model` entries retained per session (FIFO). */
73
77
  const MESSAGE_MODEL_LIMIT = 256
74
78
  const SESSION_LIMIT = 64
79
+ /** Maximum number of descendant sessions folded into one panel (cycle-safe traversal bound). */
80
+ const SUBTREE_LIMIT = 64
75
81
  /** Maximum number of model rows rendered in the sidebar panel. */
76
82
  export const MODEL_ROW_LIMIT = 4
77
83
 
@@ -89,6 +95,7 @@ function emptyAggregate(): ModelAggregate {
89
95
  totalDurationMs: 0,
90
96
  totalTtftMs: 0,
91
97
  stepCount: 0,
98
+ lastAt: 0,
92
99
  }
93
100
  }
94
101
 
@@ -140,12 +147,13 @@ export function averageTtft(average: SessionAverage | undefined) {
140
147
  /** Cache hit percentage (e.g. `31%`) or `undefined` when nothing promptable has been seen. */
141
148
  export function cacheText(stats: { cacheRead: number; cachePrompt: number } | undefined) {
142
149
  if (!stats || stats.cachePrompt <= 0) return undefined
143
- return `${Math.round((stats.cacheRead / stats.cachePrompt) * 100)}%`
150
+ return `${((stats.cacheRead / stats.cachePrompt) * 100).toFixed(1)}%`
144
151
  }
145
152
 
146
153
  export function costText(stats: { cost: number; steps: number } | undefined) {
147
154
  if (!stats || stats.steps <= 0) return undefined
148
- return `$${stats.cost >= 1 ? stats.cost.toFixed(2) : stats.cost.toFixed(4)}`
155
+ // Always four decimals so the cost column keeps a stable width as spend grows.
156
+ return `$${stats.cost.toFixed(4)}`
149
157
  }
150
158
 
151
159
  /** FIFO-bound a Set so it never grows past `limit`. */
@@ -172,6 +180,8 @@ export type ModelRow = {
172
180
  key: string
173
181
  label: string
174
182
  active: boolean
183
+ /** True when any descendant (subagent) session contributed data to this row. */
184
+ subagent?: boolean
175
185
  tps?: string
176
186
  avg?: string
177
187
  ttft?: string
@@ -221,7 +231,15 @@ export function getLastInternals(): TrackerInternals | undefined {
221
231
  }
222
232
 
223
233
  export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerHandle {
224
- const tracker: Tracker = { samples: {}, timings: {}, averages: {}, stats: {}, dedup: {}, models: {} }
234
+ const tracker: Tracker = {
235
+ samples: {},
236
+ timings: {},
237
+ averages: {},
238
+ stats: {},
239
+ dedup: {},
240
+ models: {},
241
+ parentOf: {},
242
+ }
225
243
  const partDecisions = new Map<string, boolean>()
226
244
  const mounted = new Set<string>()
227
245
  const order: string[] = []
@@ -251,6 +269,7 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
251
269
  delete tracker.samples[sessionID]
252
270
  delete tracker.dedup[sessionID]
253
271
  delete tracker.models[sessionID]
272
+ delete tracker.parentOf[sessionID]
254
273
  clearTimings(sessionID)
255
274
  }
256
275
 
@@ -305,7 +324,7 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
305
324
  }
306
325
 
307
326
  /** Live rate restricted to samples attributed to `key` via the `messageID -> model` mapping. */
308
- const liveRateFor = (sessionID: string, key: string) => {
327
+ const sessionLiveRate = (sessionID: string, key: string) => {
309
328
  const status = api.state.session.status(sessionID)
310
329
  if (!status || status.type === "idle") return undefined
311
330
 
@@ -321,34 +340,158 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
321
340
  return formatRate(tokens / (activeDuration(samples, now) / 1_000))
322
341
  }
323
342
 
324
- const modelRows = (sessionID: string): ModelPanel => {
343
+ /** Fold a session's parent from `session.created`/`session.updated`, or lazily from state.
344
+ * A successful lookup is cached; an unknown session is retried on the next render. */
345
+ const resolveParent = (sessionID: string) => {
346
+ if (sessionID in tracker.parentOf) return
347
+ const info = api.state.session.get(sessionID)
348
+ if (!info) return
349
+ tracker.parentOf[sessionID] = info.parentID
350
+ }
351
+
352
+ const rememberParent = (sessionID: string, parentID: string | undefined) => {
353
+ if (sessionID in tracker.parentOf && tracker.parentOf[sessionID] === parentID) return
354
+ tracker.parentOf[sessionID] = parentID
355
+ bump()
356
+ }
357
+
358
+ const knownSessions = () => {
359
+ const ids = new Set<string>(Object.keys(tracker.models))
360
+ for (const id of Object.keys(tracker.stats)) ids.add(id)
361
+ for (const id of Object.keys(tracker.averages)) ids.add(id)
362
+ return ids
363
+ }
364
+
365
+ /** Breadth-first descendants of `sessionID` (excluding itself), cycle-safe and capped. */
366
+ const descendants = (sessionID: string) => {
367
+ const children = new Map<string, string[]>()
368
+ for (const [child, parent] of Object.entries(tracker.parentOf)) {
369
+ if (!parent) continue
370
+ const siblings = children.get(parent)
371
+ if (siblings) siblings.push(child)
372
+ else children.set(parent, [child])
373
+ }
374
+
375
+ const seen = new Set<string>([sessionID])
376
+ const found: string[] = []
377
+ const queue = [sessionID]
378
+ while (queue.length > 0 && found.length < SUBTREE_LIMIT) {
379
+ const current = queue.shift()!
380
+ for (const child of children.get(current) ?? []) {
381
+ if (seen.has(child)) continue
382
+ seen.add(child)
383
+ found.push(child)
384
+ queue.push(child)
385
+ if (found.length >= SUBTREE_LIMIT) break
386
+ }
387
+ }
388
+ return found
389
+ }
390
+
391
+ const mergeAggregate = (target: ModelAggregate, source: ModelAggregate) => {
392
+ target.cacheRead += source.cacheRead
393
+ target.cachePrompt += source.cachePrompt
394
+ target.cost += source.cost
395
+ target.steps += source.steps
396
+ target.totalTokens += source.totalTokens
397
+ target.totalDurationMs += source.totalDurationMs
398
+ target.totalTtftMs += source.totalTtftMs
399
+ target.stepCount += source.stepCount
400
+ target.lastAt = Math.max(target.lastAt, source.lastAt)
401
+ }
402
+
403
+ const sessionModelAt = (sessionID: string) => {
325
404
  const models = tracker.models[sessionID]
405
+ if (!models) return 0
406
+ let at = 0
407
+ for (const key of models.order) {
408
+ const aggregate = models.aggregates[key]
409
+ if (aggregate && aggregate.lastAt > at) at = aggregate.lastAt
410
+ }
411
+ return at
412
+ }
413
+
414
+ /** Live rate for a merged row: the parent and its subagents share one row, so only the
415
+ * session/model pair that most recently saw activity contributes. Concurrent streams in a
416
+ * parent and a child are therefore approximated by the newer one instead of being summed. */
417
+ const liveRateFor = (sessions: string[], key: string) => {
418
+ let latest: { sessionID: string; at: number } | undefined
419
+ for (const sessionID of sessions) {
420
+ const aggregate = tracker.models[sessionID]?.aggregates[key]
421
+ if (!aggregate) continue
422
+ if (!latest || aggregate.lastAt >= latest.at) latest = { sessionID, at: aggregate.lastAt }
423
+ }
424
+ if (!latest) return undefined
425
+ return sessionLiveRate(latest.sessionID, key)
426
+ }
427
+
428
+ const modelRows = (sessionID: string): ModelPanel => {
429
+ // Link sessions that only announced themselves through message events (a child can stream
430
+ // before its `session.created` event is delivered).
431
+ for (const id of knownSessions()) resolveParent(id)
432
+
433
+ const subtree = [sessionID, ...descendants(sessionID)]
326
434
  const retries = tracker.stats[sessionID]?.retries ?? 0
327
- const cache = cacheText(tracker.stats[sessionID])
328
- if (!models) return { rows: [], overflow: 0, retries, cache }
329
435
 
330
- const active = models.active
436
+ // Session CACHE uses the same subtree scope as the model rows.
437
+ let cacheRead = 0
438
+ let cachePrompt = 0
439
+ for (const id of subtree) {
440
+ const stats = tracker.stats[id]
441
+ if (!stats) continue
442
+ cacheRead += stats.cacheRead
443
+ cachePrompt += stats.cachePrompt
444
+ }
445
+ const cache = cacheText({ cacheRead, cachePrompt })
446
+
447
+ // Fold the subtree oldest→newest so `order` keeps its "oldest first" meaning across sessions.
448
+ const sessions = subtree
449
+ .filter((id) => tracker.models[id])
450
+ .sort((left, right) => sessionModelAt(left) - sessionModelAt(right))
451
+ if (sessions.length === 0) return { rows: [], overflow: 0, retries, cache }
452
+
453
+ const order: string[] = []
454
+ const aggregates: Record<string, ModelAggregate> = {}
455
+ const subagentModels = new Set<string>()
456
+ for (const id of sessions) {
457
+ const models = tracker.models[id]
458
+ for (const key of models.order) {
459
+ const aggregate = models.aggregates[key]
460
+ if (!aggregate) continue
461
+ let merged = aggregates[key]
462
+ if (!merged) {
463
+ merged = emptyAggregate()
464
+ aggregates[key] = merged
465
+ order.push(key)
466
+ }
467
+ mergeAggregate(merged, aggregate)
468
+ if (id !== sessionID) subagentModels.add(key)
469
+ }
470
+ }
471
+
472
+ let active: string | undefined
473
+ for (const id of sessions) active = tracker.models[id].active ?? active
474
+
331
475
  // `order` is oldest→newest; reverse for most-recent-first ordering of the non-active rows.
332
- const recency = [...models.order].reverse().filter((key) => key in models.aggregates)
333
- const ordered =
334
- active && active in models.aggregates ? [active, ...recency.filter((key) => key !== active)] : recency
476
+ const recency = [...order].reverse()
477
+ const ordered = active && active in aggregates ? [active, ...recency.filter((key) => key !== active)] : recency
335
478
 
336
479
  const visible = ordered.slice(0, MODEL_ROW_LIMIT)
337
480
  const overflow = Math.max(0, ordered.length - visible.length)
338
481
 
339
482
  const rows = visible.map<ModelRow>((key) => {
340
- const aggregate = models.aggregates[key]
483
+ const aggregate = aggregates[key]
341
484
  const isActive = key === active
342
- const cost = costText(aggregate)
343
485
  return {
344
486
  key,
345
487
  label: key,
346
488
  active: isActive,
347
- tps: isActive ? liveRateFor(sessionID, key) : undefined,
489
+ subagent: subagentModels.has(key),
490
+ tps: isActive ? liveRateFor(subtree, key) : undefined,
348
491
  avg: averageRate(aggregate),
349
492
  ttft: averageTtft(aggregate),
350
493
  cache: cacheText(aggregate),
351
- cost: cost === undefined ? undefined : cost,
494
+ cost: costText(aggregate),
352
495
  }
353
496
  })
354
497
 
@@ -393,6 +536,7 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
393
536
  tracker.models[sessionID] ?? (tracker.models[sessionID] = { order: [], aggregates: {}, messageModels: new Map() })
394
537
  touchModel(models, key)
395
538
  models.active = key
539
+ models.aggregates[key] = { ...models.aggregates[key], lastAt: Date.now() }
396
540
  boundedSet(models.messageModels, info.id, key, MESSAGE_MODEL_LIMIT)
397
541
 
398
542
  if (!info.time.completed) {
@@ -527,6 +671,19 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
527
671
  }),
528
672
  )
529
673
 
674
+ // Subagent sessions announce their parent here; the tree is also backfilled lazily from state.
675
+ listeners.push(
676
+ api.event.on("session.created", (event) => {
677
+ rememberParent(event.properties.info.id, event.properties.info.parentID)
678
+ }),
679
+ )
680
+
681
+ listeners.push(
682
+ api.event.on("session.updated", (event) => {
683
+ rememberParent(event.properties.info.id, event.properties.info.parentID)
684
+ }),
685
+ )
686
+
530
687
  // The 1s tick re-evaluates live TPS staleness and prunes stale stream samples.
531
688
  const timer = setInterval(() => {
532
689
  prune()
package/tui.tsx CHANGED
@@ -9,7 +9,40 @@ import {
9
9
  type TrackerHandle,
10
10
  } from "./tracker"
11
11
 
12
- /** A single model row: fold marker + summary line, plus a detail line when expanded. */
12
+ /** Detail lines sit three cells in from the fold marker, visually owned by the row above. */
13
+ const DETAIL_INDENT = " "
14
+ /** Fixed cells keep every label on the same column across rows without measuring the panel. */
15
+ const RATE_CELL = 4
16
+ const TTFT_CELL = 6
17
+ const CACHE_CELL = 6
18
+
19
+ /** Split `provider/model` so the provider can be de-emphasised. Model ids may contain slashes, so
20
+ * only the first slash separates the provider. When the model already starts with the provider name
21
+ * (`deepseek/deepseek-flash`) the redundant prefix is dropped. */
22
+ function splitLabel(label: string) {
23
+ const slash = label.indexOf("/")
24
+ if (slash <= 0) return { provider: "", model: label }
25
+ const provider = label.slice(0, slash)
26
+ const model = label.slice(slash + 1)
27
+ if (model.toLowerCase().startsWith(provider.toLowerCase())) return { provider: "", model }
28
+ return { provider: `${provider}/`, model }
29
+ }
30
+
31
+ function rateCell(value: string | undefined) {
32
+ return value === undefined ? "—" : `${value.padStart(RATE_CELL)}t/s`
33
+ }
34
+
35
+ function ttftCell(value: string | undefined) {
36
+ return (value ?? "—").padEnd(TTFT_CELL)
37
+ }
38
+
39
+ function cacheCell(value: string | undefined) {
40
+ if (value === undefined) return "—".padStart(CACHE_CELL)
41
+ const percent = value.endsWith("%") ? value.slice(0, -1) : value
42
+ return `${percent.padStart(5)}%`
43
+ }
44
+
45
+ /** A single model row: fold marker + summary line, plus its detail lines when expanded. */
13
46
  function ModelRowView(props: { api: TuiPluginApi; row: ModelRow; expanded: boolean; onToggle: () => void }) {
14
47
  // Guard against a drag-select being read as a click: only toggle when the press and the
15
48
  // release land on the same cell.
@@ -27,29 +60,39 @@ function ModelRowView(props: { api: TuiPluginApi; row: ModelRow; expanded: boole
27
60
  }
28
61
 
29
62
  const theme = () => props.api.theme.current
63
+ const parts = () => splitLabel(props.row.label)
30
64
 
31
65
  return (
32
66
  <box flexDirection="column" onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}>
33
67
  <text wrapMode="none" truncate>
34
68
  <span style={{ fg: theme().textMuted } as Record<string, unknown>}>{`${props.expanded ? "▼" : "▶"} `}</span>
35
- <span style={{ fg: theme().text } as Record<string, unknown>}>{props.row.label}</span>
36
- <Show when={props.row.active}>
37
- <span style={{ fg: theme().success } as Record<string, unknown>}>{" active"}</span>
69
+ <Show when={props.row.subagent}>
70
+ <span style={{ fg: theme().textMuted } as Record<string, unknown>}>{"↳ "}</span>
71
+ </Show>
72
+ <Show when={parts().provider}>
73
+ {/* Dimmed foreground sits between the model name and muted metadata without borrowing
74
+ * a status colour, so the provider reads as its own tier even as themes change. */}
75
+ <span style={{ fg: theme().text, dim: true } as Record<string, unknown>}>{parts().provider}</span>
38
76
  </Show>
77
+ <span style={{ fg: theme().text } as Record<string, unknown>}>{parts().model}</span>
39
78
  </text>
40
79
  <Show when={props.expanded}>
41
- <text fg={theme().text} wrapMode="none" truncate>
42
- {`TPS ${props.row.tps ?? "—"} · AVG ${props.row.avg ?? "—"}`}
80
+ <text fg={theme().textMuted} wrapMode="none" truncate>
81
+ {`${DETAIL_INDENT}AVG ${rateCell(props.row.avg)}${
82
+ props.row.tps === undefined ? "" : ` · TPS ${rateCell(props.row.tps)}`
83
+ }`}
43
84
  </text>
44
- <text fg={theme().text} wrapMode="none" truncate>
45
- {`TTFT ${props.row.ttft ?? "—"} · CACHE ${props.row.cache ?? "—"} · ${props.row.cost ?? "—"}`}
85
+ <text fg={theme().textMuted} wrapMode="none" truncate>
86
+ {`${DETAIL_INDENT}TTFT ${ttftCell(props.row.ttft)} · CACHE ${cacheCell(props.row.cache)} · ${
87
+ props.row.cost ?? "—"
88
+ }`}
46
89
  </text>
47
90
  </Show>
48
91
  </box>
49
92
  )
50
93
  }
51
94
 
52
- /** Per-session model panel, mounted at `sidebar_content`. */
95
+ /** Per-session model panel, mounted at `sidebar_footer`. */
53
96
  function ModelPanelView(props: {
54
97
  api: TuiPluginApi
55
98
  sessionID: string
@@ -112,9 +155,13 @@ function ModelPanelView(props: {
112
155
 
113
156
  return (
114
157
  <box flexDirection="column">
115
- <text fg={props.api.theme.current.textMuted}>Performance · observed</text>
158
+ <text fg={props.api.theme.current.text} wrapMode="none" truncate>
159
+ <b>Performance · observed</b>
160
+ </text>
116
161
  <Show when={panel().rows.length === 0}>
117
- <text fg={props.api.theme.current.textMuted}>—</text>
162
+ <text fg={props.api.theme.current.textMuted} wrapMode="none" truncate>
163
+ —
164
+ </text>
118
165
  </Show>
119
166
  <For each={panel().rows}>
120
167
  {(row) => (
@@ -122,9 +169,11 @@ function ModelPanelView(props: {
122
169
  )}
123
170
  </For>
124
171
  <Show when={panel().overflow > 0}>
125
- <text fg={props.api.theme.current.textMuted}>{`+${panel().overflow} more models`}</text>
172
+ <text fg={props.api.theme.current.textMuted} wrapMode="none" truncate>
173
+ {`+${panel().overflow} more models`}
174
+ </text>
126
175
  </Show>
127
- <text fg={props.api.theme.current.text}>
176
+ <text fg={props.api.theme.current.textMuted} wrapMode="none" truncate>
128
177
  {`Session CACHE ${panel().cache ?? "—"}${panel().retries > 0 ? ` · retries ${panel().retries}` : ""}`}
129
178
  </text>
130
179
  </box>
@@ -139,7 +188,7 @@ const tui: TuiPlugin = async (api) => {
139
188
 
140
189
  api.slots.register({
141
190
  slots: {
142
- sidebar_content(_context, props) {
191
+ sidebar_footer(_context, props) {
143
192
  return <ModelPanelView api={api} sessionID={props.session_id} tracker={tracker} revision={revision} />
144
193
  },
145
194
  },