opencode-tui-stats 0.2.0 → 0.3.0

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 -16
  3. package/tui.tsx +110 -8
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.2.0",
4
+ "version": "0.3.0",
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
@@ -2,6 +2,7 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
2
2
 
3
3
  export type StreamSample = {
4
4
  at: number
5
+ messageID: string
5
6
  tokens: number
6
7
  }
7
8
 
@@ -28,12 +29,37 @@ export type SessionStats = {
28
29
  lastAttempt: number
29
30
  }
30
31
 
32
+ /** Per-model aggregate within a session, keyed by `providerID/modelID`.
33
+ * `cacheRead`/`cachePrompt`/`cost`/`steps` mirror {@link SessionStats}; the `total*`/`stepCount`
34
+ * fields mirror {@link SessionAverage} so the existing rate/TTFT helpers can be reused as-is. */
35
+ export type ModelAggregate = {
36
+ cacheRead: number
37
+ cachePrompt: number
38
+ cost: number
39
+ steps: number
40
+ totalTokens: number
41
+ totalDurationMs: number
42
+ totalTtftMs: number
43
+ stepCount: number
44
+ }
45
+
46
+ export type SessionModels = {
47
+ /** Most recently observed assistant `providerID/modelID` for the session. */
48
+ active?: string
49
+ /** Model keys in observation order (oldest first, most recent last). */
50
+ order: string[]
51
+ aggregates: Record<string, ModelAggregate>
52
+ /** `messageID -> providerID/modelID`, used to attribute live samples to a model. */
53
+ messageModels: Map<string, string>
54
+ }
55
+
31
56
  export type Tracker = {
32
57
  samples: Record<string, StreamSample[]>
33
58
  timings: Record<string, MessageTiming>
34
59
  averages: Record<string, SessionAverage>
35
60
  stats: Record<string, SessionStats>
36
61
  dedup: Record<string, Set<string>>
62
+ models: Record<string, SessionModels>
37
63
  }
38
64
 
39
65
  const STREAM_WINDOW_MS = 5_000
@@ -43,12 +69,33 @@ const PART_CACHE_LIMIT = 1_024
43
69
  /** Per-session dedup window: only the most recent completed messages are deduplicated;
44
70
  * a repeated completed event beyond this window accumulates again. */
45
71
  const DEDUP_WINDOW_LIMIT = 64
72
+ /** Cap on `messageID -> model` entries retained per session (FIFO). */
73
+ const MESSAGE_MODEL_LIMIT = 256
46
74
  const SESSION_LIMIT = 64
75
+ /** Maximum number of model rows rendered in the sidebar panel. */
76
+ export const MODEL_ROW_LIMIT = 4
47
77
 
48
78
  function emptyStats(): SessionStats {
49
79
  return { cacheRead: 0, cachePrompt: 0, cost: 0, steps: 0, retries: 0, lastAttempt: 0 }
50
80
  }
51
81
 
82
+ function emptyAggregate(): ModelAggregate {
83
+ return {
84
+ cacheRead: 0,
85
+ cachePrompt: 0,
86
+ cost: 0,
87
+ steps: 0,
88
+ totalTokens: 0,
89
+ totalDurationMs: 0,
90
+ totalTtftMs: 0,
91
+ stepCount: 0,
92
+ }
93
+ }
94
+
95
+ export function modelKey(providerID: string, modelID: string) {
96
+ return `${providerID}/${modelID}`
97
+ }
98
+
52
99
  export function estimateTokens(delta: string) {
53
100
  return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5))
54
101
  }
@@ -90,13 +137,14 @@ export function averageTtft(average: SessionAverage | undefined) {
90
137
  return formatTtft(average.totalTtftMs / average.stepCount / 1_000)
91
138
  }
92
139
 
93
- export function cacheRate(stats: SessionStats | undefined) {
94
- if (!stats || stats.cachePrompt <= 0) return "-"
140
+ /** Cache hit percentage (e.g. `31%`) or `undefined` when nothing promptable has been seen. */
141
+ export function cacheText(stats: { cacheRead: number; cachePrompt: number } | undefined) {
142
+ if (!stats || stats.cachePrompt <= 0) return undefined
95
143
  return `${Math.round((stats.cacheRead / stats.cachePrompt) * 100)}%`
96
144
  }
97
145
 
98
- export function costText(stats: SessionStats | undefined) {
99
- if (!stats || stats.steps <= 0) return "-"
146
+ export function costText(stats: { cost: number; steps: number } | undefined) {
147
+ if (!stats || stats.steps <= 0) return undefined
100
148
  return `$${stats.cost >= 1 ? stats.cost.toFixed(2) : stats.cost.toFixed(4)}`
101
149
  }
102
150
 
@@ -120,6 +168,25 @@ function boundedSet<K, V>(map: Map<K, V>, key: K, value: V, limit: number) {
120
168
  map.set(key, value)
121
169
  }
122
170
 
171
+ export type ModelRow = {
172
+ key: string
173
+ label: string
174
+ active: boolean
175
+ tps?: string
176
+ avg?: string
177
+ ttft?: string
178
+ cache?: string
179
+ cost?: string
180
+ }
181
+
182
+ export type ModelPanel = {
183
+ rows: ModelRow[]
184
+ overflow: number
185
+ retries: number
186
+ /** Session-level cache hit percentage (e.g. `31%`), or `undefined` when nothing promptable was seen. */
187
+ cache?: string
188
+ }
189
+
123
190
  export type TrackerInternals = {
124
191
  sizes(): {
125
192
  parts: number
@@ -134,10 +201,11 @@ export type TrackerInternals = {
134
201
  dedupSize(sessionID: string): number
135
202
  retries(sessionID: string): number
136
203
  mounted(): string[]
204
+ activeModel(sessionID: string): string | undefined
137
205
  }
138
206
 
139
207
  export type TrackerHandle = {
140
- statusText(sessionID: string): string
208
+ modelRows(sessionID: string): ModelPanel
141
209
  mountSession(sessionID: string): void
142
210
  unmountSession(sessionID: string): void
143
211
  prune(now?: number): void
@@ -153,7 +221,7 @@ export function getLastInternals(): TrackerInternals | undefined {
153
221
  }
154
222
 
155
223
  export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerHandle {
156
- const tracker: Tracker = { samples: {}, timings: {}, averages: {}, stats: {}, dedup: {} }
224
+ const tracker: Tracker = { samples: {}, timings: {}, averages: {}, stats: {}, dedup: {}, models: {} }
157
225
  const partDecisions = new Map<string, boolean>()
158
226
  const mounted = new Set<string>()
159
227
  const order: string[] = []
@@ -182,6 +250,7 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
182
250
  delete tracker.averages[sessionID]
183
251
  delete tracker.samples[sessionID]
184
252
  delete tracker.dedup[sessionID]
253
+ delete tracker.models[sessionID]
185
254
  clearTimings(sessionID)
186
255
  }
187
256
 
@@ -203,6 +272,13 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
203
272
  }
204
273
  }
205
274
 
275
+ const touchModel = (models: SessionModels, key: string) => {
276
+ const at = models.order.indexOf(key)
277
+ if (at !== -1) models.order.splice(at, 1)
278
+ models.order.push(key)
279
+ if (!models.aggregates[key]) models.aggregates[key] = emptyAggregate()
280
+ }
281
+
206
282
  const prune = (now = Date.now()) => {
207
283
  let changed = false
208
284
 
@@ -228,12 +304,16 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
228
304
  bump()
229
305
  }
230
306
 
231
- const liveRate = (sessionID: string) => {
307
+ /** Live rate restricted to samples attributed to `key` via the `messageID -> model` mapping. */
308
+ const liveRateFor = (sessionID: string, key: string) => {
232
309
  const status = api.state.session.status(sessionID)
233
310
  if (!status || status.type === "idle") return undefined
234
311
 
235
312
  const now = Date.now()
236
- const samples = (tracker.samples[sessionID] ?? []).filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
313
+ const mapping = tracker.models[sessionID]?.messageModels
314
+ const samples = (tracker.samples[sessionID] ?? []).filter(
315
+ (sample) => now - sample.at <= STREAM_WINDOW_MS && mapping?.get(sample.messageID) === key,
316
+ )
237
317
  const last = samples[samples.length - 1]
238
318
  if (!last || now - last.at > LIVE_STALE_MS) return undefined
239
319
 
@@ -241,13 +321,38 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
241
321
  return formatRate(tokens / (activeDuration(samples, now) / 1_000))
242
322
  }
243
323
 
244
- const statusText = (sessionID: string) => {
245
- const average = tracker.averages[sessionID]
246
- const stats = tracker.stats[sessionID]
247
- const live = liveRate(sessionID) ?? "-"
248
- const avg = averageRate(average) ?? "-"
249
- const ttft = averageTtft(average) ?? "-"
250
- return `TPS ${live} | AVG ${avg} | TTFT ${ttft} | CACHE ${cacheRate(stats)} | COST ${costText(stats)} | RETRY ${stats?.retries ?? 0}`
324
+ const modelRows = (sessionID: string): ModelPanel => {
325
+ const models = tracker.models[sessionID]
326
+ const retries = tracker.stats[sessionID]?.retries ?? 0
327
+ const cache = cacheText(tracker.stats[sessionID])
328
+ if (!models) return { rows: [], overflow: 0, retries, cache }
329
+
330
+ const active = models.active
331
+ // `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
335
+
336
+ const visible = ordered.slice(0, MODEL_ROW_LIMIT)
337
+ const overflow = Math.max(0, ordered.length - visible.length)
338
+
339
+ const rows = visible.map<ModelRow>((key) => {
340
+ const aggregate = models.aggregates[key]
341
+ const isActive = key === active
342
+ const cost = costText(aggregate)
343
+ return {
344
+ key,
345
+ label: key,
346
+ active: isActive,
347
+ tps: isActive ? liveRateFor(sessionID, key) : undefined,
348
+ avg: averageRate(aggregate),
349
+ ttft: averageTtft(aggregate),
350
+ cache: cacheText(aggregate),
351
+ cost: cost === undefined ? undefined : cost,
352
+ }
353
+ })
354
+
355
+ return { rows, overflow, retries, cache }
251
356
  }
252
357
 
253
358
  listeners.push(
@@ -269,6 +374,7 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
269
374
 
270
375
  appendSample(sessionID, messageID, {
271
376
  at: Date.now(),
377
+ messageID,
272
378
  tokens: estimateTokens(event.properties.delta),
273
379
  })
274
380
  }),
@@ -279,6 +385,15 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
279
385
  const info = event.properties.info
280
386
  if (info.role !== "assistant") return
281
387
  const sessionID = event.properties.sessionID
388
+ const key = modelKey(info.providerID, info.modelID)
389
+
390
+ // Observe the model as soon as a message appears so the sidebar can attribute live samples
391
+ // and mark the most recently used model as active.
392
+ const models =
393
+ tracker.models[sessionID] ?? (tracker.models[sessionID] = { order: [], aggregates: {}, messageModels: new Map() })
394
+ touchModel(models, key)
395
+ models.active = key
396
+ boundedSet(models.messageModels, info.id, key, MESSAGE_MODEL_LIMIT)
282
397
 
283
398
  if (!info.time.completed) {
284
399
  const existing = tracker.timings[info.id]
@@ -308,6 +423,14 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
308
423
  cost: stats.cost + info.cost,
309
424
  steps: stats.steps + 1,
310
425
  }
426
+ const aggregate = models.aggregates[key] ?? emptyAggregate()
427
+ models.aggregates[key] = {
428
+ ...aggregate,
429
+ cacheRead: aggregate.cacheRead + cacheRead,
430
+ cachePrompt: aggregate.cachePrompt + info.tokens.input + cacheRead + cacheWrite,
431
+ cost: aggregate.cost + info.cost,
432
+ steps: aggregate.steps + 1,
433
+ }
311
434
  }
312
435
 
313
436
  const timing = tracker.timings[info.id]
@@ -328,6 +451,14 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
328
451
  totalTtftMs: totals.totalTtftMs + Math.max(timing.firstResponseAt - timing.requestStartAt, 0),
329
452
  stepCount: totals.stepCount + 1,
330
453
  }
454
+ const aggregate = models.aggregates[key] ?? emptyAggregate()
455
+ models.aggregates[key] = {
456
+ ...aggregate,
457
+ totalTokens: aggregate.totalTokens + tokens,
458
+ totalDurationMs: aggregate.totalDurationMs + durationMs,
459
+ totalTtftMs: aggregate.totalTtftMs + Math.max(timing.firstResponseAt - timing.requestStartAt, 0),
460
+ stepCount: aggregate.stepCount + 1,
461
+ }
331
462
  }
332
463
  }
333
464
  delete tracker.timings[info.id]
@@ -396,6 +527,7 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
396
527
  }),
397
528
  )
398
529
 
530
+ // The 1s tick re-evaluates live TPS staleness and prunes stale stream samples.
399
531
  const timer = setInterval(() => {
400
532
  prune()
401
533
  bump()
@@ -415,11 +547,12 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
415
547
  dedupSize: (sessionID) => tracker.dedup[sessionID]?.size ?? 0,
416
548
  retries: (sessionID) => tracker.stats[sessionID]?.retries ?? 0,
417
549
  mounted: () => [...mounted],
550
+ activeModel: (sessionID) => tracker.models[sessionID]?.active,
418
551
  }
419
552
  lastInternals = internals
420
553
 
421
554
  return {
422
- statusText,
555
+ modelRows,
423
556
  mountSession(sessionID) {
424
557
  mounted.add(sessionID)
425
558
  },
@@ -437,3 +570,26 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
437
570
  },
438
571
  }
439
572
  }
573
+
574
+ /** Test-only fold controller. Kept in this non-entry module: the plugin entry (`tui.tsx`)
575
+ * may only expose a `default` export, because the legacy loader treats every runtime export
576
+ * as a plugin. */
577
+ export type PanelController = {
578
+ toggle(key: string): void
579
+ isExpanded(key: string): boolean
580
+ }
581
+
582
+ let lastPanelController: PanelController | undefined
583
+
584
+ export function setLastPanelController(controller: PanelController) {
585
+ lastPanelController = controller
586
+ }
587
+
588
+ /** Clear the controller only when it is still the current one. */
589
+ export function clearLastPanelController(controller: PanelController) {
590
+ if (lastPanelController === controller) lastPanelController = undefined
591
+ }
592
+
593
+ export function getLastPanelController(): PanelController | undefined {
594
+ return lastPanelController
595
+ }
package/tui.tsx CHANGED
@@ -1,17 +1,68 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
- import { createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
4
- import { createTracker, type TrackerHandle } from "./tracker"
3
+ import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
4
+ import {
5
+ clearLastPanelController,
6
+ createTracker,
7
+ setLastPanelController,
8
+ type ModelRow,
9
+ type TrackerHandle,
10
+ } from "./tracker"
5
11
 
6
- function SessionPromptRight(props: {
12
+ /** A single model row: fold marker + summary line, plus a detail line when expanded. */
13
+ function ModelRowView(props: { api: TuiPluginApi; row: ModelRow; expanded: boolean; onToggle: () => void }) {
14
+ // Guard against a drag-select being read as a click: only toggle when the press and the
15
+ // release land on the same cell.
16
+ let pressed: { x: number; y: number } | undefined
17
+
18
+ const handleMouseDown = (event: { x: number; y: number }) => {
19
+ pressed = { x: event.x, y: event.y }
20
+ }
21
+
22
+ const handleMouseUp = (event: { x: number; y: number }) => {
23
+ const start = pressed
24
+ pressed = undefined
25
+ if (start && (start.x !== event.x || start.y !== event.y)) return
26
+ props.onToggle()
27
+ }
28
+
29
+ const theme = () => props.api.theme.current
30
+
31
+ return (
32
+ <box flexDirection="column" onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}>
33
+ <text wrapMode="none" truncate>
34
+ <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>
38
+ </Show>
39
+ </text>
40
+ <Show when={props.expanded}>
41
+ <text fg={theme().text} wrapMode="none" truncate>
42
+ {`TPS ${props.row.tps ?? "—"} · AVG ${props.row.avg ?? "—"}`}
43
+ </text>
44
+ <text fg={theme().text} wrapMode="none" truncate>
45
+ {`TTFT ${props.row.ttft ?? "—"} · CACHE ${props.row.cache ?? "—"} · ${props.row.cost ?? "—"}`}
46
+ </text>
47
+ </Show>
48
+ </box>
49
+ )
50
+ }
51
+
52
+ /** Per-session model panel, mounted at `sidebar_content`. */
53
+ function ModelPanelView(props: {
7
54
  api: TuiPluginApi
8
55
  sessionID: string
9
56
  tracker: TrackerHandle
10
57
  revision: Accessor<number>
11
58
  }) {
12
59
  let mounted: string | undefined
60
+ const initialActive = props.tracker.internals.activeModel(props.sessionID)
61
+ const [expanded, setExpanded] = createSignal<Set<string>>(new Set(initialActive ? [initialActive] : []))
62
+ let seededActive: string | undefined = initialActive
63
+ let lastSession: string | undefined = props.sessionID
13
64
 
14
- const content = createMemo(() => {
65
+ const panel = createMemo(() => {
15
66
  props.revision()
16
67
  const sessionID = props.sessionID
17
68
  if (mounted !== sessionID) {
@@ -19,14 +70,65 @@ function SessionPromptRight(props: {
19
70
  props.tracker.mountSession(sessionID)
20
71
  mounted = sessionID
21
72
  }
22
- return props.tracker.statusText(sessionID)
73
+ return props.tracker.modelRows(sessionID)
74
+ })
75
+
76
+ // Fold state is in-memory only and resets per session. The active model starts expanded and
77
+ // every other row starts collapsed; when the active model changes we return to that default
78
+ // so the panel cannot accumulate open rows as models are switched.
79
+ createEffect(() => {
80
+ props.revision()
81
+ const sessionID = props.sessionID
82
+ const active = props.tracker.internals.activeModel(sessionID)
83
+ if (sessionID !== lastSession) {
84
+ lastSession = sessionID
85
+ seededActive = active
86
+ setExpanded(new Set<string>(active ? [active] : []))
87
+ return
88
+ }
89
+ if (active && active !== seededActive) {
90
+ seededActive = active
91
+ setExpanded(new Set<string>([active]))
92
+ }
23
93
  })
24
94
 
95
+ const toggle = (key: string) => {
96
+ setExpanded((prev) => {
97
+ const next = new Set(prev)
98
+ if (next.has(key)) next.delete(key)
99
+ else next.add(key)
100
+ return next
101
+ })
102
+ props.api.renderer?.requestRender?.()
103
+ }
104
+
105
+ const controller = { toggle, isExpanded: (key: string) => expanded().has(key) }
106
+ setLastPanelController(controller)
107
+
25
108
  onCleanup(() => {
26
109
  if (mounted !== undefined) props.tracker.unmountSession(mounted)
110
+ clearLastPanelController(controller)
27
111
  })
28
112
 
29
- return <text fg={props.api.theme.current.textMuted}>{content()}</text>
113
+ return (
114
+ <box flexDirection="column">
115
+ <text fg={props.api.theme.current.textMuted}>Performance · observed</text>
116
+ <Show when={panel().rows.length === 0}>
117
+ <text fg={props.api.theme.current.textMuted}>—</text>
118
+ </Show>
119
+ <For each={panel().rows}>
120
+ {(row) => (
121
+ <ModelRowView api={props.api} row={row} expanded={expanded().has(row.key)} onToggle={() => toggle(row.key)} />
122
+ )}
123
+ </For>
124
+ <Show when={panel().overflow > 0}>
125
+ <text fg={props.api.theme.current.textMuted}>{`+${panel().overflow} more models`}</text>
126
+ </Show>
127
+ <text fg={props.api.theme.current.text}>
128
+ {`Session CACHE ${panel().cache ?? "—"}${panel().retries > 0 ? ` · retries ${panel().retries}` : ""}`}
129
+ </text>
130
+ </box>
131
+ )
30
132
  }
31
133
 
32
134
  const tui: TuiPlugin = async (api) => {
@@ -37,8 +139,8 @@ const tui: TuiPlugin = async (api) => {
37
139
 
38
140
  api.slots.register({
39
141
  slots: {
40
- session_prompt_right(_context, props) {
41
- return <SessionPromptRight api={api} sessionID={props.session_id} tracker={tracker} revision={revision} />
142
+ sidebar_content(_context, props) {
143
+ return <ModelPanelView api={api} sessionID={props.session_id} tracker={tracker} revision={revision} />
42
144
  },
43
145
  },
44
146
  })