opencode-tui-stats 0.4.1 → 0.4.3

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,752 +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
- }
57
-
58
- export type Tracker = {
59
- samples: Record<string, StreamSample[]>
60
- timings: Record<string, MessageTiming>
61
- averages: Record<string, SessionAverage>
62
- stats: Record<string, SessionStats>
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>
67
- }
68
-
69
- const STREAM_WINDOW_MS = 5_000
70
- const LIVE_STALE_MS = 1_500
71
- const SINGLE_SAMPLE_MS = 1_000
72
- const PART_CACHE_LIMIT = 1_024
73
- /** Per-session dedup window: only the most recent completed messages are deduplicated;
74
- * a repeated completed event beyond this window accumulates again. */
75
- const DEDUP_WINDOW_LIMIT = 64
76
- /** Cap on `messageID -> model` entries retained per session (FIFO). */
77
- const MESSAGE_MODEL_LIMIT = 256
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
83
-
84
- function emptyStats(): SessionStats {
85
- return { cacheRead: 0, cachePrompt: 0, cost: 0, steps: 0, retries: 0, lastAttempt: 0 }
86
- }
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
-
106
- export function estimateTokens(delta: string) {
107
- return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5))
108
- }
109
-
110
- export function formatRate(value: number) {
111
- if (!Number.isFinite(value) || value <= 0) return undefined
112
- if (value >= 100) return Math.round(value).toString()
113
- if (value >= 10) return value.toFixed(1)
114
- return value.toFixed(2)
115
- }
116
-
117
- export function formatTtft(value: number) {
118
- if (!Number.isFinite(value) || value < 0) return undefined
119
- return `${value.toFixed(1)}s`
120
- }
121
-
122
- export function activeDuration(samples: StreamSample[], tailAt?: number) {
123
- if (samples.length === 0) return 0
124
- if (samples.length === 1) {
125
- const tailDuration = tailAt ? Math.max(0, tailAt - samples[0].at) : SINGLE_SAMPLE_MS
126
- return Math.min(Math.max(tailDuration, 250), SINGLE_SAMPLE_MS)
127
- }
128
-
129
- let duration = 0
130
- for (let index = 1; index < samples.length; index++) {
131
- duration += Math.max(0, samples[index].at - samples[index - 1].at)
132
- }
133
- if (tailAt) duration += Math.max(0, tailAt - samples[samples.length - 1].at)
134
- return Math.max(duration, SINGLE_SAMPLE_MS)
135
- }
136
-
137
- export function averageRate(average: SessionAverage | undefined) {
138
- if (!average || average.totalTokens <= 0 || average.totalDurationMs <= 0) return undefined
139
- return formatRate(average.totalTokens / (average.totalDurationMs / 1_000))
140
- }
141
-
142
- export function averageTtft(average: SessionAverage | undefined) {
143
- if (!average || average.stepCount <= 0) return undefined
144
- return formatTtft(average.totalTtftMs / average.stepCount / 1_000)
145
- }
146
-
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
150
- return `${((stats.cacheRead / stats.cachePrompt) * 100).toFixed(1)}%`
151
- }
152
-
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)}`
157
- }
158
-
159
- /** FIFO-bound a Set so it never grows past `limit`. */
160
- function boundedAdd(set: Set<string>, key: string, limit: number) {
161
- if (set.has(key)) return
162
- if (set.size >= limit) {
163
- const oldest = set.values().next().value
164
- if (oldest !== undefined) set.delete(oldest)
165
- }
166
- set.add(key)
167
- }
168
-
169
- /** FIFO-bound a Map so it never grows past `limit`. */
170
- function boundedSet<K, V>(map: Map<K, V>, key: K, value: V, limit: number) {
171
- if (map.has(key)) return
172
- if (map.size >= limit) {
173
- const oldest = map.keys().next().value
174
- if (oldest !== undefined) map.delete(oldest)
175
- }
176
- map.set(key, value)
177
- }
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
-
200
- export type TrackerInternals = {
201
- sizes(): {
202
- parts: number
203
- dedup: number
204
- stats: number
205
- averages: number
206
- timings: number
207
- mounted: number
208
- }
209
- partDecision(partID: string): boolean | undefined
210
- hasStats(sessionID: string): boolean
211
- dedupSize(sessionID: string): number
212
- retries(sessionID: string): number
213
- mounted(): string[]
214
- activeModel(sessionID: string): string | undefined
215
- }
216
-
217
- export type TrackerHandle = {
218
- modelRows(sessionID: string): ModelPanel
219
- mountSession(sessionID: string): void
220
- unmountSession(sessionID: string): void
221
- prune(now?: number): void
222
- internals: TrackerInternals
223
- dispose(): void
224
- }
225
-
226
- let lastInternals: TrackerInternals | undefined
227
-
228
- /** Test-only access to the most recently created tracker's internals. */
229
- export function getLastInternals(): TrackerInternals | undefined {
230
- return lastInternals
231
- }
232
-
233
- export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerHandle {
234
- const tracker: Tracker = {
235
- samples: {},
236
- timings: {},
237
- averages: {},
238
- stats: {},
239
- dedup: {},
240
- models: {},
241
- parentOf: {},
242
- }
243
- const partDecisions = new Map<string, boolean>()
244
- const mounted = new Set<string>()
245
- const order: string[] = []
246
- const listeners: Array<() => void> = []
247
-
248
- const bump = () => onChange()
249
-
250
- const clearLive = (sessionID: string) => {
251
- if (!tracker.samples[sessionID]?.length) return
252
- delete tracker.samples[sessionID]
253
- bump()
254
- }
255
-
256
- const clearTimings = (sessionID: string) => {
257
- let changed = false
258
- for (const [messageID, timing] of Object.entries(tracker.timings)) {
259
- if (timing.sessionID !== sessionID) continue
260
- delete tracker.timings[messageID]
261
- changed = true
262
- }
263
- if (changed) bump()
264
- }
265
-
266
- const evictSession = (sessionID: string) => {
267
- delete tracker.stats[sessionID]
268
- delete tracker.averages[sessionID]
269
- delete tracker.samples[sessionID]
270
- delete tracker.dedup[sessionID]
271
- delete tracker.models[sessionID]
272
- delete tracker.parentOf[sessionID]
273
- clearTimings(sessionID)
274
- }
275
-
276
- const touchSession = (sessionID: string) => {
277
- const at = order.indexOf(sessionID)
278
- if (at !== -1) order.splice(at, 1)
279
- order.push(sessionID)
280
-
281
- // Evict least recently used sessions past the cap, never the sessions currently mounted for display.
282
- let index = 0
283
- while (order.length > SESSION_LIMIT && index < order.length) {
284
- const candidate = order[index]
285
- if (mounted.has(candidate)) {
286
- index += 1
287
- continue
288
- }
289
- order.splice(index, 1)
290
- evictSession(candidate)
291
- }
292
- }
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
-
301
- const prune = (now = Date.now()) => {
302
- let changed = false
303
-
304
- for (const [sessionID, samples] of Object.entries(tracker.samples)) {
305
- const next = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
306
- if (next.length === samples.length) continue
307
- changed = true
308
- if (next.length > 0) tracker.samples[sessionID] = next
309
- else delete tracker.samples[sessionID]
310
- }
311
-
312
- if (changed) bump()
313
- }
314
-
315
- const appendSample = (sessionID: string, messageID: string, sample: StreamSample) => {
316
- touchSession(sessionID)
317
- tracker.samples[sessionID] = [
318
- ...(tracker.samples[sessionID] ?? []).filter((item) => sample.at - item.at <= STREAM_WINDOW_MS),
319
- sample,
320
- ]
321
- const timing = tracker.timings[messageID]
322
- if (timing && timing.firstResponseAt === undefined) timing.firstResponseAt = sample.at
323
- bump()
324
- }
325
-
326
- /** Live rate restricted to samples attributed to `key` via the `messageID -> model` mapping. */
327
- const sessionLiveRate = (sessionID: string, key: string) => {
328
- const status = api.state.session.status(sessionID)
329
- if (!status || status.type === "idle") return undefined
330
-
331
- const now = Date.now()
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
- )
336
- const last = samples[samples.length - 1]
337
- if (!last || now - last.at > LIVE_STALE_MS) return undefined
338
-
339
- const tokens = samples.reduce((sum, sample) => sum + sample.tokens, 0)
340
- return formatRate(tokens / (activeDuration(samples, now) / 1_000))
341
- }
342
-
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 }
499
- }
500
-
501
- listeners.push(
502
- api.event.on("message.part.delta", (event) => {
503
- if (event.properties.field !== "text") return
504
- const { sessionID, messageID, partID } = event.properties
505
-
506
- // partID → decision (true = counted, false = ignored). A part that is not registered yet is
507
- // never cached, so the next delta retries the lookup; once found the accept/reject decision
508
- // is cached so each part is scanned at most once.
509
- let counted = partDecisions.get(partID)
510
- if (counted === undefined) {
511
- const part = api.state.part(messageID).find((item) => item.id === partID)
512
- if (!part) return
513
- counted = part.type === "text" || part.type === "reasoning"
514
- boundedSet(partDecisions, partID, counted, PART_CACHE_LIMIT)
515
- }
516
- if (!counted) return
517
-
518
- appendSample(sessionID, messageID, {
519
- at: Date.now(),
520
- messageID,
521
- tokens: estimateTokens(event.properties.delta),
522
- })
523
- }),
524
- )
525
-
526
- listeners.push(
527
- api.event.on("message.updated", (event) => {
528
- const info = event.properties.info
529
- if (info.role !== "assistant") return
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)
541
-
542
- if (!info.time.completed) {
543
- const existing = tracker.timings[info.id]
544
- tracker.timings[info.id] = {
545
- sessionID,
546
- requestStartAt: info.time.created,
547
- firstResponseAt: existing?.firstResponseAt,
548
- lastToolCallAt: existing?.lastToolCallAt,
549
- }
550
- bump()
551
- return
552
- }
553
-
554
- // Dedup only the most recent completed messages for this session; a repeated completed
555
- // event beyond the window accumulates again.
556
- const seen = tracker.dedup[sessionID] ?? (tracker.dedup[sessionID] = new Set<string>())
557
- if (!seen.has(info.id)) {
558
- boundedAdd(seen, info.id, DEDUP_WINDOW_LIMIT)
559
- touchSession(sessionID)
560
- const stats = tracker.stats[sessionID] ?? emptyStats()
561
- const cacheRead = info.tokens.cache.read
562
- const cacheWrite = info.tokens.cache.write
563
- tracker.stats[sessionID] = {
564
- ...stats,
565
- cacheRead: stats.cacheRead + cacheRead,
566
- cachePrompt: stats.cachePrompt + info.tokens.input + cacheRead + cacheWrite,
567
- cost: stats.cost + info.cost,
568
- steps: stats.steps + 1,
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
- }
578
- }
579
-
580
- const timing = tracker.timings[info.id]
581
- if (timing?.sessionID === sessionID && timing.firstResponseAt !== undefined) {
582
- const tokens = info.tokens.output + info.tokens.reasoning
583
- const endAt = info.finish === "tool-calls" ? timing.lastToolCallAt : info.time.completed
584
- const durationMs = endAt !== undefined ? Math.max(endAt - timing.firstResponseAt, 1) : undefined
585
- if (tokens > 0 && durationMs !== undefined) {
586
- const totals = tracker.averages[sessionID] ?? {
587
- totalTokens: 0,
588
- totalDurationMs: 0,
589
- totalTtftMs: 0,
590
- stepCount: 0,
591
- }
592
- tracker.averages[sessionID] = {
593
- totalTokens: totals.totalTokens + tokens,
594
- totalDurationMs: totals.totalDurationMs + durationMs,
595
- totalTtftMs: totals.totalTtftMs + Math.max(timing.firstResponseAt - timing.requestStartAt, 0),
596
- stepCount: totals.stepCount + 1,
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
- }
606
- }
607
- }
608
- delete tracker.timings[info.id]
609
- prune(info.time.completed)
610
- bump()
611
- }),
612
- )
613
-
614
- listeners.push(
615
- api.event.on("message.part.updated", (event) => {
616
- const part = event.properties.part
617
- if (part.type !== "tool") return
618
-
619
- if (part.state.status === "running" || part.state.status === "completed" || part.state.status === "error") {
620
- clearLive(part.sessionID)
621
- }
622
-
623
- const timing = tracker.timings[part.messageID]
624
- if (!timing) return
625
-
626
- if (part.state.status === "pending") {
627
- if (timing.firstResponseAt === undefined) timing.firstResponseAt = Date.now()
628
- bump()
629
- return
630
- }
631
- if (part.state.status === "running") {
632
- timing.lastToolCallAt = part.state.time.start
633
- bump()
634
- }
635
- }),
636
- )
637
-
638
- listeners.push(
639
- api.event.on("session.status", (event) => {
640
- const { sessionID, status } = event.properties
641
- const type = status.type
642
-
643
- if (type === "idle") clearTimings(sessionID)
644
-
645
- const stats = tracker.stats[sessionID] ?? emptyStats()
646
-
647
- if (type === "retry") {
648
- const attempt = status.attempt
649
- if (attempt <= stats.lastAttempt) return
650
- touchSession(sessionID)
651
- tracker.stats[sessionID] = {
652
- ...stats,
653
- retries: stats.retries + (attempt - stats.lastAttempt),
654
- lastAttempt: attempt,
655
- }
656
- bump()
657
- return
658
- }
659
-
660
- // Any non-retry status resets the attempt baseline for the next retry burst.
661
- if (stats.lastAttempt === 0) return
662
- touchSession(sessionID)
663
- tracker.stats[sessionID] = { ...stats, lastAttempt: 0 }
664
- bump()
665
- }),
666
- )
667
-
668
- listeners.push(
669
- api.event.on("session.idle", (event) => {
670
- clearTimings(event.properties.sessionID)
671
- }),
672
- )
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.
688
- const timer = setInterval(() => {
689
- prune()
690
- bump()
691
- }, 1_000)
692
-
693
- const internals: TrackerInternals = {
694
- sizes: () => ({
695
- parts: partDecisions.size,
696
- dedup: Object.values(tracker.dedup).reduce((sum, set) => sum + set.size, 0),
697
- stats: Object.keys(tracker.stats).length,
698
- averages: Object.keys(tracker.averages).length,
699
- timings: Object.keys(tracker.timings).length,
700
- mounted: mounted.size,
701
- }),
702
- partDecision: (partID) => partDecisions.get(partID),
703
- hasStats: (sessionID) => sessionID in tracker.stats,
704
- dedupSize: (sessionID) => tracker.dedup[sessionID]?.size ?? 0,
705
- retries: (sessionID) => tracker.stats[sessionID]?.retries ?? 0,
706
- mounted: () => [...mounted],
707
- activeModel: (sessionID) => tracker.models[sessionID]?.active,
708
- }
709
- lastInternals = internals
710
-
711
- return {
712
- modelRows,
713
- mountSession(sessionID) {
714
- mounted.add(sessionID)
715
- },
716
- unmountSession(sessionID) {
717
- mounted.delete(sessionID)
718
- },
719
- prune,
720
- internals,
721
- dispose() {
722
- for (const unsubscribe of listeners) unsubscribe()
723
- listeners.length = 0
724
- clearInterval(timer)
725
- mounted.clear()
726
- if (lastInternals === internals) lastInternals = undefined
727
- },
728
- }
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
- }