@jack-yang/pi-tps-meter 0.1.1 → 0.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/CHANGELOG.md +10 -0
- package/README.md +23 -15
- package/extensions/index.ts +51 -13
- package/package.json +2 -2
- package/src/config.ts +36 -7
- package/src/core.ts +7 -0
- package/src/format.ts +36 -1
- package/src/spinner.ts +84 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-11-03
|
|
4
|
+
|
|
5
|
+
- New default display `right`: one line below the editor, right-aligned so it ends in the last
|
|
6
|
+
column, directly above the footer. Other extensions' footer statuses (such as pi-lens's
|
|
7
|
+
`LSP Inactive`) can no longer push it around. `/tps` now cycles right → status → widget → off;
|
|
8
|
+
a saved `mode` in config.json still wins.
|
|
9
|
+
- The thinking particle stream from opencode-tps-meter is back: while the model streams its
|
|
10
|
+
reasoning, braille particles flow away from the reading, newest brightest, fading per cell.
|
|
11
|
+
Configurable via `spinner` (`theme` tech / red / none, `colors`, `intervalMs`, `cells`, `enabled`).
|
|
12
|
+
|
|
3
13
|
## [0.1.1] - 2026-11-03
|
|
4
14
|
|
|
5
15
|
- Published from GitHub Actions through npm Trusted Publishing, so this and later versions
|
package/README.md
CHANGED
|
@@ -9,20 +9,17 @@
|
|
|
9
9
|
## 显示效果
|
|
10
10
|
|
|
11
11
|
```
|
|
12
|
-
TPS 42.5 | AVG 38.2 | TTFT 0.8s
|
|
12
|
+
⠔⠀⠈⠀⠤⠔ TPS 42.5 | AVG 38.2 | TTFT 0.8s
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
默认显示在编辑器下方、右对齐(`/tps right`),紧贴 footer,和下方的模型名右侧对齐;位置固定,不会被其他扩展的 footer 状态(比如 pi-lens 的 `LSP Inactive`)挤开。下图是模型思考时的样子,读数左边是思考粒子流:
|
|
16
16
|
|
|
17
|
-

|
|
17
|
+

|
|
22
18
|
|
|
23
19
|
- **TPS**:流式输出时的实时速度,基于 5 秒滑动窗口,约 10Hz 刷新;超过 1.5 秒没有新 token 时显示 `--`。
|
|
24
20
|
- **AVG**:会话级累计平均。所有已完成消息的权威 token 数之和,除以各消息生成时长之和。
|
|
25
21
|
- **TTFT**:从本轮请求开始到第一个文本或思考 token 的时间。首个 token 到达立即显示,一直保留到下一次生成开始。
|
|
22
|
+
- **思考粒子流**:模型流式输出思考内容时,一串盲文点从读数旁流出:最新的一格最亮,越旧越淡,亮色随时间沿渐变色漂移;开始输出正文就消失。只在思考时出现,平时不占位。
|
|
26
23
|
|
|
27
24
|
流结束后 AVG / TTFT 摘要不会清空:工具执行期间、等待下一轮响应期间都保持可见,下一条助手消息开始时才更新。
|
|
28
25
|
|
|
@@ -65,32 +62,43 @@ pi -e npm:@jack-yang/pi-tps-meter # 只在本次启动试用,不写
|
|
|
65
62
|
|
|
66
63
|
| 命令 | 作用 |
|
|
67
64
|
|---|---|
|
|
68
|
-
| `/tps` | 循环切换 status → widget → off |
|
|
69
|
-
| `/tps
|
|
70
|
-
| `/tps
|
|
65
|
+
| `/tps` | 循环切换 right → status → widget → off |
|
|
66
|
+
| `/tps right` | 编辑器下方、右对齐(默认):紧贴 footer,位置固定 |
|
|
67
|
+
| `/tps status` | footer 的扩展状态行,和其他扩展共用一行,按扩展名排序 |
|
|
68
|
+
| `/tps widget` | 编辑器上方的单行组件 |
|
|
71
69
|
| `/tps off` | 完全隐藏。计量仍在后台进行,切回来立即显示当前读数 |
|
|
72
70
|
|
|
73
71
|
模式写入配置文件,下次启动沿用。
|
|
74
72
|
|
|
73
|
+
为什么右对齐是单独一行,而不是放进 footer 状态行的右端:pi 把所有扩展的状态按名字排序、左对齐拼成一行,扩展没法指定位置;要放到右端只能整个替换 pi 的 footer,不值得。编辑器下方的组件是自己渲染的,可以按终端宽度右对齐,窗口缩放时也贴着右边。
|
|
74
|
+
|
|
75
|
+
如果也嫌 pi-lens 的 `LSP Inactive` 占地方,pi-lens 自己有开关:在 `~/.pi-lens/config.json` 里设 `ui.compactLspStatus: true`(压成一个图标)或 `ui.hideLspStatus: true`(不显示)。
|
|
76
|
+
|
|
75
77
|
## 配置
|
|
76
78
|
|
|
77
79
|
配置文件位于 `~/.pi/agent/pi-tps-meter/config.json`;设置了 `PI_CODING_AGENT_DIR` 时,位于该目录下的 `pi-tps-meter/config.json`。
|
|
78
80
|
|
|
79
81
|
```json
|
|
80
82
|
{
|
|
81
|
-
"mode": "
|
|
83
|
+
"mode": "right",
|
|
82
84
|
"tiers": {
|
|
83
85
|
"tps": { "slow": 20, "normal": 50, "fast": 100 },
|
|
84
86
|
"ttft": { "fast": 10000, "ok": 20000 }
|
|
85
|
-
}
|
|
87
|
+
},
|
|
88
|
+
"spinner": { "theme": "tech", "intervalMs": 100, "cells": 6 }
|
|
86
89
|
}
|
|
87
90
|
```
|
|
88
91
|
|
|
89
92
|
| 键 | 默认值 | 说明 |
|
|
90
93
|
|---|---|---|
|
|
91
|
-
| `mode` | `"
|
|
94
|
+
| `mode` | `"right"` | `right` / `status` / `widget` / `off`。`/tps` 只改写这个键,其他内容原样保留 |
|
|
92
95
|
| `tiers.tps` | `{ slow: 20, normal: 50, fast: 100 }` | TPS 与 AVG 的分档阈值(tok/s) |
|
|
93
96
|
| `tiers.ttft` | `{ fast: 10000, ok: 20000 }` | TTFT 分档阈值(毫秒) |
|
|
97
|
+
| `spinner.theme` | `"tech"` | 粒子流配色:`"tech"`(青 → 蓝 → 紫)、`"red"`(KITT 风格红);`"none"` 关闭粒子流 |
|
|
98
|
+
| `spinner.colors` | 随 theme | 自定义渐变色,`"#rrggbb"` / `"#rgb"` 数组,优先于 theme |
|
|
99
|
+
| `spinner.intervalMs` | `100` | 粒子前进一格的间隔(毫秒) |
|
|
100
|
+
| `spinner.cells` | `6` | 粒子流格数(宽度),至少 2 |
|
|
101
|
+
| `spinner.enabled` | `true` | `false` 关闭粒子流 |
|
|
94
102
|
|
|
95
103
|
- 所有键都可省略,按字段覆盖默认值,例如只写 `{ "tiers": { "tps": { "fast": 80 } } }`。不是数字的值会被忽略。
|
|
96
104
|
- 文件在会话启动时读取,手动修改后执行 `/reload` 生效。
|
|
@@ -138,14 +146,14 @@ TTFT 从 `turn_start` 开始计时(在 pi 里,一个 turn 就是一次模型
|
|
|
138
146
|
- 下一条助手消息开始时清空 TTFT,原版要等到下一个首 token 才覆盖。
|
|
139
147
|
- toolcall delta 计入 TPS 和校准,因为 pi 的 `usage.output` 包含工具调用参数的 token;原版只统计文本和推理。TTFT 仍然只认文本和思考。
|
|
140
148
|
- 权威 token 数直接用 `usage.output`:pi 的 output 已经包含推理 token,原版需要把 output 和 reasoning 相加。
|
|
141
|
-
-
|
|
149
|
+
- 思考粒子流已移植(配色预设、渐变、FIFO 流动、配置项都和原版一致),并做了原版想做但受限于 opencode 做不到的一点:每个粒子单独着色,真正从亮到暗渐隐(原版受 opentui 限制,整串只能用一种颜色)。右对齐时粒子流放在读数左边、方向镜像,读数本身不会移动。
|
|
142
150
|
- 颜色用 ANSI 基本色:红 31、黄 33、绿 32、青 36,标签用暗色;原版用的是 opencode 主题槽位。
|
|
143
151
|
|
|
144
152
|
## 已知限制
|
|
145
153
|
|
|
146
154
|
- **不流式输出的推理**:有些模型按推理 token 计费,但不把推理内容流式发出来。这时校准比率会顶到 3.0 上限,AVG 也会偏高(原版同样如此)。
|
|
147
155
|
- **一次性返回工具参数的 provider**(如 Gemini):消息末尾会出现一个短暂的 TPS 尖峰,消息结束后立即清除。
|
|
148
|
-
-
|
|
156
|
+
- **`status` 模式依赖 pi 的默认 footer**:如果另一个扩展换掉了 footer,并且不渲染扩展状态(比如 awesome-pi-themes 的 look-pack),用默认的 `right` 或 `widget`。
|
|
149
157
|
- **AVG 和校准只在当前会话内累计**:`/new`、`/resume`、`/fork`、`/reload` 会重新开始;不会从历史记录里重建。
|
|
150
158
|
- **TTFT 包含客户端准备时间**:包括请求前的上下文处理(实测约 7ms)和 provider 层的自动重试等待,也就是用户实际等待的时间。
|
|
151
159
|
- **时间戳取自 pi 把事件派发给扩展的时刻**:其他扩展的慢 handler 会推迟派发,从而影响读数。
|
package/extensions/index.ts
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
// pi-tps-meter — live TPS / AVG / TTFT for the pi coding agent.
|
|
2
2
|
//
|
|
3
|
-
// Port of opencode-tps-meter: the measurement core (src/core.ts)
|
|
4
|
-
// (src/
|
|
5
|
-
//
|
|
3
|
+
// Port of opencode-tps-meter: the measurement core (src/core.ts), the formatting (src/format.ts)
|
|
4
|
+
// and the thinking particle stream (src/spinner.ts) are the original's. This file is the replaced
|
|
5
|
+
// host layer: pi events in; a right-aligned line below the editor, a footer status, or a line
|
|
6
|
+
// above the editor out.
|
|
6
7
|
|
|
7
8
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
8
9
|
import { DISPLAY_MODES, isDisplayMode, loadConfig, nextMode, resolveConfig, saveMode, type DisplayMode, type MeterConfig } from "../src/config.ts"
|
|
9
10
|
import { abandonMessage, completeMessage, createMeterState, readMeter, recordDelta, startMessage, startTurn, type DeltaKind, type MeterState } from "../src/core.ts"
|
|
10
|
-
import { renderLine } from "../src/format.ts"
|
|
11
|
+
import { alignRight, renderLine, withSpinner } from "../src/format.ts"
|
|
12
|
+
import { advanceSpinner, createSpinner, spinnerCells, type Rgb, type SpinnerState } from "../src/spinner.ts"
|
|
11
13
|
|
|
12
|
-
// One key for
|
|
14
|
+
// One key for every surface: the footer status entry and the widget.
|
|
13
15
|
const KEY = "tps-meter"
|
|
14
16
|
|
|
15
17
|
// ~10Hz: smooth enough for a rolling number without re-rendering on every delta.
|
|
16
18
|
const REFRESH_MS = 100
|
|
17
19
|
|
|
20
|
+
// Older particles fade toward this neutral grey (the original faded toward the theme's muted text).
|
|
21
|
+
const SPINNER_MUTED: Rgb = { r: 128, g: 132, b: 142 }
|
|
22
|
+
|
|
18
23
|
type UI = ExtensionContext["ui"]
|
|
19
24
|
|
|
20
25
|
export default function tpsMeter(pi: ExtensionAPI): void {
|
|
@@ -23,17 +28,42 @@ export default function tpsMeter(pi: ExtensionAPI): void {
|
|
|
23
28
|
// start a session, so it does no IO and starts no timers.
|
|
24
29
|
let config: MeterConfig = resolveConfig(undefined)
|
|
25
30
|
let ui: UI | undefined
|
|
26
|
-
let
|
|
31
|
+
let tui = false
|
|
27
32
|
let ticker: ReturnType<typeof setInterval> | undefined
|
|
33
|
+
let spinner: SpinnerState | undefined
|
|
28
34
|
// What is on screen right now, so unchanged readings cost no re-render.
|
|
29
35
|
let shownMode: DisplayMode | undefined
|
|
30
36
|
let shownLine: string | undefined
|
|
37
|
+
// The mounted right-aligned component's host, told to redraw when the line changes.
|
|
38
|
+
let rightHost: { requestRender(): void } | undefined
|
|
31
39
|
|
|
32
40
|
// Handlers re-bind from their own ctx; the ticker only ever uses the latest binding.
|
|
33
41
|
const bind = (ctx: ExtensionContext): void => {
|
|
34
42
|
ui = ctx.hasUI ? ctx.ui : undefined
|
|
35
|
-
// The TUI renders ANSI
|
|
36
|
-
|
|
43
|
+
// The TUI renders ANSI and components; RPC clients get plain text lines.
|
|
44
|
+
tui = ctx.mode === "tui"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// The particle stream exists only while reasoning streams, and only in the TUI (in RPC it would
|
|
48
|
+
// just be noise at 10Hz). A fresh stream starts each time thinking starts.
|
|
49
|
+
const decorate = (target: UI, line: string, now: number): string => {
|
|
50
|
+
if (!tui || !config.spinner.enabled || !state.thinking) {
|
|
51
|
+
spinner = undefined
|
|
52
|
+
return line
|
|
53
|
+
}
|
|
54
|
+
if (spinner === undefined) spinner = createSpinner(config.spinner.cells, now, Math.random)
|
|
55
|
+
else advanceSpinner(spinner, config.spinner, now, Math.random)
|
|
56
|
+
const paint = (text: string, c: Rgb) => target.theme.style(text, { fg: { kind: "rgb", r: c.r, g: c.g, b: c.b } })
|
|
57
|
+
return withSpinner(line, spinnerCells(spinner, config.spinner, SPINNER_MUTED), paint, config.mode === "right" ? "before" : "after")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A component rather than a string widget: it right-aligns itself to the width of every render,
|
|
61
|
+
// so it stays in the corner across terminal resizes, and an update is only a redraw request.
|
|
62
|
+
const mountRight = (target: UI): void => {
|
|
63
|
+
target.setWidget(KEY, (host) => {
|
|
64
|
+
rightHost = host
|
|
65
|
+
return { render: (width: number) => [alignRight(shownLine ?? "", width)], invalidate() {} }
|
|
66
|
+
}, { placement: "belowEditor" })
|
|
37
67
|
}
|
|
38
68
|
|
|
39
69
|
const render = (): void => {
|
|
@@ -42,16 +72,22 @@ export default function tpsMeter(pi: ExtensionAPI): void {
|
|
|
42
72
|
if (mode !== shownMode) {
|
|
43
73
|
// Exactly one surface (or none) shows the meter: clear the one being left.
|
|
44
74
|
if (shownMode === "status") ui.setStatus(KEY, undefined)
|
|
45
|
-
if (shownMode === "widget") ui.setWidget(KEY, undefined)
|
|
75
|
+
if (shownMode === "widget" || shownMode === "right") ui.setWidget(KEY, undefined)
|
|
76
|
+
rightHost = undefined
|
|
46
77
|
shownMode = mode
|
|
47
78
|
shownLine = undefined
|
|
79
|
+
if (mode === "right" && tui) mountRight(ui)
|
|
48
80
|
}
|
|
49
81
|
if (mode === "off") return
|
|
50
|
-
const
|
|
82
|
+
const now = Date.now()
|
|
83
|
+
const line = decorate(ui, renderLine(readMeter(state, now), config.tiers, tui), now)
|
|
51
84
|
if (line === shownLine) return
|
|
52
85
|
shownLine = line
|
|
53
86
|
if (mode === "status") ui.setStatus(KEY, line)
|
|
54
|
-
else ui.setWidget(KEY, [line])
|
|
87
|
+
else if (mode === "widget") ui.setWidget(KEY, [line])
|
|
88
|
+
// RPC ignores component widgets; there the line goes out as plain text below the editor.
|
|
89
|
+
else if (tui) rightHost?.requestRender()
|
|
90
|
+
else ui.setWidget(KEY, [line], { placement: "belowEditor" })
|
|
55
91
|
}
|
|
56
92
|
|
|
57
93
|
const stopTicker = (): void => {
|
|
@@ -130,10 +166,12 @@ export default function tpsMeter(pi: ExtensionAPI): void {
|
|
|
130
166
|
// reference to this session's UI are the only things left for us to drop.
|
|
131
167
|
stopTicker()
|
|
132
168
|
ui = undefined
|
|
169
|
+
rightHost = undefined
|
|
170
|
+
spinner = undefined
|
|
133
171
|
})
|
|
134
172
|
|
|
135
173
|
pi.registerCommand("tps", {
|
|
136
|
-
description: "TPS meter display: /tps cycles status → widget → off, /tps status|widget|off sets it",
|
|
174
|
+
description: "TPS meter display: /tps cycles right → status → widget → off, /tps right|status|widget|off sets it",
|
|
137
175
|
getArgumentCompletions: (prefix) => {
|
|
138
176
|
// Only strict prefixes: pi's editor makes Enter apply an open argument completion instead
|
|
139
177
|
// of submitting, so a fully typed mode must close the popup or `/tps off` needs two Enters.
|
|
@@ -145,7 +183,7 @@ export default function tpsMeter(pi: ExtensionAPI): void {
|
|
|
145
183
|
bind(ctx)
|
|
146
184
|
const arg = args.trim().toLowerCase()
|
|
147
185
|
if (arg && !isDisplayMode(arg)) {
|
|
148
|
-
ctx.ui.notify(`tps-meter: unknown mode "${arg}" (use status | widget | off)`, "error")
|
|
186
|
+
ctx.ui.notify(`tps-meter: unknown mode "${arg}" (use right | status | widget | off)`, "error")
|
|
149
187
|
return
|
|
150
188
|
}
|
|
151
189
|
const mode = isDisplayMode(arg) ? arg : nextMode(config.mode)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@jack-yang/pi-tps-meter",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
5
|
"description": "Live TPS / AVG / TTFT meter for the pi coding agent, calibrated against authoritative token counts (port of opencode-tps-meter)",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"keywords": [
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"extensions": [
|
|
37
37
|
"./extensions/index.ts"
|
|
38
38
|
],
|
|
39
|
-
"image": "https://raw.githubusercontent.com/yangwudong/pi-tps-meter/main/docs/screenshot-
|
|
39
|
+
"image": "https://raw.githubusercontent.com/yangwudong/pi-tps-meter/main/docs/screenshot-right.png"
|
|
40
40
|
},
|
|
41
41
|
"engines": {
|
|
42
42
|
"node": ">=22.19.0"
|
package/src/config.ts
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
|
-
// Display mode persistence and
|
|
1
|
+
// Display mode persistence, tier and spinner overrides — the only module that touches the filesystem.
|
|
2
2
|
//
|
|
3
3
|
// File: <agent dir>/pi-tps-meter/config.json, where <agent dir> is $PI_CODING_AGENT_DIR or
|
|
4
4
|
// ~/.pi/agent, resolved the same way pi resolves its own config directory.
|
|
5
5
|
//
|
|
6
|
-
// { "mode": "
|
|
7
|
-
//
|
|
6
|
+
// { "mode": "right",
|
|
7
|
+
// "tiers": { "tps": { "slow": 20, "normal": 50, "fast": 100 }, "ttft": { "fast": 10000, "ok": 20000 } },
|
|
8
|
+
// "spinner": { "theme": "tech", "colors": ["#00e5ff", "#2979ff"], "intervalMs": 100, "cells": 6, "enabled": true } }
|
|
8
9
|
|
|
9
10
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
10
11
|
import { homedir } from "node:os"
|
|
11
12
|
import { dirname, join } from "node:path"
|
|
12
13
|
import type { Tiers } from "./format.ts"
|
|
14
|
+
import { parseHex, THEME_PRESETS, type Rgb, type SpinnerConfig } from "./spinner.ts"
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
// right: one line below the editor, right-aligned (a fixed spot that other extensions' footer
|
|
17
|
+
// statuses cannot push around); status: pi's footer status line; widget: a line above the editor.
|
|
18
|
+
export type DisplayMode = "right" | "status" | "widget" | "off"
|
|
15
19
|
|
|
16
20
|
// Also the cycle order of a bare `/tps`.
|
|
17
|
-
export const DISPLAY_MODES: readonly DisplayMode[] = ["status", "widget", "off"]
|
|
21
|
+
export const DISPLAY_MODES: readonly DisplayMode[] = ["right", "status", "widget", "off"]
|
|
18
22
|
|
|
19
|
-
export type MeterConfig = { mode: DisplayMode; tiers: Tiers }
|
|
23
|
+
export type MeterConfig = { mode: DisplayMode; tiers: Tiers; spinner: SpinnerConfig }
|
|
20
24
|
|
|
21
25
|
export type LoadedConfig = { config: MeterConfig; warning?: string }
|
|
22
26
|
|
|
@@ -33,17 +37,29 @@ export function nextMode(mode: DisplayMode): DisplayMode {
|
|
|
33
37
|
// The original's resolveConfig override shape: `tiers.tps` / `tiers.ttft`, field by field over
|
|
34
38
|
// the defaults. Values that are not finite numbers fall back to the default instead of reaching
|
|
35
39
|
// the tier comparisons, where a string or null would silently mis-tier every reading.
|
|
40
|
+
// `spinner` follows the original too: custom `colors` win over the `theme` preset, theme "none"
|
|
41
|
+
// or `enabled: false` turns the stream off, `cells` is at least 2.
|
|
36
42
|
export function resolveConfig(raw: unknown): MeterConfig {
|
|
37
43
|
const o = isObject(raw) ? raw : {}
|
|
38
44
|
const tiers = isObject(o.tiers) ? o.tiers : {}
|
|
39
45
|
const tp = isObject(tiers.tps) ? tiers.tps : {}
|
|
40
46
|
const tt = isObject(tiers.ttft) ? tiers.ttft : {}
|
|
47
|
+
const sp = isObject(o.spinner) ? o.spinner : {}
|
|
48
|
+
const theme = typeof sp.theme === "string" ? sp.theme : "tech"
|
|
49
|
+
const custom = hexColors(sp.colors)
|
|
50
|
+
const preset = hexColors(THEME_PRESETS[theme] ?? THEME_PRESETS.tech)
|
|
41
51
|
return {
|
|
42
|
-
mode: isDisplayMode(o.mode) ? o.mode : "
|
|
52
|
+
mode: isDisplayMode(o.mode) ? o.mode : "right",
|
|
43
53
|
tiers: {
|
|
44
54
|
tps: { slow: num(tp.slow, 20), normal: num(tp.normal, 50), fast: num(tp.fast, 100) },
|
|
45
55
|
ttft: { fast: num(tt.fast, 10_000), ok: num(tt.ok, 20_000) },
|
|
46
56
|
},
|
|
57
|
+
spinner: {
|
|
58
|
+
enabled: sp.enabled !== false && theme !== "none",
|
|
59
|
+
colors: custom.length > 0 ? custom : preset,
|
|
60
|
+
intervalMs: positive(sp.intervalMs) ?? 100,
|
|
61
|
+
cells: (positive(sp.cells) ?? 0) >= 2 ? Math.floor(sp.cells as number) : 6,
|
|
62
|
+
},
|
|
47
63
|
}
|
|
48
64
|
}
|
|
49
65
|
|
|
@@ -105,3 +121,16 @@ function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
105
121
|
function num(value: unknown, fallback: number): number {
|
|
106
122
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback
|
|
107
123
|
}
|
|
124
|
+
|
|
125
|
+
function positive(value: unknown): number | undefined {
|
|
126
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The valid "#rrggbb" / "#rgb" entries of a config value, in order; anything else is dropped.
|
|
130
|
+
function hexColors(value: unknown): Rgb[] {
|
|
131
|
+
if (!Array.isArray(value)) return []
|
|
132
|
+
return value.flatMap((c) => {
|
|
133
|
+
const rgb = typeof c === "string" ? parseHex(c) : undefined
|
|
134
|
+
return rgb ? [rgb] : []
|
|
135
|
+
})
|
|
136
|
+
}
|
package/src/core.ts
CHANGED
|
@@ -36,6 +36,7 @@ export type MeterState = {
|
|
|
36
36
|
lastDeltaAt: number | undefined // lastDeltaByMessage
|
|
37
37
|
estimatedTokens: number // estimatedTokensByMessage
|
|
38
38
|
ttftMs: number | undefined // liveTtftBySession
|
|
39
|
+
thinking: boolean // thinkingBySession: the latest delta was reasoning (drives the particle stream)
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
export type MeterReading = {
|
|
@@ -93,6 +94,7 @@ export function createMeterState(): MeterState {
|
|
|
93
94
|
lastDeltaAt: undefined,
|
|
94
95
|
estimatedTokens: 0,
|
|
95
96
|
ttftMs: undefined,
|
|
97
|
+
thinking: false,
|
|
96
98
|
}
|
|
97
99
|
}
|
|
98
100
|
|
|
@@ -118,6 +120,7 @@ export function startMessage(state: MeterState, at: number): void {
|
|
|
118
120
|
state.lastDeltaAt = undefined
|
|
119
121
|
state.estimatedTokens = 0
|
|
120
122
|
state.samples = []
|
|
123
|
+
state.thinking = false
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
// One streamed chunk (the original's recordDelta). Every kind feeds the window, the span and the
|
|
@@ -133,6 +136,9 @@ export function recordDelta(state: MeterState, at: number, delta: string, kind:
|
|
|
133
136
|
if (state.firstDeltaAt === undefined) state.firstDeltaAt = at
|
|
134
137
|
state.lastDeltaAt = at
|
|
135
138
|
state.estimatedTokens += rawTokens
|
|
139
|
+
// As the original's setThinking: reasoning turns it on; text and the tool-call stream (the
|
|
140
|
+
// original cleared it on tool input start) turn it off.
|
|
141
|
+
state.thinking = kind === "thinking"
|
|
136
142
|
if (kind === "toolcall" || state.ttftMs !== undefined || state.ttftOrigin === undefined) return false
|
|
137
143
|
state.ttftMs = Math.max(0, at - state.ttftOrigin)
|
|
138
144
|
return true
|
|
@@ -178,6 +184,7 @@ function endMessage(state: MeterState): void {
|
|
|
178
184
|
state.lastDeltaAt = undefined
|
|
179
185
|
state.estimatedTokens = 0
|
|
180
186
|
state.samples = []
|
|
187
|
+
state.thinking = false
|
|
181
188
|
}
|
|
182
189
|
|
|
183
190
|
// What the meter shows at `now` (the original's MeterDisplay sync).
|
package/src/format.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
// Display formatting and tier colouring, ported from opencode-tps-meter/tui.tsx
|
|
1
|
+
// Display formatting and tier colouring, ported from opencode-tps-meter/tui.tsx, plus the layout
|
|
2
|
+
// of the thinking particle stream and right alignment.
|
|
2
3
|
// Pure: no pi imports. The caller decides whether the host renders ANSI.
|
|
3
4
|
|
|
4
5
|
import type { MeterReading } from "./core.ts"
|
|
6
|
+
import type { Rgb, SpinnerCell } from "./spinner.ts"
|
|
5
7
|
|
|
6
8
|
export type TpsTiers = { slow: number; normal: number; fast: number }
|
|
7
9
|
export type TtftTiers = { fast: number; ok: number }
|
|
@@ -102,3 +104,36 @@ export function renderLine(reading: MeterReading, tiers: Tiers, color: boolean):
|
|
|
102
104
|
]
|
|
103
105
|
return cells.map((c) => (color ? paint(c.text, c.hue) : c.text)).join("")
|
|
104
106
|
}
|
|
107
|
+
|
|
108
|
+
// Gaps in the stream are U+2800, the empty braille cell: one column, blank on screen, and not
|
|
109
|
+
// whitespace. pi's footer sanitizer collapses runs of spaces and trims both ends of a status (JS
|
|
110
|
+
// trim() also strips no-break spaces), either of which would make the stream jitter in width.
|
|
111
|
+
const GAP = "\u2800"
|
|
112
|
+
|
|
113
|
+
// The particle stream flows away from the meter. After the reading (status / widget) it runs
|
|
114
|
+
// left to right, newest cell next to TTFT, as in the original. Before a right-aligned reading it
|
|
115
|
+
// is mirrored, newest cell next to TPS, so the reading itself never moves.
|
|
116
|
+
export function withSpinner(
|
|
117
|
+
line: string,
|
|
118
|
+
cells: SpinnerCell[],
|
|
119
|
+
paintRgb: (text: string, color: Rgb) => string,
|
|
120
|
+
side: "before" | "after",
|
|
121
|
+
): string {
|
|
122
|
+
const ordered = side === "after" ? cells : [...cells].reverse()
|
|
123
|
+
const stream = ordered.map((c) => (c.glyph === " " ? GAP : paintRgb(c.glyph, c.color))).join("")
|
|
124
|
+
return side === "after" ? `${line} ${stream}` : `${stream} ${line}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Terminal columns of a string produced here: ANSI SGR codes take none, and every character used
|
|
128
|
+
// (ASCII, braille) takes exactly one.
|
|
129
|
+
export function visibleWidth(text: string): number {
|
|
130
|
+
return [...text.replace(/\x1b\[[0-9;]*m/g, "")].length
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Pads `line` so it ends in the last column. A line too wide for the terminal loses its colours and
|
|
134
|
+
// is cut, because a TUI line must never exceed the width it is given.
|
|
135
|
+
export function alignRight(line: string, width: number): string {
|
|
136
|
+
const w = visibleWidth(line)
|
|
137
|
+
if (w <= width) return " ".repeat(width - w) + line
|
|
138
|
+
return [...line.replace(/\x1b\[[0-9;]*m/g, "")].slice(w - width).join("")
|
|
139
|
+
}
|
package/src/spinner.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The "thinking" particle stream, ported from opencode-tps-meter/tui.tsx: while reasoning streams,
|
|
2
|
+
// a short FIFO of braille dots flows away from the meter, newest cell brightest, older cells fading
|
|
3
|
+
// into the muted colour, the bright colour drifting along a gradient over time.
|
|
4
|
+
//
|
|
5
|
+
// Pure: randomness and the clock are parameters. Colours are plain sRGB; the extension paints them
|
|
6
|
+
// through pi's theme, which converts to truecolor or 256 colours for the terminal.
|
|
7
|
+
|
|
8
|
+
export type Rgb = { r: number; g: number; b: number }
|
|
9
|
+
|
|
10
|
+
export type SpinnerConfig = { enabled: boolean; colors: Rgb[]; intervalMs: number; cells: number }
|
|
11
|
+
|
|
12
|
+
export type SpinnerState = { stream: string[]; hueFrame: number; lastFrameAt: number }
|
|
13
|
+
|
|
14
|
+
export type SpinnerCell = { glyph: string; color: Rgb }
|
|
15
|
+
|
|
16
|
+
export type Random = () => number
|
|
17
|
+
|
|
18
|
+
const DOT_GLYPHS = ["⠁", "⠂", "⠄", "⠈", "⠐", "⠠", "⠒", "⠑", "⠡", "⠢", "⠤", "⠨", "⠰", "⠸", "⠘", "⠔", "⠖", "⠦"]
|
|
19
|
+
|
|
20
|
+
// One full sweep of the gradient takes this many frames (6s at the default 100ms).
|
|
21
|
+
const HUE_FRAMES = 60
|
|
22
|
+
|
|
23
|
+
export const THEME_PRESETS: Record<string, string[]> = {
|
|
24
|
+
tech: ["#00e5ff", "#2979ff", "#651fff"],
|
|
25
|
+
red: ["#ff1744", "#ff5252", "#ff8a80"],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// "#rrggbb" or "#rgb". The leading # is required: without it, words such as "bad" or "add" would
|
|
29
|
+
// read as three-digit colours.
|
|
30
|
+
export function parseHex(hex: string): Rgb | undefined {
|
|
31
|
+
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim())
|
|
32
|
+
if (!m) return undefined
|
|
33
|
+
const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join("") : m[1]
|
|
34
|
+
return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function lerp(a: number, b: number, t: number): number {
|
|
38
|
+
return a + (b - a) * t
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function lerpColor(a: Rgb, b: Rgb, t: number): Rgb {
|
|
42
|
+
return { r: Math.round(lerp(a.r, b.r, t)), g: Math.round(lerp(a.g, b.g, t)), b: Math.round(lerp(a.b, b.b, t)) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Position t in [0, 1] along a multi-stop gradient.
|
|
46
|
+
export function gradientColor(colors: Rgb[], t: number): Rgb {
|
|
47
|
+
if (colors.length === 0) return { r: 136, g: 136, b: 136 }
|
|
48
|
+
if (colors.length === 1) return colors[0]
|
|
49
|
+
const c = Math.max(0, Math.min(1, t))
|
|
50
|
+
const seg = c * (colors.length - 1)
|
|
51
|
+
const i = Math.min(Math.floor(seg), colors.length - 2)
|
|
52
|
+
return lerpColor(colors[i], colors[i + 1], seg - i)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Roughly one cell in five is a gap, so the stream reads as particles rather than a solid bar.
|
|
56
|
+
function spawnGlyph(random: Random): string {
|
|
57
|
+
return random() < 0.18 ? " " : DOT_GLYPHS[Math.floor(random() * DOT_GLYPHS.length)]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A fresh stream each time thinking starts, so consecutive thinking spans do not look identical.
|
|
61
|
+
export function createSpinner(cells: number, now: number, random: Random): SpinnerState {
|
|
62
|
+
return { stream: Array.from({ length: cells }, () => spawnGlyph(random)), hueFrame: 0, lastFrameAt: now }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Frames follow wall time (one per `intervalMs`), not render calls, so the stream keeps its speed
|
|
66
|
+
// whatever the refresh rate. The frame count is capped so a long pause cannot loop for ages.
|
|
67
|
+
export function advanceSpinner(state: SpinnerState, cfg: SpinnerConfig, now: number, random: Random): void {
|
|
68
|
+
const due = Math.floor((now - state.lastFrameAt) / cfg.intervalMs)
|
|
69
|
+
if (due <= 0) return
|
|
70
|
+
for (let i = 0; i < Math.min(due, cfg.cells); i++) {
|
|
71
|
+
state.stream.unshift(spawnGlyph(random))
|
|
72
|
+
if (state.stream.length > cfg.cells) state.stream.length = cfg.cells
|
|
73
|
+
}
|
|
74
|
+
state.hueFrame += due
|
|
75
|
+
state.lastFrameAt += due * cfg.intervalMs
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Cell 0 is the newest and carries the current gradient colour; each older cell fades a step
|
|
79
|
+
// further toward `muted`. Gaps stay muted.
|
|
80
|
+
export function spinnerCells(state: SpinnerState, cfg: SpinnerConfig, muted: Rgb): SpinnerCell[] {
|
|
81
|
+
const bright = gradientColor(cfg.colors, (state.hueFrame % HUE_FRAMES) / HUE_FRAMES)
|
|
82
|
+
const last = Math.max(1, state.stream.length - 1)
|
|
83
|
+
return state.stream.map((glyph, i) => ({ glyph, color: glyph === " " ? muted : lerpColor(bright, muted, i / last) }))
|
|
84
|
+
}
|