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.
- package/package.json +1 -1
- package/tracker.ts +330 -17
- 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.
|
|
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
|
-
|
|
94
|
-
|
|
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:
|
|
99
|
-
if (!stats || stats.steps <= 0) return
|
|
100
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
41
|
-
return <
|
|
189
|
+
sidebar_footer(_context, props) {
|
|
190
|
+
return <ModelPanelView api={api} sessionID={props.session_id} tracker={tracker} revision={revision} />
|
|
42
191
|
},
|
|
43
192
|
},
|
|
44
193
|
})
|