@jack-yang/opencode-tps-meter 0.1.0 → 1.0.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 +83 -13
- package/README.zh-CN.md +166 -0
- package/package.json +4 -1
- package/tui.tsx +187 -28
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": "
|
|
4
|
+
"version": "1.0.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": {
|
|
@@ -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,7 +1,9 @@
|
|
|
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"
|
|
4
|
-
import {
|
|
5
|
+
import type { PluginOptions } from "@opencode-ai/plugin"
|
|
6
|
+
import { createSignal, onCleanup, Show } from "solid-js"
|
|
5
7
|
|
|
6
8
|
type DeltaSample = { at: number; rawTokens: number }
|
|
7
9
|
|
|
@@ -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
|
+
const [thinking, setThinking] = createSignal(false)
|
|
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,28 @@ 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
|
+
setThinking(thinkingNow)
|
|
212
|
+
if (thinkingNow) {
|
|
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
|
+
}
|
|
145
221
|
if (tpsRef) {
|
|
146
222
|
tpsRef.content = live !== undefined ? formatTps(live) ?? "--" : "--"
|
|
147
|
-
tpsRef.fg = live !== undefined ? tpsColor(live, theme) : muted
|
|
223
|
+
tpsRef.fg = live !== undefined ? tpsColor(live, props.config.tps, theme) : muted
|
|
148
224
|
}
|
|
149
225
|
if (avgRef) {
|
|
150
226
|
avgRef.content = avg !== undefined ? formatTps(avg) ?? "--" : "--"
|
|
151
|
-
avgRef.fg = avg !== undefined ? tpsColor(avg, theme) : muted
|
|
227
|
+
avgRef.fg = avg !== undefined ? tpsColor(avg, props.config.tps, theme) : muted
|
|
152
228
|
}
|
|
153
229
|
if (ttftRef) {
|
|
154
230
|
ttftRef.content = ttft !== undefined ? formatTtft(ttft) ?? "--" : "--"
|
|
155
|
-
ttftRef.fg = ttft !== undefined ? ttftColor(ttft, theme) : muted
|
|
231
|
+
ttftRef.fg = ttft !== undefined ? ttftColor(ttft, props.config.ttft, theme) : muted
|
|
156
232
|
}
|
|
157
233
|
props.api.renderer.requestRender()
|
|
158
234
|
}
|
|
@@ -161,18 +237,27 @@ function MeterDisplay(props: {
|
|
|
161
237
|
onCleanup(unsubscribe)
|
|
162
238
|
|
|
163
239
|
return (
|
|
164
|
-
<box flexDirection="row">
|
|
240
|
+
<box flexDirection="row" alignItems="center">
|
|
165
241
|
<text fg={muted}>TPS </text>
|
|
166
242
|
<text ref={(el: TextRenderable) => { tpsRef = el; sync() }} fg={muted}>--</text>
|
|
167
243
|
<text fg={muted}> | AVG </text>
|
|
168
244
|
<text ref={(el: TextRenderable) => { avgRef = el; sync() }} fg={muted}>--</text>
|
|
169
245
|
<text fg={muted}> | TTFT </text>
|
|
170
246
|
<text ref={(el: TextRenderable) => { ttftRef = el; sync() }} fg={muted}>--</text>
|
|
247
|
+
<Show when={thinking()}>
|
|
248
|
+
<text fg={muted}> </text>
|
|
249
|
+
{Array.from({ length: cellCount }).map((_, i) => (
|
|
250
|
+
<text ref={(el: TextRenderable) => { spinRefs[i] = el; sync() }} fg={muted}></text>
|
|
251
|
+
))}
|
|
252
|
+
</Show>
|
|
171
253
|
</box>
|
|
172
254
|
)
|
|
173
255
|
}
|
|
174
256
|
|
|
175
|
-
const tui: TuiPlugin = async (api) => {
|
|
257
|
+
const tui: TuiPlugin = async (api, options) => {
|
|
258
|
+
const config = resolveConfig(options)
|
|
259
|
+
const muted = api.theme.current.textMuted
|
|
260
|
+
|
|
176
261
|
const tracker: TrackerState = {
|
|
177
262
|
useV2Events: false,
|
|
178
263
|
samplesBySession: {},
|
|
@@ -183,20 +268,75 @@ const tui: TuiPlugin = async (api) => {
|
|
|
183
268
|
liveTtftBySession: {},
|
|
184
269
|
activeMessageBySession: {},
|
|
185
270
|
statsBySession: {},
|
|
271
|
+
thinkingBySession: {},
|
|
186
272
|
}
|
|
187
273
|
const listeners = new Set<Listener>()
|
|
188
274
|
const bump = () => { for (const l of listeners) l() }
|
|
189
275
|
const subscribe = (listener: Listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }
|
|
190
276
|
|
|
277
|
+
const N = config.spinner.cells
|
|
278
|
+
const randomDot = () => DOT_GLYPHS[Math.floor(Math.random() * DOT_GLYPHS.length)]
|
|
279
|
+
const spawnGlyph = () => (Math.random() < 0.18 ? " " : randomDot())
|
|
280
|
+
let stream: string[] = []
|
|
281
|
+
let hueFrame = 0
|
|
282
|
+
let spinnerTimer: ReturnType<typeof setInterval> | undefined
|
|
283
|
+
|
|
284
|
+
const initStream = () => {
|
|
285
|
+
stream = Array.from({ length: N }, () => spawnGlyph())
|
|
286
|
+
hueFrame = 0
|
|
287
|
+
}
|
|
288
|
+
const tickStream = () => {
|
|
289
|
+
stream.unshift(spawnGlyph())
|
|
290
|
+
if (stream.length > N) stream.pop()
|
|
291
|
+
hueFrame++
|
|
292
|
+
}
|
|
293
|
+
initStream()
|
|
294
|
+
|
|
295
|
+
const spinnerCells = () => {
|
|
296
|
+
const bright = gradientColor(config.spinner.colors, (hueFrame % 60) / 60)
|
|
297
|
+
const out: { glyph: string; color: RGBA }[] = []
|
|
298
|
+
for (let i = 0; i < N; i++) {
|
|
299
|
+
const g = stream[i] ?? " "
|
|
300
|
+
out.push({ glyph: g, color: g === " " ? muted : lerpColor(bright, muted, i / (N - 1)) })
|
|
301
|
+
}
|
|
302
|
+
return out
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const anyThinking = () => {
|
|
306
|
+
for (const v of Object.values(tracker.thinkingBySession)) if (v) return true
|
|
307
|
+
return false
|
|
308
|
+
}
|
|
309
|
+
const syncSpinnerTimer = () => {
|
|
310
|
+
if (config.spinner.enabled && anyThinking() && !spinnerTimer) {
|
|
311
|
+
spinnerTimer = setInterval(() => {
|
|
312
|
+
tickStream()
|
|
313
|
+
bump()
|
|
314
|
+
}, config.spinner.intervalMs)
|
|
315
|
+
} else if ((!anyThinking() || !config.spinner.enabled) && spinnerTimer) {
|
|
316
|
+
clearInterval(spinnerTimer)
|
|
317
|
+
spinnerTimer = undefined
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const setThinking = (sessionID: string, value: boolean) => {
|
|
321
|
+
if (tracker.thinkingBySession[sessionID] === value) return
|
|
322
|
+
tracker.thinkingBySession[sessionID] = value
|
|
323
|
+
if (value) initStream()
|
|
324
|
+
syncSpinnerTimer()
|
|
325
|
+
}
|
|
326
|
+
|
|
191
327
|
const onTextDelta = api.event.on("session.next.text.delta", (evt) => {
|
|
192
328
|
tracker.useV2Events = true
|
|
193
|
-
|
|
329
|
+
const { sessionID, assistantMessageID, timestamp, delta } = evt.properties
|
|
330
|
+
recordDelta(tracker, sessionID, assistantMessageID, timestamp, delta)
|
|
331
|
+
setThinking(sessionID, false)
|
|
194
332
|
bump()
|
|
195
333
|
})
|
|
196
334
|
|
|
197
335
|
const onReasoningDelta = api.event.on("session.next.reasoning.delta", (evt) => {
|
|
198
336
|
tracker.useV2Events = true
|
|
199
|
-
|
|
337
|
+
const { sessionID, assistantMessageID, timestamp, delta } = evt.properties
|
|
338
|
+
recordDelta(tracker, sessionID, assistantMessageID, timestamp, delta)
|
|
339
|
+
setThinking(sessionID, true)
|
|
200
340
|
bump()
|
|
201
341
|
})
|
|
202
342
|
|
|
@@ -206,7 +346,9 @@ const tui: TuiPlugin = async (api) => {
|
|
|
206
346
|
const parts = api.state.part(evt.properties.messageID)
|
|
207
347
|
const part = parts.find((p) => p.id === evt.properties.partID)
|
|
208
348
|
if (!part || (part.type !== "text" && part.type !== "reasoning")) return
|
|
209
|
-
|
|
349
|
+
const { sessionID, messageID } = evt.properties
|
|
350
|
+
recordDelta(tracker, sessionID, messageID, Date.now(), evt.properties.delta)
|
|
351
|
+
setThinking(sessionID, part.type === "reasoning")
|
|
210
352
|
bump()
|
|
211
353
|
})
|
|
212
354
|
|
|
@@ -248,18 +390,24 @@ const tui: TuiPlugin = async (api) => {
|
|
|
248
390
|
delete tracker.messageCreatedAt[info.id]
|
|
249
391
|
delete tracker.activeMessageBySession[sessionID]
|
|
250
392
|
delete tracker.samplesBySession[sessionID]
|
|
393
|
+
setThinking(sessionID, false)
|
|
251
394
|
bump()
|
|
252
395
|
})
|
|
253
396
|
|
|
397
|
+
const clearSessionLive = (sessionID: string) => {
|
|
398
|
+
delete tracker.samplesBySession[sessionID]
|
|
399
|
+
setThinking(sessionID, false)
|
|
400
|
+
}
|
|
401
|
+
|
|
254
402
|
const onToolInputStarted = api.event.on("session.next.tool.input.started", (evt) => {
|
|
255
|
-
|
|
403
|
+
clearSessionLive(evt.properties.sessionID)
|
|
256
404
|
bump()
|
|
257
405
|
})
|
|
258
406
|
|
|
259
407
|
const onPartUpdated = api.event.on("message.part.updated", (evt) => {
|
|
260
408
|
if (evt.properties.part.type !== "tool") return
|
|
261
409
|
if (["running", "completed", "error"].includes(evt.properties.part.state.status)) {
|
|
262
|
-
|
|
410
|
+
clearSessionLive(evt.properties.part.sessionID ?? evt.properties.sessionID)
|
|
263
411
|
bump()
|
|
264
412
|
}
|
|
265
413
|
})
|
|
@@ -279,12 +427,23 @@ const tui: TuiPlugin = async (api) => {
|
|
|
279
427
|
onTextDelta(); onReasoningDelta(); onPartDelta()
|
|
280
428
|
onMessageUpdated(); onToolInputStarted(); onPartUpdated()
|
|
281
429
|
clearInterval(timer)
|
|
430
|
+
if (spinnerTimer) clearInterval(spinnerTimer)
|
|
282
431
|
})
|
|
283
432
|
|
|
284
433
|
api.slots.register({
|
|
285
434
|
slots: {
|
|
286
435
|
session_prompt_right(_ctx, value) {
|
|
287
|
-
return
|
|
436
|
+
return (
|
|
437
|
+
<MeterDisplay
|
|
438
|
+
api={api}
|
|
439
|
+
sessionID={value.session_id}
|
|
440
|
+
tracker={tracker}
|
|
441
|
+
config={config}
|
|
442
|
+
muted={muted}
|
|
443
|
+
spinnerCells={spinnerCells}
|
|
444
|
+
subscribe={subscribe}
|
|
445
|
+
/>
|
|
446
|
+
)
|
|
288
447
|
},
|
|
289
448
|
},
|
|
290
449
|
})
|