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/README.en.md +1 -1
- package/README.md +1 -1
- package/dist/tui.js +949 -0
- package/package.json +5 -4
- package/tracker.ts +0 -768
- package/tui.tsx +0 -215
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
|
-
}
|