opencode-tui-stats 0.1.1 → 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 +2 -1
  2. package/tracker.ts +595 -0
  3. package/tui.tsx +114 -209
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.1.1",
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": {
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "files": [
35
35
  "tui.tsx",
36
+ "tracker.ts",
36
37
  "README.md",
37
38
  "assets/"
38
39
  ]
package/tracker.ts ADDED
@@ -0,0 +1,595 @@
1
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
2
+
3
+ export type StreamSample = {
4
+ at: number
5
+ messageID: string
6
+ tokens: number
7
+ }
8
+
9
+ export type MessageTiming = {
10
+ sessionID: string
11
+ requestStartAt: number
12
+ firstResponseAt?: number
13
+ lastToolCallAt?: number
14
+ }
15
+
16
+ export type SessionAverage = {
17
+ totalTokens: number
18
+ totalDurationMs: number
19
+ totalTtftMs: number
20
+ stepCount: number
21
+ }
22
+
23
+ export type SessionStats = {
24
+ cacheRead: number
25
+ cachePrompt: number
26
+ cost: number
27
+ steps: number
28
+ retries: number
29
+ lastAttempt: number
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
+
56
+ export type Tracker = {
57
+ samples: Record<string, StreamSample[]>
58
+ timings: Record<string, MessageTiming>
59
+ averages: Record<string, SessionAverage>
60
+ stats: Record<string, SessionStats>
61
+ dedup: Record<string, Set<string>>
62
+ models: Record<string, SessionModels>
63
+ }
64
+
65
+ const STREAM_WINDOW_MS = 5_000
66
+ const LIVE_STALE_MS = 1_500
67
+ const SINGLE_SAMPLE_MS = 1_000
68
+ const PART_CACHE_LIMIT = 1_024
69
+ /** Per-session dedup window: only the most recent completed messages are deduplicated;
70
+ * a repeated completed event beyond this window accumulates again. */
71
+ const DEDUP_WINDOW_LIMIT = 64
72
+ /** Cap on `messageID -> model` entries retained per session (FIFO). */
73
+ const MESSAGE_MODEL_LIMIT = 256
74
+ const SESSION_LIMIT = 64
75
+ /** Maximum number of model rows rendered in the sidebar panel. */
76
+ export const MODEL_ROW_LIMIT = 4
77
+
78
+ function emptyStats(): SessionStats {
79
+ return { cacheRead: 0, cachePrompt: 0, cost: 0, steps: 0, retries: 0, lastAttempt: 0 }
80
+ }
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
+
99
+ export function estimateTokens(delta: string) {
100
+ return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5))
101
+ }
102
+
103
+ export function formatRate(value: number) {
104
+ if (!Number.isFinite(value) || value <= 0) return undefined
105
+ if (value >= 100) return Math.round(value).toString()
106
+ if (value >= 10) return value.toFixed(1)
107
+ return value.toFixed(2)
108
+ }
109
+
110
+ export function formatTtft(value: number) {
111
+ if (!Number.isFinite(value) || value < 0) return undefined
112
+ return `${value.toFixed(1)}s`
113
+ }
114
+
115
+ export function activeDuration(samples: StreamSample[], tailAt?: number) {
116
+ if (samples.length === 0) return 0
117
+ if (samples.length === 1) {
118
+ const tailDuration = tailAt ? Math.max(0, tailAt - samples[0].at) : SINGLE_SAMPLE_MS
119
+ return Math.min(Math.max(tailDuration, 250), SINGLE_SAMPLE_MS)
120
+ }
121
+
122
+ let duration = 0
123
+ for (let index = 1; index < samples.length; index++) {
124
+ duration += Math.max(0, samples[index].at - samples[index - 1].at)
125
+ }
126
+ if (tailAt) duration += Math.max(0, tailAt - samples[samples.length - 1].at)
127
+ return Math.max(duration, SINGLE_SAMPLE_MS)
128
+ }
129
+
130
+ export function averageRate(average: SessionAverage | undefined) {
131
+ if (!average || average.totalTokens <= 0 || average.totalDurationMs <= 0) return undefined
132
+ return formatRate(average.totalTokens / (average.totalDurationMs / 1_000))
133
+ }
134
+
135
+ export function averageTtft(average: SessionAverage | undefined) {
136
+ if (!average || average.stepCount <= 0) return undefined
137
+ return formatTtft(average.totalTtftMs / average.stepCount / 1_000)
138
+ }
139
+
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
143
+ return `${Math.round((stats.cacheRead / stats.cachePrompt) * 100)}%`
144
+ }
145
+
146
+ export function costText(stats: { cost: number; steps: number } | undefined) {
147
+ if (!stats || stats.steps <= 0) return undefined
148
+ return `$${stats.cost >= 1 ? stats.cost.toFixed(2) : stats.cost.toFixed(4)}`
149
+ }
150
+
151
+ /** FIFO-bound a Set so it never grows past `limit`. */
152
+ function boundedAdd(set: Set<string>, key: string, limit: number) {
153
+ if (set.has(key)) return
154
+ if (set.size >= limit) {
155
+ const oldest = set.values().next().value
156
+ if (oldest !== undefined) set.delete(oldest)
157
+ }
158
+ set.add(key)
159
+ }
160
+
161
+ /** FIFO-bound a Map so it never grows past `limit`. */
162
+ function boundedSet<K, V>(map: Map<K, V>, key: K, value: V, limit: number) {
163
+ if (map.has(key)) return
164
+ if (map.size >= limit) {
165
+ const oldest = map.keys().next().value
166
+ if (oldest !== undefined) map.delete(oldest)
167
+ }
168
+ map.set(key, value)
169
+ }
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
+
190
+ export type TrackerInternals = {
191
+ sizes(): {
192
+ parts: number
193
+ dedup: number
194
+ stats: number
195
+ averages: number
196
+ timings: number
197
+ mounted: number
198
+ }
199
+ partDecision(partID: string): boolean | undefined
200
+ hasStats(sessionID: string): boolean
201
+ dedupSize(sessionID: string): number
202
+ retries(sessionID: string): number
203
+ mounted(): string[]
204
+ activeModel(sessionID: string): string | undefined
205
+ }
206
+
207
+ export type TrackerHandle = {
208
+ modelRows(sessionID: string): ModelPanel
209
+ mountSession(sessionID: string): void
210
+ unmountSession(sessionID: string): void
211
+ prune(now?: number): void
212
+ internals: TrackerInternals
213
+ dispose(): void
214
+ }
215
+
216
+ let lastInternals: TrackerInternals | undefined
217
+
218
+ /** Test-only access to the most recently created tracker's internals. */
219
+ export function getLastInternals(): TrackerInternals | undefined {
220
+ return lastInternals
221
+ }
222
+
223
+ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerHandle {
224
+ const tracker: Tracker = { samples: {}, timings: {}, averages: {}, stats: {}, dedup: {}, models: {} }
225
+ const partDecisions = new Map<string, boolean>()
226
+ const mounted = new Set<string>()
227
+ const order: string[] = []
228
+ const listeners: Array<() => void> = []
229
+
230
+ const bump = () => onChange()
231
+
232
+ const clearLive = (sessionID: string) => {
233
+ if (!tracker.samples[sessionID]?.length) return
234
+ delete tracker.samples[sessionID]
235
+ bump()
236
+ }
237
+
238
+ const clearTimings = (sessionID: string) => {
239
+ let changed = false
240
+ for (const [messageID, timing] of Object.entries(tracker.timings)) {
241
+ if (timing.sessionID !== sessionID) continue
242
+ delete tracker.timings[messageID]
243
+ changed = true
244
+ }
245
+ if (changed) bump()
246
+ }
247
+
248
+ const evictSession = (sessionID: string) => {
249
+ delete tracker.stats[sessionID]
250
+ delete tracker.averages[sessionID]
251
+ delete tracker.samples[sessionID]
252
+ delete tracker.dedup[sessionID]
253
+ delete tracker.models[sessionID]
254
+ clearTimings(sessionID)
255
+ }
256
+
257
+ const touchSession = (sessionID: string) => {
258
+ const at = order.indexOf(sessionID)
259
+ if (at !== -1) order.splice(at, 1)
260
+ order.push(sessionID)
261
+
262
+ // Evict least recently used sessions past the cap, never the sessions currently mounted for display.
263
+ let index = 0
264
+ while (order.length > SESSION_LIMIT && index < order.length) {
265
+ const candidate = order[index]
266
+ if (mounted.has(candidate)) {
267
+ index += 1
268
+ continue
269
+ }
270
+ order.splice(index, 1)
271
+ evictSession(candidate)
272
+ }
273
+ }
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
+
282
+ const prune = (now = Date.now()) => {
283
+ let changed = false
284
+
285
+ for (const [sessionID, samples] of Object.entries(tracker.samples)) {
286
+ const next = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
287
+ if (next.length === samples.length) continue
288
+ changed = true
289
+ if (next.length > 0) tracker.samples[sessionID] = next
290
+ else delete tracker.samples[sessionID]
291
+ }
292
+
293
+ if (changed) bump()
294
+ }
295
+
296
+ const appendSample = (sessionID: string, messageID: string, sample: StreamSample) => {
297
+ touchSession(sessionID)
298
+ tracker.samples[sessionID] = [
299
+ ...(tracker.samples[sessionID] ?? []).filter((item) => sample.at - item.at <= STREAM_WINDOW_MS),
300
+ sample,
301
+ ]
302
+ const timing = tracker.timings[messageID]
303
+ if (timing && timing.firstResponseAt === undefined) timing.firstResponseAt = sample.at
304
+ bump()
305
+ }
306
+
307
+ /** Live rate restricted to samples attributed to `key` via the `messageID -> model` mapping. */
308
+ const liveRateFor = (sessionID: string, key: string) => {
309
+ const status = api.state.session.status(sessionID)
310
+ if (!status || status.type === "idle") return undefined
311
+
312
+ const now = Date.now()
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
+ )
317
+ const last = samples[samples.length - 1]
318
+ if (!last || now - last.at > LIVE_STALE_MS) return undefined
319
+
320
+ const tokens = samples.reduce((sum, sample) => sum + sample.tokens, 0)
321
+ return formatRate(tokens / (activeDuration(samples, now) / 1_000))
322
+ }
323
+
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 }
356
+ }
357
+
358
+ listeners.push(
359
+ api.event.on("message.part.delta", (event) => {
360
+ if (event.properties.field !== "text") return
361
+ const { sessionID, messageID, partID } = event.properties
362
+
363
+ // partID → decision (true = counted, false = ignored). A part that is not registered yet is
364
+ // never cached, so the next delta retries the lookup; once found the accept/reject decision
365
+ // is cached so each part is scanned at most once.
366
+ let counted = partDecisions.get(partID)
367
+ if (counted === undefined) {
368
+ const part = api.state.part(messageID).find((item) => item.id === partID)
369
+ if (!part) return
370
+ counted = part.type === "text" || part.type === "reasoning"
371
+ boundedSet(partDecisions, partID, counted, PART_CACHE_LIMIT)
372
+ }
373
+ if (!counted) return
374
+
375
+ appendSample(sessionID, messageID, {
376
+ at: Date.now(),
377
+ messageID,
378
+ tokens: estimateTokens(event.properties.delta),
379
+ })
380
+ }),
381
+ )
382
+
383
+ listeners.push(
384
+ api.event.on("message.updated", (event) => {
385
+ const info = event.properties.info
386
+ if (info.role !== "assistant") return
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)
397
+
398
+ if (!info.time.completed) {
399
+ const existing = tracker.timings[info.id]
400
+ tracker.timings[info.id] = {
401
+ sessionID,
402
+ requestStartAt: info.time.created,
403
+ firstResponseAt: existing?.firstResponseAt,
404
+ lastToolCallAt: existing?.lastToolCallAt,
405
+ }
406
+ bump()
407
+ return
408
+ }
409
+
410
+ // Dedup only the most recent completed messages for this session; a repeated completed
411
+ // event beyond the window accumulates again.
412
+ const seen = tracker.dedup[sessionID] ?? (tracker.dedup[sessionID] = new Set<string>())
413
+ if (!seen.has(info.id)) {
414
+ boundedAdd(seen, info.id, DEDUP_WINDOW_LIMIT)
415
+ touchSession(sessionID)
416
+ const stats = tracker.stats[sessionID] ?? emptyStats()
417
+ const cacheRead = info.tokens.cache.read
418
+ const cacheWrite = info.tokens.cache.write
419
+ tracker.stats[sessionID] = {
420
+ ...stats,
421
+ cacheRead: stats.cacheRead + cacheRead,
422
+ cachePrompt: stats.cachePrompt + info.tokens.input + cacheRead + cacheWrite,
423
+ cost: stats.cost + info.cost,
424
+ steps: stats.steps + 1,
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
+ }
434
+ }
435
+
436
+ const timing = tracker.timings[info.id]
437
+ if (timing?.sessionID === sessionID && timing.firstResponseAt !== undefined) {
438
+ const tokens = info.tokens.output + info.tokens.reasoning
439
+ const endAt = info.finish === "tool-calls" ? timing.lastToolCallAt : info.time.completed
440
+ const durationMs = endAt !== undefined ? Math.max(endAt - timing.firstResponseAt, 1) : undefined
441
+ if (tokens > 0 && durationMs !== undefined) {
442
+ const totals = tracker.averages[sessionID] ?? {
443
+ totalTokens: 0,
444
+ totalDurationMs: 0,
445
+ totalTtftMs: 0,
446
+ stepCount: 0,
447
+ }
448
+ tracker.averages[sessionID] = {
449
+ totalTokens: totals.totalTokens + tokens,
450
+ totalDurationMs: totals.totalDurationMs + durationMs,
451
+ totalTtftMs: totals.totalTtftMs + Math.max(timing.firstResponseAt - timing.requestStartAt, 0),
452
+ stepCount: totals.stepCount + 1,
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
+ }
462
+ }
463
+ }
464
+ delete tracker.timings[info.id]
465
+ prune(info.time.completed)
466
+ bump()
467
+ }),
468
+ )
469
+
470
+ listeners.push(
471
+ api.event.on("message.part.updated", (event) => {
472
+ const part = event.properties.part
473
+ if (part.type !== "tool") return
474
+
475
+ if (part.state.status === "running" || part.state.status === "completed" || part.state.status === "error") {
476
+ clearLive(part.sessionID)
477
+ }
478
+
479
+ const timing = tracker.timings[part.messageID]
480
+ if (!timing) return
481
+
482
+ if (part.state.status === "pending") {
483
+ if (timing.firstResponseAt === undefined) timing.firstResponseAt = Date.now()
484
+ bump()
485
+ return
486
+ }
487
+ if (part.state.status === "running") {
488
+ timing.lastToolCallAt = part.state.time.start
489
+ bump()
490
+ }
491
+ }),
492
+ )
493
+
494
+ listeners.push(
495
+ api.event.on("session.status", (event) => {
496
+ const { sessionID, status } = event.properties
497
+ const type = status.type
498
+
499
+ if (type === "idle") clearTimings(sessionID)
500
+
501
+ const stats = tracker.stats[sessionID] ?? emptyStats()
502
+
503
+ if (type === "retry") {
504
+ const attempt = status.attempt
505
+ if (attempt <= stats.lastAttempt) return
506
+ touchSession(sessionID)
507
+ tracker.stats[sessionID] = {
508
+ ...stats,
509
+ retries: stats.retries + (attempt - stats.lastAttempt),
510
+ lastAttempt: attempt,
511
+ }
512
+ bump()
513
+ return
514
+ }
515
+
516
+ // Any non-retry status resets the attempt baseline for the next retry burst.
517
+ if (stats.lastAttempt === 0) return
518
+ touchSession(sessionID)
519
+ tracker.stats[sessionID] = { ...stats, lastAttempt: 0 }
520
+ bump()
521
+ }),
522
+ )
523
+
524
+ listeners.push(
525
+ api.event.on("session.idle", (event) => {
526
+ clearTimings(event.properties.sessionID)
527
+ }),
528
+ )
529
+
530
+ // The 1s tick re-evaluates live TPS staleness and prunes stale stream samples.
531
+ const timer = setInterval(() => {
532
+ prune()
533
+ bump()
534
+ }, 1_000)
535
+
536
+ const internals: TrackerInternals = {
537
+ sizes: () => ({
538
+ parts: partDecisions.size,
539
+ dedup: Object.values(tracker.dedup).reduce((sum, set) => sum + set.size, 0),
540
+ stats: Object.keys(tracker.stats).length,
541
+ averages: Object.keys(tracker.averages).length,
542
+ timings: Object.keys(tracker.timings).length,
543
+ mounted: mounted.size,
544
+ }),
545
+ partDecision: (partID) => partDecisions.get(partID),
546
+ hasStats: (sessionID) => sessionID in tracker.stats,
547
+ dedupSize: (sessionID) => tracker.dedup[sessionID]?.size ?? 0,
548
+ retries: (sessionID) => tracker.stats[sessionID]?.retries ?? 0,
549
+ mounted: () => [...mounted],
550
+ activeModel: (sessionID) => tracker.models[sessionID]?.active,
551
+ }
552
+ lastInternals = internals
553
+
554
+ return {
555
+ modelRows,
556
+ mountSession(sessionID) {
557
+ mounted.add(sessionID)
558
+ },
559
+ unmountSession(sessionID) {
560
+ mounted.delete(sessionID)
561
+ },
562
+ prune,
563
+ internals,
564
+ dispose() {
565
+ for (const unsubscribe of listeners) unsubscribe()
566
+ listeners.length = 0
567
+ clearInterval(timer)
568
+ mounted.clear()
569
+ if (lastInternals === internals) lastInternals = undefined
570
+ },
571
+ }
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,241 +1,146 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
2
  import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
3
- import { createMemo, createSignal, type Accessor } from "solid-js"
4
-
5
- type StreamSample = {
6
- at: number
7
- tokens: number
8
- }
9
-
10
- type MessageTiming = {
11
- sessionID: string
12
- requestStartAt: number
13
- firstResponseAt?: number
14
- lastToolCallAt?: number
15
- }
16
-
17
- type SessionAverage = {
18
- totalTokens: number
19
- totalDurationMs: number
20
- totalTtftMs: number
21
- stepCount: number
22
- }
23
-
24
- type Tracker = {
25
- samples: Record<string, StreamSample[]>
26
- timings: Record<string, MessageTiming>
27
- averages: Record<string, SessionAverage>
28
- }
29
-
30
- const STREAM_WINDOW_MS = 5_000
31
- const LIVE_STALE_MS = 1_500
32
- const SINGLE_SAMPLE_MS = 1_000
33
-
34
- function estimateTokens(delta: string) {
35
- return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5))
36
- }
37
-
38
- function formatRate(value: number) {
39
- if (!Number.isFinite(value) || value <= 0) return undefined
40
- if (value >= 100) return Math.round(value).toString()
41
- if (value >= 10) return value.toFixed(1)
42
- return value.toFixed(2)
43
- }
44
-
45
- function formatTtft(value: number) {
46
- if (!Number.isFinite(value) || value < 0) return undefined
47
- return `${value.toFixed(1)}s`
48
- }
49
-
50
- function activeDuration(samples: StreamSample[], tailAt?: number) {
51
- if (samples.length === 0) return 0
52
- if (samples.length === 1) {
53
- const tailDuration = tailAt ? Math.max(0, tailAt - samples[0].at) : SINGLE_SAMPLE_MS
54
- return Math.min(Math.max(tailDuration, 250), SINGLE_SAMPLE_MS)
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"
11
+
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 }
55
20
  }
56
21
 
57
- let duration = 0
58
- for (let index = 1; index < samples.length; index++) {
59
- duration += Math.max(0, samples[index].at - samples[index - 1].at)
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()
60
27
  }
61
- if (tailAt) duration += Math.max(0, tailAt - samples[samples.length - 1].at)
62
- return Math.max(duration, SINGLE_SAMPLE_MS)
63
- }
64
-
65
- function averageRate(average: SessionAverage | undefined) {
66
- if (!average || average.totalTokens <= 0 || average.totalDurationMs <= 0) return undefined
67
- return formatRate(average.totalTokens / (average.totalDurationMs / 1_000))
68
- }
69
-
70
- function averageTtft(average: SessionAverage | undefined) {
71
- if (!average || average.stepCount <= 0) return undefined
72
- return formatTtft(average.totalTtftMs / average.stepCount / 1_000)
73
- }
74
28
 
75
- function liveRate(api: TuiPluginApi, tracker: Tracker, sessionID: string) {
76
- const status = api.state.session.status(sessionID)
77
- if (!status || status.type === "idle") return undefined
78
-
79
- const now = Date.now()
80
- const samples = (tracker.samples[sessionID] ?? []).filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
81
- const last = samples[samples.length - 1]
82
- if (!last || now - last.at > LIVE_STALE_MS) return undefined
83
-
84
- const tokens = samples.reduce((sum, sample) => sum + sample.tokens, 0)
85
- return formatRate(tokens / (activeDuration(samples, now) / 1_000))
86
- }
87
-
88
- function statusText(api: TuiPluginApi, tracker: Tracker, sessionID: string) {
89
- const average = tracker.averages[sessionID]
90
- const live = liveRate(api, tracker, sessionID) ?? "-"
91
- const avg = averageRate(average) ?? "-"
92
- const ttft = averageTtft(average) ?? "-"
93
- return `TPS ${live} | AVG ${avg} | TTFT ${ttft}`
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
+ )
94
50
  }
95
51
 
96
- function SessionPromptRight(props: {
52
+ /** Per-session model panel, mounted at `sidebar_content`. */
53
+ function ModelPanelView(props: {
97
54
  api: TuiPluginApi
98
55
  sessionID: string
99
- tracker: Tracker
56
+ tracker: TrackerHandle
100
57
  revision: Accessor<number>
101
58
  }) {
102
- const content = createMemo(() => {
103
- props.revision()
104
- return statusText(props.api, props.tracker, props.sessionID)
105
- })
106
-
107
- return <text fg={props.api.theme.current.textMuted}>{content()}</text>
108
- }
109
-
110
- const tui: TuiPlugin = async (api) => {
111
- const tracker: Tracker = {
112
- samples: {},
113
- timings: {},
114
- averages: {},
115
- }
116
- const [revision, setRevision] = createSignal(0)
117
- const bump = () => setRevision((value) => value + 1)
118
-
119
- const pruneSamples = (now = Date.now()) => {
120
- let changed = false
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
121
64
 
122
- for (const [sessionID, samples] of Object.entries(tracker.samples)) {
123
- const next = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
124
- if (next.length === samples.length) continue
125
- changed = true
126
- if (next.length > 0) tracker.samples[sessionID] = next
127
- else delete tracker.samples[sessionID]
65
+ const panel = createMemo(() => {
66
+ props.revision()
67
+ const sessionID = props.sessionID
68
+ if (mounted !== sessionID) {
69
+ if (mounted !== undefined) props.tracker.unmountSession(mounted)
70
+ props.tracker.mountSession(sessionID)
71
+ mounted = sessionID
128
72
  }
129
-
130
- if (changed) bump()
131
- }
132
-
133
- const clearLive = (sessionID: string) => {
134
- if (!tracker.samples[sessionID]?.length) return
135
- delete tracker.samples[sessionID]
136
- bump()
137
- }
138
-
139
- const appendSample = (sessionID: string, messageID: string, sample: StreamSample) => {
140
- tracker.samples[sessionID] = [
141
- ...(tracker.samples[sessionID] ?? []).filter((item) => sample.at - item.at <= STREAM_WINDOW_MS),
142
- sample,
143
- ]
144
- const timing = tracker.timings[messageID]
145
- if (timing && timing.firstResponseAt === undefined) timing.firstResponseAt = sample.at
146
- bump()
147
- }
148
-
149
- const onDelta = api.event.on("message.part.delta", (event) => {
150
- if (event.properties.field !== "text") return
151
- const part = api.state.part(event.properties.messageID).find((item) => item.id === event.properties.partID)
152
- if (!part || (part.type !== "text" && part.type !== "reasoning")) return
153
- appendSample(event.properties.sessionID, event.properties.messageID, {
154
- at: Date.now(),
155
- tokens: estimateTokens(event.properties.delta),
156
- })
73
+ return props.tracker.modelRows(sessionID)
157
74
  })
158
75
 
159
- const onMessage = api.event.on("message.updated", (event) => {
160
- const info = event.properties.info
161
- if (info.role !== "assistant") return
162
- const sessionID = event.properties.sessionID
163
-
164
- if (!info.time.completed) {
165
- const existing = tracker.timings[info.id]
166
- tracker.timings[info.id] = {
167
- sessionID,
168
- requestStartAt: info.time.created,
169
- firstResponseAt: existing?.firstResponseAt,
170
- lastToolCallAt: existing?.lastToolCallAt,
171
- }
172
- bump()
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] : []))
173
87
  return
174
88
  }
175
-
176
- const timing = tracker.timings[info.id]
177
- if (timing?.sessionID === sessionID && timing.firstResponseAt !== undefined) {
178
- const tokens = info.tokens.output + info.tokens.reasoning
179
- const endAt = info.finish === "tool-calls" ? timing.lastToolCallAt : info.time.completed
180
- const durationMs = endAt !== undefined ? Math.max(endAt - timing.firstResponseAt, 1) : undefined
181
- if (tokens > 0 && durationMs !== undefined) {
182
- const totals = tracker.averages[sessionID] ?? {
183
- totalTokens: 0,
184
- totalDurationMs: 0,
185
- totalTtftMs: 0,
186
- stepCount: 0,
187
- }
188
- tracker.averages[sessionID] = {
189
- totalTokens: totals.totalTokens + tokens,
190
- totalDurationMs: totals.totalDurationMs + durationMs,
191
- totalTtftMs: totals.totalTtftMs + Math.max(timing.firstResponseAt - timing.requestStartAt, 0),
192
- stepCount: totals.stepCount + 1,
193
- }
194
- }
89
+ if (active && active !== seededActive) {
90
+ seededActive = active
91
+ setExpanded(new Set<string>([active]))
195
92
  }
196
- delete tracker.timings[info.id]
197
- pruneSamples(info.time.completed)
198
- bump()
199
93
  })
200
94
 
201
- const onPart = api.event.on("message.part.updated", (event) => {
202
- const part = event.properties.part
203
- if (part.type !== "tool") return
204
-
205
- if (part.state.status === "running" || part.state.status === "completed" || part.state.status === "error") {
206
- clearLive(part.sessionID)
207
- }
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
+ }
208
104
 
209
- const timing = tracker.timings[part.messageID]
210
- if (!timing) return
105
+ const controller = { toggle, isExpanded: (key: string) => expanded().has(key) }
106
+ setLastPanelController(controller)
211
107
 
212
- if (part.state.status === "pending") {
213
- if (timing.firstResponseAt === undefined) timing.firstResponseAt = event.properties.time
214
- bump()
215
- return
216
- }
217
- if (part.state.status === "running") {
218
- timing.lastToolCallAt = part.state.time.start
219
- bump()
220
- }
108
+ onCleanup(() => {
109
+ if (mounted !== undefined) props.tracker.unmountSession(mounted)
110
+ clearLastPanelController(controller)
221
111
  })
222
112
 
223
- const timer = setInterval(() => {
224
- pruneSamples()
225
- bump()
226
- }, 1_000)
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
+ )
132
+ }
227
133
 
228
- api.lifecycle.onDispose(() => {
229
- onDelta()
230
- onMessage()
231
- onPart()
232
- clearInterval(timer)
233
- })
134
+ const tui: TuiPlugin = async (api) => {
135
+ const [revision, setRevision] = createSignal(0)
136
+ const tracker = createTracker(api, () => setRevision((value) => value + 1))
137
+
138
+ api.lifecycle.onDispose(() => tracker.dispose())
234
139
 
235
140
  api.slots.register({
236
141
  slots: {
237
- session_prompt_right(_context, props) {
238
- 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} />
239
144
  },
240
145
  },
241
146
  })