opencode-tui-stats 0.4.2 → 0.4.4

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/tracker.ts DELETED
@@ -1,768 +0,0 @@
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
- /** 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
- /** Last measured live rate per model key, kept after the stream goes idle/stale so the
57
- * final reading stays visible instead of disappearing. */
58
- lastRate: Record<string, string>
59
- }
60
-
61
- export type Tracker = {
62
- samples: Record<string, StreamSample[]>
63
- timings: Record<string, MessageTiming>
64
- averages: Record<string, SessionAverage>
65
- stats: Record<string, SessionStats>
66
- dedup: Record<string, Set<string>>
67
- models: Record<string, SessionModels>
68
- /** `sessionID -> parentID`; a key that is present with an `undefined` value is a confirmed root. */
69
- parentOf: Record<string, string | undefined>
70
- }
71
-
72
- const STREAM_WINDOW_MS = 5_000
73
- const LIVE_STALE_MS = 1_500
74
- const SINGLE_SAMPLE_MS = 1_000
75
- const PART_CACHE_LIMIT = 1_024
76
- /** Per-session dedup window: only the most recent completed messages are deduplicated;
77
- * a repeated completed event beyond this window accumulates again. */
78
- const DEDUP_WINDOW_LIMIT = 64
79
- /** Cap on `messageID -> model` entries retained per session (FIFO). */
80
- const MESSAGE_MODEL_LIMIT = 256
81
- const SESSION_LIMIT = 64
82
- /** Maximum number of descendant sessions folded into one panel (cycle-safe traversal bound). */
83
- const SUBTREE_LIMIT = 64
84
- /** Maximum number of model rows rendered in the sidebar panel. */
85
- export const MODEL_ROW_LIMIT = 4
86
-
87
- function emptyStats(): SessionStats {
88
- return { cacheRead: 0, cachePrompt: 0, cost: 0, steps: 0, retries: 0, lastAttempt: 0 }
89
- }
90
-
91
- function emptyAggregate(): ModelAggregate {
92
- return {
93
- cacheRead: 0,
94
- cachePrompt: 0,
95
- cost: 0,
96
- steps: 0,
97
- totalTokens: 0,
98
- totalDurationMs: 0,
99
- totalTtftMs: 0,
100
- stepCount: 0,
101
- lastAt: 0,
102
- }
103
- }
104
-
105
- export function modelKey(providerID: string, modelID: string) {
106
- return `${providerID}/${modelID}`
107
- }
108
-
109
- export function estimateTokens(delta: string) {
110
- return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5))
111
- }
112
-
113
- export function formatRate(value: number) {
114
- if (!Number.isFinite(value) || value <= 0) return undefined
115
- if (value >= 100) return Math.round(value).toString()
116
- if (value >= 10) return value.toFixed(1)
117
- return value.toFixed(2)
118
- }
119
-
120
- export function formatTtft(value: number) {
121
- if (!Number.isFinite(value) || value < 0) return undefined
122
- return `${value.toFixed(1)}s`
123
- }
124
-
125
- export function activeDuration(samples: StreamSample[], tailAt?: number) {
126
- if (samples.length === 0) return 0
127
- if (samples.length === 1) {
128
- const tailDuration = tailAt ? Math.max(0, tailAt - samples[0].at) : SINGLE_SAMPLE_MS
129
- return Math.min(Math.max(tailDuration, 250), SINGLE_SAMPLE_MS)
130
- }
131
-
132
- let duration = 0
133
- for (let index = 1; index < samples.length; index++) {
134
- duration += Math.max(0, samples[index].at - samples[index - 1].at)
135
- }
136
- if (tailAt) duration += Math.max(0, tailAt - samples[samples.length - 1].at)
137
- return Math.max(duration, SINGLE_SAMPLE_MS)
138
- }
139
-
140
- export function averageRate(average: SessionAverage | undefined) {
141
- if (!average || average.totalTokens <= 0 || average.totalDurationMs <= 0) return undefined
142
- return formatRate(average.totalTokens / (average.totalDurationMs / 1_000))
143
- }
144
-
145
- export function averageTtft(average: SessionAverage | undefined) {
146
- if (!average || average.stepCount <= 0) return undefined
147
- return formatTtft(average.totalTtftMs / average.stepCount / 1_000)
148
- }
149
-
150
- /** Cache hit percentage (e.g. `31%`) or `undefined` when nothing promptable has been seen. */
151
- export function cacheText(stats: { cacheRead: number; cachePrompt: number } | undefined) {
152
- if (!stats || stats.cachePrompt <= 0) return undefined
153
- return `${((stats.cacheRead / stats.cachePrompt) * 100).toFixed(1)}%`
154
- }
155
-
156
- export function costText(stats: { cost: number; steps: number } | undefined) {
157
- if (!stats || stats.steps <= 0) return undefined
158
- // Always four decimals so the cost column keeps a stable width as spend grows.
159
- return `$${stats.cost.toFixed(4)}`
160
- }
161
-
162
- /** FIFO-bound a Set so it never grows past `limit`. */
163
- function boundedAdd(set: Set<string>, key: string, limit: number) {
164
- if (set.has(key)) return
165
- if (set.size >= limit) {
166
- const oldest = set.values().next().value
167
- if (oldest !== undefined) set.delete(oldest)
168
- }
169
- set.add(key)
170
- }
171
-
172
- /** FIFO-bound a Map so it never grows past `limit`. */
173
- function boundedSet<K, V>(map: Map<K, V>, key: K, value: V, limit: number) {
174
- if (map.has(key)) return
175
- if (map.size >= limit) {
176
- const oldest = map.keys().next().value
177
- if (oldest !== undefined) map.delete(oldest)
178
- }
179
- map.set(key, value)
180
- }
181
-
182
- export type ModelRow = {
183
- key: string
184
- label: string
185
- active: boolean
186
- /** True when any descendant (subagent) session contributed data to this row. */
187
- subagent?: boolean
188
- /** Last measured live rate, or `undefined` when this model has never streamed. */
189
- tps?: string
190
- /** True while `tps` is backed by an active stream; false for a frozen final value. */
191
- tpsLive: boolean
192
- avg?: string
193
- ttft?: string
194
- cache?: string
195
- cost?: string
196
- }
197
-
198
- export type ModelPanel = {
199
- rows: ModelRow[]
200
- overflow: number
201
- retries: number
202
- /** Session-level cache hit percentage (e.g. `31%`), or `undefined` when nothing promptable was seen. */
203
- cache?: string
204
- }
205
-
206
- export type TrackerInternals = {
207
- sizes(): {
208
- parts: number
209
- dedup: number
210
- stats: number
211
- averages: number
212
- timings: number
213
- mounted: number
214
- }
215
- partDecision(partID: string): boolean | undefined
216
- hasStats(sessionID: string): boolean
217
- dedupSize(sessionID: string): number
218
- retries(sessionID: string): number
219
- mounted(): string[]
220
- activeModel(sessionID: string): string | undefined
221
- }
222
-
223
- export type TrackerHandle = {
224
- modelRows(sessionID: string): ModelPanel
225
- mountSession(sessionID: string): void
226
- unmountSession(sessionID: string): void
227
- prune(now?: number): void
228
- internals: TrackerInternals
229
- dispose(): void
230
- }
231
-
232
- let lastInternals: TrackerInternals | undefined
233
-
234
- /** Test-only access to the most recently created tracker's internals. */
235
- export function getLastInternals(): TrackerInternals | undefined {
236
- return lastInternals
237
- }
238
-
239
- export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerHandle {
240
- const tracker: Tracker = {
241
- samples: {},
242
- timings: {},
243
- averages: {},
244
- stats: {},
245
- dedup: {},
246
- models: {},
247
- parentOf: {},
248
- }
249
- const partDecisions = new Map<string, boolean>()
250
- const mounted = new Set<string>()
251
- const order: string[] = []
252
- const listeners: Array<() => void> = []
253
-
254
- const bump = () => onChange()
255
-
256
- const clearLive = (sessionID: string) => {
257
- if (!tracker.samples[sessionID]?.length) return
258
- delete tracker.samples[sessionID]
259
- bump()
260
- }
261
-
262
- const clearTimings = (sessionID: string) => {
263
- let changed = false
264
- for (const [messageID, timing] of Object.entries(tracker.timings)) {
265
- if (timing.sessionID !== sessionID) continue
266
- delete tracker.timings[messageID]
267
- changed = true
268
- }
269
- if (changed) bump()
270
- }
271
-
272
- const evictSession = (sessionID: string) => {
273
- delete tracker.stats[sessionID]
274
- delete tracker.averages[sessionID]
275
- delete tracker.samples[sessionID]
276
- delete tracker.dedup[sessionID]
277
- delete tracker.models[sessionID]
278
- delete tracker.parentOf[sessionID]
279
- clearTimings(sessionID)
280
- }
281
-
282
- const touchSession = (sessionID: string) => {
283
- const at = order.indexOf(sessionID)
284
- if (at !== -1) order.splice(at, 1)
285
- order.push(sessionID)
286
-
287
- // Evict least recently used sessions past the cap, never the sessions currently mounted for display.
288
- let index = 0
289
- while (order.length > SESSION_LIMIT && index < order.length) {
290
- const candidate = order[index]
291
- if (mounted.has(candidate)) {
292
- index += 1
293
- continue
294
- }
295
- order.splice(index, 1)
296
- evictSession(candidate)
297
- }
298
- }
299
-
300
- const touchModel = (models: SessionModels, key: string) => {
301
- const at = models.order.indexOf(key)
302
- if (at !== -1) models.order.splice(at, 1)
303
- models.order.push(key)
304
- if (!models.aggregates[key]) models.aggregates[key] = emptyAggregate()
305
- }
306
-
307
- const prune = (now = Date.now()) => {
308
- let changed = false
309
-
310
- for (const [sessionID, samples] of Object.entries(tracker.samples)) {
311
- const next = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
312
- if (next.length === samples.length) continue
313
- changed = true
314
- if (next.length > 0) tracker.samples[sessionID] = next
315
- else delete tracker.samples[sessionID]
316
- }
317
-
318
- if (changed) bump()
319
- }
320
-
321
- const appendSample = (sessionID: string, messageID: string, sample: StreamSample) => {
322
- touchSession(sessionID)
323
- tracker.samples[sessionID] = [
324
- ...(tracker.samples[sessionID] ?? []).filter((item) => sample.at - item.at <= STREAM_WINDOW_MS),
325
- sample,
326
- ]
327
- const timing = tracker.timings[messageID]
328
- if (timing && timing.firstResponseAt === undefined) timing.firstResponseAt = sample.at
329
- bump()
330
- }
331
-
332
- /** Live rate restricted to samples attributed to `key` via the `messageID -> model` mapping.
333
- * When the session is idle or the samples are stale, the last measured value is frozen and
334
- * returned with `live: false` instead of disappearing. */
335
- const sessionLiveRate = (sessionID: string, key: string): { value?: string; live: boolean } => {
336
- const frozen = { value: tracker.models[sessionID]?.lastRate[key], live: false }
337
- const status = api.state.session.status(sessionID)
338
- if (!status || status.type === "idle") return frozen
339
-
340
- const now = Date.now()
341
- const mapping = tracker.models[sessionID]?.messageModels
342
- const samples = (tracker.samples[sessionID] ?? []).filter(
343
- (sample) => now - sample.at <= STREAM_WINDOW_MS && mapping?.get(sample.messageID) === key,
344
- )
345
- const last = samples[samples.length - 1]
346
- if (!last || now - last.at > LIVE_STALE_MS) return frozen
347
-
348
- const tokens = samples.reduce((sum, sample) => sum + sample.tokens, 0)
349
- const value = formatRate(tokens / (activeDuration(samples, now) / 1_000))
350
- if (value === undefined) return frozen
351
- const models = tracker.models[sessionID]
352
- if (models) models.lastRate[key] = value
353
- return { value, live: true }
354
- }
355
-
356
- /** Fold a session's parent from `session.created`/`session.updated`, or lazily from state.
357
- * A successful lookup is cached; an unknown session is retried on the next render. */
358
- const resolveParent = (sessionID: string) => {
359
- if (sessionID in tracker.parentOf) return
360
- const info = api.state.session.get(sessionID)
361
- if (!info) return
362
- tracker.parentOf[sessionID] = info.parentID
363
- }
364
-
365
- const rememberParent = (sessionID: string, parentID: string | undefined) => {
366
- if (sessionID in tracker.parentOf && tracker.parentOf[sessionID] === parentID) return
367
- tracker.parentOf[sessionID] = parentID
368
- bump()
369
- }
370
-
371
- const knownSessions = () => {
372
- const ids = new Set<string>(Object.keys(tracker.models))
373
- for (const id of Object.keys(tracker.stats)) ids.add(id)
374
- for (const id of Object.keys(tracker.averages)) ids.add(id)
375
- return ids
376
- }
377
-
378
- /** Breadth-first descendants of `sessionID` (excluding itself), cycle-safe and capped. */
379
- const descendants = (sessionID: string) => {
380
- const children = new Map<string, string[]>()
381
- for (const [child, parent] of Object.entries(tracker.parentOf)) {
382
- if (!parent) continue
383
- const siblings = children.get(parent)
384
- if (siblings) siblings.push(child)
385
- else children.set(parent, [child])
386
- }
387
-
388
- const seen = new Set<string>([sessionID])
389
- const found: string[] = []
390
- const queue = [sessionID]
391
- while (queue.length > 0 && found.length < SUBTREE_LIMIT) {
392
- const current = queue.shift()!
393
- for (const child of children.get(current) ?? []) {
394
- if (seen.has(child)) continue
395
- seen.add(child)
396
- found.push(child)
397
- queue.push(child)
398
- if (found.length >= SUBTREE_LIMIT) break
399
- }
400
- }
401
- return found
402
- }
403
-
404
- const mergeAggregate = (target: ModelAggregate, source: ModelAggregate) => {
405
- target.cacheRead += source.cacheRead
406
- target.cachePrompt += source.cachePrompt
407
- target.cost += source.cost
408
- target.steps += source.steps
409
- target.totalTokens += source.totalTokens
410
- target.totalDurationMs += source.totalDurationMs
411
- target.totalTtftMs += source.totalTtftMs
412
- target.stepCount += source.stepCount
413
- target.lastAt = Math.max(target.lastAt, source.lastAt)
414
- }
415
-
416
- const sessionModelAt = (sessionID: string) => {
417
- const models = tracker.models[sessionID]
418
- if (!models) return 0
419
- let at = 0
420
- for (const key of models.order) {
421
- const aggregate = models.aggregates[key]
422
- if (aggregate && aggregate.lastAt > at) at = aggregate.lastAt
423
- }
424
- return at
425
- }
426
-
427
- /** Live rate for a merged row: the parent and its subagents share one row, so only the
428
- * session/model pair that most recently saw activity contributes. Concurrent streams in a
429
- * parent and a child are therefore approximated by the newer one instead of being summed. */
430
- const liveRateFor = (sessions: string[], key: string): { value?: string; live: boolean } => {
431
- let latest: { sessionID: string; at: number } | undefined
432
- for (const sessionID of sessions) {
433
- const aggregate = tracker.models[sessionID]?.aggregates[key]
434
- if (!aggregate) continue
435
- if (!latest || aggregate.lastAt >= latest.at) latest = { sessionID, at: aggregate.lastAt }
436
- }
437
- if (!latest) return { live: false }
438
- return sessionLiveRate(latest.sessionID, key)
439
- }
440
-
441
- const modelRows = (sessionID: string): ModelPanel => {
442
- // Link sessions that only announced themselves through message events (a child can stream
443
- // before its `session.created` event is delivered).
444
- for (const id of knownSessions()) resolveParent(id)
445
-
446
- const subtree = [sessionID, ...descendants(sessionID)]
447
- const retries = tracker.stats[sessionID]?.retries ?? 0
448
-
449
- // Session CACHE uses the same subtree scope as the model rows.
450
- let cacheRead = 0
451
- let cachePrompt = 0
452
- for (const id of subtree) {
453
- const stats = tracker.stats[id]
454
- if (!stats) continue
455
- cacheRead += stats.cacheRead
456
- cachePrompt += stats.cachePrompt
457
- }
458
- const cache = cacheText({ cacheRead, cachePrompt })
459
-
460
- // Fold the subtree oldest→newest so `order` keeps its "oldest first" meaning across sessions.
461
- const sessions = subtree
462
- .filter((id) => tracker.models[id])
463
- .sort((left, right) => sessionModelAt(left) - sessionModelAt(right))
464
- if (sessions.length === 0) return { rows: [], overflow: 0, retries, cache }
465
-
466
- const order: string[] = []
467
- const aggregates: Record<string, ModelAggregate> = {}
468
- const subagentModels = new Set<string>()
469
- for (const id of sessions) {
470
- const models = tracker.models[id]
471
- for (const key of models.order) {
472
- const aggregate = models.aggregates[key]
473
- if (!aggregate) continue
474
- let merged = aggregates[key]
475
- if (!merged) {
476
- merged = emptyAggregate()
477
- aggregates[key] = merged
478
- order.push(key)
479
- }
480
- mergeAggregate(merged, aggregate)
481
- if (id !== sessionID) subagentModels.add(key)
482
- }
483
- }
484
-
485
- let active: string | undefined
486
- for (const id of sessions) active = tracker.models[id].active ?? active
487
-
488
- // `order` is oldest→newest; reverse for most-recent-first ordering of the non-active rows.
489
- const recency = [...order].reverse()
490
- const ordered = active && active in aggregates ? [active, ...recency.filter((key) => key !== active)] : recency
491
-
492
- const visible = ordered.slice(0, MODEL_ROW_LIMIT)
493
- const overflow = Math.max(0, ordered.length - visible.length)
494
-
495
- const rows = visible.map<ModelRow>((key) => {
496
- const aggregate = aggregates[key]
497
- const isActive = key === active
498
- const tps = isActive ? liveRateFor(subtree, key) : { value: undefined, live: false }
499
- return {
500
- key,
501
- label: key,
502
- active: isActive,
503
- subagent: subagentModels.has(key),
504
- tps: tps.value,
505
- tpsLive: tps.live,
506
- avg: averageRate(aggregate),
507
- ttft: averageTtft(aggregate),
508
- cache: cacheText(aggregate),
509
- cost: costText(aggregate),
510
- }
511
- })
512
-
513
- return { rows, overflow, retries, cache }
514
- }
515
-
516
- listeners.push(
517
- api.event.on("message.part.delta", (event) => {
518
- if (event.properties.field !== "text") return
519
- const { sessionID, messageID, partID } = event.properties
520
-
521
- // partID → decision (true = counted, false = ignored). A part that is not registered yet is
522
- // never cached, so the next delta retries the lookup; once found the accept/reject decision
523
- // is cached so each part is scanned at most once.
524
- let counted = partDecisions.get(partID)
525
- if (counted === undefined) {
526
- const part = api.state.part(messageID).find((item) => item.id === partID)
527
- if (!part) return
528
- counted = part.type === "text" || part.type === "reasoning"
529
- boundedSet(partDecisions, partID, counted, PART_CACHE_LIMIT)
530
- }
531
- if (!counted) return
532
-
533
- appendSample(sessionID, messageID, {
534
- at: Date.now(),
535
- messageID,
536
- tokens: estimateTokens(event.properties.delta),
537
- })
538
- }),
539
- )
540
-
541
- listeners.push(
542
- api.event.on("message.updated", (event) => {
543
- const info = event.properties.info
544
- if (info.role !== "assistant") return
545
- const sessionID = event.properties.sessionID
546
- const key = modelKey(info.providerID, info.modelID)
547
-
548
- // Observe the model as soon as a message appears so the sidebar can attribute live samples
549
- // and mark the most recently used model as active.
550
- const models =
551
- tracker.models[sessionID] ??
552
- (tracker.models[sessionID] = { order: [], aggregates: {}, messageModels: new Map(), lastRate: {} })
553
- touchModel(models, key)
554
- models.active = key
555
- models.aggregates[key] = { ...models.aggregates[key], lastAt: Date.now() }
556
- boundedSet(models.messageModels, info.id, key, MESSAGE_MODEL_LIMIT)
557
-
558
- if (!info.time.completed) {
559
- const existing = tracker.timings[info.id]
560
- tracker.timings[info.id] = {
561
- sessionID,
562
- requestStartAt: info.time.created,
563
- firstResponseAt: existing?.firstResponseAt,
564
- lastToolCallAt: existing?.lastToolCallAt,
565
- }
566
- bump()
567
- return
568
- }
569
-
570
- // Dedup only the most recent completed messages for this session; a repeated completed
571
- // event beyond the window accumulates again.
572
- const seen = tracker.dedup[sessionID] ?? (tracker.dedup[sessionID] = new Set<string>())
573
- if (!seen.has(info.id)) {
574
- boundedAdd(seen, info.id, DEDUP_WINDOW_LIMIT)
575
- touchSession(sessionID)
576
- const stats = tracker.stats[sessionID] ?? emptyStats()
577
- const cacheRead = info.tokens.cache.read
578
- const cacheWrite = info.tokens.cache.write
579
- tracker.stats[sessionID] = {
580
- ...stats,
581
- cacheRead: stats.cacheRead + cacheRead,
582
- cachePrompt: stats.cachePrompt + info.tokens.input + cacheRead + cacheWrite,
583
- cost: stats.cost + info.cost,
584
- steps: stats.steps + 1,
585
- }
586
- const aggregate = models.aggregates[key] ?? emptyAggregate()
587
- models.aggregates[key] = {
588
- ...aggregate,
589
- cacheRead: aggregate.cacheRead + cacheRead,
590
- cachePrompt: aggregate.cachePrompt + info.tokens.input + cacheRead + cacheWrite,
591
- cost: aggregate.cost + info.cost,
592
- steps: aggregate.steps + 1,
593
- }
594
- }
595
-
596
- const timing = tracker.timings[info.id]
597
- if (timing?.sessionID === sessionID && timing.firstResponseAt !== undefined) {
598
- const tokens = info.tokens.output + info.tokens.reasoning
599
- const endAt = info.finish === "tool-calls" ? timing.lastToolCallAt : info.time.completed
600
- const durationMs = endAt !== undefined ? Math.max(endAt - timing.firstResponseAt, 1) : undefined
601
- if (tokens > 0 && durationMs !== undefined) {
602
- const totals = tracker.averages[sessionID] ?? {
603
- totalTokens: 0,
604
- totalDurationMs: 0,
605
- totalTtftMs: 0,
606
- stepCount: 0,
607
- }
608
- tracker.averages[sessionID] = {
609
- totalTokens: totals.totalTokens + tokens,
610
- totalDurationMs: totals.totalDurationMs + durationMs,
611
- totalTtftMs: totals.totalTtftMs + Math.max(timing.firstResponseAt - timing.requestStartAt, 0),
612
- stepCount: totals.stepCount + 1,
613
- }
614
- const aggregate = models.aggregates[key] ?? emptyAggregate()
615
- models.aggregates[key] = {
616
- ...aggregate,
617
- totalTokens: aggregate.totalTokens + tokens,
618
- totalDurationMs: aggregate.totalDurationMs + durationMs,
619
- totalTtftMs: aggregate.totalTtftMs + Math.max(timing.firstResponseAt - timing.requestStartAt, 0),
620
- stepCount: aggregate.stepCount + 1,
621
- }
622
- }
623
- }
624
- delete tracker.timings[info.id]
625
- prune(info.time.completed)
626
- bump()
627
- }),
628
- )
629
-
630
- listeners.push(
631
- api.event.on("message.part.updated", (event) => {
632
- const part = event.properties.part
633
- if (part.type !== "tool") return
634
-
635
- if (part.state.status === "running" || part.state.status === "completed" || part.state.status === "error") {
636
- clearLive(part.sessionID)
637
- }
638
-
639
- const timing = tracker.timings[part.messageID]
640
- if (!timing) return
641
-
642
- if (part.state.status === "pending") {
643
- if (timing.firstResponseAt === undefined) timing.firstResponseAt = Date.now()
644
- bump()
645
- return
646
- }
647
- if (part.state.status === "running") {
648
- timing.lastToolCallAt = part.state.time.start
649
- bump()
650
- }
651
- }),
652
- )
653
-
654
- listeners.push(
655
- api.event.on("session.status", (event) => {
656
- const { sessionID, status } = event.properties
657
- const type = status.type
658
-
659
- if (type === "idle") clearTimings(sessionID)
660
-
661
- const stats = tracker.stats[sessionID] ?? emptyStats()
662
-
663
- if (type === "retry") {
664
- const attempt = status.attempt
665
- if (attempt <= stats.lastAttempt) return
666
- touchSession(sessionID)
667
- tracker.stats[sessionID] = {
668
- ...stats,
669
- retries: stats.retries + (attempt - stats.lastAttempt),
670
- lastAttempt: attempt,
671
- }
672
- bump()
673
- return
674
- }
675
-
676
- // Any non-retry status resets the attempt baseline for the next retry burst.
677
- if (stats.lastAttempt === 0) return
678
- touchSession(sessionID)
679
- tracker.stats[sessionID] = { ...stats, lastAttempt: 0 }
680
- bump()
681
- }),
682
- )
683
-
684
- listeners.push(
685
- api.event.on("session.idle", (event) => {
686
- clearTimings(event.properties.sessionID)
687
- }),
688
- )
689
-
690
- // Subagent sessions announce their parent here; the tree is also backfilled lazily from state.
691
- listeners.push(
692
- api.event.on("session.created", (event) => {
693
- rememberParent(event.properties.info.id, event.properties.info.parentID)
694
- }),
695
- )
696
-
697
- listeners.push(
698
- api.event.on("session.updated", (event) => {
699
- rememberParent(event.properties.info.id, event.properties.info.parentID)
700
- }),
701
- )
702
-
703
- // The 1s tick re-evaluates live TPS staleness and prunes stale stream samples.
704
- const timer = setInterval(() => {
705
- prune()
706
- bump()
707
- }, 1_000)
708
-
709
- const internals: TrackerInternals = {
710
- sizes: () => ({
711
- parts: partDecisions.size,
712
- dedup: Object.values(tracker.dedup).reduce((sum, set) => sum + set.size, 0),
713
- stats: Object.keys(tracker.stats).length,
714
- averages: Object.keys(tracker.averages).length,
715
- timings: Object.keys(tracker.timings).length,
716
- mounted: mounted.size,
717
- }),
718
- partDecision: (partID) => partDecisions.get(partID),
719
- hasStats: (sessionID) => sessionID in tracker.stats,
720
- dedupSize: (sessionID) => tracker.dedup[sessionID]?.size ?? 0,
721
- retries: (sessionID) => tracker.stats[sessionID]?.retries ?? 0,
722
- mounted: () => [...mounted],
723
- activeModel: (sessionID) => tracker.models[sessionID]?.active,
724
- }
725
- lastInternals = internals
726
-
727
- return {
728
- modelRows,
729
- mountSession(sessionID) {
730
- mounted.add(sessionID)
731
- },
732
- unmountSession(sessionID) {
733
- mounted.delete(sessionID)
734
- },
735
- prune,
736
- internals,
737
- dispose() {
738
- for (const unsubscribe of listeners) unsubscribe()
739
- listeners.length = 0
740
- clearInterval(timer)
741
- mounted.clear()
742
- if (lastInternals === internals) lastInternals = undefined
743
- },
744
- }
745
- }
746
-
747
- /** Test-only fold controller. Kept in this non-entry module: the plugin entry (`tui.tsx`)
748
- * may only expose a `default` export, because the legacy loader treats every runtime export
749
- * as a plugin. */
750
- export type PanelController = {
751
- toggle(key: string): void
752
- isExpanded(key: string): boolean
753
- }
754
-
755
- let lastPanelController: PanelController | undefined
756
-
757
- export function setLastPanelController(controller: PanelController) {
758
- lastPanelController = controller
759
- }
760
-
761
- /** Clear the controller only when it is still the current one. */
762
- export function clearLastPanelController(controller: PanelController) {
763
- if (lastPanelController === controller) lastPanelController = undefined
764
- }
765
-
766
- export function getLastPanelController(): PanelController | undefined {
767
- return lastPanelController
768
- }