opencode-tui-stats 0.1.0 → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 aifcoding
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md ADDED
@@ -0,0 +1,29 @@
1
+ # opencode-tui-stats
2
+
3
+ A TUI plugin for OpenCode 1.x (v1) that shows a single line — `TPS | AVG | TTFT` — on the right side of the session prompt.
4
+
5
+ ## Demo
6
+
7
+ ![Demo](./assets/demo.gif)
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ opencode plugin opencode-tui-stats --global
13
+ ```
14
+
15
+ This command writes the plugin into opencode's TUI config. You can also install the `opencode-tui-stats` package from npm.
16
+
17
+ ## Requirements
18
+
19
+ - opencode ≥ 1.3.14
20
+
21
+ ## Metrics
22
+
23
+ - `TPS`: live generation rate within the most recent ~5-second streaming window (tokens/sec); shows `-` when the session is idle or data is stale.
24
+ - `AVG`: average generation rate of completed messages in this session (tokens/sec).
25
+ - `TTFT`: average time to first token of completed messages in this session (seconds).
26
+
27
+ ## License
28
+
29
+ MIT — see [LICENSE](./LICENSE) in the repository root.
package/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # opencode-tui-stats
2
2
 
3
- OpenCode v1 TUI 插件:在会话提示区右侧显示 `TPS | AVG | TTFT`,即实时流式输出速度(TPS)、本次会话平均生成速度(AVG)以及平均首字延迟(TTFT)。
3
+ OpenCode 1.x(v1)的 TUI 插件,在会话提示区右侧显示一行 `TPS | AVG | TTFT`。
4
+
5
+ ## 演示
6
+
7
+ ![Demo](./assets/demo.gif)
4
8
 
5
9
  ## 安装
6
10
 
@@ -8,10 +12,18 @@ OpenCode v1 TUI 插件:在会话提示区右侧显示 `TPS | AVG | TTFT`,即
8
12
  opencode plugin opencode-tui-stats --global
9
13
  ```
10
14
 
11
- 要求宿主 opencode ≥ 1.3.14。
15
+ 该命令会把插件写入 opencode 的 tui 配置。也可以从 npm 安装 `opencode-tui-stats` 包。
16
+
17
+ ## 环境要求
18
+
19
+ - opencode ≥ 1.3.14
20
+
21
+ ## 指标说明
22
+
23
+ - `TPS`:最近约 5 秒流式窗口内的实时生成速度(token/秒);会话空闲或数据过期时显示 `-`。
24
+ - `AVG`:本会话已完成消息的平均生成速度(token/秒)。
25
+ - `TTFT`:本会话已完成消息的平均首字延迟(秒)。
12
26
 
13
- ## 显示项
27
+ ## License
14
28
 
15
- - `TPS`:最近 5 秒流式窗口内的实时 token/秒,空闲或数据过期时显示 `-`。
16
- - `AVG`:本次会话已完成消息的平均 token/秒。
17
- - `TTFT`:本次会话已完成消息的平均首字延迟(秒)。
29
+ MIT,详见仓库根目录的 [LICENSE](./LICENSE)。
Binary file
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.1.0",
4
+ "version": "0.2.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,8 @@
33
33
  },
34
34
  "files": [
35
35
  "tui.tsx",
36
- "README.md"
36
+ "tracker.ts",
37
+ "README.md",
38
+ "assets/"
37
39
  ]
38
40
  }
package/tracker.ts ADDED
@@ -0,0 +1,439 @@
1
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
2
+
3
+ export type StreamSample = {
4
+ at: number
5
+ tokens: number
6
+ }
7
+
8
+ export type MessageTiming = {
9
+ sessionID: string
10
+ requestStartAt: number
11
+ firstResponseAt?: number
12
+ lastToolCallAt?: number
13
+ }
14
+
15
+ export type SessionAverage = {
16
+ totalTokens: number
17
+ totalDurationMs: number
18
+ totalTtftMs: number
19
+ stepCount: number
20
+ }
21
+
22
+ export type SessionStats = {
23
+ cacheRead: number
24
+ cachePrompt: number
25
+ cost: number
26
+ steps: number
27
+ retries: number
28
+ lastAttempt: number
29
+ }
30
+
31
+ export type Tracker = {
32
+ samples: Record<string, StreamSample[]>
33
+ timings: Record<string, MessageTiming>
34
+ averages: Record<string, SessionAverage>
35
+ stats: Record<string, SessionStats>
36
+ dedup: Record<string, Set<string>>
37
+ }
38
+
39
+ const STREAM_WINDOW_MS = 5_000
40
+ const LIVE_STALE_MS = 1_500
41
+ const SINGLE_SAMPLE_MS = 1_000
42
+ const PART_CACHE_LIMIT = 1_024
43
+ /** Per-session dedup window: only the most recent completed messages are deduplicated;
44
+ * a repeated completed event beyond this window accumulates again. */
45
+ const DEDUP_WINDOW_LIMIT = 64
46
+ const SESSION_LIMIT = 64
47
+
48
+ function emptyStats(): SessionStats {
49
+ return { cacheRead: 0, cachePrompt: 0, cost: 0, steps: 0, retries: 0, lastAttempt: 0 }
50
+ }
51
+
52
+ export function estimateTokens(delta: string) {
53
+ return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5))
54
+ }
55
+
56
+ export function formatRate(value: number) {
57
+ if (!Number.isFinite(value) || value <= 0) return undefined
58
+ if (value >= 100) return Math.round(value).toString()
59
+ if (value >= 10) return value.toFixed(1)
60
+ return value.toFixed(2)
61
+ }
62
+
63
+ export function formatTtft(value: number) {
64
+ if (!Number.isFinite(value) || value < 0) return undefined
65
+ return `${value.toFixed(1)}s`
66
+ }
67
+
68
+ export function activeDuration(samples: StreamSample[], tailAt?: number) {
69
+ if (samples.length === 0) return 0
70
+ if (samples.length === 1) {
71
+ const tailDuration = tailAt ? Math.max(0, tailAt - samples[0].at) : SINGLE_SAMPLE_MS
72
+ return Math.min(Math.max(tailDuration, 250), SINGLE_SAMPLE_MS)
73
+ }
74
+
75
+ let duration = 0
76
+ for (let index = 1; index < samples.length; index++) {
77
+ duration += Math.max(0, samples[index].at - samples[index - 1].at)
78
+ }
79
+ if (tailAt) duration += Math.max(0, tailAt - samples[samples.length - 1].at)
80
+ return Math.max(duration, SINGLE_SAMPLE_MS)
81
+ }
82
+
83
+ export function averageRate(average: SessionAverage | undefined) {
84
+ if (!average || average.totalTokens <= 0 || average.totalDurationMs <= 0) return undefined
85
+ return formatRate(average.totalTokens / (average.totalDurationMs / 1_000))
86
+ }
87
+
88
+ export function averageTtft(average: SessionAverage | undefined) {
89
+ if (!average || average.stepCount <= 0) return undefined
90
+ return formatTtft(average.totalTtftMs / average.stepCount / 1_000)
91
+ }
92
+
93
+ export function cacheRate(stats: SessionStats | undefined) {
94
+ if (!stats || stats.cachePrompt <= 0) return "-"
95
+ return `${Math.round((stats.cacheRead / stats.cachePrompt) * 100)}%`
96
+ }
97
+
98
+ export function costText(stats: SessionStats | undefined) {
99
+ if (!stats || stats.steps <= 0) return "-"
100
+ return `$${stats.cost >= 1 ? stats.cost.toFixed(2) : stats.cost.toFixed(4)}`
101
+ }
102
+
103
+ /** FIFO-bound a Set so it never grows past `limit`. */
104
+ function boundedAdd(set: Set<string>, key: string, limit: number) {
105
+ if (set.has(key)) return
106
+ if (set.size >= limit) {
107
+ const oldest = set.values().next().value
108
+ if (oldest !== undefined) set.delete(oldest)
109
+ }
110
+ set.add(key)
111
+ }
112
+
113
+ /** FIFO-bound a Map so it never grows past `limit`. */
114
+ function boundedSet<K, V>(map: Map<K, V>, key: K, value: V, limit: number) {
115
+ if (map.has(key)) return
116
+ if (map.size >= limit) {
117
+ const oldest = map.keys().next().value
118
+ if (oldest !== undefined) map.delete(oldest)
119
+ }
120
+ map.set(key, value)
121
+ }
122
+
123
+ export type TrackerInternals = {
124
+ sizes(): {
125
+ parts: number
126
+ dedup: number
127
+ stats: number
128
+ averages: number
129
+ timings: number
130
+ mounted: number
131
+ }
132
+ partDecision(partID: string): boolean | undefined
133
+ hasStats(sessionID: string): boolean
134
+ dedupSize(sessionID: string): number
135
+ retries(sessionID: string): number
136
+ mounted(): string[]
137
+ }
138
+
139
+ export type TrackerHandle = {
140
+ statusText(sessionID: string): string
141
+ mountSession(sessionID: string): void
142
+ unmountSession(sessionID: string): void
143
+ prune(now?: number): void
144
+ internals: TrackerInternals
145
+ dispose(): void
146
+ }
147
+
148
+ let lastInternals: TrackerInternals | undefined
149
+
150
+ /** Test-only access to the most recently created tracker's internals. */
151
+ export function getLastInternals(): TrackerInternals | undefined {
152
+ return lastInternals
153
+ }
154
+
155
+ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerHandle {
156
+ const tracker: Tracker = { samples: {}, timings: {}, averages: {}, stats: {}, dedup: {} }
157
+ const partDecisions = new Map<string, boolean>()
158
+ const mounted = new Set<string>()
159
+ const order: string[] = []
160
+ const listeners: Array<() => void> = []
161
+
162
+ const bump = () => onChange()
163
+
164
+ const clearLive = (sessionID: string) => {
165
+ if (!tracker.samples[sessionID]?.length) return
166
+ delete tracker.samples[sessionID]
167
+ bump()
168
+ }
169
+
170
+ const clearTimings = (sessionID: string) => {
171
+ let changed = false
172
+ for (const [messageID, timing] of Object.entries(tracker.timings)) {
173
+ if (timing.sessionID !== sessionID) continue
174
+ delete tracker.timings[messageID]
175
+ changed = true
176
+ }
177
+ if (changed) bump()
178
+ }
179
+
180
+ const evictSession = (sessionID: string) => {
181
+ delete tracker.stats[sessionID]
182
+ delete tracker.averages[sessionID]
183
+ delete tracker.samples[sessionID]
184
+ delete tracker.dedup[sessionID]
185
+ clearTimings(sessionID)
186
+ }
187
+
188
+ const touchSession = (sessionID: string) => {
189
+ const at = order.indexOf(sessionID)
190
+ if (at !== -1) order.splice(at, 1)
191
+ order.push(sessionID)
192
+
193
+ // Evict least recently used sessions past the cap, never the sessions currently mounted for display.
194
+ let index = 0
195
+ while (order.length > SESSION_LIMIT && index < order.length) {
196
+ const candidate = order[index]
197
+ if (mounted.has(candidate)) {
198
+ index += 1
199
+ continue
200
+ }
201
+ order.splice(index, 1)
202
+ evictSession(candidate)
203
+ }
204
+ }
205
+
206
+ const prune = (now = Date.now()) => {
207
+ let changed = false
208
+
209
+ for (const [sessionID, samples] of Object.entries(tracker.samples)) {
210
+ const next = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
211
+ if (next.length === samples.length) continue
212
+ changed = true
213
+ if (next.length > 0) tracker.samples[sessionID] = next
214
+ else delete tracker.samples[sessionID]
215
+ }
216
+
217
+ if (changed) bump()
218
+ }
219
+
220
+ const appendSample = (sessionID: string, messageID: string, sample: StreamSample) => {
221
+ touchSession(sessionID)
222
+ tracker.samples[sessionID] = [
223
+ ...(tracker.samples[sessionID] ?? []).filter((item) => sample.at - item.at <= STREAM_WINDOW_MS),
224
+ sample,
225
+ ]
226
+ const timing = tracker.timings[messageID]
227
+ if (timing && timing.firstResponseAt === undefined) timing.firstResponseAt = sample.at
228
+ bump()
229
+ }
230
+
231
+ const liveRate = (sessionID: string) => {
232
+ const status = api.state.session.status(sessionID)
233
+ if (!status || status.type === "idle") return undefined
234
+
235
+ const now = Date.now()
236
+ const samples = (tracker.samples[sessionID] ?? []).filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
237
+ const last = samples[samples.length - 1]
238
+ if (!last || now - last.at > LIVE_STALE_MS) return undefined
239
+
240
+ const tokens = samples.reduce((sum, sample) => sum + sample.tokens, 0)
241
+ return formatRate(tokens / (activeDuration(samples, now) / 1_000))
242
+ }
243
+
244
+ const statusText = (sessionID: string) => {
245
+ const average = tracker.averages[sessionID]
246
+ const stats = tracker.stats[sessionID]
247
+ const live = liveRate(sessionID) ?? "-"
248
+ const avg = averageRate(average) ?? "-"
249
+ const ttft = averageTtft(average) ?? "-"
250
+ return `TPS ${live} | AVG ${avg} | TTFT ${ttft} | CACHE ${cacheRate(stats)} | COST ${costText(stats)} | RETRY ${stats?.retries ?? 0}`
251
+ }
252
+
253
+ listeners.push(
254
+ api.event.on("message.part.delta", (event) => {
255
+ if (event.properties.field !== "text") return
256
+ const { sessionID, messageID, partID } = event.properties
257
+
258
+ // partID → decision (true = counted, false = ignored). A part that is not registered yet is
259
+ // never cached, so the next delta retries the lookup; once found the accept/reject decision
260
+ // is cached so each part is scanned at most once.
261
+ let counted = partDecisions.get(partID)
262
+ if (counted === undefined) {
263
+ const part = api.state.part(messageID).find((item) => item.id === partID)
264
+ if (!part) return
265
+ counted = part.type === "text" || part.type === "reasoning"
266
+ boundedSet(partDecisions, partID, counted, PART_CACHE_LIMIT)
267
+ }
268
+ if (!counted) return
269
+
270
+ appendSample(sessionID, messageID, {
271
+ at: Date.now(),
272
+ tokens: estimateTokens(event.properties.delta),
273
+ })
274
+ }),
275
+ )
276
+
277
+ listeners.push(
278
+ api.event.on("message.updated", (event) => {
279
+ const info = event.properties.info
280
+ if (info.role !== "assistant") return
281
+ const sessionID = event.properties.sessionID
282
+
283
+ if (!info.time.completed) {
284
+ const existing = tracker.timings[info.id]
285
+ tracker.timings[info.id] = {
286
+ sessionID,
287
+ requestStartAt: info.time.created,
288
+ firstResponseAt: existing?.firstResponseAt,
289
+ lastToolCallAt: existing?.lastToolCallAt,
290
+ }
291
+ bump()
292
+ return
293
+ }
294
+
295
+ // Dedup only the most recent completed messages for this session; a repeated completed
296
+ // event beyond the window accumulates again.
297
+ const seen = tracker.dedup[sessionID] ?? (tracker.dedup[sessionID] = new Set<string>())
298
+ if (!seen.has(info.id)) {
299
+ boundedAdd(seen, info.id, DEDUP_WINDOW_LIMIT)
300
+ touchSession(sessionID)
301
+ const stats = tracker.stats[sessionID] ?? emptyStats()
302
+ const cacheRead = info.tokens.cache.read
303
+ const cacheWrite = info.tokens.cache.write
304
+ tracker.stats[sessionID] = {
305
+ ...stats,
306
+ cacheRead: stats.cacheRead + cacheRead,
307
+ cachePrompt: stats.cachePrompt + info.tokens.input + cacheRead + cacheWrite,
308
+ cost: stats.cost + info.cost,
309
+ steps: stats.steps + 1,
310
+ }
311
+ }
312
+
313
+ const timing = tracker.timings[info.id]
314
+ if (timing?.sessionID === sessionID && timing.firstResponseAt !== undefined) {
315
+ const tokens = info.tokens.output + info.tokens.reasoning
316
+ const endAt = info.finish === "tool-calls" ? timing.lastToolCallAt : info.time.completed
317
+ const durationMs = endAt !== undefined ? Math.max(endAt - timing.firstResponseAt, 1) : undefined
318
+ if (tokens > 0 && durationMs !== undefined) {
319
+ const totals = tracker.averages[sessionID] ?? {
320
+ totalTokens: 0,
321
+ totalDurationMs: 0,
322
+ totalTtftMs: 0,
323
+ stepCount: 0,
324
+ }
325
+ tracker.averages[sessionID] = {
326
+ totalTokens: totals.totalTokens + tokens,
327
+ totalDurationMs: totals.totalDurationMs + durationMs,
328
+ totalTtftMs: totals.totalTtftMs + Math.max(timing.firstResponseAt - timing.requestStartAt, 0),
329
+ stepCount: totals.stepCount + 1,
330
+ }
331
+ }
332
+ }
333
+ delete tracker.timings[info.id]
334
+ prune(info.time.completed)
335
+ bump()
336
+ }),
337
+ )
338
+
339
+ listeners.push(
340
+ api.event.on("message.part.updated", (event) => {
341
+ const part = event.properties.part
342
+ if (part.type !== "tool") return
343
+
344
+ if (part.state.status === "running" || part.state.status === "completed" || part.state.status === "error") {
345
+ clearLive(part.sessionID)
346
+ }
347
+
348
+ const timing = tracker.timings[part.messageID]
349
+ if (!timing) return
350
+
351
+ if (part.state.status === "pending") {
352
+ if (timing.firstResponseAt === undefined) timing.firstResponseAt = Date.now()
353
+ bump()
354
+ return
355
+ }
356
+ if (part.state.status === "running") {
357
+ timing.lastToolCallAt = part.state.time.start
358
+ bump()
359
+ }
360
+ }),
361
+ )
362
+
363
+ listeners.push(
364
+ api.event.on("session.status", (event) => {
365
+ const { sessionID, status } = event.properties
366
+ const type = status.type
367
+
368
+ if (type === "idle") clearTimings(sessionID)
369
+
370
+ const stats = tracker.stats[sessionID] ?? emptyStats()
371
+
372
+ if (type === "retry") {
373
+ const attempt = status.attempt
374
+ if (attempt <= stats.lastAttempt) return
375
+ touchSession(sessionID)
376
+ tracker.stats[sessionID] = {
377
+ ...stats,
378
+ retries: stats.retries + (attempt - stats.lastAttempt),
379
+ lastAttempt: attempt,
380
+ }
381
+ bump()
382
+ return
383
+ }
384
+
385
+ // Any non-retry status resets the attempt baseline for the next retry burst.
386
+ if (stats.lastAttempt === 0) return
387
+ touchSession(sessionID)
388
+ tracker.stats[sessionID] = { ...stats, lastAttempt: 0 }
389
+ bump()
390
+ }),
391
+ )
392
+
393
+ listeners.push(
394
+ api.event.on("session.idle", (event) => {
395
+ clearTimings(event.properties.sessionID)
396
+ }),
397
+ )
398
+
399
+ const timer = setInterval(() => {
400
+ prune()
401
+ bump()
402
+ }, 1_000)
403
+
404
+ const internals: TrackerInternals = {
405
+ sizes: () => ({
406
+ parts: partDecisions.size,
407
+ dedup: Object.values(tracker.dedup).reduce((sum, set) => sum + set.size, 0),
408
+ stats: Object.keys(tracker.stats).length,
409
+ averages: Object.keys(tracker.averages).length,
410
+ timings: Object.keys(tracker.timings).length,
411
+ mounted: mounted.size,
412
+ }),
413
+ partDecision: (partID) => partDecisions.get(partID),
414
+ hasStats: (sessionID) => sessionID in tracker.stats,
415
+ dedupSize: (sessionID) => tracker.dedup[sessionID]?.size ?? 0,
416
+ retries: (sessionID) => tracker.stats[sessionID]?.retries ?? 0,
417
+ mounted: () => [...mounted],
418
+ }
419
+ lastInternals = internals
420
+
421
+ return {
422
+ statusText,
423
+ mountSession(sessionID) {
424
+ mounted.add(sessionID)
425
+ },
426
+ unmountSession(sessionID) {
427
+ mounted.delete(sessionID)
428
+ },
429
+ prune,
430
+ internals,
431
+ dispose() {
432
+ for (const unsubscribe of listeners) unsubscribe()
433
+ listeners.length = 0
434
+ clearInterval(timer)
435
+ mounted.clear()
436
+ if (lastInternals === internals) lastInternals = undefined
437
+ },
438
+ }
439
+ }
package/tui.tsx CHANGED
@@ -1,236 +1,39 @@
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
- type StreamSample = {
6
- at: number
7
- tokens: number
8
- }
9
-
10
- type MessageTiming = {
11
- sessionID: string
12
- requestStartAt: number
13
- firstResponseAt?: number
14
- lastToolCallAt?: number
15
- }
16
-
17
- type SessionAverage = {
18
- totalTokens: number
19
- totalDurationMs: number
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)
55
- }
56
-
57
- let duration = 0
58
- for (let index = 1; index < samples.length; index++) {
59
- duration += Math.max(0, samples[index].at - samples[index - 1].at)
60
- }
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
-
75
- function liveRate(api: TuiPluginApi, tracker: Tracker, sessionID: string) {
76
- const status = api.state.session.status(sessionID)
77
- if (!status || status.type === "idle") return undefined
78
-
79
- const now = Date.now()
80
- const samples = (tracker.samples[sessionID] ?? []).filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
81
- const last = samples[samples.length - 1]
82
- if (!last || now - last.at > LIVE_STALE_MS) return undefined
83
-
84
- const tokens = samples.reduce((sum, sample) => sum + sample.tokens, 0)
85
- return formatRate(tokens / (activeDuration(samples, now) / 1_000))
86
- }
87
-
88
- function statusText(api: TuiPluginApi, tracker: Tracker, sessionID: string) {
89
- const average = tracker.averages[sessionID]
90
- const live = liveRate(api, tracker, sessionID) ?? "-"
91
- const avg = averageRate(average) ?? "-"
92
- const ttft = averageTtft(average) ?? "-"
93
- return `TPS ${live} | AVG ${avg} | TTFT ${ttft}`
94
- }
3
+ import { createMemo, createSignal, onCleanup, type Accessor } from "solid-js"
4
+ import { createTracker, type TrackerHandle } from "./tracker"
95
5
 
96
6
  function SessionPromptRight(props: {
97
7
  api: TuiPluginApi
98
8
  sessionID: string
99
- tracker: Tracker
9
+ tracker: TrackerHandle
100
10
  revision: Accessor<number>
101
11
  }) {
12
+ let mounted: string | undefined
13
+
102
14
  const content = createMemo(() => {
103
15
  props.revision()
104
- return statusText(props.api, props.tracker, props.sessionID)
16
+ const sessionID = props.sessionID
17
+ if (mounted !== sessionID) {
18
+ if (mounted !== undefined) props.tracker.unmountSession(mounted)
19
+ props.tracker.mountSession(sessionID)
20
+ mounted = sessionID
21
+ }
22
+ return props.tracker.statusText(sessionID)
23
+ })
24
+
25
+ onCleanup(() => {
26
+ if (mounted !== undefined) props.tracker.unmountSession(mounted)
105
27
  })
106
28
 
107
29
  return <text fg={props.api.theme.current.textMuted}>{content()}</text>
108
30
  }
109
31
 
110
32
  const tui: TuiPlugin = async (api) => {
111
- const tracker: Tracker = {
112
- samples: {},
113
- timings: {},
114
- averages: {},
115
- }
116
33
  const [revision, setRevision] = createSignal(0)
117
- const bump = () => setRevision((value) => value + 1)
118
-
119
- const pruneSamples = (now = Date.now()) => {
120
- let changed = false
121
-
122
- for (const [sessionID, samples] of Object.entries(tracker.samples)) {
123
- const next = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
124
- if (next.length === samples.length) continue
125
- changed = true
126
- if (next.length > 0) tracker.samples[sessionID] = next
127
- else delete tracker.samples[sessionID]
128
- }
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
- })
157
- })
158
-
159
- const onMessage = api.event.on("message.updated", (event) => {
160
- const info = event.properties.info
161
- if (info.role !== "assistant") return
162
- const sessionID = event.properties.sessionID
163
-
164
- if (!info.time.completed) {
165
- const existing = tracker.timings[info.id]
166
- tracker.timings[info.id] = {
167
- sessionID,
168
- requestStartAt: info.time.created,
169
- firstResponseAt: existing?.firstResponseAt,
170
- lastToolCallAt: existing?.lastToolCallAt,
171
- }
172
- bump()
173
- return
174
- }
175
-
176
- const timing = tracker.timings[info.id]
177
- if (timing?.sessionID === sessionID && timing.firstResponseAt !== undefined) {
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
- }
195
- }
196
- delete tracker.timings[info.id]
197
- pruneSamples(info.time.completed)
198
- bump()
199
- })
200
-
201
- const onPart = api.event.on("message.part.updated", (event) => {
202
- const part = event.properties.part
203
- if (part.type !== "tool") return
204
-
205
- if (part.state.status === "running" || part.state.status === "completed" || part.state.status === "error") {
206
- clearLive(part.sessionID)
207
- }
34
+ const tracker = createTracker(api, () => setRevision((value) => value + 1))
208
35
 
209
- const timing = tracker.timings[part.messageID]
210
- if (!timing) return
211
-
212
- if (part.state.status === "pending") {
213
- if (timing.firstResponseAt === undefined) timing.firstResponseAt = event.properties.time
214
- bump()
215
- return
216
- }
217
- if (part.state.status === "running") {
218
- timing.lastToolCallAt = part.state.time.start
219
- bump()
220
- }
221
- })
222
-
223
- const timer = setInterval(() => {
224
- pruneSamples()
225
- bump()
226
- }, 1_000)
227
-
228
- api.lifecycle.onDispose(() => {
229
- onDelta()
230
- onMessage()
231
- onPart()
232
- clearInterval(timer)
233
- })
36
+ api.lifecycle.onDispose(() => tracker.dispose())
234
37
 
235
38
  api.slots.register({
236
39
  slots: {