@jack-yang/opencode-tps-meter 1.1.1 → 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 +43 -5
- package/README.zh-CN.md +45 -7
- package/package.json +6 -3
- package/tui.tsx +271 -114
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
|
-
[](https://www.npmjs.com/package/@jack-yang/opencode-tps-meter) [](https://www.npmjs.com/package/@jack-yang/opencode-tps-meter) [](https://github.com/yangwudong/opencode-tps-meter) ](https://www.npmjs.com/package/@jack-yang/opencode-tps-meter) [](https://www.npmjs.com/package/@jack-yang/opencode-tps-meter) [](https://github.com/yangwudong/opencode-tps-meter) 
|
|
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
|
-
|
|
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.
|
|
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
|
-
[](https://www.npmjs.com/package/@jack-yang/opencode-tps-meter) [](https://www.npmjs.com/package/@jack-yang/opencode-tps-meter) [](https://github.com/yangwudong/opencode-tps-meter) ](https://www.npmjs.com/package/@jack-yang/opencode-tps-meter) [](https://www.npmjs.com/package/@jack-yang/opencode-tps-meter) [](https://github.com/yangwudong/opencode-tps-meter) 
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
|
@@ -14,8 +17,8 @@
|
|
|
14
17
|
},
|
|
15
18
|
"peerDependencies": {
|
|
16
19
|
"@opencode-ai/plugin": ">=1.4.3",
|
|
17
|
-
"@opentui/core": "
|
|
18
|
-
"@opentui/solid": "
|
|
20
|
+
"@opentui/core": ">=0.4.2 <1",
|
|
21
|
+
"@opentui/solid": ">=0.4.2 <1",
|
|
19
22
|
"solid-js": "^1.9.12"
|
|
20
23
|
},
|
|
21
24
|
"devDependencies": {
|
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,
|
|
149
|
+
function tierColor(tier: SpeedTier, p: Palette): RGBA {
|
|
116
150
|
switch (tier) {
|
|
117
|
-
case "slow": return
|
|
118
|
-
case "normal": return
|
|
119
|
-
case "fast": return
|
|
120
|
-
case "faster": return
|
|
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,
|
|
124
|
-
return tierColor(speedTier(tps, c),
|
|
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,
|
|
167
|
+
function ttftColor(ms: number, c: TtftTiers, p: Palette): RGBA {
|
|
134
168
|
switch (ttftTier(ms, c)) {
|
|
135
|
-
case "fast": return
|
|
136
|
-
case "ok": return
|
|
137
|
-
case "slow": return
|
|
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
|
-
|
|
185
|
-
api: Parameters<TuiPlugin>[0]
|
|
186
|
-
sessionID: string
|
|
218
|
+
type MeterCore = {
|
|
187
219
|
tracker: TrackerState
|
|
188
|
-
|
|
189
|
-
muted: RGBA
|
|
190
|
-
spinnerCells: () => { glyph: string; color: RGBA }[]
|
|
220
|
+
bump: () => void
|
|
191
221
|
subscribe: (listener: Listener) => () => void
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
497
|
+
requestRender={() => api.renderer.requestRender()}
|
|
431
498
|
sessionID={value.session_id}
|
|
432
499
|
tracker={tracker}
|
|
433
500
|
config={config}
|
|
434
|
-
|
|
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
|
|
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
|