@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.
Files changed (4) hide show
  1. package/README.md +83 -13
  2. package/README.zh-CN.md +166 -0
  3. package/package.json +4 -1
  4. 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
+ ![](https://img.shields.io/npm/v/@jack-yang/opencode-tps-meter) ![](https://img.shields.io/npm/l/@jack-yang/opencode-tps-meter) ![](https://img.shields.io/github/stars/yangwudong/opencode-tps-meter) ![](https://img.shields.io/badge/opencode-1.17.20-tested-blue)
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 | < 0.5s | Green |
31
- | OK | 0.5–2s | Yellow |
32
- | Slow | > 2s | Red |
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` (not `opencode.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
- ### Option 1: Local file
61
+ ```json
62
+ {
63
+ "plugin": [
64
+ "@jack-yang/opencode-tps-meter"
65
+ ]
66
+ }
67
+ ```
39
68
 
40
- 1. Copy `tui.tsx` to your opencode config directory:
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
- cp tui.tsx ~/.config/opencode/tps-meter.tsx
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
- ### Option 2: Project-local
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
- Add a `.opencode/tui.json` in your project root with the same plugin path (relative to the config file location).
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 only when a message completes. Generation duration = `last delta - first delta` per message (excludes tool execution time and TTFT).
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 — copy it to your config directory.
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
 
@@ -0,0 +1,166 @@
1
+ # opencode-tps-meter
2
+
3
+ [English](./README.md) | 中文
4
+
5
+ 一个 [opencode](https://opencode.ai) 的 TUI 插件,在输入框旁边实时显示 LLM 输出速度指标。
6
+
7
+ ![](https://img.shields.io/npm/v/@jack-yang/opencode-tps-meter) ![](https://img.shields.io/npm/l/@jack-yang/opencode-tps-meter) ![](https://img.shields.io/github/stars/yangwudong/opencode-tps-meter) ![](https://img.shields.io/badge/opencode-1.17.20-tested-blue)
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.0",
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 type { TextRenderable, RGBA } from "@opentui/core"
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 { onCleanup } from "solid-js"
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 < 20) return "slow"
52
- if (tps < 50) return "normal"
53
- if (tps < 100) return "fast"
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 < 500) return "fast"
71
- if (ms < 2000) return "ok"
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
- if (ratios.length === 0) return 1.0
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 = theme.textMuted
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
- recordDelta(tracker, evt.properties.sessionID, evt.properties.assistantMessageID, evt.properties.timestamp, evt.properties.delta)
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
- recordDelta(tracker, evt.properties.sessionID, evt.properties.assistantMessageID, evt.properties.timestamp, evt.properties.delta)
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
- recordDelta(tracker, evt.properties.sessionID, evt.properties.messageID, Date.now(), evt.properties.delta)
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
- delete tracker.samplesBySession[evt.properties.sessionID]
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
- delete tracker.samplesBySession[evt.properties.part.sessionID ?? evt.properties.sessionID]
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 <MeterDisplay api={api} sessionID={value.session_id} tracker={tracker} subscribe={subscribe} />
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
  })