@jack-yang/opencode-tps-meter 0.1.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.
Files changed (3) hide show
  1. package/README.md +94 -0
  2. package/package.json +53 -0
  3. package/tui.tsx +294 -0
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # opencode-tps-meter
2
+
3
+ A TUI plugin for [opencode](https://opencode.ai) that displays real-time LLM output speed metrics next to the session prompt.
4
+
5
+ ## Display
6
+
7
+ ```
8
+ TPS 42.5 | AVG 38.2 | TTFT 0.8s
9
+ ```
10
+
11
+ - **TPS** — current tokens per second (live, color-coded)
12
+ - **AVG** — session-wide cumulative average from completed messages (color-coded)
13
+ - **TTFT** — time to first token, shown immediately on first token, persists until next generation (color-coded)
14
+
15
+ ## Color Tiers
16
+
17
+ **TPS & AVG** (higher is better):
18
+
19
+ | Tier | Range | Color |
20
+ |------|-------|-------|
21
+ | Slow | < 20 TPS | Red |
22
+ | Normal | 20–50 TPS | Yellow |
23
+ | Fast | 50–100 TPS | Green |
24
+ | Faster | > 100 TPS | Cyan |
25
+
26
+ **TTFT** (lower is better):
27
+
28
+ | Tier | Range | Color |
29
+ |------|-------|-------|
30
+ | Fast | < 0.5s | Green |
31
+ | OK | 0.5–2s | Yellow |
32
+ | Slow | > 2s | Red |
33
+
34
+ ## Installation
35
+
36
+ This plugin must be configured in `tui.json` (not `opencode.json`).
37
+
38
+ ### Option 1: Local file
39
+
40
+ 1. Copy `tui.tsx` to your opencode config directory:
41
+
42
+ ```bash
43
+ cp tui.tsx ~/.config/opencode/tps-meter.tsx
44
+ ```
45
+
46
+ 2. Add to `~/.config/opencode/tui.json`:
47
+
48
+ ```json
49
+ {
50
+ "plugin": [
51
+ "./tps-meter.tsx"
52
+ ]
53
+ }
54
+ ```
55
+
56
+ 3. Restart opencode.
57
+
58
+ ### Option 2: Project-local
59
+
60
+ Add a `.opencode/tui.json` in your project root with the same plugin path (relative to the config file location).
61
+
62
+ ## How It Works
63
+
64
+ ### Live TPS
65
+
66
+ Tracks text/reasoning delta events in a rolling 5-second window. Uses wall-clock duration (`now - oldest sample`) instead of inter-arrival time sums to avoid inflation from network buffering.
67
+
68
+ Token estimation uses `ceil(byteLength / 4)` with a **calibration factor**: when a message completes, the estimated token count is compared against actual `tokens.output + tokens.reasoning` from the message metadata, and a running median ratio corrects future estimates.
69
+
70
+ ### AVG
71
+
72
+ Session-wide cumulative average from all completed messages: `sum(actual tokens) / sum(generation durations)`. Updates only when a message completes. Generation duration = `last delta - first delta` per message (excludes tool execution time and TTFT).
73
+
74
+ ### TTFT
75
+
76
+ Time from message creation (`info.time.created`) to the first text/reasoning delta. Displayed immediately when the first token arrives, and persists until the next generation starts.
77
+
78
+ ## Development
79
+
80
+ ```bash
81
+ npm install
82
+ npm test
83
+ ```
84
+
85
+ Pure measurement functions (`measure.ts`) have 29 unit tests. The TUI plugin (`tui.tsx`) is a standalone single file (all functions inline) for direct deployment — copy it to your config directory.
86
+
87
+ ## Requirements
88
+
89
+ - opencode >= 1.4.3
90
+ - The plugin file must be placed where `tui.json` can reference it via relative path
91
+
92
+ ## License
93
+
94
+ MIT
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package.json",
3
+ "name": "@jack-yang/opencode-tps-meter",
4
+ "version": "0.1.0",
5
+ "description": "Live TPS/AVG/TTFT meter plugin for OpenCode TUI with color-coded speed tiers",
6
+ "type": "module",
7
+ "exports": {
8
+ "./tui": {
9
+ "import": "./tui.tsx"
10
+ }
11
+ },
12
+ "engines": {
13
+ "opencode": ">=1.4.3"
14
+ },
15
+ "peerDependencies": {
16
+ "@opencode-ai/plugin": ">=1.4.3",
17
+ "@opentui/core": "^0.4.2",
18
+ "@opentui/solid": "^0.4.2",
19
+ "solid-js": "^1.9.12"
20
+ },
21
+ "devDependencies": {
22
+ "vitest": "^3.2.4",
23
+ "typescript": "^5.9.2"
24
+ },
25
+ "scripts": {
26
+ "test": "vitest run",
27
+ "test:watch": "vitest"
28
+ },
29
+ "files": [
30
+ "tui.tsx",
31
+ "README.md"
32
+ ],
33
+ "keywords": [
34
+ "opencode",
35
+ "opencode-plugin",
36
+ "tui",
37
+ "tps",
38
+ "llm",
39
+ "ai"
40
+ ],
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/yangwudong/opencode-tps-meter.git"
45
+ },
46
+ "homepage": "https://github.com/yangwudong/opencode-tps-meter#readme",
47
+ "bugs": {
48
+ "url": "https://github.com/yangwudong/opencode-tps-meter/issues"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }
package/tui.tsx ADDED
@@ -0,0 +1,294 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { TextRenderable, RGBA } from "@opentui/core"
3
+ import type { TuiPlugin, TuiPluginModule, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
4
+ import { onCleanup } from "solid-js"
5
+
6
+ type DeltaSample = { at: number; rawTokens: number }
7
+
8
+ type SessionStats = {
9
+ totalOutputTokens: number
10
+ totalDurationMs: number
11
+ calibrationRatios: number[]
12
+ }
13
+
14
+ type TrackerState = {
15
+ useV2Events: boolean
16
+ samplesBySession: Record<string, DeltaSample[]>
17
+ messageCreatedAt: Record<string, number>
18
+ firstDeltaByMessage: Record<string, number>
19
+ lastDeltaByMessage: Record<string, number>
20
+ estimatedTokensByMessage: Record<string, number>
21
+ liveTtftBySession: Record<string, number>
22
+ activeMessageBySession: Record<string, string | undefined>
23
+ statsBySession: Record<string, SessionStats>
24
+ }
25
+
26
+ type Listener = () => void
27
+
28
+ const WINDOW_MS = 5_000
29
+ const STALE_MS = 1_500
30
+ const MIN_DURATION_MS = 1_000
31
+ const MAX_CALIBRATION_RATIOS = 10
32
+
33
+ function estimateTokens(delta: string): number {
34
+ return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 4))
35
+ }
36
+
37
+ function formatTps(value: number): string | undefined {
38
+ if (!Number.isFinite(value) || value <= 0) return undefined
39
+ if (value >= 100) return Math.round(value).toString()
40
+ if (value >= 10) return value.toFixed(1)
41
+ return value.toFixed(2)
42
+ }
43
+
44
+ function formatTtft(valueMs: number): string | undefined {
45
+ if (!Number.isFinite(valueMs) || valueMs < 0) return undefined
46
+ return `${(valueMs / 1000).toFixed(1)}s`
47
+ }
48
+
49
+ type SpeedTier = "slow" | "normal" | "fast" | "faster"
50
+ function speedTier(tps: number): SpeedTier {
51
+ if (tps < 20) return "slow"
52
+ if (tps < 50) return "normal"
53
+ if (tps < 100) return "fast"
54
+ return "faster"
55
+ }
56
+ function tierColor(tier: SpeedTier, theme: TuiThemeCurrent): RGBA {
57
+ switch (tier) {
58
+ case "slow": return theme.error
59
+ case "normal": return theme.warning
60
+ case "fast": return theme.success
61
+ case "faster": return theme.accent
62
+ }
63
+ }
64
+ function tpsColor(tps: number, theme: TuiThemeCurrent): RGBA {
65
+ return tierColor(speedTier(tps), theme)
66
+ }
67
+
68
+ type TtftTier = "fast" | "ok" | "slow"
69
+ function ttftTier(ms: number): TtftTier {
70
+ if (ms < 500) return "fast"
71
+ if (ms < 2000) return "ok"
72
+ return "slow"
73
+ }
74
+ function ttftColor(ms: number, theme: TuiThemeCurrent): RGBA {
75
+ switch (ttftTier(ms)) {
76
+ case "fast": return theme.success
77
+ case "ok": return theme.warning
78
+ case "slow": return theme.error
79
+ }
80
+ }
81
+
82
+ function median(values: number[]): number {
83
+ if (values.length === 0) return 1.0
84
+ const sorted = [...values].sort((a, b) => a - b)
85
+ const mid = Math.floor(sorted.length / 2)
86
+ if (sorted.length % 2 !== 0) return sorted[mid]
87
+ return Math.round(((sorted[mid - 1] + sorted[mid]) / 2) * 1e10) / 1e10
88
+ }
89
+ function calibrationFactor(ratios: number[]): number {
90
+ if (ratios.length === 0) return 1.0
91
+ return median(ratios)
92
+ }
93
+
94
+ function calculateLiveTps(samples: DeltaSample[], now: number, calibration: number): number | undefined {
95
+ if (samples.length === 0) return undefined
96
+ const cutoff = now - WINDOW_MS
97
+ const relevant = samples.filter((s) => s.at >= cutoff)
98
+ if (relevant.length === 0) return undefined
99
+ const last = relevant[relevant.length - 1]
100
+ if (now - last.at > STALE_MS) return undefined
101
+ const oldest = relevant[0]
102
+ const durationMs = Math.max(now - oldest.at, MIN_DURATION_MS)
103
+ const totalRaw = relevant.reduce((sum, s) => sum + s.rawTokens, 0)
104
+ return (totalRaw * calibration) / (durationMs / 1000)
105
+ }
106
+
107
+ function recordDelta(tracker: TrackerState, sessionID: string, messageID: string, at: number, delta: string) {
108
+ const rawTokens = estimateTokens(delta)
109
+ const cutoff = at - WINDOW_MS
110
+ const existing = tracker.samplesBySession[sessionID] ?? []
111
+ tracker.samplesBySession[sessionID] = [...existing.filter((s) => s.at >= cutoff), { at, rawTokens }]
112
+ if (tracker.firstDeltaByMessage[messageID] === undefined) {
113
+ tracker.firstDeltaByMessage[messageID] = at
114
+ const created = tracker.messageCreatedAt[messageID]
115
+ if (created !== undefined) {
116
+ tracker.liveTtftBySession[sessionID] = Math.max(0, at - created)
117
+ }
118
+ }
119
+ tracker.lastDeltaByMessage[messageID] = at
120
+ tracker.estimatedTokensByMessage[messageID] = (tracker.estimatedTokensByMessage[messageID] ?? 0) + rawTokens
121
+ tracker.activeMessageBySession[sessionID] = messageID
122
+ }
123
+
124
+ function MeterDisplay(props: {
125
+ api: Parameters<TuiPlugin>[0]
126
+ sessionID: string
127
+ tracker: TrackerState
128
+ subscribe: (listener: Listener) => () => void
129
+ }) {
130
+ let tpsRef: TextRenderable | undefined
131
+ let avgRef: TextRenderable | undefined
132
+ let ttftRef: TextRenderable | undefined
133
+ const theme = props.api.theme.current
134
+ const muted = theme.textMuted
135
+
136
+ const sync = () => {
137
+ const stats = props.tracker.statsBySession[props.sessionID]
138
+ const calibration = calibrationFactor(stats?.calibrationRatios ?? [])
139
+ const samples = props.tracker.samplesBySession[props.sessionID] ?? []
140
+ const now = Date.now()
141
+ const live = calculateLiveTps(samples, now, calibration)
142
+ const avg = stats && stats.totalDurationMs > 0 ? stats.totalOutputTokens / (stats.totalDurationMs / 1000) : undefined
143
+ const ttft = props.tracker.liveTtftBySession[props.sessionID]
144
+
145
+ if (tpsRef) {
146
+ tpsRef.content = live !== undefined ? formatTps(live) ?? "--" : "--"
147
+ tpsRef.fg = live !== undefined ? tpsColor(live, theme) : muted
148
+ }
149
+ if (avgRef) {
150
+ avgRef.content = avg !== undefined ? formatTps(avg) ?? "--" : "--"
151
+ avgRef.fg = avg !== undefined ? tpsColor(avg, theme) : muted
152
+ }
153
+ if (ttftRef) {
154
+ ttftRef.content = ttft !== undefined ? formatTtft(ttft) ?? "--" : "--"
155
+ ttftRef.fg = ttft !== undefined ? ttftColor(ttft, theme) : muted
156
+ }
157
+ props.api.renderer.requestRender()
158
+ }
159
+
160
+ const unsubscribe = props.subscribe(sync)
161
+ onCleanup(unsubscribe)
162
+
163
+ return (
164
+ <box flexDirection="row">
165
+ <text fg={muted}>TPS </text>
166
+ <text ref={(el: TextRenderable) => { tpsRef = el; sync() }} fg={muted}>--</text>
167
+ <text fg={muted}> | AVG </text>
168
+ <text ref={(el: TextRenderable) => { avgRef = el; sync() }} fg={muted}>--</text>
169
+ <text fg={muted}> | TTFT </text>
170
+ <text ref={(el: TextRenderable) => { ttftRef = el; sync() }} fg={muted}>--</text>
171
+ </box>
172
+ )
173
+ }
174
+
175
+ const tui: TuiPlugin = async (api) => {
176
+ const tracker: TrackerState = {
177
+ useV2Events: false,
178
+ samplesBySession: {},
179
+ messageCreatedAt: {},
180
+ firstDeltaByMessage: {},
181
+ lastDeltaByMessage: {},
182
+ estimatedTokensByMessage: {},
183
+ liveTtftBySession: {},
184
+ activeMessageBySession: {},
185
+ statsBySession: {},
186
+ }
187
+ const listeners = new Set<Listener>()
188
+ const bump = () => { for (const l of listeners) l() }
189
+ const subscribe = (listener: Listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }
190
+
191
+ const onTextDelta = api.event.on("session.next.text.delta", (evt) => {
192
+ tracker.useV2Events = true
193
+ recordDelta(tracker, evt.properties.sessionID, evt.properties.assistantMessageID, evt.properties.timestamp, evt.properties.delta)
194
+ bump()
195
+ })
196
+
197
+ const onReasoningDelta = api.event.on("session.next.reasoning.delta", (evt) => {
198
+ tracker.useV2Events = true
199
+ recordDelta(tracker, evt.properties.sessionID, evt.properties.assistantMessageID, evt.properties.timestamp, evt.properties.delta)
200
+ bump()
201
+ })
202
+
203
+ const onPartDelta = api.event.on("message.part.delta", (evt) => {
204
+ if (tracker.useV2Events) return
205
+ if (evt.properties.field !== "text") return
206
+ const parts = api.state.part(evt.properties.messageID)
207
+ const part = parts.find((p) => p.id === evt.properties.partID)
208
+ if (!part || (part.type !== "text" && part.type !== "reasoning")) return
209
+ recordDelta(tracker, evt.properties.sessionID, evt.properties.messageID, Date.now(), evt.properties.delta)
210
+ bump()
211
+ })
212
+
213
+ const onMessageUpdated = api.event.on("message.updated", (evt) => {
214
+ const info = evt.properties.info
215
+ if (info.role !== "assistant") return
216
+
217
+ if (!info.time.completed) {
218
+ tracker.messageCreatedAt[info.id] = info.time.created
219
+ return
220
+ }
221
+
222
+ const sessionID = info.sessionID ?? evt.properties.sessionID
223
+ if (!tracker.statsBySession[sessionID]) {
224
+ tracker.statsBySession[sessionID] = { totalOutputTokens: 0, totalDurationMs: 0, calibrationRatios: [] }
225
+ }
226
+ const stats = tracker.statsBySession[sessionID]!
227
+
228
+ const actualTokens = info.tokens.output + info.tokens.reasoning
229
+ const firstDelta = tracker.firstDeltaByMessage[info.id]
230
+ const lastDelta = tracker.lastDeltaByMessage[info.id]
231
+ const generationMs = firstDelta !== undefined && lastDelta !== undefined
232
+ ? Math.max(lastDelta - firstDelta, MIN_DURATION_MS) : 0
233
+
234
+ if (actualTokens > 0 && generationMs > 0) {
235
+ stats.totalOutputTokens += actualTokens
236
+ stats.totalDurationMs += generationMs
237
+
238
+ const estimated = tracker.estimatedTokensByMessage[info.id] ?? 0
239
+ if (estimated > 0) {
240
+ const ratio = Math.min(Math.max(actualTokens / estimated, 0.3), 3.0)
241
+ stats.calibrationRatios = [...stats.calibrationRatios, ratio].slice(-MAX_CALIBRATION_RATIOS)
242
+ }
243
+ }
244
+
245
+ delete tracker.estimatedTokensByMessage[info.id]
246
+ delete tracker.firstDeltaByMessage[info.id]
247
+ delete tracker.lastDeltaByMessage[info.id]
248
+ delete tracker.messageCreatedAt[info.id]
249
+ delete tracker.activeMessageBySession[sessionID]
250
+ delete tracker.samplesBySession[sessionID]
251
+ bump()
252
+ })
253
+
254
+ const onToolInputStarted = api.event.on("session.next.tool.input.started", (evt) => {
255
+ delete tracker.samplesBySession[evt.properties.sessionID]
256
+ bump()
257
+ })
258
+
259
+ const onPartUpdated = api.event.on("message.part.updated", (evt) => {
260
+ if (evt.properties.part.type !== "tool") return
261
+ if (["running", "completed", "error"].includes(evt.properties.part.state.status)) {
262
+ delete tracker.samplesBySession[evt.properties.part.sessionID ?? evt.properties.sessionID]
263
+ bump()
264
+ }
265
+ })
266
+
267
+ const timer = setInterval(() => {
268
+ const now = Date.now()
269
+ const cutoff = now - WINDOW_MS
270
+ for (const [sid, samples] of Object.entries(tracker.samplesBySession)) {
271
+ const pruned = samples.filter((s) => s.at >= cutoff)
272
+ if (pruned.length > 0) tracker.samplesBySession[sid] = pruned
273
+ else delete tracker.samplesBySession[sid]
274
+ }
275
+ bump()
276
+ }, 1000)
277
+
278
+ api.lifecycle.onDispose(() => {
279
+ onTextDelta(); onReasoningDelta(); onPartDelta()
280
+ onMessageUpdated(); onToolInputStarted(); onPartUpdated()
281
+ clearInterval(timer)
282
+ })
283
+
284
+ api.slots.register({
285
+ slots: {
286
+ session_prompt_right(_ctx, value) {
287
+ return <MeterDisplay api={api} sessionID={value.session_id} tracker={tracker} subscribe={subscribe} />
288
+ },
289
+ },
290
+ })
291
+ }
292
+
293
+ const plugin: TuiPluginModule & { id: string } = { id: "opencode-tps-meter", tui }
294
+ export default plugin