opencode-tui-stats 0.2.0 → 0.4.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 +330 -17
  3. package/tui.tsx +157 -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.4.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,41 @@ 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
+ /** Last time this model was observed in this session (merged with `max` across a subtree). */
45
+ lastAt: number
46
+ }
47
+
48
+ export type SessionModels = {
49
+ /** Most recently observed assistant `providerID/modelID` for the session. */
50
+ active?: string
51
+ /** Model keys in observation order (oldest first, most recent last). */
52
+ order: string[]
53
+ aggregates: Record<string, ModelAggregate>
54
+ /** `messageID -> providerID/modelID`, used to attribute live samples to a model. */
55
+ messageModels: Map<string, string>
56
+ }
57
+
31
58
  export type Tracker = {
32
59
  samples: Record<string, StreamSample[]>
33
60
  timings: Record<string, MessageTiming>
34
61
  averages: Record<string, SessionAverage>
35
62
  stats: Record<string, SessionStats>
36
63
  dedup: Record<string, Set<string>>
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>
37
67
  }
38
68
 
39
69
  const STREAM_WINDOW_MS = 5_000
@@ -43,12 +73,36 @@ const PART_CACHE_LIMIT = 1_024
43
73
  /** Per-session dedup window: only the most recent completed messages are deduplicated;
44
74
  * a repeated completed event beyond this window accumulates again. */
45
75
  const DEDUP_WINDOW_LIMIT = 64
76
+ /** Cap on `messageID -> model` entries retained per session (FIFO). */
77
+ const MESSAGE_MODEL_LIMIT = 256
46
78
  const SESSION_LIMIT = 64
79
+ /** Maximum number of descendant sessions folded into one panel (cycle-safe traversal bound). */
80
+ const SUBTREE_LIMIT = 64
81
+ /** Maximum number of model rows rendered in the sidebar panel. */
82
+ export const MODEL_ROW_LIMIT = 4
47
83
 
48
84
  function emptyStats(): SessionStats {
49
85
  return { cacheRead: 0, cachePrompt: 0, cost: 0, steps: 0, retries: 0, lastAttempt: 0 }
50
86
  }
51
87
 
88
+ function emptyAggregate(): ModelAggregate {
89
+ return {
90
+ cacheRead: 0,
91
+ cachePrompt: 0,
92
+ cost: 0,
93
+ steps: 0,
94
+ totalTokens: 0,
95
+ totalDurationMs: 0,
96
+ totalTtftMs: 0,
97
+ stepCount: 0,
98
+ lastAt: 0,
99
+ }
100
+ }
101
+
102
+ export function modelKey(providerID: string, modelID: string) {
103
+ return `${providerID}/${modelID}`
104
+ }
105
+
52
106
  export function estimateTokens(delta: string) {
53
107
  return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5))
54
108
  }
@@ -90,14 +144,16 @@ export function averageTtft(average: SessionAverage | undefined) {
90
144
  return formatTtft(average.totalTtftMs / average.stepCount / 1_000)
91
145
  }
92
146
 
93
- export function cacheRate(stats: SessionStats | undefined) {
94
- if (!stats || stats.cachePrompt <= 0) return "-"
147
+ /** Cache hit percentage (e.g. `31%`) or `undefined` when nothing promptable has been seen. */
148
+ export function cacheText(stats: { cacheRead: number; cachePrompt: number } | undefined) {
149
+ if (!stats || stats.cachePrompt <= 0) return undefined
95
150
  return `${Math.round((stats.cacheRead / stats.cachePrompt) * 100)}%`
96
151
  }
97
152
 
98
- export function costText(stats: SessionStats | undefined) {
99
- if (!stats || stats.steps <= 0) return "-"
100
- return `$${stats.cost >= 1 ? stats.cost.toFixed(2) : stats.cost.toFixed(4)}`
153
+ export function costText(stats: { cost: number; steps: number } | undefined) {
154
+ if (!stats || stats.steps <= 0) return undefined
155
+ // Always four decimals so the cost column keeps a stable width as spend grows.
156
+ return `$${stats.cost.toFixed(4)}`
101
157
  }
102
158
 
103
159
  /** FIFO-bound a Set so it never grows past `limit`. */
@@ -120,6 +176,27 @@ function boundedSet<K, V>(map: Map<K, V>, key: K, value: V, limit: number) {
120
176
  map.set(key, value)
121
177
  }
122
178
 
179
+ export type ModelRow = {
180
+ key: string
181
+ label: string
182
+ active: boolean
183
+ /** True when any descendant (subagent) session contributed data to this row. */
184
+ subagent?: boolean
185
+ tps?: string
186
+ avg?: string
187
+ ttft?: string
188
+ cache?: string
189
+ cost?: string
190
+ }
191
+
192
+ export type ModelPanel = {
193
+ rows: ModelRow[]
194
+ overflow: number
195
+ retries: number
196
+ /** Session-level cache hit percentage (e.g. `31%`), or `undefined` when nothing promptable was seen. */
197
+ cache?: string
198
+ }
199
+
123
200
  export type TrackerInternals = {
124
201
  sizes(): {
125
202
  parts: number
@@ -134,10 +211,11 @@ export type TrackerInternals = {
134
211
  dedupSize(sessionID: string): number
135
212
  retries(sessionID: string): number
136
213
  mounted(): string[]
214
+ activeModel(sessionID: string): string | undefined
137
215
  }
138
216
 
139
217
  export type TrackerHandle = {
140
- statusText(sessionID: string): string
218
+ modelRows(sessionID: string): ModelPanel
141
219
  mountSession(sessionID: string): void
142
220
  unmountSession(sessionID: string): void
143
221
  prune(now?: number): void
@@ -153,7 +231,15 @@ export function getLastInternals(): TrackerInternals | undefined {
153
231
  }
154
232
 
155
233
  export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerHandle {
156
- const tracker: Tracker = { samples: {}, timings: {}, averages: {}, stats: {}, dedup: {} }
234
+ const tracker: Tracker = {
235
+ samples: {},
236
+ timings: {},
237
+ averages: {},
238
+ stats: {},
239
+ dedup: {},
240
+ models: {},
241
+ parentOf: {},
242
+ }
157
243
  const partDecisions = new Map<string, boolean>()
158
244
  const mounted = new Set<string>()
159
245
  const order: string[] = []
@@ -182,6 +268,8 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
182
268
  delete tracker.averages[sessionID]
183
269
  delete tracker.samples[sessionID]
184
270
  delete tracker.dedup[sessionID]
271
+ delete tracker.models[sessionID]
272
+ delete tracker.parentOf[sessionID]
185
273
  clearTimings(sessionID)
186
274
  }
187
275
 
@@ -203,6 +291,13 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
203
291
  }
204
292
  }
205
293
 
294
+ const touchModel = (models: SessionModels, key: string) => {
295
+ const at = models.order.indexOf(key)
296
+ if (at !== -1) models.order.splice(at, 1)
297
+ models.order.push(key)
298
+ if (!models.aggregates[key]) models.aggregates[key] = emptyAggregate()
299
+ }
300
+
206
301
  const prune = (now = Date.now()) => {
207
302
  let changed = false
208
303
 
@@ -228,12 +323,16 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
228
323
  bump()
229
324
  }
230
325
 
231
- const liveRate = (sessionID: string) => {
326
+ /** Live rate restricted to samples attributed to `key` via the `messageID -> model` mapping. */
327
+ const sessionLiveRate = (sessionID: string, key: string) => {
232
328
  const status = api.state.session.status(sessionID)
233
329
  if (!status || status.type === "idle") return undefined
234
330
 
235
331
  const now = Date.now()
236
- const samples = (tracker.samples[sessionID] ?? []).filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
332
+ const mapping = tracker.models[sessionID]?.messageModels
333
+ const samples = (tracker.samples[sessionID] ?? []).filter(
334
+ (sample) => now - sample.at <= STREAM_WINDOW_MS && mapping?.get(sample.messageID) === key,
335
+ )
237
336
  const last = samples[samples.length - 1]
238
337
  if (!last || now - last.at > LIVE_STALE_MS) return undefined
239
338
 
@@ -241,13 +340,162 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
241
340
  return formatRate(tokens / (activeDuration(samples, now) / 1_000))
242
341
  }
243
342
 
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}`
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) => {
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)]
434
+ const retries = tracker.stats[sessionID]?.retries ?? 0
435
+
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
+
475
+ // `order` is oldest→newest; reverse for most-recent-first ordering of the non-active rows.
476
+ const recency = [...order].reverse()
477
+ const ordered = active && active in aggregates ? [active, ...recency.filter((key) => key !== active)] : recency
478
+
479
+ const visible = ordered.slice(0, MODEL_ROW_LIMIT)
480
+ const overflow = Math.max(0, ordered.length - visible.length)
481
+
482
+ const rows = visible.map<ModelRow>((key) => {
483
+ const aggregate = aggregates[key]
484
+ const isActive = key === active
485
+ return {
486
+ key,
487
+ label: key,
488
+ active: isActive,
489
+ subagent: subagentModels.has(key),
490
+ tps: isActive ? liveRateFor(subtree, key) : undefined,
491
+ avg: averageRate(aggregate),
492
+ ttft: averageTtft(aggregate),
493
+ cache: cacheText(aggregate),
494
+ cost: costText(aggregate),
495
+ }
496
+ })
497
+
498
+ return { rows, overflow, retries, cache }
251
499
  }
252
500
 
253
501
  listeners.push(
@@ -269,6 +517,7 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
269
517
 
270
518
  appendSample(sessionID, messageID, {
271
519
  at: Date.now(),
520
+ messageID,
272
521
  tokens: estimateTokens(event.properties.delta),
273
522
  })
274
523
  }),
@@ -279,6 +528,16 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
279
528
  const info = event.properties.info
280
529
  if (info.role !== "assistant") return
281
530
  const sessionID = event.properties.sessionID
531
+ const key = modelKey(info.providerID, info.modelID)
532
+
533
+ // Observe the model as soon as a message appears so the sidebar can attribute live samples
534
+ // and mark the most recently used model as active.
535
+ const models =
536
+ tracker.models[sessionID] ?? (tracker.models[sessionID] = { order: [], aggregates: {}, messageModels: new Map() })
537
+ touchModel(models, key)
538
+ models.active = key
539
+ models.aggregates[key] = { ...models.aggregates[key], lastAt: Date.now() }
540
+ boundedSet(models.messageModels, info.id, key, MESSAGE_MODEL_LIMIT)
282
541
 
283
542
  if (!info.time.completed) {
284
543
  const existing = tracker.timings[info.id]
@@ -308,6 +567,14 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
308
567
  cost: stats.cost + info.cost,
309
568
  steps: stats.steps + 1,
310
569
  }
570
+ const aggregate = models.aggregates[key] ?? emptyAggregate()
571
+ models.aggregates[key] = {
572
+ ...aggregate,
573
+ cacheRead: aggregate.cacheRead + cacheRead,
574
+ cachePrompt: aggregate.cachePrompt + info.tokens.input + cacheRead + cacheWrite,
575
+ cost: aggregate.cost + info.cost,
576
+ steps: aggregate.steps + 1,
577
+ }
311
578
  }
312
579
 
313
580
  const timing = tracker.timings[info.id]
@@ -328,6 +595,14 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
328
595
  totalTtftMs: totals.totalTtftMs + Math.max(timing.firstResponseAt - timing.requestStartAt, 0),
329
596
  stepCount: totals.stepCount + 1,
330
597
  }
598
+ const aggregate = models.aggregates[key] ?? emptyAggregate()
599
+ models.aggregates[key] = {
600
+ ...aggregate,
601
+ totalTokens: aggregate.totalTokens + tokens,
602
+ totalDurationMs: aggregate.totalDurationMs + durationMs,
603
+ totalTtftMs: aggregate.totalTtftMs + Math.max(timing.firstResponseAt - timing.requestStartAt, 0),
604
+ stepCount: aggregate.stepCount + 1,
605
+ }
331
606
  }
332
607
  }
333
608
  delete tracker.timings[info.id]
@@ -396,6 +671,20 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
396
671
  }),
397
672
  )
398
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
+
687
+ // The 1s tick re-evaluates live TPS staleness and prunes stale stream samples.
399
688
  const timer = setInterval(() => {
400
689
  prune()
401
690
  bump()
@@ -415,11 +704,12 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
415
704
  dedupSize: (sessionID) => tracker.dedup[sessionID]?.size ?? 0,
416
705
  retries: (sessionID) => tracker.stats[sessionID]?.retries ?? 0,
417
706
  mounted: () => [...mounted],
707
+ activeModel: (sessionID) => tracker.models[sessionID]?.active,
418
708
  }
419
709
  lastInternals = internals
420
710
 
421
711
  return {
422
- statusText,
712
+ modelRows,
423
713
  mountSession(sessionID) {
424
714
  mounted.add(sessionID)
425
715
  },
@@ -437,3 +727,26 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
437
727
  },
438
728
  }
439
729
  }
730
+
731
+ /** Test-only fold controller. Kept in this non-entry module: the plugin entry (`tui.tsx`)
732
+ * may only expose a `default` export, because the legacy loader treats every runtime export
733
+ * as a plugin. */
734
+ export type PanelController = {
735
+ toggle(key: string): void
736
+ isExpanded(key: string): boolean
737
+ }
738
+
739
+ let lastPanelController: PanelController | undefined
740
+
741
+ export function setLastPanelController(controller: PanelController) {
742
+ lastPanelController = controller
743
+ }
744
+
745
+ /** Clear the controller only when it is still the current one. */
746
+ export function clearLastPanelController(controller: PanelController) {
747
+ if (lastPanelController === controller) lastPanelController = undefined
748
+ }
749
+
750
+ export function getLastPanelController(): PanelController | undefined {
751
+ return lastPanelController
752
+ }
package/tui.tsx CHANGED
@@ -1,17 +1,109 @@
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
+ /** 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 = 4
18
+
19
+ /** Split `provider/model` so the provider can be muted. Model ids may contain slashes, so only the
20
+ * 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(3)}%`
43
+ }
44
+
45
+ /** A single model row: fold marker + summary line, plus its detail lines when expanded. */
46
+ function ModelRowView(props: { api: TuiPluginApi; row: ModelRow; expanded: boolean; onToggle: () => void }) {
47
+ // Guard against a drag-select being read as a click: only toggle when the press and the
48
+ // release land on the same cell.
49
+ let pressed: { x: number; y: number } | undefined
50
+
51
+ const handleMouseDown = (event: { x: number; y: number }) => {
52
+ pressed = { x: event.x, y: event.y }
53
+ }
54
+
55
+ const handleMouseUp = (event: { x: number; y: number }) => {
56
+ const start = pressed
57
+ pressed = undefined
58
+ if (start && (start.x !== event.x || start.y !== event.y)) return
59
+ props.onToggle()
60
+ }
61
+
62
+ const theme = () => props.api.theme.current
63
+ const parts = () => splitLabel(props.row.label)
64
+
65
+ return (
66
+ <box flexDirection="column" onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}>
67
+ <text wrapMode="none" truncate>
68
+ <span style={{ fg: theme().textMuted } as Record<string, unknown>}>{`${props.expanded ? "▼" : "▶"} `}</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
+ <span style={{ fg: theme().textMuted } as Record<string, unknown>}>{parts().provider}</span>
74
+ </Show>
75
+ <span style={{ fg: theme().text } as Record<string, unknown>}>{parts().model}</span>
76
+ </text>
77
+ <Show when={props.expanded}>
78
+ <text fg={theme().textMuted} wrapMode="none" truncate>
79
+ {`${DETAIL_INDENT}AVG ${rateCell(props.row.avg)}${
80
+ props.row.tps === undefined ? "" : ` · TPS ${rateCell(props.row.tps)}`
81
+ }`}
82
+ </text>
83
+ <text fg={theme().textMuted} wrapMode="none" truncate>
84
+ {`${DETAIL_INDENT}TTFT ${ttftCell(props.row.ttft)} · CACHE ${cacheCell(props.row.cache)} · ${
85
+ props.row.cost ?? "—"
86
+ }`}
87
+ </text>
88
+ </Show>
89
+ </box>
90
+ )
91
+ }
92
+
93
+ /** Per-session model panel, mounted at `sidebar_footer`. */
94
+ function ModelPanelView(props: {
7
95
  api: TuiPluginApi
8
96
  sessionID: string
9
97
  tracker: TrackerHandle
10
98
  revision: Accessor<number>
11
99
  }) {
12
100
  let mounted: string | undefined
101
+ const initialActive = props.tracker.internals.activeModel(props.sessionID)
102
+ const [expanded, setExpanded] = createSignal<Set<string>>(new Set(initialActive ? [initialActive] : []))
103
+ let seededActive: string | undefined = initialActive
104
+ let lastSession: string | undefined = props.sessionID
13
105
 
14
- const content = createMemo(() => {
106
+ const panel = createMemo(() => {
15
107
  props.revision()
16
108
  const sessionID = props.sessionID
17
109
  if (mounted !== sessionID) {
@@ -19,14 +111,71 @@ function SessionPromptRight(props: {
19
111
  props.tracker.mountSession(sessionID)
20
112
  mounted = sessionID
21
113
  }
22
- return props.tracker.statusText(sessionID)
114
+ return props.tracker.modelRows(sessionID)
23
115
  })
24
116
 
117
+ // Fold state is in-memory only and resets per session. The active model starts expanded and
118
+ // every other row starts collapsed; when the active model changes we return to that default
119
+ // so the panel cannot accumulate open rows as models are switched.
120
+ createEffect(() => {
121
+ props.revision()
122
+ const sessionID = props.sessionID
123
+ const active = props.tracker.internals.activeModel(sessionID)
124
+ if (sessionID !== lastSession) {
125
+ lastSession = sessionID
126
+ seededActive = active
127
+ setExpanded(new Set<string>(active ? [active] : []))
128
+ return
129
+ }
130
+ if (active && active !== seededActive) {
131
+ seededActive = active
132
+ setExpanded(new Set<string>([active]))
133
+ }
134
+ })
135
+
136
+ const toggle = (key: string) => {
137
+ setExpanded((prev) => {
138
+ const next = new Set(prev)
139
+ if (next.has(key)) next.delete(key)
140
+ else next.add(key)
141
+ return next
142
+ })
143
+ props.api.renderer?.requestRender?.()
144
+ }
145
+
146
+ const controller = { toggle, isExpanded: (key: string) => expanded().has(key) }
147
+ setLastPanelController(controller)
148
+
25
149
  onCleanup(() => {
26
150
  if (mounted !== undefined) props.tracker.unmountSession(mounted)
151
+ clearLastPanelController(controller)
27
152
  })
28
153
 
29
- return <text fg={props.api.theme.current.textMuted}>{content()}</text>
154
+ return (
155
+ <box flexDirection="column">
156
+ <text fg={props.api.theme.current.text} wrapMode="none" truncate>
157
+ <b>Performance · observed</b>
158
+ </text>
159
+ <Show when={panel().rows.length === 0}>
160
+ <text fg={props.api.theme.current.textMuted} wrapMode="none" truncate>
161
+ —
162
+ </text>
163
+ </Show>
164
+ <For each={panel().rows}>
165
+ {(row) => (
166
+ <ModelRowView api={props.api} row={row} expanded={expanded().has(row.key)} onToggle={() => toggle(row.key)} />
167
+ )}
168
+ </For>
169
+ <Show when={panel().overflow > 0}>
170
+ <text fg={props.api.theme.current.textMuted} wrapMode="none" truncate>
171
+ {`+${panel().overflow} more models`}
172
+ </text>
173
+ </Show>
174
+ <text fg={props.api.theme.current.textMuted} wrapMode="none" truncate>
175
+ {`Session CACHE ${panel().cache ?? "—"}${panel().retries > 0 ? ` · retries ${panel().retries}` : ""}`}
176
+ </text>
177
+ </box>
178
+ )
30
179
  }
31
180
 
32
181
  const tui: TuiPlugin = async (api) => {
@@ -37,8 +186,8 @@ const tui: TuiPlugin = async (api) => {
37
186
 
38
187
  api.slots.register({
39
188
  slots: {
40
- session_prompt_right(_context, props) {
41
- return <SessionPromptRight api={api} sessionID={props.session_id} tracker={tracker} revision={revision} />
189
+ sidebar_footer(_context, props) {
190
+ return <ModelPanelView api={api} sessionID={props.session_id} tracker={tracker} revision={revision} />
42
191
  },
43
192
  },
44
193
  })