@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 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
+ ![Demo](./assets/demo.gif)
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
+ ---
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