@esuyo/esuyo-opencode-tks 0.1.2
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 +21 -0
- package/NOTICE +17 -0
- package/README.md +119 -0
- package/assets/demo.gif +0 -0
- package/package.json +44 -0
- package/tui.tsx +363 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 8perezm
|
|
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/NOTICE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
esuyo-opencode-tks
|
|
2
|
+
==================
|
|
3
|
+
|
|
4
|
+
This project is a fork of and is derived from:
|
|
5
|
+
|
|
6
|
+
Tarquinen/oc-tps
|
|
7
|
+
https://github.com/Tarquinen/oc-tps
|
|
8
|
+
commit 89bf5b87f933ee593e85e8458ee79195a9f59a75 (version 0.0.10)
|
|
9
|
+
|
|
10
|
+
The upstream project did not declare a license at the time this fork was
|
|
11
|
+
created. The original source is included here with full attribution and is
|
|
12
|
+
used under the assumption of an MIT-compatible license; the original author
|
|
13
|
+
retains all rights to their work. If upstream adds or changes its license,
|
|
14
|
+
the attribution and license terms of this fork will be updated accordingly.
|
|
15
|
+
|
|
16
|
+
New code and modifications in this fork are released under the MIT License
|
|
17
|
+
(see LICENSE).
|
package/README.md
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# esuyo-opencode-tks
|
|
2
|
+
|
|
3
|
+
Displays live TPS (tokens per second), average TPS, average TTFT (time to first
|
|
4
|
+
token), and **GAP** (time between the end of the previous assistant response and
|
|
5
|
+
the start of the next one) in the OpenCode session prompt. Optionally appends
|
|
6
|
+
per-message stats as JSONL for later analysis.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
TPS 62.4 | AVG 58.1 | TTFT 0.9s | GAP 12.3s
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Install from the CLI:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
opencode plugin @esuyo/esuyo-opencode-tks@latest --global
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires `opencode` `1.3.14` or newer.
|
|
23
|
+
|
|
24
|
+
If you previously installed the upstream `oc-tps`, uninstall it first so both
|
|
25
|
+
plugins do not register the same slot:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
opencode plugin oc-tps --global --uninstall
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Metrics
|
|
32
|
+
|
|
33
|
+
| Metric | Meaning |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `TPS` | Live tokens/sec over the last 5s streaming window. |
|
|
36
|
+
| `AVG` | Session-average tokens/sec across completed assistant messages. |
|
|
37
|
+
| `TTFT` | Session-average time from request start to first token. |
|
|
38
|
+
| `GAP` | Time between the previous final assistant completion and this message's start, including user think-time. Shows `-` for the first message. |
|
|
39
|
+
|
|
40
|
+
GAP is measured between **final** completions only; intermediate
|
|
41
|
+
`finish: "tool-calls"` chunks do not reset the baseline.
|
|
42
|
+
|
|
43
|
+
## File logging
|
|
44
|
+
|
|
45
|
+
Every completed assistant message is appended as one JSON object per line
|
|
46
|
+
(JSONL). No prompt or response text is ever written — numeric metadata only.
|
|
47
|
+
|
|
48
|
+
Default path:
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
~/.local/share/opencode/esuyo-opencode-tks.log
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Override with an environment variable:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
export ESUYO_TPS_LOG=/path/to/tps.log
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Disable logging by pointing it at `/dev/null`:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
export ESUYO_TPS_LOG=/dev/null
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Schema (v1)
|
|
67
|
+
|
|
68
|
+
```jsonc
|
|
69
|
+
{
|
|
70
|
+
"v": 1,
|
|
71
|
+
"at": "2026-09-11T23:50:00.000Z", // completion wall-time
|
|
72
|
+
"sessionID": "ses_...",
|
|
73
|
+
"messageID": "msg_...",
|
|
74
|
+
"finish": "stop", // "stop" | "tool-calls" | ...
|
|
75
|
+
"tokensOutput": 123,
|
|
76
|
+
"tokensReasoning": 45,
|
|
77
|
+
"tokensTotal": 168,
|
|
78
|
+
"durationMs": 2345, // first response -> end
|
|
79
|
+
"ttftMs": 456, // request start -> first response
|
|
80
|
+
"gapMs": 12340, // previous completion -> this start (null on first)
|
|
81
|
+
"avgTps": 71.6,
|
|
82
|
+
"liveSamplesDropped": false
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Log writing is best-effort: I/O errors are swallowed so the TUI never crashes.
|
|
87
|
+
|
|
88
|
+
### Rotation
|
|
89
|
+
|
|
90
|
+
v1 does not rotate the log. Use `logrotate`, for example:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
/root/.local/share/opencode/esuyo-opencode-tks.log {
|
|
94
|
+
weekly
|
|
95
|
+
rotate 4
|
|
96
|
+
compress
|
|
97
|
+
missingok
|
|
98
|
+
notifempty
|
|
99
|
+
copytruncate
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Development
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npm install
|
|
107
|
+
npm run typecheck
|
|
108
|
+
npm test
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Credits & license
|
|
112
|
+
-----------------
|
|
113
|
+
|
|
114
|
+
Forked from [`Tarquinen/oc-tps`](https://github.com/Tarquinen/oc-tps) at commit
|
|
115
|
+
`89bf5b8` (v0.0.10). The upstream project declared no license; original work
|
|
116
|
+
remains attributed to its author. See [NOTICE](./NOTICE) and [LICENSE](./LICENSE)
|
|
117
|
+
(MIT).
|
|
118
|
+
|
|
119
|
+
---
|
package/assets/demo.gif
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/package.json",
|
|
3
|
+
"name": "@esuyo/esuyo-opencode-tks",
|
|
4
|
+
"version": "0.1.2",
|
|
5
|
+
"description": "Live TPS, average TPS, average TTFT, and response GAP metrics for OpenCode sessions, with optional JSONL stats logging.",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/8perezm/esuyo-opencode-tks"
|
|
9
|
+
},
|
|
10
|
+
"author": "8perezm (https://github.com/8perezm)",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
"./tui": {
|
|
15
|
+
"import": "./tui.tsx"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"opencode": ">=1.3.14"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"build:test": "tsc -p tsconfig.test.json",
|
|
24
|
+
"pretest": "npm run build:test",
|
|
25
|
+
"test": "node test/logging.test.mjs"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@opentui/core": "^0.4.5",
|
|
29
|
+
"@opentui/solid": "^0.4.5",
|
|
30
|
+
"solid-js": "^1.9.12"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@opencode-ai/plugin": ">=1.4.3",
|
|
34
|
+
"@types/node": "^22.10.0",
|
|
35
|
+
"typescript": "^5.7.0"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"assets/",
|
|
39
|
+
"README.md",
|
|
40
|
+
"NOTICE",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"tui.tsx"
|
|
43
|
+
]
|
|
44
|
+
}
|
package/tui.tsx
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import type { TextRenderable } from "@opentui/core"
|
|
3
|
+
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
4
|
+
import { appendFile, mkdir } from "node:fs/promises"
|
|
5
|
+
import { homedir } from "node:os"
|
|
6
|
+
import { dirname, join } from "node:path"
|
|
7
|
+
import { onCleanup } from "solid-js"
|
|
8
|
+
|
|
9
|
+
type StreamSample = {
|
|
10
|
+
at: number
|
|
11
|
+
tokens: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const STREAM_WINDOW_MS = 5_000
|
|
15
|
+
const LIVE_STALE_MS = 1_500
|
|
16
|
+
const SINGLE_SAMPLE_MS = 1_000
|
|
17
|
+
type MessageTiming = {
|
|
18
|
+
sessionID: string
|
|
19
|
+
requestStartAt: number
|
|
20
|
+
firstResponseAt?: number
|
|
21
|
+
firstTokenAt?: number
|
|
22
|
+
lastTokenAt?: number
|
|
23
|
+
lastToolCallAt?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type SessionAverage = {
|
|
27
|
+
totalTokens: number
|
|
28
|
+
totalDurationMs: number
|
|
29
|
+
totalTtftMs: number
|
|
30
|
+
messageCount: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type TrackerState = {
|
|
34
|
+
streamSamplesBySession: Record<string, StreamSample[]>
|
|
35
|
+
messageTimingByID: Record<string, MessageTiming>
|
|
36
|
+
sessionAverageByID: Record<string, SessionAverage>
|
|
37
|
+
lastCompletedAtBySession: Record<string, number>
|
|
38
|
+
lastGapMsBySession: Record<string, number>
|
|
39
|
+
liveSamplesDroppedBySession: Record<string, boolean>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type TrackerListener = () => void
|
|
43
|
+
|
|
44
|
+
const LOG_PATH =
|
|
45
|
+
process.env.ESUYO_TPS_LOG ?? join(homedir(), ".local/share/opencode", "esuyo-opencode-tks.log")
|
|
46
|
+
|
|
47
|
+
async function logLine(obj: unknown) {
|
|
48
|
+
try {
|
|
49
|
+
await appendFile(LOG_PATH, JSON.stringify(obj) + "\n", "utf8")
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function estimateStreamTokens(delta: string) {
|
|
54
|
+
return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatRate(value: number) {
|
|
58
|
+
if (!Number.isFinite(value) || value <= 0) return undefined
|
|
59
|
+
if (value >= 100) return `${Math.round(value)}`
|
|
60
|
+
if (value >= 10) return `${value.toFixed(1)}`
|
|
61
|
+
return `${value.toFixed(2)}`
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatTtft(value: number) {
|
|
65
|
+
if (!Number.isFinite(value) || value < 0) return undefined
|
|
66
|
+
return `${value.toFixed(1)}s`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function activeDurationMs(samples: StreamSample[], tailAt?: number) {
|
|
70
|
+
if (samples.length === 0) return 0
|
|
71
|
+
if (samples.length === 1) {
|
|
72
|
+
const tailDuration = tailAt ? Math.max(0, tailAt - samples[0].at) : SINGLE_SAMPLE_MS
|
|
73
|
+
return Math.min(Math.max(tailDuration, 250), SINGLE_SAMPLE_MS)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let duration = 0
|
|
77
|
+
for (let i = 1; i < samples.length; i++) {
|
|
78
|
+
duration += Math.max(0, samples[i].at - samples[i - 1].at)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (tailAt) {
|
|
82
|
+
duration += Math.max(0, tailAt - samples[samples.length - 1].at)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return Math.max(duration, SINGLE_SAMPLE_MS)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function SessionPromptRight(props: {
|
|
89
|
+
api: Parameters<TuiPlugin>[0]
|
|
90
|
+
sessionID: string
|
|
91
|
+
tracker: TrackerState
|
|
92
|
+
subscribe: (listener: TrackerListener) => () => void
|
|
93
|
+
}) {
|
|
94
|
+
let text: TextRenderable | undefined
|
|
95
|
+
|
|
96
|
+
const sync = () => {
|
|
97
|
+
if (!text) return
|
|
98
|
+
text.content = statusText()
|
|
99
|
+
props.api.renderer.requestRender()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const unsubscribe = props.subscribe(sync)
|
|
103
|
+
onCleanup(unsubscribe)
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<text
|
|
107
|
+
ref={(ref: TextRenderable) => {
|
|
108
|
+
text = ref
|
|
109
|
+
sync()
|
|
110
|
+
}}
|
|
111
|
+
fg={props.api.theme.current.textMuted}
|
|
112
|
+
>
|
|
113
|
+
{statusText()}
|
|
114
|
+
</text>
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
function sessionAverage() {
|
|
118
|
+
const totals = props.tracker.sessionAverageByID[props.sessionID]
|
|
119
|
+
if (!totals || totals.totalTokens <= 0 || totals.totalDurationMs <= 0) return undefined
|
|
120
|
+
return formatRate(totals.totalTokens / (totals.totalDurationMs / 1000))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function sessionTtft() {
|
|
124
|
+
const totals = props.tracker.sessionAverageByID[props.sessionID]
|
|
125
|
+
if (!totals || totals.messageCount <= 0 || totals.totalTtftMs < 0) return undefined
|
|
126
|
+
return formatTtft(totals.totalTtftMs / totals.messageCount / 1000)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function liveTps() {
|
|
130
|
+
const status = props.api.state.session.status(props.sessionID)
|
|
131
|
+
if (status?.type === "idle") return undefined
|
|
132
|
+
const samples = props.tracker.streamSamplesBySession[props.sessionID] ?? []
|
|
133
|
+
if (samples.length === 0) return undefined
|
|
134
|
+
const now = Date.now()
|
|
135
|
+
const relevant = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
|
|
136
|
+
if (relevant.length === 0) return undefined
|
|
137
|
+
const lastSample = relevant[relevant.length - 1]
|
|
138
|
+
if (!lastSample || now - lastSample.at > LIVE_STALE_MS) return undefined
|
|
139
|
+
const total = relevant.reduce((sum, sample) => sum + sample.tokens, 0)
|
|
140
|
+
const durationSeconds = activeDurationMs(relevant, now) / 1000
|
|
141
|
+
if (durationSeconds <= 0) return undefined
|
|
142
|
+
return formatRate(total / durationSeconds)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function lastGap() {
|
|
146
|
+
const ms = props.tracker.lastGapMsBySession[props.sessionID]
|
|
147
|
+
if (typeof ms !== "number" || ms < 0) return undefined
|
|
148
|
+
return ms >= 10000 ? `${(ms / 1000).toFixed(0)}s` : `${(ms / 1000).toFixed(1)}s`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function statusText() {
|
|
152
|
+
const live = liveTps() ?? "-"
|
|
153
|
+
const avg = sessionAverage() ?? "-"
|
|
154
|
+
const ttft = sessionTtft() ?? "-"
|
|
155
|
+
const gap = lastGap() ?? "-"
|
|
156
|
+
return `TPS ${live} | AVG ${avg} | TTFT ${ttft} | GAP ${gap}`
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const tui: TuiPlugin = async (api) => {
|
|
161
|
+
const tracker: TrackerState = {
|
|
162
|
+
streamSamplesBySession: {},
|
|
163
|
+
messageTimingByID: {},
|
|
164
|
+
sessionAverageByID: {},
|
|
165
|
+
lastCompletedAtBySession: {},
|
|
166
|
+
lastGapMsBySession: {},
|
|
167
|
+
liveSamplesDroppedBySession: {},
|
|
168
|
+
}
|
|
169
|
+
const listeners = new Set<TrackerListener>()
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
await mkdir(dirname(LOG_PATH), { recursive: true })
|
|
173
|
+
} catch {}
|
|
174
|
+
|
|
175
|
+
const bump = () => {
|
|
176
|
+
for (const listener of listeners) listener()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const pruneSamples = (now = Date.now()) => {
|
|
180
|
+
let changed = false
|
|
181
|
+
|
|
182
|
+
for (const [sessionID, samples] of Object.entries(tracker.streamSamplesBySession)) {
|
|
183
|
+
const next = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
|
|
184
|
+
if (next.length !== samples.length) {
|
|
185
|
+
changed = true
|
|
186
|
+
if (next.length > 0) tracker.streamSamplesBySession[sessionID] = next
|
|
187
|
+
else delete tracker.streamSamplesBySession[sessionID]
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (changed) bump()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const clearLiveSamples = (sessionID: string) => {
|
|
195
|
+
if (!tracker.streamSamplesBySession[sessionID]?.length) return
|
|
196
|
+
delete tracker.streamSamplesBySession[sessionID]
|
|
197
|
+
tracker.liveSamplesDroppedBySession[sessionID] = true
|
|
198
|
+
bump()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const appendSample = (sessionID: string, messageID: string, sample: StreamSample) => {
|
|
202
|
+
const now = sample.at
|
|
203
|
+
tracker.streamSamplesBySession[sessionID] = [
|
|
204
|
+
...(tracker.streamSamplesBySession[sessionID] ?? []).filter((item) => now - item.at <= STREAM_WINDOW_MS),
|
|
205
|
+
sample,
|
|
206
|
+
]
|
|
207
|
+
const timing = tracker.messageTimingByID[messageID]
|
|
208
|
+
if (timing) {
|
|
209
|
+
tracker.messageTimingByID[messageID] = timing.firstTokenAt
|
|
210
|
+
? { ...timing, lastTokenAt: now }
|
|
211
|
+
: {
|
|
212
|
+
...timing,
|
|
213
|
+
firstResponseAt: timing.firstResponseAt ?? now,
|
|
214
|
+
firstTokenAt: now,
|
|
215
|
+
lastTokenAt: now,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
bump()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const onDelta = api.event.on("message.part.delta", (evt) => {
|
|
222
|
+
if (evt.properties.field !== "text") return
|
|
223
|
+
const parts = api.state.part(evt.properties.messageID)
|
|
224
|
+
const part = parts.find((item) => item.id === evt.properties.partID)
|
|
225
|
+
if (!part) return
|
|
226
|
+
if (part.type !== "text" && part.type !== "reasoning") return
|
|
227
|
+
appendSample(evt.properties.sessionID, evt.properties.messageID, {
|
|
228
|
+
at: Date.now(),
|
|
229
|
+
tokens: estimateStreamTokens(evt.properties.delta),
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
const onMessage = api.event.on("message.updated", (evt) => {
|
|
234
|
+
if (evt.properties.info.role !== "assistant") return
|
|
235
|
+
const sessionID = evt.properties.info.sessionID ?? evt.properties.sessionID
|
|
236
|
+
|
|
237
|
+
if (!evt.properties.info.time.completed) {
|
|
238
|
+
const existing = tracker.messageTimingByID[evt.properties.info.id]
|
|
239
|
+
if (!existing) tracker.liveSamplesDroppedBySession[sessionID] = false
|
|
240
|
+
const prev = tracker.lastCompletedAtBySession[sessionID]
|
|
241
|
+
if (typeof prev === "number") {
|
|
242
|
+
tracker.lastGapMsBySession[sessionID] = Math.max(evt.properties.info.time.created - prev, 0)
|
|
243
|
+
}
|
|
244
|
+
tracker.messageTimingByID[evt.properties.info.id] = {
|
|
245
|
+
sessionID,
|
|
246
|
+
requestStartAt: evt.properties.info.time.created,
|
|
247
|
+
firstResponseAt: existing?.firstResponseAt,
|
|
248
|
+
firstTokenAt: existing?.firstTokenAt,
|
|
249
|
+
lastTokenAt: existing?.lastTokenAt,
|
|
250
|
+
lastToolCallAt: existing?.lastToolCallAt,
|
|
251
|
+
}
|
|
252
|
+
bump()
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const timing = tracker.messageTimingByID[evt.properties.info.id]
|
|
257
|
+
if (timing?.sessionID === sessionID && typeof timing.firstResponseAt === "number") {
|
|
258
|
+
const totalTokens = evt.properties.info.tokens.output + evt.properties.info.tokens.reasoning
|
|
259
|
+
const endAt =
|
|
260
|
+
evt.properties.info.finish === "tool-calls"
|
|
261
|
+
? timing.lastToolCallAt
|
|
262
|
+
: evt.properties.info.time.completed
|
|
263
|
+
const durationMs = typeof endAt === "number" ? Math.max(endAt - timing.firstResponseAt, 1) : undefined
|
|
264
|
+
const ttftMs = Math.max(timing.firstResponseAt - timing.requestStartAt, 0)
|
|
265
|
+
if (totalTokens > 0 && durationMs) {
|
|
266
|
+
const totals = tracker.sessionAverageByID[sessionID] ?? {
|
|
267
|
+
totalTokens: 0,
|
|
268
|
+
totalDurationMs: 0,
|
|
269
|
+
totalTtftMs: 0,
|
|
270
|
+
messageCount: 0,
|
|
271
|
+
}
|
|
272
|
+
tracker.sessionAverageByID[sessionID] = {
|
|
273
|
+
totalTokens: totals.totalTokens + totalTokens,
|
|
274
|
+
totalDurationMs: totals.totalDurationMs + durationMs,
|
|
275
|
+
totalTtftMs: totals.totalTtftMs + ttftMs,
|
|
276
|
+
messageCount: totals.messageCount + 1,
|
|
277
|
+
}
|
|
278
|
+
void logLine({
|
|
279
|
+
v: 1,
|
|
280
|
+
at: new Date(evt.properties.info.time.completed).toISOString(),
|
|
281
|
+
sessionID,
|
|
282
|
+
messageID: evt.properties.info.id,
|
|
283
|
+
finish: evt.properties.info.finish,
|
|
284
|
+
tokensOutput: evt.properties.info.tokens.output,
|
|
285
|
+
tokensReasoning: evt.properties.info.tokens.reasoning,
|
|
286
|
+
tokensTotal: totalTokens,
|
|
287
|
+
durationMs,
|
|
288
|
+
ttftMs,
|
|
289
|
+
gapMs: tracker.lastGapMsBySession[sessionID] ?? null,
|
|
290
|
+
avgTps: Number((totalTokens / (durationMs / 1000)).toFixed(2)),
|
|
291
|
+
liveSamplesDropped: tracker.liveSamplesDroppedBySession[sessionID] ?? false,
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (evt.properties.info.finish !== "tool-calls") {
|
|
296
|
+
tracker.lastCompletedAtBySession[sessionID] = evt.properties.info.time.completed
|
|
297
|
+
}
|
|
298
|
+
delete tracker.messageTimingByID[evt.properties.info.id]
|
|
299
|
+
delete tracker.liveSamplesDroppedBySession[sessionID]
|
|
300
|
+
pruneSamples(evt.properties.info.time.completed)
|
|
301
|
+
bump()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
const onPart = api.event.on("message.part.updated", (evt) => {
|
|
305
|
+
if (evt.properties.part.type !== "tool") return
|
|
306
|
+
const sessionID = evt.properties.part.sessionID ?? evt.properties.sessionID
|
|
307
|
+
if (
|
|
308
|
+
evt.properties.part.state.status === "running" ||
|
|
309
|
+
evt.properties.part.state.status === "completed" ||
|
|
310
|
+
evt.properties.part.state.status === "error"
|
|
311
|
+
) {
|
|
312
|
+
clearLiveSamples(sessionID)
|
|
313
|
+
}
|
|
314
|
+
const timing = tracker.messageTimingByID[evt.properties.part.messageID]
|
|
315
|
+
if (!timing) return
|
|
316
|
+
if (evt.properties.part.state.status === "pending") {
|
|
317
|
+
tracker.messageTimingByID[evt.properties.part.messageID] = {
|
|
318
|
+
...timing,
|
|
319
|
+
firstResponseAt: timing.firstResponseAt ?? evt.properties.time,
|
|
320
|
+
}
|
|
321
|
+
bump()
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
if (evt.properties.part.state.status !== "running") return
|
|
325
|
+
tracker.messageTimingByID[evt.properties.part.messageID] = {
|
|
326
|
+
...timing,
|
|
327
|
+
lastToolCallAt: evt.properties.part.state.time.start,
|
|
328
|
+
}
|
|
329
|
+
bump()
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
const timer = setInterval(() => {
|
|
333
|
+
pruneSamples()
|
|
334
|
+
bump()
|
|
335
|
+
}, 1000)
|
|
336
|
+
|
|
337
|
+
api.lifecycle.onDispose(() => {
|
|
338
|
+
onDelta()
|
|
339
|
+
onMessage()
|
|
340
|
+
onPart()
|
|
341
|
+
clearInterval(timer)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
api.slots.register({
|
|
345
|
+
slots: {
|
|
346
|
+
session_prompt_right(_ctx, value) {
|
|
347
|
+
return <SessionPromptRight api={api} sessionID={value.session_id} tracker={tracker} subscribe={(listener) => {
|
|
348
|
+
listeners.add(listener)
|
|
349
|
+
return () => {
|
|
350
|
+
listeners.delete(listener)
|
|
351
|
+
}
|
|
352
|
+
}} />
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const plugin: TuiPluginModule & { id: string } = {
|
|
359
|
+
id: "esuyo-opencode-tks",
|
|
360
|
+
tui,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export default plugin
|