@jack-yang/opencode-tps-meter 1.1.2 → 1.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/README.md CHANGED
@@ -4,7 +4,7 @@ English | [中文](./README.zh-CN.md)
4
4
 
5
5
  A TUI plugin for [opencode](https://opencode.ai) that displays real-time LLM output speed metrics next to the session prompt.
6
6
 
7
- [![npm version](https://img.shields.io/npm/v/@jack-yang/opencode-tps-meter)](https://www.npmjs.com/package/@jack-yang/opencode-tps-meter) [![license](https://img.shields.io/npm/l/@jack-yang/opencode-tps-meter)](https://www.npmjs.com/package/@jack-yang/opencode-tps-meter) [![stars](https://img.shields.io/github/stars/yangwudong/opencode-tps-meter)](https://github.com/yangwudong/opencode-tps-meter) ![opencode](https://img.shields.io/badge/opencode-1.17.20-tested-blue)
7
+ [![npm version](https://img.shields.io/npm/v/@jack-yang/opencode-tps-meter)](https://www.npmjs.com/package/@jack-yang/opencode-tps-meter) [![license](https://img.shields.io/npm/l/@jack-yang/opencode-tps-meter)](https://www.npmjs.com/package/@jack-yang/opencode-tps-meter) [![stars](https://img.shields.io/github/stars/yangwudong/opencode-tps-meter)](https://github.com/yangwudong/opencode-tps-meter) ![opencode](https://img.shields.io/badge/opencode-1.x%20%2F%202.x-blue)
8
8
 
9
9
  ## Display
10
10
 
@@ -44,17 +44,24 @@ TPS 42.5 | AVG 38.2 | TTFT 0.8s ⠒⠑⠂
44
44
 
45
45
  ## Installation
46
46
 
47
- > **Important:** This plugin must be configured in `tui.json`, **not** `opencode.json`.
47
+ One package supports both opencode 1.x and 2.x — pick the section for your version.
48
+
49
+ > **Important (opencode 1.x):** the plugin must be configured in `tui.json`, **not** `opencode.json`.
50
+ > **opencode 2.x:** terminal-client plugins live in `cli.json` (installed under the `plugins` key). If you upgrade to 2.x, your `tui.json` is migrated automatically.
48
51
 
49
52
  ### One command (recommended)
50
53
 
51
54
  ```bash
55
+ # opencode 1.x
52
56
  opencode plugin @jack-yang/opencode-tps-meter -g
57
+
58
+ # opencode 2.x
59
+ opencode plugin add @jack-yang/opencode-tps-meter
53
60
  ```
54
61
 
55
62
  Installs the plugin and updates your config. Restart opencode — done. (Applies default options; to customize, use the tuple form in [Configuration](#configuration).)
56
63
 
57
- ### Manual: npm
64
+ ### Manual: npm (opencode 1.x)
58
65
 
59
66
  Add `@jack-yang/opencode-tps-meter` to the `plugin` array in `~/.config/opencode/tui.json`:
60
67
 
@@ -66,6 +73,18 @@ Add `@jack-yang/opencode-tps-meter` to the `plugin` array in `~/.config/opencode
66
73
  }
67
74
  ```
68
75
 
76
+ ### Manual: npm (opencode 2.x)
77
+
78
+ Add the package to the `plugins` array in `~/.config/opencode/cli.json`:
79
+
80
+ ```json
81
+ {
82
+ "plugins": [
83
+ "@jack-yang/opencode-tps-meter"
84
+ ]
85
+ }
86
+ ```
87
+
69
88
  ### GitHub
70
89
 
71
90
  ```json
@@ -98,7 +117,9 @@ curl -o ~/.config/opencode/tps-meter.tsx https://raw.githubusercontent.com/yangw
98
117
 
99
118
  ## Configuration
100
119
 
101
- All options are optional — defaults are sane. Pass them via the tuple form in `tui.json`:
120
+ All options are optional — defaults are sane.
121
+
122
+ **opencode 1.x** — tuple form in `tui.json`:
102
123
 
103
124
  ```json
104
125
  {
@@ -119,6 +140,22 @@ All options are optional — defaults are sane. Pass them via the tuple form in
119
140
  }
120
141
  ```
121
142
 
143
+ **opencode 2.x** — object form in `cli.json` (same options):
144
+
145
+ ```json
146
+ {
147
+ "plugins": [
148
+ {
149
+ "package": "@jack-yang/opencode-tps-meter",
150
+ "options": {
151
+ "spinner": { "theme": "tech", "cells": 6 },
152
+ "tiers": { "tps": { "slow": 20, "normal": 50, "fast": 100 } }
153
+ }
154
+ }
155
+ ]
156
+ }
157
+ ```
158
+
122
159
  | Option | Default | Description |
123
160
  |---|---|---|
124
161
  | `spinner.theme` | `"tech"` | Color preset: `"tech"` (cyan→blue→purple) or `"red"` (KITT-style red). `"none"` disables the thinking stream. |
@@ -127,6 +164,7 @@ All options are optional — defaults are sane. Pass them via the tuple form in
127
164
  | `spinner.cells` | `6` | Number of cells (width) of the thinking stream. |
128
165
  | `tiers.tps` | `{20,50,100}` | TPS/AVG thresholds (slow/normal/fast, in TPS); above `fast` is "faster". |
129
166
  | `tiers.ttft` | `{10000,20000}` | TTFT thresholds in ms (fast/ok); above `ok` is "slow". |
167
+ | `slot` | `"prompt.footer.status"` | opencode 2.x only: which TUI slot the meter renders into. |
130
168
 
131
169
  ## How It Works
132
170
 
@@ -157,7 +195,7 @@ Pure measurement functions (`measure.ts`) have 29 unit tests. The TUI plugin (`t
157
195
 
158
196
  ## Requirements
159
197
 
160
- - opencode >= 1.4.3 (tested on 1.17.20)
198
+ - opencode 1.x >= 1.4.3 (tested on 1.17.20 / 1.18.30) or opencode 2.x
161
199
 
162
200
  ## License
163
201
 
package/README.zh-CN.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  一个 [opencode](https://opencode.ai) 的 TUI 插件,在输入框旁边实时显示 LLM 输出速度指标。
6
6
 
7
- [![npm version](https://img.shields.io/npm/v/@jack-yang/opencode-tps-meter)](https://www.npmjs.com/package/@jack-yang/opencode-tps-meter) [![license](https://img.shields.io/npm/l/@jack-yang/opencode-tps-meter)](https://www.npmjs.com/package/@jack-yang/opencode-tps-meter) [![stars](https://img.shields.io/github/stars/yangwudong/opencode-tps-meter)](https://github.com/yangwudong/opencode-tps-meter) ![opencode](https://img.shields.io/badge/opencode-1.17.20-tested-blue)
7
+ [![npm version](https://img.shields.io/npm/v/@jack-yang/opencode-tps-meter)](https://www.npmjs.com/package/@jack-yang/opencode-tps-meter) [![license](https://img.shields.io/npm/l/@jack-yang/opencode-tps-meter)](https://www.npmjs.com/package/@jack-yang/opencode-tps-meter) [![stars](https://img.shields.io/github/stars/yangwudong/opencode-tps-meter)](https://github.com/yangwudong/opencode-tps-meter) ![opencode](https://img.shields.io/badge/opencode-1.x%20%2F%202.x-blue)
8
8
 
9
9
  ## 显示效果
10
10
 
@@ -44,17 +44,24 @@ TPS 42.5 | AVG 38.2 | TTFT 0.8s ⠒⠑⠂
44
44
 
45
45
  ## 安装
46
46
 
47
- > **注意:** 本插件必须配置在 `tui.json` 中,**不是** `opencode.json`。
47
+ 一个包同时支持 opencode 1.x 和 2.x,按你的版本选择对应方式。
48
+
49
+ > **注意(opencode 1.x):** 插件必须配置在 `tui.json` 中,**不是** `opencode.json`。
50
+ > **opencode 2.x:** 终端客户端插件配置在 `cli.json` 的 `plugins` 键下。升级到 2.x 时,`tui.json` 会被自动迁移。
48
51
 
49
52
  ### 一条命令(推荐)
50
53
 
51
54
  ```bash
55
+ # opencode 1.x
52
56
  opencode plugin @jack-yang/opencode-tps-meter -g
57
+
58
+ # opencode 2.x
59
+ opencode plugin add @jack-yang/opencode-tps-meter
53
60
  ```
54
61
 
55
- 安装插件并更新配置。重启 opencode 即可。(使用默认配置;如需自定义,见下方[配置](#配置)的元组形式。)
62
+ 安装插件并更新配置。重启 opencode 即可。(使用默认配置;如需自定义,见下方[配置](#配置)。)
56
63
 
57
- ### 手动:npm
64
+ ### 手动:npm(opencode 1.x)
58
65
 
59
66
  在 `~/.config/opencode/tui.json` 的 `plugin` 数组中添加 `@jack-yang/opencode-tps-meter`:
60
67
 
@@ -66,6 +73,18 @@ opencode plugin @jack-yang/opencode-tps-meter -g
66
73
  }
67
74
  ```
68
75
 
76
+ ### 手动:npm(opencode 2.x)
77
+
78
+ 在 `~/.config/opencode/cli.json` 的 `plugins` 数组中添加:
79
+
80
+ ```json
81
+ {
82
+ "plugins": [
83
+ "@jack-yang/opencode-tps-meter"
84
+ ]
85
+ }
86
+ ```
87
+
69
88
  ### GitHub
70
89
 
71
90
  ```json
@@ -98,7 +117,9 @@ curl -o ~/.config/opencode/tps-meter.tsx https://raw.githubusercontent.com/yangw
98
117
 
99
118
  ## 配置
100
119
 
101
- 所有配置项都是可选的——默认值即开即用。通过 `tui.json` 的元组形式传入:
120
+ 所有配置项都是可选的——默认值即开即用。
121
+
122
+ **opencode 1.x** —— `tui.json` 的元组形式:
102
123
 
103
124
  ```json
104
125
  {
@@ -119,6 +140,22 @@ curl -o ~/.config/opencode/tps-meter.tsx https://raw.githubusercontent.com/yangw
119
140
  }
120
141
  ```
121
142
 
143
+ **opencode 2.x** —— `cli.json` 的对象形式(配置项相同):
144
+
145
+ ```json
146
+ {
147
+ "plugins": [
148
+ {
149
+ "package": "@jack-yang/opencode-tps-meter",
150
+ "options": {
151
+ "spinner": { "theme": "tech", "cells": 6 },
152
+ "tiers": { "tps": { "slow": 20, "normal": 50, "fast": 100 } }
153
+ }
154
+ }
155
+ ]
156
+ }
157
+ ```
158
+
122
159
  | 配置项 | 默认值 | 说明 |
123
160
  |---|---|---|
124
161
  | `spinner.theme` | `"tech"` | 配色预设:`"tech"`(青→蓝→紫)或 `"red"`(KITT 风格红)。`"none"` 关闭思考粒子流。 |
@@ -127,8 +164,9 @@ curl -o ~/.config/opencode/tps-meter.tsx https://raw.githubusercontent.com/yangw
127
164
  | `spinner.cells` | `6` | 粒子流格数(宽度)。 |
128
165
  | `tiers.tps` | `{20,50,100}` | TPS/AVG 阈值(慢/正常/快,单位 TPS);超过 fast 为"很快"。 |
129
166
  | `tiers.ttft` | `{10000,20000}` | TTFT 阈值(毫秒,快/正常);超过 ok 为"慢"。 |
167
+ | `slot` | `"prompt.footer.status"` | 仅 opencode 2.x:仪表渲染到的 TUI slot。 |
130
168
 
131
- > 一条命令安装(`opencode plugin ...`)使用默认值;如需自定义,改用上面的元组形式。
169
+ > 一条命令安装(`opencode plugin ...`)使用默认值;如需自定义,改用上面的元组/对象形式。
132
170
 
133
171
  ## 工作原理
134
172
 
@@ -159,7 +197,7 @@ npm test
159
197
 
160
198
  ## 环境要求
161
199
 
162
- - opencode >= 1.4.3(已在 1.17.20 测试)
200
+ - opencode 1.x >= 1.4.3(已在 1.17.20 / 1.18.30 测试)或 opencode 2.x
163
201
 
164
202
  ## 许可证
165
203
 
package/package.json CHANGED
@@ -1,10 +1,13 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@jack-yang/opencode-tps-meter",
4
- "version": "1.1.2",
4
+ "version": "1.2.0",
5
5
  "description": "Live TPS/AVG/TTFT meter plugin for OpenCode TUI with color-coded speed tiers",
6
6
  "type": "module",
7
7
  "exports": {
8
+ ".": {
9
+ "import": "./tui.tsx"
10
+ },
8
11
  "./tui": {
9
12
  "import": "./tui.tsx"
10
13
  }
package/tui.tsx CHANGED
@@ -106,22 +106,56 @@ function formatTtft(valueMs: number): string | undefined {
106
106
  }
107
107
 
108
108
  type SpeedTier = "slow" | "normal" | "fast" | "faster"
109
+ type Palette = { muted: RGBA; error: RGBA; warning: RGBA; success: RGBA; accent: RGBA }
110
+
111
+ type V2Event = { type: string; created: number; data: Record<string, any> }
112
+ type V2Theme = {
113
+ text: {
114
+ muted: RGBA
115
+ feedback: Record<"error" | "warning" | "success" | "info", { base: RGBA; muted: RGBA }>
116
+ }
117
+ hue: Record<string, Partial<Record<number, RGBA>>>
118
+ }
119
+ type V2Context = {
120
+ options: Record<string, unknown> | undefined
121
+ renderer: { requestRender(): void }
122
+ theme: V2Theme
123
+ data: { on(type: string, handler: (event: V2Event) => void): () => void }
124
+ ui: {
125
+ slot(claim: { append: string; render: (input: { sessionID?: string }) => unknown }): () => void
126
+ router: { current(): { type: string; sessionID?: string } }
127
+ }
128
+ }
129
+
130
+ function v1Palette(t: TuiThemeCurrent): Palette {
131
+ return { muted: t.textMuted, error: t.error, warning: t.warning, success: t.success, accent: t.accent }
132
+ }
133
+ function v2Palette(t: V2Theme): Palette {
134
+ return {
135
+ muted: t.text.muted,
136
+ error: t.text.feedback.error.base,
137
+ warning: t.text.feedback.warning.base,
138
+ success: t.text.feedback.success.base,
139
+ accent: t.hue.accent?.[500] ?? t.text.feedback.info.base,
140
+ }
141
+ }
142
+
109
143
  function speedTier(tps: number, c: TpsTiers): SpeedTier {
110
144
  if (tps < c.slow) return "slow"
111
145
  if (tps < c.normal) return "normal"
112
146
  if (tps < c.fast) return "fast"
113
147
  return "faster"
114
148
  }
115
- function tierColor(tier: SpeedTier, theme: TuiThemeCurrent): RGBA {
149
+ function tierColor(tier: SpeedTier, p: Palette): RGBA {
116
150
  switch (tier) {
117
- case "slow": return theme.error
118
- case "normal": return theme.warning
119
- case "fast": return theme.success
120
- case "faster": return theme.accent
151
+ case "slow": return p.error
152
+ case "normal": return p.warning
153
+ case "fast": return p.success
154
+ case "faster": return p.accent
121
155
  }
122
156
  }
123
- function tpsColor(tps: number, c: TpsTiers, theme: TuiThemeCurrent): RGBA {
124
- return tierColor(speedTier(tps, c), theme)
157
+ function tpsColor(tps: number, c: TpsTiers, p: Palette): RGBA {
158
+ return tierColor(speedTier(tps, c), p)
125
159
  }
126
160
 
127
161
  type TtftTier = "fast" | "ok" | "slow"
@@ -130,11 +164,11 @@ function ttftTier(ms: number, c: TtftTiers): TtftTier {
130
164
  if (ms < c.ok) return "ok"
131
165
  return "slow"
132
166
  }
133
- function ttftColor(ms: number, c: TtftTiers, theme: TuiThemeCurrent): RGBA {
167
+ function ttftColor(ms: number, c: TtftTiers, p: Palette): RGBA {
134
168
  switch (ttftTier(ms, c)) {
135
- case "fast": return theme.success
136
- case "ok": return theme.warning
137
- case "slow": return theme.error
169
+ case "fast": return p.success
170
+ case "ok": return p.warning
171
+ case "slow": return p.error
138
172
  }
139
173
  }
140
174
 
@@ -181,75 +215,17 @@ function recordDelta(tracker: TrackerState, sessionID: string, messageID: string
181
215
 
182
216
  const DOT_GLYPHS = ["⠁", "⠂", "⠄", "⠈", "⠐", "⠠", "⠒", "⠑", "⠡", "⠢", "⠤", "⠨", "⠰", "⠸", "⠘", "⠔", "⠖", "⠦"]
183
217
 
184
- function MeterDisplay(props: {
185
- api: Parameters<TuiPlugin>[0]
186
- sessionID: string
218
+ type MeterCore = {
187
219
  tracker: TrackerState
188
- config: ResolvedConfig
189
- muted: RGBA
190
- spinnerCells: () => { glyph: string; color: RGBA }[]
220
+ bump: () => void
191
221
  subscribe: (listener: Listener) => () => void
192
- }) {
193
- let particleRef: TextRenderable | undefined
194
- let tpsRef: TextRenderable | undefined
195
- let avgRef: TextRenderable | undefined
196
- let ttftRef: TextRenderable | undefined
197
- const theme = props.api.theme.current
198
- const muted = props.muted
199
-
200
- const sync = () => {
201
- const stats = props.tracker.statsBySession[props.sessionID]
202
- const calibration = calibrationFactor(stats?.calibrationRatios ?? [])
203
- const samples = props.tracker.samplesBySession[props.sessionID] ?? []
204
- const now = Date.now()
205
- const live = calculateLiveTps(samples, now, calibration)
206
- const avg = stats && stats.totalDurationMs > 0 ? stats.totalOutputTokens / (stats.totalDurationMs / 1000) : undefined
207
- const ttft = props.tracker.liveTtftBySession[props.sessionID]
208
- const thinkingNow = props.tracker.thinkingBySession[props.sessionID] === true && props.config.spinner.enabled
209
- if (particleRef) {
210
- if (thinkingNow) {
211
- const cells = props.spinnerCells()
212
- particleRef.content = " " + cells.map((c) => c.glyph).join("")
213
- particleRef.fg = cells[0]?.color ?? muted
214
- } else {
215
- particleRef.content = ""
216
- }
217
- }
218
- if (tpsRef) {
219
- tpsRef.content = live !== undefined ? formatTps(live) ?? "--" : "--"
220
- tpsRef.fg = live !== undefined ? tpsColor(live, props.config.tps, theme) : muted
221
- }
222
- if (avgRef) {
223
- avgRef.content = avg !== undefined ? formatTps(avg) ?? "--" : "--"
224
- avgRef.fg = avg !== undefined ? tpsColor(avg, props.config.tps, theme) : muted
225
- }
226
- if (ttftRef) {
227
- ttftRef.content = ttft !== undefined ? formatTtft(ttft) ?? "--" : "--"
228
- ttftRef.fg = ttft !== undefined ? ttftColor(ttft, props.config.ttft, theme) : muted
229
- }
230
- props.api.renderer.requestRender()
231
- }
232
-
233
- const unsubscribe = props.subscribe(sync)
234
- onCleanup(unsubscribe)
235
-
236
- return (
237
- <box flexDirection="row" alignItems="center">
238
- <text fg={muted}>TPS </text>
239
- <text ref={(el: TextRenderable) => { tpsRef = el; sync() }} fg={muted}>--</text>
240
- <text fg={muted}> | AVG </text>
241
- <text ref={(el: TextRenderable) => { avgRef = el; sync() }} fg={muted}>--</text>
242
- <text fg={muted}> | TTFT </text>
243
- <text ref={(el: TextRenderable) => { ttftRef = el; sync() }} fg={muted}>--</text>
244
- <text ref={(el: TextRenderable) => { particleRef = el; sync() }} fg={muted}></text>
245
- </box>
246
- )
222
+ spinnerCells: () => { glyph: string; color: RGBA }[]
223
+ setThinking: (sessionID: string, value: boolean) => void
224
+ clearSessionLive: (sessionID: string) => void
225
+ dispose: () => void
247
226
  }
248
227
 
249
- const tui: TuiPlugin = async (api, options) => {
250
- const config = resolveConfig(options)
251
- const muted = api.theme.current.textMuted
252
-
228
+ function createMeterCore(config: ResolvedConfig, muted: RGBA): MeterCore {
253
229
  const tracker: TrackerState = {
254
230
  useV2Events: false,
255
231
  samplesBySession: {},
@@ -315,6 +291,130 @@ const tui: TuiPlugin = async (api, options) => {
315
291
  if (value) initStream()
316
292
  syncSpinnerTimer()
317
293
  }
294
+ const clearSessionLive = (sessionID: string) => {
295
+ delete tracker.samplesBySession[sessionID]
296
+ setThinking(sessionID, false)
297
+ }
298
+
299
+ return {
300
+ tracker,
301
+ bump,
302
+ subscribe,
303
+ spinnerCells,
304
+ setThinking,
305
+ clearSessionLive,
306
+ dispose: () => {
307
+ if (spinnerTimer) clearInterval(spinnerTimer)
308
+ },
309
+ }
310
+ }
311
+
312
+ function recordMessageCompleted(core: MeterCore, info: {
313
+ id: string
314
+ sessionID: string
315
+ tokens: { output: number; reasoning: number }
316
+ }) {
317
+ const tracker = core.tracker
318
+ const sessionID = info.sessionID
319
+ if (!tracker.statsBySession[sessionID]) {
320
+ tracker.statsBySession[sessionID] = { totalOutputTokens: 0, totalDurationMs: 0, calibrationRatios: [] }
321
+ }
322
+ const stats = tracker.statsBySession[sessionID]!
323
+
324
+ const actualTokens = info.tokens.output + info.tokens.reasoning
325
+ const firstDelta = tracker.firstDeltaByMessage[info.id]
326
+ const lastDelta = tracker.lastDeltaByMessage[info.id]
327
+ const generationMs = firstDelta !== undefined && lastDelta !== undefined
328
+ ? Math.max(lastDelta - firstDelta, MIN_DURATION_MS) : 0
329
+
330
+ if (actualTokens > 0 && generationMs > 0) {
331
+ stats.totalOutputTokens += actualTokens
332
+ stats.totalDurationMs += generationMs
333
+
334
+ const estimated = tracker.estimatedTokensByMessage[info.id] ?? 0
335
+ if (estimated > 0) {
336
+ const ratio = Math.min(Math.max(actualTokens / estimated, 0.3), 3.0)
337
+ stats.calibrationRatios = [...stats.calibrationRatios, ratio].slice(-MAX_CALIBRATION_RATIOS)
338
+ }
339
+ }
340
+
341
+ delete tracker.estimatedTokensByMessage[info.id]
342
+ delete tracker.firstDeltaByMessage[info.id]
343
+ delete tracker.lastDeltaByMessage[info.id]
344
+ delete tracker.messageCreatedAt[info.id]
345
+ delete tracker.activeMessageBySession[sessionID]
346
+ delete tracker.samplesBySession[sessionID]
347
+ core.setThinking(sessionID, false)
348
+ }
349
+
350
+ function MeterDisplay(props: {
351
+ requestRender: () => void
352
+ sessionID: string | undefined
353
+ tracker: TrackerState
354
+ config: ResolvedConfig
355
+ palette: Palette
356
+ spinnerCells: () => { glyph: string; color: RGBA }[]
357
+ subscribe: (listener: Listener) => () => void
358
+ }) {
359
+ let particleRef: TextRenderable | undefined
360
+ let tpsRef: TextRenderable | undefined
361
+ let avgRef: TextRenderable | undefined
362
+ let ttftRef: TextRenderable | undefined
363
+ const p = props.palette
364
+
365
+ const sync = () => {
366
+ const stats = props.tracker.statsBySession[props.sessionID]
367
+ const calibration = calibrationFactor(stats?.calibrationRatios ?? [])
368
+ const samples = props.tracker.samplesBySession[props.sessionID] ?? []
369
+ const now = Date.now()
370
+ const live = calculateLiveTps(samples, now, calibration)
371
+ const avg = stats && stats.totalDurationMs > 0 ? stats.totalOutputTokens / (stats.totalDurationMs / 1000) : undefined
372
+ const ttft = props.tracker.liveTtftBySession[props.sessionID]
373
+ const thinkingNow = props.tracker.thinkingBySession[props.sessionID] === true && props.config.spinner.enabled
374
+ if (particleRef) {
375
+ if (thinkingNow) {
376
+ const cells = props.spinnerCells()
377
+ particleRef.content = " " + cells.map((c) => c.glyph).join("")
378
+ particleRef.fg = cells[0]?.color ?? p.muted
379
+ } else {
380
+ particleRef.content = ""
381
+ }
382
+ }
383
+ if (tpsRef) {
384
+ tpsRef.content = live !== undefined ? formatTps(live) ?? "--" : "--"
385
+ tpsRef.fg = live !== undefined ? tpsColor(live, props.config.tps, p) : p.muted
386
+ }
387
+ if (avgRef) {
388
+ avgRef.content = avg !== undefined ? formatTps(avg) ?? "--" : "--"
389
+ avgRef.fg = avg !== undefined ? tpsColor(avg, props.config.tps, p) : p.muted
390
+ }
391
+ if (ttftRef) {
392
+ ttftRef.content = ttft !== undefined ? formatTtft(ttft) ?? "--" : "--"
393
+ ttftRef.fg = ttft !== undefined ? ttftColor(ttft, props.config.ttft, p) : p.muted
394
+ }
395
+ props.requestRender()
396
+ }
397
+
398
+ const unsubscribe = props.subscribe(sync)
399
+ onCleanup(unsubscribe)
400
+
401
+ return (
402
+ <box flexDirection="row" alignItems="center">
403
+ <text fg={p.muted}>TPS </text>
404
+ <text ref={(el: TextRenderable) => { tpsRef = el; sync() }} fg={p.muted}>--</text>
405
+ <text fg={p.muted}> | AVG </text>
406
+ <text ref={(el: TextRenderable) => { avgRef = el; sync() }} fg={p.muted}>--</text>
407
+ <text fg={p.muted}> | TTFT </text>
408
+ <text ref={(el: TextRenderable) => { ttftRef = el; sync() }} fg={p.muted}>--</text>
409
+ <text ref={(el: TextRenderable) => { particleRef = el; sync() }} fg={p.muted}></text>
410
+ </box>
411
+ )
412
+ }
413
+
414
+ const tui: TuiPlugin = async (api, options) => {
415
+ const config = resolveConfig(options)
416
+ const core = createMeterCore(config, api.theme.current.textMuted)
417
+ const { tracker, bump, subscribe, spinnerCells, setThinking, clearSessionLive } = core
318
418
 
319
419
  const onTextDelta = api.event.on("session.next.text.delta", (evt) => {
320
420
  tracker.useV2Events = true
@@ -354,43 +454,10 @@ const tui: TuiPlugin = async (api, options) => {
354
454
  }
355
455
 
356
456
  const sessionID = info.sessionID ?? evt.properties.sessionID
357
- if (!tracker.statsBySession[sessionID]) {
358
- tracker.statsBySession[sessionID] = { totalOutputTokens: 0, totalDurationMs: 0, calibrationRatios: [] }
359
- }
360
- const stats = tracker.statsBySession[sessionID]!
361
-
362
- const actualTokens = info.tokens.output + info.tokens.reasoning
363
- const firstDelta = tracker.firstDeltaByMessage[info.id]
364
- const lastDelta = tracker.lastDeltaByMessage[info.id]
365
- const generationMs = firstDelta !== undefined && lastDelta !== undefined
366
- ? Math.max(lastDelta - firstDelta, MIN_DURATION_MS) : 0
367
-
368
- if (actualTokens > 0 && generationMs > 0) {
369
- stats.totalOutputTokens += actualTokens
370
- stats.totalDurationMs += generationMs
371
-
372
- const estimated = tracker.estimatedTokensByMessage[info.id] ?? 0
373
- if (estimated > 0) {
374
- const ratio = Math.min(Math.max(actualTokens / estimated, 0.3), 3.0)
375
- stats.calibrationRatios = [...stats.calibrationRatios, ratio].slice(-MAX_CALIBRATION_RATIOS)
376
- }
377
- }
378
-
379
- delete tracker.estimatedTokensByMessage[info.id]
380
- delete tracker.firstDeltaByMessage[info.id]
381
- delete tracker.lastDeltaByMessage[info.id]
382
- delete tracker.messageCreatedAt[info.id]
383
- delete tracker.activeMessageBySession[sessionID]
384
- delete tracker.samplesBySession[sessionID]
385
- setThinking(sessionID, false)
457
+ recordMessageCompleted(core, { id: info.id, sessionID, tokens: info.tokens })
386
458
  bump()
387
459
  })
388
460
 
389
- const clearSessionLive = (sessionID: string) => {
390
- delete tracker.samplesBySession[sessionID]
391
- setThinking(sessionID, false)
392
- }
393
-
394
461
  const onToolInputStarted = api.event.on("session.next.tool.input.started", (evt) => {
395
462
  clearSessionLive(evt.properties.sessionID)
396
463
  bump()
@@ -419,7 +486,7 @@ const tui: TuiPlugin = async (api, options) => {
419
486
  onTextDelta(); onReasoningDelta(); onPartDelta()
420
487
  onMessageUpdated(); onToolInputStarted(); onPartUpdated()
421
488
  clearInterval(timer)
422
- if (spinnerTimer) clearInterval(spinnerTimer)
489
+ core.dispose()
423
490
  })
424
491
 
425
492
  api.slots.register({
@@ -427,11 +494,11 @@ const tui: TuiPlugin = async (api, options) => {
427
494
  session_prompt_right(_ctx, value) {
428
495
  return (
429
496
  <MeterDisplay
430
- api={api}
497
+ requestRender={() => api.renderer.requestRender()}
431
498
  sessionID={value.session_id}
432
499
  tracker={tracker}
433
500
  config={config}
434
- muted={muted}
501
+ palette={v1Palette(api.theme.current)}
435
502
  spinnerCells={spinnerCells}
436
503
  subscribe={subscribe}
437
504
  />
@@ -441,5 +508,95 @@ const tui: TuiPlugin = async (api, options) => {
441
508
  })
442
509
  }
443
510
 
444
- const plugin: TuiPluginModule & { id: string } = { id: "opencode-tps-meter", tui }
511
+ const setup = async (ctx: V2Context): Promise<() => void> => {
512
+ const config = resolveConfig(ctx.options as PluginOptions | undefined)
513
+ const core = createMeterCore(config, ctx.theme.text.muted)
514
+ const { tracker, bump, subscribe, spinnerCells, setThinking, clearSessionLive } = core
515
+
516
+ const unsubs: (() => void)[] = []
517
+ const on = (type: string, handler: (event: V2Event) => void) => {
518
+ unsubs.push(ctx.data.on(type, handler))
519
+ }
520
+
521
+ on("session.step.started", (evt) => {
522
+ tracker.messageCreatedAt[evt.data.assistantMessageID] = evt.data.started
523
+ })
524
+
525
+ const onDelta = (thinking: boolean) => (evt: V2Event) => {
526
+ const { sessionID, assistantMessageID, delta } = evt.data
527
+ recordDelta(tracker, sessionID, assistantMessageID, evt.created, delta)
528
+ setThinking(sessionID, thinking)
529
+ bump()
530
+ }
531
+ on("session.text.delta", onDelta(false))
532
+ on("session.reasoning.delta", onDelta(true))
533
+
534
+ on("session.step.ended", (evt) => {
535
+ const { sessionID, assistantMessageID, tokens } = evt.data
536
+ if (tokens) recordMessageCompleted(core, { id: assistantMessageID, sessionID, tokens })
537
+ bump()
538
+ })
539
+
540
+ on("session.step.failed", (evt) => {
541
+ const { sessionID, assistantMessageID } = evt.data
542
+ delete tracker.messageCreatedAt[assistantMessageID]
543
+ delete tracker.estimatedTokensByMessage[assistantMessageID]
544
+ delete tracker.firstDeltaByMessage[assistantMessageID]
545
+ delete tracker.lastDeltaByMessage[assistantMessageID]
546
+ clearSessionLive(sessionID)
547
+ bump()
548
+ })
549
+
550
+ on("session.tool.input.started", (evt) => {
551
+ clearSessionLive(evt.data.sessionID)
552
+ bump()
553
+ })
554
+
555
+ const timer = setInterval(() => {
556
+ const now = Date.now()
557
+ const cutoff = now - WINDOW_MS
558
+ for (const [sid, samples] of Object.entries(tracker.samplesBySession)) {
559
+ const pruned = samples.filter((s) => s.at >= cutoff)
560
+ if (pruned.length > 0) tracker.samplesBySession[sid] = pruned
561
+ else delete tracker.samplesBySession[sid]
562
+ }
563
+ bump()
564
+ }, 1000)
565
+
566
+ const opts = (ctx.options ?? {}) as { slot?: string }
567
+ const slotPath = typeof opts.slot === "string" && opts.slot ? opts.slot : "prompt.footer.status"
568
+
569
+ ctx.ui.slot({
570
+ append: slotPath,
571
+ render(input) {
572
+ const sessionID = input.sessionID ?? (() => {
573
+ const r = ctx.ui.router.current()
574
+ return r.type === "session" ? r.sessionID : undefined
575
+ })()
576
+ return (
577
+ <MeterDisplay
578
+ requestRender={() => ctx.renderer.requestRender()}
579
+ sessionID={sessionID}
580
+ tracker={tracker}
581
+ config={config}
582
+ palette={v2Palette(ctx.theme)}
583
+ spinnerCells={spinnerCells}
584
+ subscribe={subscribe}
585
+ />
586
+ )
587
+ },
588
+ })
589
+
590
+ return () => {
591
+ for (const u of unsubs) u()
592
+ clearInterval(timer)
593
+ core.dispose()
594
+ }
595
+ }
596
+
597
+ const plugin: TuiPluginModule & { id: string; setup: typeof setup } = {
598
+ id: "opencode-tps-meter",
599
+ tui,
600
+ setup,
601
+ }
445
602
  export default plugin