opencode-tui-stats 0.1.1 → 0.3.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 +2 -1
- package/tracker.ts +595 -0
- package/tui.tsx +114 -209
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.3.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": {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"files": [
|
|
35
35
|
"tui.tsx",
|
|
36
|
+
"tracker.ts",
|
|
36
37
|
"README.md",
|
|
37
38
|
"assets/"
|
|
38
39
|
]
|
package/tracker.ts
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
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
|
+
}
|
|
45
|
+
|
|
46
|
+
export type SessionModels = {
|
|
47
|
+
/** Most recently observed assistant `providerID/modelID` for the session. */
|
|
48
|
+
active?: string
|
|
49
|
+
/** Model keys in observation order (oldest first, most recent last). */
|
|
50
|
+
order: string[]
|
|
51
|
+
aggregates: Record<string, ModelAggregate>
|
|
52
|
+
/** `messageID -> providerID/modelID`, used to attribute live samples to a model. */
|
|
53
|
+
messageModels: Map<string, string>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type Tracker = {
|
|
57
|
+
samples: Record<string, StreamSample[]>
|
|
58
|
+
timings: Record<string, MessageTiming>
|
|
59
|
+
averages: Record<string, SessionAverage>
|
|
60
|
+
stats: Record<string, SessionStats>
|
|
61
|
+
dedup: Record<string, Set<string>>
|
|
62
|
+
models: Record<string, SessionModels>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const STREAM_WINDOW_MS = 5_000
|
|
66
|
+
const LIVE_STALE_MS = 1_500
|
|
67
|
+
const SINGLE_SAMPLE_MS = 1_000
|
|
68
|
+
const PART_CACHE_LIMIT = 1_024
|
|
69
|
+
/** Per-session dedup window: only the most recent completed messages are deduplicated;
|
|
70
|
+
* a repeated completed event beyond this window accumulates again. */
|
|
71
|
+
const DEDUP_WINDOW_LIMIT = 64
|
|
72
|
+
/** Cap on `messageID -> model` entries retained per session (FIFO). */
|
|
73
|
+
const MESSAGE_MODEL_LIMIT = 256
|
|
74
|
+
const SESSION_LIMIT = 64
|
|
75
|
+
/** Maximum number of model rows rendered in the sidebar panel. */
|
|
76
|
+
export const MODEL_ROW_LIMIT = 4
|
|
77
|
+
|
|
78
|
+
function emptyStats(): SessionStats {
|
|
79
|
+
return { cacheRead: 0, cachePrompt: 0, cost: 0, steps: 0, retries: 0, lastAttempt: 0 }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function emptyAggregate(): ModelAggregate {
|
|
83
|
+
return {
|
|
84
|
+
cacheRead: 0,
|
|
85
|
+
cachePrompt: 0,
|
|
86
|
+
cost: 0,
|
|
87
|
+
steps: 0,
|
|
88
|
+
totalTokens: 0,
|
|
89
|
+
totalDurationMs: 0,
|
|
90
|
+
totalTtftMs: 0,
|
|
91
|
+
stepCount: 0,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function modelKey(providerID: string, modelID: string) {
|
|
96
|
+
return `${providerID}/${modelID}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function estimateTokens(delta: string) {
|
|
100
|
+
return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function formatRate(value: number) {
|
|
104
|
+
if (!Number.isFinite(value) || value <= 0) return undefined
|
|
105
|
+
if (value >= 100) return Math.round(value).toString()
|
|
106
|
+
if (value >= 10) return value.toFixed(1)
|
|
107
|
+
return value.toFixed(2)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatTtft(value: number) {
|
|
111
|
+
if (!Number.isFinite(value) || value < 0) return undefined
|
|
112
|
+
return `${value.toFixed(1)}s`
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function activeDuration(samples: StreamSample[], tailAt?: number) {
|
|
116
|
+
if (samples.length === 0) return 0
|
|
117
|
+
if (samples.length === 1) {
|
|
118
|
+
const tailDuration = tailAt ? Math.max(0, tailAt - samples[0].at) : SINGLE_SAMPLE_MS
|
|
119
|
+
return Math.min(Math.max(tailDuration, 250), SINGLE_SAMPLE_MS)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let duration = 0
|
|
123
|
+
for (let index = 1; index < samples.length; index++) {
|
|
124
|
+
duration += Math.max(0, samples[index].at - samples[index - 1].at)
|
|
125
|
+
}
|
|
126
|
+
if (tailAt) duration += Math.max(0, tailAt - samples[samples.length - 1].at)
|
|
127
|
+
return Math.max(duration, SINGLE_SAMPLE_MS)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function averageRate(average: SessionAverage | undefined) {
|
|
131
|
+
if (!average || average.totalTokens <= 0 || average.totalDurationMs <= 0) return undefined
|
|
132
|
+
return formatRate(average.totalTokens / (average.totalDurationMs / 1_000))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function averageTtft(average: SessionAverage | undefined) {
|
|
136
|
+
if (!average || average.stepCount <= 0) return undefined
|
|
137
|
+
return formatTtft(average.totalTtftMs / average.stepCount / 1_000)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Cache hit percentage (e.g. `31%`) or `undefined` when nothing promptable has been seen. */
|
|
141
|
+
export function cacheText(stats: { cacheRead: number; cachePrompt: number } | undefined) {
|
|
142
|
+
if (!stats || stats.cachePrompt <= 0) return undefined
|
|
143
|
+
return `${Math.round((stats.cacheRead / stats.cachePrompt) * 100)}%`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function costText(stats: { cost: number; steps: number } | undefined) {
|
|
147
|
+
if (!stats || stats.steps <= 0) return undefined
|
|
148
|
+
return `$${stats.cost >= 1 ? stats.cost.toFixed(2) : stats.cost.toFixed(4)}`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** FIFO-bound a Set so it never grows past `limit`. */
|
|
152
|
+
function boundedAdd(set: Set<string>, key: string, limit: number) {
|
|
153
|
+
if (set.has(key)) return
|
|
154
|
+
if (set.size >= limit) {
|
|
155
|
+
const oldest = set.values().next().value
|
|
156
|
+
if (oldest !== undefined) set.delete(oldest)
|
|
157
|
+
}
|
|
158
|
+
set.add(key)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** FIFO-bound a Map so it never grows past `limit`. */
|
|
162
|
+
function boundedSet<K, V>(map: Map<K, V>, key: K, value: V, limit: number) {
|
|
163
|
+
if (map.has(key)) return
|
|
164
|
+
if (map.size >= limit) {
|
|
165
|
+
const oldest = map.keys().next().value
|
|
166
|
+
if (oldest !== undefined) map.delete(oldest)
|
|
167
|
+
}
|
|
168
|
+
map.set(key, value)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export type ModelRow = {
|
|
172
|
+
key: string
|
|
173
|
+
label: string
|
|
174
|
+
active: boolean
|
|
175
|
+
tps?: string
|
|
176
|
+
avg?: string
|
|
177
|
+
ttft?: string
|
|
178
|
+
cache?: string
|
|
179
|
+
cost?: string
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export type ModelPanel = {
|
|
183
|
+
rows: ModelRow[]
|
|
184
|
+
overflow: number
|
|
185
|
+
retries: number
|
|
186
|
+
/** Session-level cache hit percentage (e.g. `31%`), or `undefined` when nothing promptable was seen. */
|
|
187
|
+
cache?: string
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export type TrackerInternals = {
|
|
191
|
+
sizes(): {
|
|
192
|
+
parts: number
|
|
193
|
+
dedup: number
|
|
194
|
+
stats: number
|
|
195
|
+
averages: number
|
|
196
|
+
timings: number
|
|
197
|
+
mounted: number
|
|
198
|
+
}
|
|
199
|
+
partDecision(partID: string): boolean | undefined
|
|
200
|
+
hasStats(sessionID: string): boolean
|
|
201
|
+
dedupSize(sessionID: string): number
|
|
202
|
+
retries(sessionID: string): number
|
|
203
|
+
mounted(): string[]
|
|
204
|
+
activeModel(sessionID: string): string | undefined
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export type TrackerHandle = {
|
|
208
|
+
modelRows(sessionID: string): ModelPanel
|
|
209
|
+
mountSession(sessionID: string): void
|
|
210
|
+
unmountSession(sessionID: string): void
|
|
211
|
+
prune(now?: number): void
|
|
212
|
+
internals: TrackerInternals
|
|
213
|
+
dispose(): void
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let lastInternals: TrackerInternals | undefined
|
|
217
|
+
|
|
218
|
+
/** Test-only access to the most recently created tracker's internals. */
|
|
219
|
+
export function getLastInternals(): TrackerInternals | undefined {
|
|
220
|
+
return lastInternals
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerHandle {
|
|
224
|
+
const tracker: Tracker = { samples: {}, timings: {}, averages: {}, stats: {}, dedup: {}, models: {} }
|
|
225
|
+
const partDecisions = new Map<string, boolean>()
|
|
226
|
+
const mounted = new Set<string>()
|
|
227
|
+
const order: string[] = []
|
|
228
|
+
const listeners: Array<() => void> = []
|
|
229
|
+
|
|
230
|
+
const bump = () => onChange()
|
|
231
|
+
|
|
232
|
+
const clearLive = (sessionID: string) => {
|
|
233
|
+
if (!tracker.samples[sessionID]?.length) return
|
|
234
|
+
delete tracker.samples[sessionID]
|
|
235
|
+
bump()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const clearTimings = (sessionID: string) => {
|
|
239
|
+
let changed = false
|
|
240
|
+
for (const [messageID, timing] of Object.entries(tracker.timings)) {
|
|
241
|
+
if (timing.sessionID !== sessionID) continue
|
|
242
|
+
delete tracker.timings[messageID]
|
|
243
|
+
changed = true
|
|
244
|
+
}
|
|
245
|
+
if (changed) bump()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const evictSession = (sessionID: string) => {
|
|
249
|
+
delete tracker.stats[sessionID]
|
|
250
|
+
delete tracker.averages[sessionID]
|
|
251
|
+
delete tracker.samples[sessionID]
|
|
252
|
+
delete tracker.dedup[sessionID]
|
|
253
|
+
delete tracker.models[sessionID]
|
|
254
|
+
clearTimings(sessionID)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const touchSession = (sessionID: string) => {
|
|
258
|
+
const at = order.indexOf(sessionID)
|
|
259
|
+
if (at !== -1) order.splice(at, 1)
|
|
260
|
+
order.push(sessionID)
|
|
261
|
+
|
|
262
|
+
// Evict least recently used sessions past the cap, never the sessions currently mounted for display.
|
|
263
|
+
let index = 0
|
|
264
|
+
while (order.length > SESSION_LIMIT && index < order.length) {
|
|
265
|
+
const candidate = order[index]
|
|
266
|
+
if (mounted.has(candidate)) {
|
|
267
|
+
index += 1
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
order.splice(index, 1)
|
|
271
|
+
evictSession(candidate)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const touchModel = (models: SessionModels, key: string) => {
|
|
276
|
+
const at = models.order.indexOf(key)
|
|
277
|
+
if (at !== -1) models.order.splice(at, 1)
|
|
278
|
+
models.order.push(key)
|
|
279
|
+
if (!models.aggregates[key]) models.aggregates[key] = emptyAggregate()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const prune = (now = Date.now()) => {
|
|
283
|
+
let changed = false
|
|
284
|
+
|
|
285
|
+
for (const [sessionID, samples] of Object.entries(tracker.samples)) {
|
|
286
|
+
const next = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
|
|
287
|
+
if (next.length === samples.length) continue
|
|
288
|
+
changed = true
|
|
289
|
+
if (next.length > 0) tracker.samples[sessionID] = next
|
|
290
|
+
else delete tracker.samples[sessionID]
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (changed) bump()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const appendSample = (sessionID: string, messageID: string, sample: StreamSample) => {
|
|
297
|
+
touchSession(sessionID)
|
|
298
|
+
tracker.samples[sessionID] = [
|
|
299
|
+
...(tracker.samples[sessionID] ?? []).filter((item) => sample.at - item.at <= STREAM_WINDOW_MS),
|
|
300
|
+
sample,
|
|
301
|
+
]
|
|
302
|
+
const timing = tracker.timings[messageID]
|
|
303
|
+
if (timing && timing.firstResponseAt === undefined) timing.firstResponseAt = sample.at
|
|
304
|
+
bump()
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Live rate restricted to samples attributed to `key` via the `messageID -> model` mapping. */
|
|
308
|
+
const liveRateFor = (sessionID: string, key: string) => {
|
|
309
|
+
const status = api.state.session.status(sessionID)
|
|
310
|
+
if (!status || status.type === "idle") return undefined
|
|
311
|
+
|
|
312
|
+
const now = Date.now()
|
|
313
|
+
const mapping = tracker.models[sessionID]?.messageModels
|
|
314
|
+
const samples = (tracker.samples[sessionID] ?? []).filter(
|
|
315
|
+
(sample) => now - sample.at <= STREAM_WINDOW_MS && mapping?.get(sample.messageID) === key,
|
|
316
|
+
)
|
|
317
|
+
const last = samples[samples.length - 1]
|
|
318
|
+
if (!last || now - last.at > LIVE_STALE_MS) return undefined
|
|
319
|
+
|
|
320
|
+
const tokens = samples.reduce((sum, sample) => sum + sample.tokens, 0)
|
|
321
|
+
return formatRate(tokens / (activeDuration(samples, now) / 1_000))
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const modelRows = (sessionID: string): ModelPanel => {
|
|
325
|
+
const models = tracker.models[sessionID]
|
|
326
|
+
const retries = tracker.stats[sessionID]?.retries ?? 0
|
|
327
|
+
const cache = cacheText(tracker.stats[sessionID])
|
|
328
|
+
if (!models) return { rows: [], overflow: 0, retries, cache }
|
|
329
|
+
|
|
330
|
+
const active = models.active
|
|
331
|
+
// `order` is oldest→newest; reverse for most-recent-first ordering of the non-active rows.
|
|
332
|
+
const recency = [...models.order].reverse().filter((key) => key in models.aggregates)
|
|
333
|
+
const ordered =
|
|
334
|
+
active && active in models.aggregates ? [active, ...recency.filter((key) => key !== active)] : recency
|
|
335
|
+
|
|
336
|
+
const visible = ordered.slice(0, MODEL_ROW_LIMIT)
|
|
337
|
+
const overflow = Math.max(0, ordered.length - visible.length)
|
|
338
|
+
|
|
339
|
+
const rows = visible.map<ModelRow>((key) => {
|
|
340
|
+
const aggregate = models.aggregates[key]
|
|
341
|
+
const isActive = key === active
|
|
342
|
+
const cost = costText(aggregate)
|
|
343
|
+
return {
|
|
344
|
+
key,
|
|
345
|
+
label: key,
|
|
346
|
+
active: isActive,
|
|
347
|
+
tps: isActive ? liveRateFor(sessionID, key) : undefined,
|
|
348
|
+
avg: averageRate(aggregate),
|
|
349
|
+
ttft: averageTtft(aggregate),
|
|
350
|
+
cache: cacheText(aggregate),
|
|
351
|
+
cost: cost === undefined ? undefined : cost,
|
|
352
|
+
}
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
return { rows, overflow, retries, cache }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
listeners.push(
|
|
359
|
+
api.event.on("message.part.delta", (event) => {
|
|
360
|
+
if (event.properties.field !== "text") return
|
|
361
|
+
const { sessionID, messageID, partID } = event.properties
|
|
362
|
+
|
|
363
|
+
// partID → decision (true = counted, false = ignored). A part that is not registered yet is
|
|
364
|
+
// never cached, so the next delta retries the lookup; once found the accept/reject decision
|
|
365
|
+
// is cached so each part is scanned at most once.
|
|
366
|
+
let counted = partDecisions.get(partID)
|
|
367
|
+
if (counted === undefined) {
|
|
368
|
+
const part = api.state.part(messageID).find((item) => item.id === partID)
|
|
369
|
+
if (!part) return
|
|
370
|
+
counted = part.type === "text" || part.type === "reasoning"
|
|
371
|
+
boundedSet(partDecisions, partID, counted, PART_CACHE_LIMIT)
|
|
372
|
+
}
|
|
373
|
+
if (!counted) return
|
|
374
|
+
|
|
375
|
+
appendSample(sessionID, messageID, {
|
|
376
|
+
at: Date.now(),
|
|
377
|
+
messageID,
|
|
378
|
+
tokens: estimateTokens(event.properties.delta),
|
|
379
|
+
})
|
|
380
|
+
}),
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
listeners.push(
|
|
384
|
+
api.event.on("message.updated", (event) => {
|
|
385
|
+
const info = event.properties.info
|
|
386
|
+
if (info.role !== "assistant") return
|
|
387
|
+
const sessionID = event.properties.sessionID
|
|
388
|
+
const key = modelKey(info.providerID, info.modelID)
|
|
389
|
+
|
|
390
|
+
// Observe the model as soon as a message appears so the sidebar can attribute live samples
|
|
391
|
+
// and mark the most recently used model as active.
|
|
392
|
+
const models =
|
|
393
|
+
tracker.models[sessionID] ?? (tracker.models[sessionID] = { order: [], aggregates: {}, messageModels: new Map() })
|
|
394
|
+
touchModel(models, key)
|
|
395
|
+
models.active = key
|
|
396
|
+
boundedSet(models.messageModels, info.id, key, MESSAGE_MODEL_LIMIT)
|
|
397
|
+
|
|
398
|
+
if (!info.time.completed) {
|
|
399
|
+
const existing = tracker.timings[info.id]
|
|
400
|
+
tracker.timings[info.id] = {
|
|
401
|
+
sessionID,
|
|
402
|
+
requestStartAt: info.time.created,
|
|
403
|
+
firstResponseAt: existing?.firstResponseAt,
|
|
404
|
+
lastToolCallAt: existing?.lastToolCallAt,
|
|
405
|
+
}
|
|
406
|
+
bump()
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Dedup only the most recent completed messages for this session; a repeated completed
|
|
411
|
+
// event beyond the window accumulates again.
|
|
412
|
+
const seen = tracker.dedup[sessionID] ?? (tracker.dedup[sessionID] = new Set<string>())
|
|
413
|
+
if (!seen.has(info.id)) {
|
|
414
|
+
boundedAdd(seen, info.id, DEDUP_WINDOW_LIMIT)
|
|
415
|
+
touchSession(sessionID)
|
|
416
|
+
const stats = tracker.stats[sessionID] ?? emptyStats()
|
|
417
|
+
const cacheRead = info.tokens.cache.read
|
|
418
|
+
const cacheWrite = info.tokens.cache.write
|
|
419
|
+
tracker.stats[sessionID] = {
|
|
420
|
+
...stats,
|
|
421
|
+
cacheRead: stats.cacheRead + cacheRead,
|
|
422
|
+
cachePrompt: stats.cachePrompt + info.tokens.input + cacheRead + cacheWrite,
|
|
423
|
+
cost: stats.cost + info.cost,
|
|
424
|
+
steps: stats.steps + 1,
|
|
425
|
+
}
|
|
426
|
+
const aggregate = models.aggregates[key] ?? emptyAggregate()
|
|
427
|
+
models.aggregates[key] = {
|
|
428
|
+
...aggregate,
|
|
429
|
+
cacheRead: aggregate.cacheRead + cacheRead,
|
|
430
|
+
cachePrompt: aggregate.cachePrompt + info.tokens.input + cacheRead + cacheWrite,
|
|
431
|
+
cost: aggregate.cost + info.cost,
|
|
432
|
+
steps: aggregate.steps + 1,
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const timing = tracker.timings[info.id]
|
|
437
|
+
if (timing?.sessionID === sessionID && timing.firstResponseAt !== undefined) {
|
|
438
|
+
const tokens = info.tokens.output + info.tokens.reasoning
|
|
439
|
+
const endAt = info.finish === "tool-calls" ? timing.lastToolCallAt : info.time.completed
|
|
440
|
+
const durationMs = endAt !== undefined ? Math.max(endAt - timing.firstResponseAt, 1) : undefined
|
|
441
|
+
if (tokens > 0 && durationMs !== undefined) {
|
|
442
|
+
const totals = tracker.averages[sessionID] ?? {
|
|
443
|
+
totalTokens: 0,
|
|
444
|
+
totalDurationMs: 0,
|
|
445
|
+
totalTtftMs: 0,
|
|
446
|
+
stepCount: 0,
|
|
447
|
+
}
|
|
448
|
+
tracker.averages[sessionID] = {
|
|
449
|
+
totalTokens: totals.totalTokens + tokens,
|
|
450
|
+
totalDurationMs: totals.totalDurationMs + durationMs,
|
|
451
|
+
totalTtftMs: totals.totalTtftMs + Math.max(timing.firstResponseAt - timing.requestStartAt, 0),
|
|
452
|
+
stepCount: totals.stepCount + 1,
|
|
453
|
+
}
|
|
454
|
+
const aggregate = models.aggregates[key] ?? emptyAggregate()
|
|
455
|
+
models.aggregates[key] = {
|
|
456
|
+
...aggregate,
|
|
457
|
+
totalTokens: aggregate.totalTokens + tokens,
|
|
458
|
+
totalDurationMs: aggregate.totalDurationMs + durationMs,
|
|
459
|
+
totalTtftMs: aggregate.totalTtftMs + Math.max(timing.firstResponseAt - timing.requestStartAt, 0),
|
|
460
|
+
stepCount: aggregate.stepCount + 1,
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
delete tracker.timings[info.id]
|
|
465
|
+
prune(info.time.completed)
|
|
466
|
+
bump()
|
|
467
|
+
}),
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
listeners.push(
|
|
471
|
+
api.event.on("message.part.updated", (event) => {
|
|
472
|
+
const part = event.properties.part
|
|
473
|
+
if (part.type !== "tool") return
|
|
474
|
+
|
|
475
|
+
if (part.state.status === "running" || part.state.status === "completed" || part.state.status === "error") {
|
|
476
|
+
clearLive(part.sessionID)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const timing = tracker.timings[part.messageID]
|
|
480
|
+
if (!timing) return
|
|
481
|
+
|
|
482
|
+
if (part.state.status === "pending") {
|
|
483
|
+
if (timing.firstResponseAt === undefined) timing.firstResponseAt = Date.now()
|
|
484
|
+
bump()
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
if (part.state.status === "running") {
|
|
488
|
+
timing.lastToolCallAt = part.state.time.start
|
|
489
|
+
bump()
|
|
490
|
+
}
|
|
491
|
+
}),
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
listeners.push(
|
|
495
|
+
api.event.on("session.status", (event) => {
|
|
496
|
+
const { sessionID, status } = event.properties
|
|
497
|
+
const type = status.type
|
|
498
|
+
|
|
499
|
+
if (type === "idle") clearTimings(sessionID)
|
|
500
|
+
|
|
501
|
+
const stats = tracker.stats[sessionID] ?? emptyStats()
|
|
502
|
+
|
|
503
|
+
if (type === "retry") {
|
|
504
|
+
const attempt = status.attempt
|
|
505
|
+
if (attempt <= stats.lastAttempt) return
|
|
506
|
+
touchSession(sessionID)
|
|
507
|
+
tracker.stats[sessionID] = {
|
|
508
|
+
...stats,
|
|
509
|
+
retries: stats.retries + (attempt - stats.lastAttempt),
|
|
510
|
+
lastAttempt: attempt,
|
|
511
|
+
}
|
|
512
|
+
bump()
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Any non-retry status resets the attempt baseline for the next retry burst.
|
|
517
|
+
if (stats.lastAttempt === 0) return
|
|
518
|
+
touchSession(sessionID)
|
|
519
|
+
tracker.stats[sessionID] = { ...stats, lastAttempt: 0 }
|
|
520
|
+
bump()
|
|
521
|
+
}),
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
listeners.push(
|
|
525
|
+
api.event.on("session.idle", (event) => {
|
|
526
|
+
clearTimings(event.properties.sessionID)
|
|
527
|
+
}),
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
// The 1s tick re-evaluates live TPS staleness and prunes stale stream samples.
|
|
531
|
+
const timer = setInterval(() => {
|
|
532
|
+
prune()
|
|
533
|
+
bump()
|
|
534
|
+
}, 1_000)
|
|
535
|
+
|
|
536
|
+
const internals: TrackerInternals = {
|
|
537
|
+
sizes: () => ({
|
|
538
|
+
parts: partDecisions.size,
|
|
539
|
+
dedup: Object.values(tracker.dedup).reduce((sum, set) => sum + set.size, 0),
|
|
540
|
+
stats: Object.keys(tracker.stats).length,
|
|
541
|
+
averages: Object.keys(tracker.averages).length,
|
|
542
|
+
timings: Object.keys(tracker.timings).length,
|
|
543
|
+
mounted: mounted.size,
|
|
544
|
+
}),
|
|
545
|
+
partDecision: (partID) => partDecisions.get(partID),
|
|
546
|
+
hasStats: (sessionID) => sessionID in tracker.stats,
|
|
547
|
+
dedupSize: (sessionID) => tracker.dedup[sessionID]?.size ?? 0,
|
|
548
|
+
retries: (sessionID) => tracker.stats[sessionID]?.retries ?? 0,
|
|
549
|
+
mounted: () => [...mounted],
|
|
550
|
+
activeModel: (sessionID) => tracker.models[sessionID]?.active,
|
|
551
|
+
}
|
|
552
|
+
lastInternals = internals
|
|
553
|
+
|
|
554
|
+
return {
|
|
555
|
+
modelRows,
|
|
556
|
+
mountSession(sessionID) {
|
|
557
|
+
mounted.add(sessionID)
|
|
558
|
+
},
|
|
559
|
+
unmountSession(sessionID) {
|
|
560
|
+
mounted.delete(sessionID)
|
|
561
|
+
},
|
|
562
|
+
prune,
|
|
563
|
+
internals,
|
|
564
|
+
dispose() {
|
|
565
|
+
for (const unsubscribe of listeners) unsubscribe()
|
|
566
|
+
listeners.length = 0
|
|
567
|
+
clearInterval(timer)
|
|
568
|
+
mounted.clear()
|
|
569
|
+
if (lastInternals === internals) lastInternals = undefined
|
|
570
|
+
},
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Test-only fold controller. Kept in this non-entry module: the plugin entry (`tui.tsx`)
|
|
575
|
+
* may only expose a `default` export, because the legacy loader treats every runtime export
|
|
576
|
+
* as a plugin. */
|
|
577
|
+
export type PanelController = {
|
|
578
|
+
toggle(key: string): void
|
|
579
|
+
isExpanded(key: string): boolean
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
let lastPanelController: PanelController | undefined
|
|
583
|
+
|
|
584
|
+
export function setLastPanelController(controller: PanelController) {
|
|
585
|
+
lastPanelController = controller
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Clear the controller only when it is still the current one. */
|
|
589
|
+
export function clearLastPanelController(controller: PanelController) {
|
|
590
|
+
if (lastPanelController === controller) lastPanelController = undefined
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export function getLastPanelController(): PanelController | undefined {
|
|
594
|
+
return lastPanelController
|
|
595
|
+
}
|
package/tui.tsx
CHANGED
|
@@ -1,241 +1,146 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
3
|
-
import { createMemo, createSignal, type Accessor } from "solid-js"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
totalTtftMs: number
|
|
21
|
-
stepCount: number
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
type Tracker = {
|
|
25
|
-
samples: Record<string, StreamSample[]>
|
|
26
|
-
timings: Record<string, MessageTiming>
|
|
27
|
-
averages: Record<string, SessionAverage>
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const STREAM_WINDOW_MS = 5_000
|
|
31
|
-
const LIVE_STALE_MS = 1_500
|
|
32
|
-
const SINGLE_SAMPLE_MS = 1_000
|
|
33
|
-
|
|
34
|
-
function estimateTokens(delta: string) {
|
|
35
|
-
return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5))
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function formatRate(value: number) {
|
|
39
|
-
if (!Number.isFinite(value) || value <= 0) return undefined
|
|
40
|
-
if (value >= 100) return Math.round(value).toString()
|
|
41
|
-
if (value >= 10) return value.toFixed(1)
|
|
42
|
-
return value.toFixed(2)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function formatTtft(value: number) {
|
|
46
|
-
if (!Number.isFinite(value) || value < 0) return undefined
|
|
47
|
-
return `${value.toFixed(1)}s`
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function activeDuration(samples: StreamSample[], tailAt?: number) {
|
|
51
|
-
if (samples.length === 0) return 0
|
|
52
|
-
if (samples.length === 1) {
|
|
53
|
-
const tailDuration = tailAt ? Math.max(0, tailAt - samples[0].at) : SINGLE_SAMPLE_MS
|
|
54
|
-
return Math.min(Math.max(tailDuration, 250), SINGLE_SAMPLE_MS)
|
|
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"
|
|
11
|
+
|
|
12
|
+
/** A single model row: fold marker + summary line, plus a detail line when expanded. */
|
|
13
|
+
function ModelRowView(props: { api: TuiPluginApi; row: ModelRow; expanded: boolean; onToggle: () => void }) {
|
|
14
|
+
// Guard against a drag-select being read as a click: only toggle when the press and the
|
|
15
|
+
// release land on the same cell.
|
|
16
|
+
let pressed: { x: number; y: number } | undefined
|
|
17
|
+
|
|
18
|
+
const handleMouseDown = (event: { x: number; y: number }) => {
|
|
19
|
+
pressed = { x: event.x, y: event.y }
|
|
55
20
|
}
|
|
56
21
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
22
|
+
const handleMouseUp = (event: { x: number; y: number }) => {
|
|
23
|
+
const start = pressed
|
|
24
|
+
pressed = undefined
|
|
25
|
+
if (start && (start.x !== event.x || start.y !== event.y)) return
|
|
26
|
+
props.onToggle()
|
|
60
27
|
}
|
|
61
|
-
if (tailAt) duration += Math.max(0, tailAt - samples[samples.length - 1].at)
|
|
62
|
-
return Math.max(duration, SINGLE_SAMPLE_MS)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function averageRate(average: SessionAverage | undefined) {
|
|
66
|
-
if (!average || average.totalTokens <= 0 || average.totalDurationMs <= 0) return undefined
|
|
67
|
-
return formatRate(average.totalTokens / (average.totalDurationMs / 1_000))
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function averageTtft(average: SessionAverage | undefined) {
|
|
71
|
-
if (!average || average.stepCount <= 0) return undefined
|
|
72
|
-
return formatTtft(average.totalTtftMs / average.stepCount / 1_000)
|
|
73
|
-
}
|
|
74
28
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
29
|
+
const theme = () => props.api.theme.current
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<box flexDirection="column" onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}>
|
|
33
|
+
<text wrapMode="none" truncate>
|
|
34
|
+
<span style={{ fg: theme().textMuted } as Record<string, unknown>}>{`${props.expanded ? "▼" : "▶"} `}</span>
|
|
35
|
+
<span style={{ fg: theme().text } as Record<string, unknown>}>{props.row.label}</span>
|
|
36
|
+
<Show when={props.row.active}>
|
|
37
|
+
<span style={{ fg: theme().success } as Record<string, unknown>}>{" active"}</span>
|
|
38
|
+
</Show>
|
|
39
|
+
</text>
|
|
40
|
+
<Show when={props.expanded}>
|
|
41
|
+
<text fg={theme().text} wrapMode="none" truncate>
|
|
42
|
+
{`TPS ${props.row.tps ?? "—"} · AVG ${props.row.avg ?? "—"}`}
|
|
43
|
+
</text>
|
|
44
|
+
<text fg={theme().text} wrapMode="none" truncate>
|
|
45
|
+
{`TTFT ${props.row.ttft ?? "—"} · CACHE ${props.row.cache ?? "—"} · ${props.row.cost ?? "—"}`}
|
|
46
|
+
</text>
|
|
47
|
+
</Show>
|
|
48
|
+
</box>
|
|
49
|
+
)
|
|
94
50
|
}
|
|
95
51
|
|
|
96
|
-
|
|
52
|
+
/** Per-session model panel, mounted at `sidebar_content`. */
|
|
53
|
+
function ModelPanelView(props: {
|
|
97
54
|
api: TuiPluginApi
|
|
98
55
|
sessionID: string
|
|
99
|
-
tracker:
|
|
56
|
+
tracker: TrackerHandle
|
|
100
57
|
revision: Accessor<number>
|
|
101
58
|
}) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return <text fg={props.api.theme.current.textMuted}>{content()}</text>
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
const tui: TuiPlugin = async (api) => {
|
|
111
|
-
const tracker: Tracker = {
|
|
112
|
-
samples: {},
|
|
113
|
-
timings: {},
|
|
114
|
-
averages: {},
|
|
115
|
-
}
|
|
116
|
-
const [revision, setRevision] = createSignal(0)
|
|
117
|
-
const bump = () => setRevision((value) => value + 1)
|
|
118
|
-
|
|
119
|
-
const pruneSamples = (now = Date.now()) => {
|
|
120
|
-
let changed = false
|
|
59
|
+
let mounted: string | undefined
|
|
60
|
+
const initialActive = props.tracker.internals.activeModel(props.sessionID)
|
|
61
|
+
const [expanded, setExpanded] = createSignal<Set<string>>(new Set(initialActive ? [initialActive] : []))
|
|
62
|
+
let seededActive: string | undefined = initialActive
|
|
63
|
+
let lastSession: string | undefined = props.sessionID
|
|
121
64
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
|
|
65
|
+
const panel = createMemo(() => {
|
|
66
|
+
props.revision()
|
|
67
|
+
const sessionID = props.sessionID
|
|
68
|
+
if (mounted !== sessionID) {
|
|
69
|
+
if (mounted !== undefined) props.tracker.unmountSession(mounted)
|
|
70
|
+
props.tracker.mountSession(sessionID)
|
|
71
|
+
mounted = sessionID
|
|
128
72
|
}
|
|
129
|
-
|
|
130
|
-
if (changed) bump()
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const clearLive = (sessionID: string) => {
|
|
134
|
-
if (!tracker.samples[sessionID]?.length) return
|
|
135
|
-
delete tracker.samples[sessionID]
|
|
136
|
-
bump()
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const appendSample = (sessionID: string, messageID: string, sample: StreamSample) => {
|
|
140
|
-
tracker.samples[sessionID] = [
|
|
141
|
-
...(tracker.samples[sessionID] ?? []).filter((item) => sample.at - item.at <= STREAM_WINDOW_MS),
|
|
142
|
-
sample,
|
|
143
|
-
]
|
|
144
|
-
const timing = tracker.timings[messageID]
|
|
145
|
-
if (timing && timing.firstResponseAt === undefined) timing.firstResponseAt = sample.at
|
|
146
|
-
bump()
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const onDelta = api.event.on("message.part.delta", (event) => {
|
|
150
|
-
if (event.properties.field !== "text") return
|
|
151
|
-
const part = api.state.part(event.properties.messageID).find((item) => item.id === event.properties.partID)
|
|
152
|
-
if (!part || (part.type !== "text" && part.type !== "reasoning")) return
|
|
153
|
-
appendSample(event.properties.sessionID, event.properties.messageID, {
|
|
154
|
-
at: Date.now(),
|
|
155
|
-
tokens: estimateTokens(event.properties.delta),
|
|
156
|
-
})
|
|
73
|
+
return props.tracker.modelRows(sessionID)
|
|
157
74
|
})
|
|
158
75
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
lastToolCallAt: existing?.lastToolCallAt,
|
|
171
|
-
}
|
|
172
|
-
bump()
|
|
76
|
+
// Fold state is in-memory only and resets per session. The active model starts expanded and
|
|
77
|
+
// every other row starts collapsed; when the active model changes we return to that default
|
|
78
|
+
// so the panel cannot accumulate open rows as models are switched.
|
|
79
|
+
createEffect(() => {
|
|
80
|
+
props.revision()
|
|
81
|
+
const sessionID = props.sessionID
|
|
82
|
+
const active = props.tracker.internals.activeModel(sessionID)
|
|
83
|
+
if (sessionID !== lastSession) {
|
|
84
|
+
lastSession = sessionID
|
|
85
|
+
seededActive = active
|
|
86
|
+
setExpanded(new Set<string>(active ? [active] : []))
|
|
173
87
|
return
|
|
174
88
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const tokens = info.tokens.output + info.tokens.reasoning
|
|
179
|
-
const endAt = info.finish === "tool-calls" ? timing.lastToolCallAt : info.time.completed
|
|
180
|
-
const durationMs = endAt !== undefined ? Math.max(endAt - timing.firstResponseAt, 1) : undefined
|
|
181
|
-
if (tokens > 0 && durationMs !== undefined) {
|
|
182
|
-
const totals = tracker.averages[sessionID] ?? {
|
|
183
|
-
totalTokens: 0,
|
|
184
|
-
totalDurationMs: 0,
|
|
185
|
-
totalTtftMs: 0,
|
|
186
|
-
stepCount: 0,
|
|
187
|
-
}
|
|
188
|
-
tracker.averages[sessionID] = {
|
|
189
|
-
totalTokens: totals.totalTokens + tokens,
|
|
190
|
-
totalDurationMs: totals.totalDurationMs + durationMs,
|
|
191
|
-
totalTtftMs: totals.totalTtftMs + Math.max(timing.firstResponseAt - timing.requestStartAt, 0),
|
|
192
|
-
stepCount: totals.stepCount + 1,
|
|
193
|
-
}
|
|
194
|
-
}
|
|
89
|
+
if (active && active !== seededActive) {
|
|
90
|
+
seededActive = active
|
|
91
|
+
setExpanded(new Set<string>([active]))
|
|
195
92
|
}
|
|
196
|
-
delete tracker.timings[info.id]
|
|
197
|
-
pruneSamples(info.time.completed)
|
|
198
|
-
bump()
|
|
199
93
|
})
|
|
200
94
|
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
95
|
+
const toggle = (key: string) => {
|
|
96
|
+
setExpanded((prev) => {
|
|
97
|
+
const next = new Set(prev)
|
|
98
|
+
if (next.has(key)) next.delete(key)
|
|
99
|
+
else next.add(key)
|
|
100
|
+
return next
|
|
101
|
+
})
|
|
102
|
+
props.api.renderer?.requestRender?.()
|
|
103
|
+
}
|
|
208
104
|
|
|
209
|
-
|
|
210
|
-
|
|
105
|
+
const controller = { toggle, isExpanded: (key: string) => expanded().has(key) }
|
|
106
|
+
setLastPanelController(controller)
|
|
211
107
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
return
|
|
216
|
-
}
|
|
217
|
-
if (part.state.status === "running") {
|
|
218
|
-
timing.lastToolCallAt = part.state.time.start
|
|
219
|
-
bump()
|
|
220
|
-
}
|
|
108
|
+
onCleanup(() => {
|
|
109
|
+
if (mounted !== undefined) props.tracker.unmountSession(mounted)
|
|
110
|
+
clearLastPanelController(controller)
|
|
221
111
|
})
|
|
222
112
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
113
|
+
return (
|
|
114
|
+
<box flexDirection="column">
|
|
115
|
+
<text fg={props.api.theme.current.textMuted}>Performance · observed</text>
|
|
116
|
+
<Show when={panel().rows.length === 0}>
|
|
117
|
+
<text fg={props.api.theme.current.textMuted}>—</text>
|
|
118
|
+
</Show>
|
|
119
|
+
<For each={panel().rows}>
|
|
120
|
+
{(row) => (
|
|
121
|
+
<ModelRowView api={props.api} row={row} expanded={expanded().has(row.key)} onToggle={() => toggle(row.key)} />
|
|
122
|
+
)}
|
|
123
|
+
</For>
|
|
124
|
+
<Show when={panel().overflow > 0}>
|
|
125
|
+
<text fg={props.api.theme.current.textMuted}>{`+${panel().overflow} more models`}</text>
|
|
126
|
+
</Show>
|
|
127
|
+
<text fg={props.api.theme.current.text}>
|
|
128
|
+
{`Session CACHE ${panel().cache ?? "—"}${panel().retries > 0 ? ` · retries ${panel().retries}` : ""}`}
|
|
129
|
+
</text>
|
|
130
|
+
</box>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
227
133
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
})
|
|
134
|
+
const tui: TuiPlugin = async (api) => {
|
|
135
|
+
const [revision, setRevision] = createSignal(0)
|
|
136
|
+
const tracker = createTracker(api, () => setRevision((value) => value + 1))
|
|
137
|
+
|
|
138
|
+
api.lifecycle.onDispose(() => tracker.dispose())
|
|
234
139
|
|
|
235
140
|
api.slots.register({
|
|
236
141
|
slots: {
|
|
237
|
-
|
|
238
|
-
return <
|
|
142
|
+
sidebar_content(_context, props) {
|
|
143
|
+
return <ModelPanelView api={api} sessionID={props.session_id} tracker={tracker} revision={revision} />
|
|
239
144
|
},
|
|
240
145
|
},
|
|
241
146
|
})
|