@jack-yang/opencode-tps-meter 0.1.1 → 1.0.1
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 +83 -13
- package/README.zh-CN.md +166 -0
- package/package.json +4 -1
- package/tui.tsx +187 -27
package/README.md
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
# opencode-tps-meter
|
|
2
2
|
|
|
3
|
+
English | [中文](./README.zh-CN.md)
|
|
4
|
+
|
|
3
5
|
A TUI plugin for [opencode](https://opencode.ai) that displays real-time LLM output speed metrics next to the session prompt.
|
|
4
6
|
|
|
7
|
+
   
|
|
8
|
+
|
|
5
9
|
## Display
|
|
6
10
|
|
|
11
|
+
Idle:
|
|
7
12
|
```
|
|
8
13
|
TPS 42.5 | AVG 38.2 | TTFT 0.8s
|
|
9
14
|
```
|
|
10
15
|
|
|
16
|
+
While the model is reasoning (a flowing particle stream appears to the right of TTFT):
|
|
17
|
+
```
|
|
18
|
+
TPS 42.5 | AVG 38.2 | TTFT 0.8s ⠒⠑⠂
|
|
19
|
+
```
|
|
20
|
+
|
|
11
21
|
- **TPS** — current tokens per second (live, color-coded)
|
|
12
22
|
- **AVG** — session-wide cumulative average from completed messages (color-coded)
|
|
13
23
|
- **TTFT** — time to first token, shown immediately on first token, persists until next generation (color-coded)
|
|
24
|
+
- **Thinking stream** — while reasoning streams, a FIFO particle stream (newest dot on the left, fading right, color shifts over time) appears to the right of TTFT. Only rendered while thinking — no space is reserved when idle.
|
|
14
25
|
|
|
15
26
|
## Color Tiers
|
|
16
27
|
|
|
@@ -27,20 +38,50 @@ TPS 42.5 | AVG 38.2 | TTFT 0.8s
|
|
|
27
38
|
|
|
28
39
|
| Tier | Range | Color |
|
|
29
40
|
|------|-------|-------|
|
|
30
|
-
| Fast | <
|
|
31
|
-
| OK |
|
|
32
|
-
| Slow | >
|
|
41
|
+
| Fast | < 10s | Green |
|
|
42
|
+
| OK | 10–20s | Yellow |
|
|
43
|
+
| Slow | > 20s | Red |
|
|
33
44
|
|
|
34
45
|
## Installation
|
|
35
46
|
|
|
36
|
-
This plugin must be configured in `tui.json
|
|
47
|
+
> **Important:** This plugin must be configured in `tui.json`, **not** `opencode.json`.
|
|
48
|
+
|
|
49
|
+
### One command (recommended)
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
opencode plugin @jack-yang/opencode-tps-meter -g
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Installs the plugin and updates your config. Restart opencode — done. (Applies default options; to customize, use the tuple form in [Configuration](#configuration).)
|
|
56
|
+
|
|
57
|
+
### Manual: npm
|
|
58
|
+
|
|
59
|
+
Add `@jack-yang/opencode-tps-meter` to the `plugin` array in `~/.config/opencode/tui.json`:
|
|
37
60
|
|
|
38
|
-
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"plugin": [
|
|
64
|
+
"@jack-yang/opencode-tps-meter"
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
```
|
|
39
68
|
|
|
40
|
-
|
|
69
|
+
### GitHub
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"plugin": [
|
|
74
|
+
"git+https://github.com/yangwudong/opencode-tps-meter.git"
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Local file
|
|
80
|
+
|
|
81
|
+
1. Download [`tui.tsx`](./tui.tsx) to your opencode config directory:
|
|
41
82
|
|
|
42
83
|
```bash
|
|
43
|
-
|
|
84
|
+
curl -o ~/.config/opencode/tps-meter.tsx https://raw.githubusercontent.com/yangwudong/opencode-tps-meter/main/tui.tsx
|
|
44
85
|
```
|
|
45
86
|
|
|
46
87
|
2. Add to `~/.config/opencode/tui.json`:
|
|
@@ -55,9 +96,37 @@ cp tui.tsx ~/.config/opencode/tps-meter.tsx
|
|
|
55
96
|
|
|
56
97
|
3. Restart opencode.
|
|
57
98
|
|
|
58
|
-
|
|
99
|
+
## Configuration
|
|
100
|
+
|
|
101
|
+
All options are optional — defaults are sane. Pass them via the tuple form in `tui.json`:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"plugin": [
|
|
106
|
+
["@jack-yang/opencode-tps-meter", {
|
|
107
|
+
"spinner": {
|
|
108
|
+
"theme": "tech",
|
|
109
|
+
"colors": ["#00e5ff", "#2979ff", "#651fff"],
|
|
110
|
+
"intervalMs": 100,
|
|
111
|
+
"cells": 6
|
|
112
|
+
},
|
|
113
|
+
"tiers": {
|
|
114
|
+
"tps": { "slow": 20, "normal": 50, "fast": 100 },
|
|
115
|
+
"ttft": { "fast": 10000, "ok": 20000 }
|
|
116
|
+
}
|
|
117
|
+
}]
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
```
|
|
59
121
|
|
|
60
|
-
|
|
122
|
+
| Option | Default | Description |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `spinner.theme` | `"tech"` | Color preset: `"tech"` (cyan→blue→purple) or `"red"` (KITT-style red). `"none"` disables the thinking stream. |
|
|
125
|
+
| `spinner.colors` | _from theme_ | Custom gradient (hex strings); overrides `theme`. |
|
|
126
|
+
| `spinner.intervalMs` | `100` | Particle stream frame interval (ms). |
|
|
127
|
+
| `spinner.cells` | `6` | Number of cells (width) of the thinking stream. |
|
|
128
|
+
| `tiers.tps` | `{20,50,100}` | TPS/AVG thresholds (slow/normal/fast, in TPS); above `fast` is "faster". |
|
|
129
|
+
| `tiers.ttft` | `{10000,20000}` | TTFT thresholds in ms (fast/ok); above `ok` is "slow". |
|
|
61
130
|
|
|
62
131
|
## How It Works
|
|
63
132
|
|
|
@@ -69,7 +138,7 @@ Token estimation uses `ceil(byteLength / 4)` with a **calibration factor**: when
|
|
|
69
138
|
|
|
70
139
|
### AVG
|
|
71
140
|
|
|
72
|
-
Session-wide cumulative average from all completed messages: `sum(actual tokens) / sum(generation durations)`. Updates
|
|
141
|
+
Session-wide cumulative average from all completed messages: `sum(actual tokens) / sum(generation durations)`. Updates when a message completes. Generation duration = `last delta - first delta` per message (excludes tool execution time and TTFT).
|
|
73
142
|
|
|
74
143
|
### TTFT
|
|
75
144
|
|
|
@@ -78,16 +147,17 @@ Time from message creation (`info.time.created`) to the first text/reasoning del
|
|
|
78
147
|
## Development
|
|
79
148
|
|
|
80
149
|
```bash
|
|
150
|
+
git clone https://github.com/yangwudong/opencode-tps-meter.git
|
|
151
|
+
cd opencode-tps-meter
|
|
81
152
|
npm install
|
|
82
153
|
npm test
|
|
83
154
|
```
|
|
84
155
|
|
|
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
|
|
156
|
+
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.
|
|
86
157
|
|
|
87
158
|
## Requirements
|
|
88
159
|
|
|
89
|
-
- opencode >= 1.4.3
|
|
90
|
-
- The plugin file must be placed where `tui.json` can reference it via relative path
|
|
160
|
+
- opencode >= 1.4.3 (tested on 1.17.20)
|
|
91
161
|
|
|
92
162
|
## License
|
|
93
163
|
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# opencode-tps-meter
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | 中文
|
|
4
|
+
|
|
5
|
+
一个 [opencode](https://opencode.ai) 的 TUI 插件,在输入框旁边实时显示 LLM 输出速度指标。
|
|
6
|
+
|
|
7
|
+
   
|
|
8
|
+
|
|
9
|
+
## 显示效果
|
|
10
|
+
|
|
11
|
+
空闲时:
|
|
12
|
+
```
|
|
13
|
+
TPS 42.5 | AVG 38.2 | TTFT 0.8s
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
模型推理时(TTFT 右侧出现流动的粒子条):
|
|
17
|
+
```
|
|
18
|
+
TPS 42.5 | AVG 38.2 | TTFT 0.8s ⠒⠑⠂
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- **TPS** — 当前每秒 token 数(实时跳动,带颜色)
|
|
22
|
+
- **AVG** — 整个会话的累计平均速度(消息完成后更新,带颜色)
|
|
23
|
+
- **TTFT** — 首个 token 的等待时间,第一个 token 到达即显示,保留到下次生成(带颜色)
|
|
24
|
+
- **思考粒子条** — 推理流式输出时,TTFT 右侧出现一个 FIFO 粒子流(左侧最新、向右渐暗,颜色随时间渐变)。仅在思考时渲染,空闲时不占位。
|
|
25
|
+
|
|
26
|
+
## 颜色等级
|
|
27
|
+
|
|
28
|
+
**TPS 和 AVG**(越高越好):
|
|
29
|
+
|
|
30
|
+
| 等级 | 范围 | 颜色 |
|
|
31
|
+
|------|------|------|
|
|
32
|
+
| 慢 | < 20 TPS | 红色 |
|
|
33
|
+
| 正常 | 20–50 TPS | 黄色 |
|
|
34
|
+
| 快 | 50–100 TPS | 绿色 |
|
|
35
|
+
| 很快 | > 100 TPS | 青色 |
|
|
36
|
+
|
|
37
|
+
**TTFT**(越低越好):
|
|
38
|
+
|
|
39
|
+
| 等级 | 范围 | 颜色 |
|
|
40
|
+
|------|------|------|
|
|
41
|
+
| 快 | < 10s | 绿色 |
|
|
42
|
+
| 一般 | 10–20s | 黄色 |
|
|
43
|
+
| 慢 | > 20s | 红色 |
|
|
44
|
+
|
|
45
|
+
## 安装
|
|
46
|
+
|
|
47
|
+
> **注意:** 本插件必须配置在 `tui.json` 中,**不是** `opencode.json`。
|
|
48
|
+
|
|
49
|
+
### 一条命令(推荐)
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
opencode plugin @jack-yang/opencode-tps-meter -g
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
安装插件并更新配置。重启 opencode 即可。(使用默认配置;如需自定义,见下方[配置](#配置)的元组形式。)
|
|
56
|
+
|
|
57
|
+
### 手动:npm
|
|
58
|
+
|
|
59
|
+
在 `~/.config/opencode/tui.json` 的 `plugin` 数组中添加 `@jack-yang/opencode-tps-meter`:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"plugin": [
|
|
64
|
+
"@jack-yang/opencode-tps-meter"
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### GitHub
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"plugin": [
|
|
74
|
+
"git+https://github.com/yangwudong/opencode-tps-meter.git"
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### 本地文件
|
|
80
|
+
|
|
81
|
+
1. 下载 [`tui.tsx`](./tui.tsx) 到 opencode 配置目录:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
curl -o ~/.config/opencode/tps-meter.tsx https://raw.githubusercontent.com/yangwudong/opencode-tps-meter/main/tui.tsx
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
2. 在 `~/.config/opencode/tui.json` 中引用:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"plugin": [
|
|
92
|
+
"./tps-meter.tsx"
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
3. 重启 opencode。
|
|
98
|
+
|
|
99
|
+
## 配置
|
|
100
|
+
|
|
101
|
+
所有配置项都是可选的——默认值即开即用。通过 `tui.json` 的元组形式传入:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"plugin": [
|
|
106
|
+
["@jack-yang/opencode-tps-meter", {
|
|
107
|
+
"spinner": {
|
|
108
|
+
"theme": "tech",
|
|
109
|
+
"colors": ["#00e5ff", "#2979ff", "#651fff"],
|
|
110
|
+
"intervalMs": 100,
|
|
111
|
+
"cells": 6
|
|
112
|
+
},
|
|
113
|
+
"tiers": {
|
|
114
|
+
"tps": { "slow": 20, "normal": 50, "fast": 100 },
|
|
115
|
+
"ttft": { "fast": 10000, "ok": 20000 }
|
|
116
|
+
}
|
|
117
|
+
}]
|
|
118
|
+
]
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
| 配置项 | 默认值 | 说明 |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `spinner.theme` | `"tech"` | 配色预设:`"tech"`(青→蓝→紫)或 `"red"`(KITT 风格红)。`"none"` 关闭思考粒子流。 |
|
|
125
|
+
| `spinner.colors` | _随 theme_ | 自定义渐变(十六进制字符串数组);覆盖 theme。 |
|
|
126
|
+
| `spinner.intervalMs` | `100` | 粒子流帧间隔(毫秒)。 |
|
|
127
|
+
| `spinner.cells` | `6` | 粒子流格数(宽度)。 |
|
|
128
|
+
| `tiers.tps` | `{20,50,100}` | TPS/AVG 阈值(慢/正常/快,单位 TPS);超过 fast 为"很快"。 |
|
|
129
|
+
| `tiers.ttft` | `{10000,20000}` | TTFT 阈值(毫秒,快/正常);超过 ok 为"慢"。 |
|
|
130
|
+
|
|
131
|
+
> 一条命令安装(`opencode plugin ...`)使用默认值;如需自定义,改用上面的元组形式。
|
|
132
|
+
|
|
133
|
+
## 工作原理
|
|
134
|
+
|
|
135
|
+
### 实时 TPS
|
|
136
|
+
|
|
137
|
+
在 5 秒滚动窗口内追踪文本/推理的 delta 事件。使用壁钟时长(`当前时间 - 最早样本时间`)而非间隔时间累加,避免网络批量传输导致的 TPS 虚高。
|
|
138
|
+
|
|
139
|
+
Token 估算使用 `ceil(字节数 / 4)`,并带有**校准因子**:消息完成时,将估算的 token 数与实际的 `tokens.output + tokens.reasoning` 对比,用中位数比率自动修正后续估算。
|
|
140
|
+
|
|
141
|
+
### AVG
|
|
142
|
+
|
|
143
|
+
所有已完成消息的会话级累计平均值:`总实际 token 数 / 总生成时长`。消息完成时更新。生成时长 = 每条消息的 `最后 delta - 首个 delta`(不含工具执行时间和 TTFT)。
|
|
144
|
+
|
|
145
|
+
### TTFT
|
|
146
|
+
|
|
147
|
+
从消息创建(`info.time.created`)到第一个文本/推理 delta 的时间。第一个 token 到达时立即显示,保留到下次生成开始。
|
|
148
|
+
|
|
149
|
+
## 开发
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
git clone https://github.com/yangwudong/opencode-tps-meter.git
|
|
153
|
+
cd opencode-tps-meter
|
|
154
|
+
npm install
|
|
155
|
+
npm test
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
纯计算函数(`measure.ts`)有 29 个单元测试。TUI 插件(`tui.tsx`)是独立的单文件(所有函数内联),可直接部署。
|
|
159
|
+
|
|
160
|
+
## 环境要求
|
|
161
|
+
|
|
162
|
+
- opencode >= 1.4.3(已在 1.17.20 测试)
|
|
163
|
+
|
|
164
|
+
## 许可证
|
|
165
|
+
|
|
166
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@jack-yang/opencode-tps-meter",
|
|
4
|
-
"version": "0.1
|
|
4
|
+
"version": "1.0.1",
|
|
5
5
|
"description": "Live TPS/AVG/TTFT meter plugin for OpenCode TUI with color-coded speed tiers",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
|
@@ -49,5 +49,8 @@
|
|
|
49
49
|
},
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"overrides": {
|
|
54
|
+
"@babel/core": "^7.29.6"
|
|
52
55
|
}
|
|
53
56
|
}
|
package/tui.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import
|
|
2
|
+
import { RGBA } from "@opentui/core"
|
|
3
|
+
import type { TextRenderable } from "@opentui/core"
|
|
3
4
|
import type { TuiPlugin, TuiPluginModule, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
|
5
|
+
import type { PluginOptions } from "@opencode-ai/plugin"
|
|
4
6
|
import { onCleanup } from "solid-js"
|
|
5
7
|
|
|
6
8
|
type DeltaSample = { at: number; rawTokens: number }
|
|
@@ -21,10 +23,67 @@ type TrackerState = {
|
|
|
21
23
|
liveTtftBySession: Record<string, number>
|
|
22
24
|
activeMessageBySession: Record<string, string | undefined>
|
|
23
25
|
statsBySession: Record<string, SessionStats>
|
|
26
|
+
thinkingBySession: Record<string, boolean>
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
type Listener = () => void
|
|
27
30
|
|
|
31
|
+
type TpsTiers = { slow: number; normal: number; fast: number }
|
|
32
|
+
type TtftTiers = { fast: number; ok: number }
|
|
33
|
+
type SpinnerCfg = { colors: RGBA[]; intervalMs: number; enabled: boolean; cells: number }
|
|
34
|
+
type ResolvedConfig = { spinner: SpinnerCfg; tps: TpsTiers; ttft: TtftTiers }
|
|
35
|
+
|
|
36
|
+
const THEME_PRESETS: Record<string, string[]> = {
|
|
37
|
+
tech: ["#00e5ff", "#2979ff", "#651fff"],
|
|
38
|
+
red: ["#ff1744", "#ff5252", "#ff8a80"],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveConfig(options: PluginOptions | undefined): ResolvedConfig {
|
|
42
|
+
const o = (options ?? {}) as {
|
|
43
|
+
spinner?: { theme?: string; colors?: string[]; intervalMs?: number; enabled?: boolean; cells?: number }
|
|
44
|
+
tiers?: { tps?: Partial<TpsTiers>; ttft?: Partial<TtftTiers> }
|
|
45
|
+
}
|
|
46
|
+
const sp = o.spinner ?? {}
|
|
47
|
+
let colors: RGBA[]
|
|
48
|
+
if (Array.isArray(sp.colors) && sp.colors.length > 0) {
|
|
49
|
+
colors = sp.colors.map((c) => RGBA.fromHex(c))
|
|
50
|
+
} else {
|
|
51
|
+
const preset = THEME_PRESETS[sp.theme ?? "tech"] ?? THEME_PRESETS.tech
|
|
52
|
+
colors = preset.map((c) => RGBA.fromHex(c))
|
|
53
|
+
}
|
|
54
|
+
const cells = typeof sp.cells === "number" && sp.cells >= 2 ? Math.floor(sp.cells) : 6
|
|
55
|
+
const spinner: SpinnerCfg = {
|
|
56
|
+
colors,
|
|
57
|
+
intervalMs: typeof sp.intervalMs === "number" && sp.intervalMs > 0 ? sp.intervalMs : 100,
|
|
58
|
+
enabled: sp.enabled !== false && (sp.theme ?? "tech") !== "none",
|
|
59
|
+
cells,
|
|
60
|
+
}
|
|
61
|
+
const tiers = o.tiers ?? {}
|
|
62
|
+
const tp = tiers.tps ?? {}
|
|
63
|
+
const tt = tiers.ttft ?? {}
|
|
64
|
+
return {
|
|
65
|
+
spinner,
|
|
66
|
+
tps: { slow: tp.slow ?? 20, normal: tp.normal ?? 50, fast: tp.fast ?? 100 },
|
|
67
|
+
ttft: { fast: tt.fast ?? 10000, ok: tt.ok ?? 20000 },
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function lerp(a: number, b: number, t: number): number {
|
|
72
|
+
return a + (b - a) * t
|
|
73
|
+
}
|
|
74
|
+
function lerpColor(a: RGBA, b: RGBA, t: number): RGBA {
|
|
75
|
+
return RGBA.fromValues(lerp(a.r, b.r, t), lerp(a.g, b.g, t), lerp(a.b, b.b, t), 1)
|
|
76
|
+
}
|
|
77
|
+
function gradientColor(colors: RGBA[], t: number): RGBA {
|
|
78
|
+
if (colors.length === 0) return RGBA.fromHex("#888888")
|
|
79
|
+
if (colors.length === 1) return colors[0]
|
|
80
|
+
const c = Math.max(0, Math.min(1, t))
|
|
81
|
+
const seg = c * (colors.length - 1)
|
|
82
|
+
const i = Math.min(Math.floor(seg), colors.length - 2)
|
|
83
|
+
const f = seg - i
|
|
84
|
+
return lerpColor(colors[i], colors[i + 1], f)
|
|
85
|
+
}
|
|
86
|
+
|
|
28
87
|
const WINDOW_MS = 5_000
|
|
29
88
|
const STALE_MS = 1_500
|
|
30
89
|
const MIN_DURATION_MS = 1_000
|
|
@@ -47,10 +106,10 @@ function formatTtft(valueMs: number): string | undefined {
|
|
|
47
106
|
}
|
|
48
107
|
|
|
49
108
|
type SpeedTier = "slow" | "normal" | "fast" | "faster"
|
|
50
|
-
function speedTier(tps: number): SpeedTier {
|
|
51
|
-
if (tps <
|
|
52
|
-
if (tps <
|
|
53
|
-
if (tps <
|
|
109
|
+
function speedTier(tps: number, c: TpsTiers): SpeedTier {
|
|
110
|
+
if (tps < c.slow) return "slow"
|
|
111
|
+
if (tps < c.normal) return "normal"
|
|
112
|
+
if (tps < c.fast) return "fast"
|
|
54
113
|
return "faster"
|
|
55
114
|
}
|
|
56
115
|
function tierColor(tier: SpeedTier, theme: TuiThemeCurrent): RGBA {
|
|
@@ -61,18 +120,18 @@ function tierColor(tier: SpeedTier, theme: TuiThemeCurrent): RGBA {
|
|
|
61
120
|
case "faster": return theme.accent
|
|
62
121
|
}
|
|
63
122
|
}
|
|
64
|
-
function tpsColor(tps: number, theme: TuiThemeCurrent): RGBA {
|
|
65
|
-
return tierColor(speedTier(tps), theme)
|
|
123
|
+
function tpsColor(tps: number, c: TpsTiers, theme: TuiThemeCurrent): RGBA {
|
|
124
|
+
return tierColor(speedTier(tps, c), theme)
|
|
66
125
|
}
|
|
67
126
|
|
|
68
127
|
type TtftTier = "fast" | "ok" | "slow"
|
|
69
|
-
function ttftTier(ms: number): TtftTier {
|
|
70
|
-
if (ms <
|
|
71
|
-
if (ms <
|
|
128
|
+
function ttftTier(ms: number, c: TtftTiers): TtftTier {
|
|
129
|
+
if (ms < c.fast) return "fast"
|
|
130
|
+
if (ms < c.ok) return "ok"
|
|
72
131
|
return "slow"
|
|
73
132
|
}
|
|
74
|
-
function ttftColor(ms: number, theme: TuiThemeCurrent): RGBA {
|
|
75
|
-
switch (ttftTier(ms)) {
|
|
133
|
+
function ttftColor(ms: number, c: TtftTiers, theme: TuiThemeCurrent): RGBA {
|
|
134
|
+
switch (ttftTier(ms, c)) {
|
|
76
135
|
case "fast": return theme.success
|
|
77
136
|
case "ok": return theme.warning
|
|
78
137
|
case "slow": return theme.error
|
|
@@ -87,8 +146,7 @@ function median(values: number[]): number {
|
|
|
87
146
|
return Math.round(((sorted[mid - 1] + sorted[mid]) / 2) * 1e10) / 1e10
|
|
88
147
|
}
|
|
89
148
|
function calibrationFactor(ratios: number[]): number {
|
|
90
|
-
|
|
91
|
-
return median(ratios)
|
|
149
|
+
return ratios.length === 0 ? 1.0 : median(ratios)
|
|
92
150
|
}
|
|
93
151
|
|
|
94
152
|
function calculateLiveTps(samples: DeltaSample[], now: number, calibration: number): number | undefined {
|
|
@@ -121,17 +179,25 @@ function recordDelta(tracker: TrackerState, sessionID: string, messageID: string
|
|
|
121
179
|
tracker.activeMessageBySession[sessionID] = messageID
|
|
122
180
|
}
|
|
123
181
|
|
|
182
|
+
const DOT_GLYPHS = ["⠁", "⠂", "⠄", "⠈", "⠐", "⠠", "⠒", "⠑", "⠡", "⠢", "⠤", "⠨", "⠰", "⠸", "⠘", "⠔", "⠖", "⠦"]
|
|
183
|
+
|
|
124
184
|
function MeterDisplay(props: {
|
|
125
185
|
api: Parameters<TuiPlugin>[0]
|
|
126
186
|
sessionID: string
|
|
127
187
|
tracker: TrackerState
|
|
188
|
+
config: ResolvedConfig
|
|
189
|
+
muted: RGBA
|
|
190
|
+
spinnerCells: () => { glyph: string; color: RGBA }[]
|
|
128
191
|
subscribe: (listener: Listener) => () => void
|
|
129
192
|
}) {
|
|
193
|
+
const cellCount = props.config.spinner.enabled ? props.config.spinner.cells : 0
|
|
194
|
+
const spinRefs: (TextRenderable | undefined)[] = []
|
|
195
|
+
let sepRef: TextRenderable | undefined
|
|
130
196
|
let tpsRef: TextRenderable | undefined
|
|
131
197
|
let avgRef: TextRenderable | undefined
|
|
132
198
|
let ttftRef: TextRenderable | undefined
|
|
133
199
|
const theme = props.api.theme.current
|
|
134
|
-
const muted =
|
|
200
|
+
const muted = props.muted
|
|
135
201
|
|
|
136
202
|
const sync = () => {
|
|
137
203
|
const stats = props.tracker.statsBySession[props.sessionID]
|
|
@@ -141,18 +207,31 @@ function MeterDisplay(props: {
|
|
|
141
207
|
const live = calculateLiveTps(samples, now, calibration)
|
|
142
208
|
const avg = stats && stats.totalDurationMs > 0 ? stats.totalOutputTokens / (stats.totalDurationMs / 1000) : undefined
|
|
143
209
|
const ttft = props.tracker.liveTtftBySession[props.sessionID]
|
|
144
|
-
|
|
210
|
+
const thinkingNow = props.tracker.thinkingBySession[props.sessionID] === true && props.config.spinner.enabled
|
|
211
|
+
if (thinkingNow) {
|
|
212
|
+
if (sepRef) sepRef.content = " "
|
|
213
|
+
const cells = props.spinnerCells()
|
|
214
|
+
for (let i = 0; i < spinRefs.length; i++) {
|
|
215
|
+
const ref = spinRefs[i]
|
|
216
|
+
if (!ref) continue
|
|
217
|
+
const cell = cells[i]
|
|
218
|
+
if (cell) { ref.content = cell.glyph; ref.fg = cell.color }
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
if (sepRef) sepRef.content = ""
|
|
222
|
+
for (const ref of spinRefs) if (ref) ref.content = ""
|
|
223
|
+
}
|
|
145
224
|
if (tpsRef) {
|
|
146
225
|
tpsRef.content = live !== undefined ? formatTps(live) ?? "--" : "--"
|
|
147
|
-
tpsRef.fg = live !== undefined ? tpsColor(live, theme) : muted
|
|
226
|
+
tpsRef.fg = live !== undefined ? tpsColor(live, props.config.tps, theme) : muted
|
|
148
227
|
}
|
|
149
228
|
if (avgRef) {
|
|
150
229
|
avgRef.content = avg !== undefined ? formatTps(avg) ?? "--" : "--"
|
|
151
|
-
avgRef.fg = avg !== undefined ? tpsColor(avg, theme) : muted
|
|
230
|
+
avgRef.fg = avg !== undefined ? tpsColor(avg, props.config.tps, theme) : muted
|
|
152
231
|
}
|
|
153
232
|
if (ttftRef) {
|
|
154
233
|
ttftRef.content = ttft !== undefined ? formatTtft(ttft) ?? "--" : "--"
|
|
155
|
-
ttftRef.fg = ttft !== undefined ? ttftColor(ttft, theme) : muted
|
|
234
|
+
ttftRef.fg = ttft !== undefined ? ttftColor(ttft, props.config.ttft, theme) : muted
|
|
156
235
|
}
|
|
157
236
|
props.api.renderer.requestRender()
|
|
158
237
|
}
|
|
@@ -161,18 +240,25 @@ function MeterDisplay(props: {
|
|
|
161
240
|
onCleanup(unsubscribe)
|
|
162
241
|
|
|
163
242
|
return (
|
|
164
|
-
<box flexDirection="row">
|
|
243
|
+
<box flexDirection="row" alignItems="center">
|
|
165
244
|
<text fg={muted}>TPS </text>
|
|
166
245
|
<text ref={(el: TextRenderable) => { tpsRef = el; sync() }} fg={muted}>--</text>
|
|
167
246
|
<text fg={muted}> | AVG </text>
|
|
168
247
|
<text ref={(el: TextRenderable) => { avgRef = el; sync() }} fg={muted}>--</text>
|
|
169
248
|
<text fg={muted}> | TTFT </text>
|
|
170
249
|
<text ref={(el: TextRenderable) => { ttftRef = el; sync() }} fg={muted}>--</text>
|
|
250
|
+
<text ref={(el: TextRenderable) => { sepRef = el; sync() }} fg={muted}></text>
|
|
251
|
+
{Array.from({ length: cellCount }).map((_, i) => (
|
|
252
|
+
<text ref={(el: TextRenderable) => { spinRefs[i] = el; sync() }} fg={muted}></text>
|
|
253
|
+
))}
|
|
171
254
|
</box>
|
|
172
255
|
)
|
|
173
256
|
}
|
|
174
257
|
|
|
175
|
-
const tui: TuiPlugin = async (api) => {
|
|
258
|
+
const tui: TuiPlugin = async (api, options) => {
|
|
259
|
+
const config = resolveConfig(options)
|
|
260
|
+
const muted = api.theme.current.textMuted
|
|
261
|
+
|
|
176
262
|
const tracker: TrackerState = {
|
|
177
263
|
useV2Events: false,
|
|
178
264
|
samplesBySession: {},
|
|
@@ -183,20 +269,75 @@ const tui: TuiPlugin = async (api) => {
|
|
|
183
269
|
liveTtftBySession: {},
|
|
184
270
|
activeMessageBySession: {},
|
|
185
271
|
statsBySession: {},
|
|
272
|
+
thinkingBySession: {},
|
|
186
273
|
}
|
|
187
274
|
const listeners = new Set<Listener>()
|
|
188
275
|
const bump = () => { for (const l of listeners) l() }
|
|
189
276
|
const subscribe = (listener: Listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }
|
|
190
277
|
|
|
278
|
+
const N = config.spinner.cells
|
|
279
|
+
const randomDot = () => DOT_GLYPHS[Math.floor(Math.random() * DOT_GLYPHS.length)]
|
|
280
|
+
const spawnGlyph = () => (Math.random() < 0.18 ? " " : randomDot())
|
|
281
|
+
let stream: string[] = []
|
|
282
|
+
let hueFrame = 0
|
|
283
|
+
let spinnerTimer: ReturnType<typeof setInterval> | undefined
|
|
284
|
+
|
|
285
|
+
const initStream = () => {
|
|
286
|
+
stream = Array.from({ length: N }, () => spawnGlyph())
|
|
287
|
+
hueFrame = 0
|
|
288
|
+
}
|
|
289
|
+
const tickStream = () => {
|
|
290
|
+
stream.unshift(spawnGlyph())
|
|
291
|
+
if (stream.length > N) stream.pop()
|
|
292
|
+
hueFrame++
|
|
293
|
+
}
|
|
294
|
+
initStream()
|
|
295
|
+
|
|
296
|
+
const spinnerCells = () => {
|
|
297
|
+
const bright = gradientColor(config.spinner.colors, (hueFrame % 60) / 60)
|
|
298
|
+
const out: { glyph: string; color: RGBA }[] = []
|
|
299
|
+
for (let i = 0; i < N; i++) {
|
|
300
|
+
const g = stream[i] ?? " "
|
|
301
|
+
out.push({ glyph: g, color: g === " " ? muted : lerpColor(bright, muted, i / (N - 1)) })
|
|
302
|
+
}
|
|
303
|
+
return out
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const anyThinking = () => {
|
|
307
|
+
for (const v of Object.values(tracker.thinkingBySession)) if (v) return true
|
|
308
|
+
return false
|
|
309
|
+
}
|
|
310
|
+
const syncSpinnerTimer = () => {
|
|
311
|
+
if (config.spinner.enabled && anyThinking() && !spinnerTimer) {
|
|
312
|
+
spinnerTimer = setInterval(() => {
|
|
313
|
+
tickStream()
|
|
314
|
+
bump()
|
|
315
|
+
}, config.spinner.intervalMs)
|
|
316
|
+
} else if ((!anyThinking() || !config.spinner.enabled) && spinnerTimer) {
|
|
317
|
+
clearInterval(spinnerTimer)
|
|
318
|
+
spinnerTimer = undefined
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const setThinking = (sessionID: string, value: boolean) => {
|
|
322
|
+
if (tracker.thinkingBySession[sessionID] === value) return
|
|
323
|
+
tracker.thinkingBySession[sessionID] = value
|
|
324
|
+
if (value) initStream()
|
|
325
|
+
syncSpinnerTimer()
|
|
326
|
+
}
|
|
327
|
+
|
|
191
328
|
const onTextDelta = api.event.on("session.next.text.delta", (evt) => {
|
|
192
329
|
tracker.useV2Events = true
|
|
193
|
-
|
|
330
|
+
const { sessionID, assistantMessageID, timestamp, delta } = evt.properties
|
|
331
|
+
recordDelta(tracker, sessionID, assistantMessageID, timestamp, delta)
|
|
332
|
+
setThinking(sessionID, false)
|
|
194
333
|
bump()
|
|
195
334
|
})
|
|
196
335
|
|
|
197
336
|
const onReasoningDelta = api.event.on("session.next.reasoning.delta", (evt) => {
|
|
198
337
|
tracker.useV2Events = true
|
|
199
|
-
|
|
338
|
+
const { sessionID, assistantMessageID, timestamp, delta } = evt.properties
|
|
339
|
+
recordDelta(tracker, sessionID, assistantMessageID, timestamp, delta)
|
|
340
|
+
setThinking(sessionID, true)
|
|
200
341
|
bump()
|
|
201
342
|
})
|
|
202
343
|
|
|
@@ -206,7 +347,9 @@ const tui: TuiPlugin = async (api) => {
|
|
|
206
347
|
const parts = api.state.part(evt.properties.messageID)
|
|
207
348
|
const part = parts.find((p) => p.id === evt.properties.partID)
|
|
208
349
|
if (!part || (part.type !== "text" && part.type !== "reasoning")) return
|
|
209
|
-
|
|
350
|
+
const { sessionID, messageID } = evt.properties
|
|
351
|
+
recordDelta(tracker, sessionID, messageID, Date.now(), evt.properties.delta)
|
|
352
|
+
setThinking(sessionID, part.type === "reasoning")
|
|
210
353
|
bump()
|
|
211
354
|
})
|
|
212
355
|
|
|
@@ -248,18 +391,24 @@ const tui: TuiPlugin = async (api) => {
|
|
|
248
391
|
delete tracker.messageCreatedAt[info.id]
|
|
249
392
|
delete tracker.activeMessageBySession[sessionID]
|
|
250
393
|
delete tracker.samplesBySession[sessionID]
|
|
394
|
+
setThinking(sessionID, false)
|
|
251
395
|
bump()
|
|
252
396
|
})
|
|
253
397
|
|
|
398
|
+
const clearSessionLive = (sessionID: string) => {
|
|
399
|
+
delete tracker.samplesBySession[sessionID]
|
|
400
|
+
setThinking(sessionID, false)
|
|
401
|
+
}
|
|
402
|
+
|
|
254
403
|
const onToolInputStarted = api.event.on("session.next.tool.input.started", (evt) => {
|
|
255
|
-
|
|
404
|
+
clearSessionLive(evt.properties.sessionID)
|
|
256
405
|
bump()
|
|
257
406
|
})
|
|
258
407
|
|
|
259
408
|
const onPartUpdated = api.event.on("message.part.updated", (evt) => {
|
|
260
409
|
if (evt.properties.part.type !== "tool") return
|
|
261
410
|
if (["running", "completed", "error"].includes(evt.properties.part.state.status)) {
|
|
262
|
-
|
|
411
|
+
clearSessionLive(evt.properties.part.sessionID ?? evt.properties.sessionID)
|
|
263
412
|
bump()
|
|
264
413
|
}
|
|
265
414
|
})
|
|
@@ -279,12 +428,23 @@ const tui: TuiPlugin = async (api) => {
|
|
|
279
428
|
onTextDelta(); onReasoningDelta(); onPartDelta()
|
|
280
429
|
onMessageUpdated(); onToolInputStarted(); onPartUpdated()
|
|
281
430
|
clearInterval(timer)
|
|
431
|
+
if (spinnerTimer) clearInterval(spinnerTimer)
|
|
282
432
|
})
|
|
283
433
|
|
|
284
434
|
api.slots.register({
|
|
285
435
|
slots: {
|
|
286
436
|
session_prompt_right(_ctx, value) {
|
|
287
|
-
return
|
|
437
|
+
return (
|
|
438
|
+
<MeterDisplay
|
|
439
|
+
api={api}
|
|
440
|
+
sessionID={value.session_id}
|
|
441
|
+
tracker={tracker}
|
|
442
|
+
config={config}
|
|
443
|
+
muted={muted}
|
|
444
|
+
spinnerCells={spinnerCells}
|
|
445
|
+
subscribe={subscribe}
|
|
446
|
+
/>
|
|
447
|
+
)
|
|
288
448
|
},
|
|
289
449
|
},
|
|
290
450
|
})
|