@jack-yang/pi-tps-meter 0.0.0-stage → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## [0.1.1] - 2026-11-03
4
+
5
+ - Published from GitHub Actions through npm Trusted Publishing, so this and later versions
6
+ carry a signed provenance attestation linking the package to its source commit.
7
+ - No functional changes.
8
+
9
+ ## [0.1.0] - 2026-11-03
10
+
11
+ - Initial release: port of opencode-tps-meter to pi 1.5.0.
12
+ - Live TPS (5s sliding window, ~10Hz), session AVG and TTFT, colour-coded by tier.
13
+ - Calibration of the UTF-8 byte estimate against `usage.output` (median of the last 10 ratios).
14
+ - `/tps` cycles status → widget → off; `/tps status|widget|off` sets the mode. The mode is
15
+ persisted in `~/.pi/agent/pi-tps-meter/config.json`, which also takes tier overrides.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jack Yang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,163 @@
1
- # Temporary Holding Version
1
+ # pi-tps-meter
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/@jack-yang/pi-tps-meter)](https://www.npmjs.com/package/@jack-yang/pi-tps-meter) [![license](https://img.shields.io/npm/l/@jack-yang/pi-tps-meter)](./LICENSE) [![CI](https://github.com/yangwudong/pi-tps-meter/actions/workflows/ci.yml/badge.svg)](https://github.com/yangwudong/pi-tps-meter/actions/workflows/ci.yml) ![pi](https://img.shields.io/badge/pi-%E2%89%A5%201.5.0-blue)
4
+
5
+ 为 [pi coding agent](https://pi.dev) 实时显示 token 生成速度的扩展:**TPS**(当前速度)、**AVG**(会话均值)、**TTFT**(首 token 延迟),并用模型返回的权威 token 数自动校准,中文内容下读数也可信。
6
+
7
+ 移植自 [opencode-tps-meter](https://github.com/yangwudong/opencode-tps-meter):测量核心(5 秒滑动窗口、权威 token 校准、AVG 口径)与分档配色和原版一致,只替换了宿主事件层和渲染层。
8
+
9
+ ## 显示效果
10
+
11
+ ```
12
+ TPS 42.5 | AVG 38.2 | TTFT 0.8s
13
+ ```
14
+
15
+ 底部状态条(`/tps status`,默认),流式输出中:
16
+
17
+ ![底部状态条模式:流式输出中,TPS 40.8 / AVG 39.8 / TTFT 2.0s](https://raw.githubusercontent.com/yangwudong/pi-tps-meter/main/docs/screenshot-status.png)
18
+
19
+ 编辑器上方组件(`/tps widget`):
20
+
21
+ ![编辑器上方组件模式:TPS 40.2 / AVG 38.1 / TTFT 3.2s](https://raw.githubusercontent.com/yangwudong/pi-tps-meter/main/docs/screenshot-widget.png)
22
+
23
+ - **TPS**:流式输出时的实时速度,基于 5 秒滑动窗口,约 10Hz 刷新;超过 1.5 秒没有新 token 时显示 `--`。
24
+ - **AVG**:会话级累计平均。所有已完成消息的权威 token 数之和,除以各消息生成时长之和。
25
+ - **TTFT**:从本轮请求开始到第一个文本或思考 token 的时间。首个 token 到达立即显示,一直保留到下一次生成开始。
26
+
27
+ 流结束后 AVG / TTFT 摘要不会清空:工具执行期间、等待下一轮响应期间都保持可见,下一条助手消息开始时才更新。
28
+
29
+ ## 颜色
30
+
31
+ **TPS / AVG**(越高越好)
32
+
33
+ | 档位 | 范围(tok/s) | 颜色 |
34
+ |---|---|---|
35
+ | slow | < 20 | 红 |
36
+ | normal | 20–50 | 黄 |
37
+ | fast | 50–100 | 绿 |
38
+ | faster | ≥ 100 | 青 |
39
+
40
+ **TTFT**(越低越好)
41
+
42
+ | 档位 | 范围 | 颜色 |
43
+ |---|---|---|
44
+ | fast | < 10s | 绿 |
45
+ | ok | 10–20s | 黄 |
46
+ | slow | ≥ 20s | 红 |
47
+
48
+ 档位下界包含在内:恰好 20 TPS 算 normal,恰好 10s 算 ok。阈值可以在配置文件里改。标签和 `--` 用暗色显示。
49
+
50
+ ## 安装
51
+
52
+ ```bash
53
+ pi install npm:@jack-yang/pi-tps-meter # 从 npm 安装(推荐)
54
+ pi install /path/to/pi-tps-meter # 本地目录:不复制,直接从该路径加载
55
+ pi -e npm:@jack-yang/pi-tps-meter # 只在本次启动试用,不写入 settings
56
+ ```
57
+
58
+ 安装后重启 pi,或在已打开的会话里执行 `/reload`。以后升级用 `pi update --extensions`。npm 版和本地目录版只装一个,两个都装会出现两条读数,命令也会变成 `/tps:1` 和 `/tps:2`。
59
+
60
+ 本地目录请用不带 `npm:` 前缀的路径形式。`pi install npm:./pi-tps-meter` 在 pi 1.5.0 下能装上,但加载不了:pi 按 `npm:` 后面的字符串推算安装目录,路径形式的 spec 永远对不上 `node_modules/@jack-yang/pi-tps-meter`;而且这个相对路径会原样写进 settings,下次启动时 pi 会在新的工作目录下重装并失败。
61
+
62
+ 环境要求:pi ≥ 1.5.0(已在 1.5.0 实测)。没有运行时依赖,也不发起任何网络请求。
63
+
64
+ ## 命令
65
+
66
+ | 命令 | 作用 |
67
+ |---|---|
68
+ | `/tps` | 循环切换 status → widget → off |
69
+ | `/tps status` | 显示在底部状态条(footer),和其他扩展的状态共用一行 |
70
+ | `/tps widget` | 显示为编辑器上方的单行组件 |
71
+ | `/tps off` | 完全隐藏。计量仍在后台进行,切回来立即显示当前读数 |
72
+
73
+ 模式写入配置文件,下次启动沿用。
74
+
75
+ ## 配置
76
+
77
+ 配置文件位于 `~/.pi/agent/pi-tps-meter/config.json`;设置了 `PI_CODING_AGENT_DIR` 时,位于该目录下的 `pi-tps-meter/config.json`。
78
+
79
+ ```json
80
+ {
81
+ "mode": "status",
82
+ "tiers": {
83
+ "tps": { "slow": 20, "normal": 50, "fast": 100 },
84
+ "ttft": { "fast": 10000, "ok": 20000 }
85
+ }
86
+ }
87
+ ```
88
+
89
+ | 键 | 默认值 | 说明 |
90
+ |---|---|---|
91
+ | `mode` | `"status"` | `status` / `widget` / `off`。`/tps` 只改写这个键,其他内容原样保留 |
92
+ | `tiers.tps` | `{ slow: 20, normal: 50, fast: 100 }` | TPS 与 AVG 的分档阈值(tok/s) |
93
+ | `tiers.ttft` | `{ fast: 10000, ok: 20000 }` | TTFT 分档阈值(毫秒) |
94
+
95
+ - 所有键都可省略,按字段覆盖默认值,例如只写 `{ "tiers": { "tps": { "fast": 80 } } }`。不是数字的值会被忽略。
96
+ - 文件在会话启动时读取,手动修改后执行 `/reload` 生效。
97
+ - 文件无法解析时,使用默认值并给出提示;这种情况下 `/tps` 也不会覆盖它。
98
+
99
+ ## 工作原理
100
+
101
+ ### TPS
102
+
103
+ 每个流式 delta(text、thinking、toolcall 三种都算)按 UTF-8 字节数 ÷ 4 估算 token 数,记入 5 秒滑动窗口:
104
+
105
+ ```
106
+ TPS = 窗口内估算 token 之和 × 校准因子 ÷ (当前时间 − 窗口内最早样本)
107
+ ```
108
+
109
+ 时长下限是 1 秒,避免刚开始的一两个 chunk 显示成尖峰;最后一个样本已经超过 1.5 秒时不显示。跨度用墙钟时间计算,不累加 delta 之间的间隔,因为网络会把多个 token 打包到达,间隔之和会让速率虚高。
110
+
111
+ ### 校准
112
+
113
+ 字节 ÷ 4 对中文偏差很大(一个汉字占 3 字节)。每条消息结束时,用 pi 给出的权威输出 token 数(`usage.output`)除以这条消息的累计估算值,得到一个比率(限制在 0.3–3.0 之间)。扩展保留最近 10 个比率,取**中位数**作为校准因子,用于之后消息的实时速率;第一条消息结束之前,因子是 1.0。
114
+
115
+ 实测(GLM-5.3-Flash,400–500 字中文短文):未校准时实时 TPS 偏高 17%–30%;校准一轮之后,按校准因子换算的整条消息 token 数与权威值相差 +1%~+12%。
116
+
117
+ ### AVG
118
+
119
+ ```
120
+ AVG = Σ 权威 token 数 ÷ Σ (最后一个 delta − 第一个 delta)
121
+ ```
122
+
123
+ 只统计已完成的消息,单条消息的时长下限为 1 秒,所以 TTFT 等待和工具执行时间都不计入。出错或被中断的消息(`usage` 不完整)不计入 AVG,也不参与校准。
124
+
125
+ ### TTFT
126
+
127
+ TTFT 从 `turn_start` 开始计时(在 pi 里,一个 turn 就是一次模型请求),到这条消息的第一个 text 或 thinking delta 为止。toolcall delta 不会结束 TTFT 等待,但会计入 TPS。只调用工具、没有任何文本或思考输出的消息,TTFT 显示 `--`。
128
+
129
+ 为什么不从 `message_start` 开始算:pi 收到 HTTP 响应头之后才发出 `message_start`,而多数服务端要排完队、做完 prefill 才返回响应头。实测中,响应头在请求发出约 2 秒后才到,`message_start` 和第一个 delta 落在同一毫秒;从 `message_start` 算,TTFT 几乎永远是 0。
130
+
131
+ ### 工具调用
132
+
133
+ 工具在两条助手消息之间执行。每条消息结束时清空滑动窗口,所以工具耗时不会摊进下一条消息的速率。
134
+
135
+ ## 与 opencode-tps-meter 的差异
136
+
137
+ - TTFT 起点用 `turn_start`,对应原版的 `session.step.started`(模型调用开始)。
138
+ - 下一条助手消息开始时清空 TTFT,原版要等到下一个首 token 才覆盖。
139
+ - toolcall delta 计入 TPS 和校准,因为 pi 的 `usage.output` 包含工具调用参数的 token;原版只统计文本和推理。TTFT 仍然只认文本和思考。
140
+ - 权威 token 数直接用 `usage.output`:pi 的 output 已经包含推理 token,原版需要把 output 和 reasoning 相加。
141
+ - 没有移植思考粒子动画,pi 自带工作指示器。
142
+ - 颜色用 ANSI 基本色:红 31、黄 33、绿 32、青 36,标签用暗色;原版用的是 opencode 主题槽位。
143
+
144
+ ## 已知限制
145
+
146
+ - **不流式输出的推理**:有些模型按推理 token 计费,但不把推理内容流式发出来。这时校准比率会顶到 3.0 上限,AVG 也会偏高(原版同样如此)。
147
+ - **一次性返回工具参数的 provider**(如 Gemini):消息末尾会出现一个短暂的 TPS 尖峰,消息结束后立即清除。
148
+ - **底部状态条依赖 pi 的默认 footer**:如果另一个扩展换掉了 footer,并且不渲染扩展状态(比如 awesome-pi-themes 的 look-pack),请改用 `/tps widget`。
149
+ - **AVG 和校准只在当前会话内累计**:`/new`、`/resume`、`/fork`、`/reload` 会重新开始;不会从历史记录里重建。
150
+ - **TTFT 包含客户端准备时间**:包括请求前的上下文处理(实测约 7ms)和 provider 层的自动重试等待,也就是用户实际等待的时间。
151
+ - **时间戳取自 pi 把事件派发给扩展的时刻**:其他扩展的慢 handler 会推迟派发,从而影响读数。
152
+
153
+ ## 开发
154
+
155
+ ```bash
156
+ npm test # node --test,不需要安装依赖(Node ≥ 22.19)
157
+ npm install && npm run typecheck # 可选:tsc 严格检查,会装上 pi 作为 peer 依赖以获得类型
158
+ pi -e . # 在真实会话中试用
159
+ ```
160
+
161
+ ## 许可证
162
+
163
+ MIT
@@ -0,0 +1,160 @@
1
+ // pi-tps-meter — live TPS / AVG / TTFT for the pi coding agent.
2
+ //
3
+ // Port of opencode-tps-meter: the measurement core (src/core.ts) and the formatting
4
+ // (src/format.ts) are the original's. This file is the replaced host layer: pi events in,
5
+ // a footer status line or a widget above the editor out.
6
+
7
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
8
+ import { DISPLAY_MODES, isDisplayMode, loadConfig, nextMode, resolveConfig, saveMode, type DisplayMode, type MeterConfig } from "../src/config.ts"
9
+ import { abandonMessage, completeMessage, createMeterState, readMeter, recordDelta, startMessage, startTurn, type DeltaKind, type MeterState } from "../src/core.ts"
10
+ import { renderLine } from "../src/format.ts"
11
+
12
+ // One key for both surfaces: the footer status entry and the widget.
13
+ const KEY = "tps-meter"
14
+
15
+ // ~10Hz: smooth enough for a rolling number without re-rendering on every delta.
16
+ const REFRESH_MS = 100
17
+
18
+ type UI = ExtensionContext["ui"]
19
+
20
+ export default function tpsMeter(pi: ExtensionAPI): void {
21
+ let state: MeterState = createMeterState()
22
+ // Defaults until session_start reads the file: the factory may run in invocations that never
23
+ // start a session, so it does no IO and starts no timers.
24
+ let config: MeterConfig = resolveConfig(undefined)
25
+ let ui: UI | undefined
26
+ let color = false
27
+ let ticker: ReturnType<typeof setInterval> | undefined
28
+ // What is on screen right now, so unchanged readings cost no re-render.
29
+ let shownMode: DisplayMode | undefined
30
+ let shownLine: string | undefined
31
+
32
+ // Handlers re-bind from their own ctx; the ticker only ever uses the latest binding.
33
+ const bind = (ctx: ExtensionContext): void => {
34
+ ui = ctx.hasUI ? ctx.ui : undefined
35
+ // The TUI renders ANSI in status text and widget lines; RPC clients get plain text.
36
+ color = ctx.mode === "tui"
37
+ }
38
+
39
+ const render = (): void => {
40
+ if (!ui) return
41
+ const mode = config.mode
42
+ if (mode !== shownMode) {
43
+ // Exactly one surface (or none) shows the meter: clear the one being left.
44
+ if (shownMode === "status") ui.setStatus(KEY, undefined)
45
+ if (shownMode === "widget") ui.setWidget(KEY, undefined)
46
+ shownMode = mode
47
+ shownLine = undefined
48
+ }
49
+ if (mode === "off") return
50
+ const line = renderLine(readMeter(state, Date.now()), config.tiers, color)
51
+ if (line === shownLine) return
52
+ shownLine = line
53
+ if (mode === "status") ui.setStatus(KEY, line)
54
+ else ui.setWidget(KEY, [line])
55
+ }
56
+
57
+ const stopTicker = (): void => {
58
+ if (ticker === undefined) return
59
+ clearInterval(ticker)
60
+ ticker = undefined
61
+ }
62
+
63
+ // Ticks only while a message streams: that is the only time the reading changes by itself
64
+ // (the window slides, STALE_MS expires). Between messages the line is static.
65
+ const startTicker = (): void => {
66
+ if (ticker !== undefined || !ui || config.mode === "off" || !state.streaming) return
67
+ ticker = setInterval(() => {
68
+ render()
69
+ if (!state.streaming || config.mode === "off") stopTicker()
70
+ }, REFRESH_MS)
71
+ // Never the reason the process stays alive.
72
+ ticker.unref?.()
73
+ }
74
+
75
+ pi.on("session_start", (_event, ctx) => {
76
+ stopTicker()
77
+ state = createMeterState()
78
+ const loaded = loadConfig()
79
+ config = loaded.config
80
+ shownMode = undefined
81
+ shownLine = undefined
82
+ bind(ctx)
83
+ if (loaded.warning) ui?.notify(`tps-meter: config ignored, using defaults (${loaded.warning})`, "warning")
84
+ render()
85
+ })
86
+
87
+ pi.on("turn_start", (event) => {
88
+ startTurn(state, Number.isFinite(event.timestamp) ? event.timestamp : Date.now())
89
+ })
90
+
91
+ pi.on("message_start", (event, ctx) => {
92
+ if (event.message.role !== "assistant") return
93
+ bind(ctx)
94
+ startMessage(state, Date.now())
95
+ startTicker()
96
+ })
97
+
98
+ pi.on("message_update", (event) => {
99
+ const ev = event.assistantMessageEvent
100
+ let kind: DeltaKind
101
+ switch (ev.type) {
102
+ case "text_delta": kind = "text"; break
103
+ case "thinking_delta": kind = "thinking"; break
104
+ case "toolcall_delta": kind = "toolcall"; break
105
+ default: return
106
+ }
107
+ // The first text/thinking token shows TTFT at once instead of on the next tick.
108
+ if (recordDelta(state, Date.now(), ev.delta, kind)) render()
109
+ })
110
+
111
+ pi.on("message_end", (event, ctx) => {
112
+ const message = event.message
113
+ if (message.role !== "assistant") return
114
+ bind(ctx)
115
+ if (message.stopReason === "error" || message.stopReason === "aborted") abandonMessage(state)
116
+ else completeMessage(state, message.usage?.output ?? 0)
117
+ stopTicker()
118
+ render()
119
+ })
120
+
121
+ // The summary (AVG, TTFT) outlives the stream and stays as it is through tool runs and the
122
+ // wait for the next response. turn_end only re-asserts it; it never clears it.
123
+ pi.on("turn_end", (_event, ctx) => {
124
+ bind(ctx)
125
+ render()
126
+ })
127
+
128
+ pi.on("session_shutdown", () => {
129
+ // pi resets extension UI itself when it reloads or replaces the runtime; the timer and the
130
+ // reference to this session's UI are the only things left for us to drop.
131
+ stopTicker()
132
+ ui = undefined
133
+ })
134
+
135
+ pi.registerCommand("tps", {
136
+ description: "TPS meter display: /tps cycles status → widget → off, /tps status|widget|off sets it",
137
+ getArgumentCompletions: (prefix) => {
138
+ // Only strict prefixes: pi's editor makes Enter apply an open argument completion instead
139
+ // of submitting, so a fully typed mode must close the popup or `/tps off` needs two Enters.
140
+ const typed = prefix.trim().toLowerCase()
141
+ const items = DISPLAY_MODES.filter((m) => m.startsWith(typed) && m !== typed).map((m) => ({ value: m, label: m }))
142
+ return items.length > 0 ? items : null
143
+ },
144
+ handler: async (args, ctx) => {
145
+ bind(ctx)
146
+ const arg = args.trim().toLowerCase()
147
+ if (arg && !isDisplayMode(arg)) {
148
+ ctx.ui.notify(`tps-meter: unknown mode "${arg}" (use status | widget | off)`, "error")
149
+ return
150
+ }
151
+ const mode = isDisplayMode(arg) ? arg : nextMode(config.mode)
152
+ config = { ...config, mode }
153
+ const saved = saveMode(mode)
154
+ render()
155
+ startTicker()
156
+ if (saved.ok) ctx.ui.notify(`tps-meter: ${mode}`, "info")
157
+ else ctx.ui.notify(`tps-meter: ${mode} (not saved: ${saved.error})`, "warning")
158
+ },
159
+ })
160
+ }
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
+ "$schema": "https://json.schemastore.org/package.json",
2
3
  "name": "@jack-yang/pi-tps-meter",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
4
+ "version": "0.1.1",
5
+ "description": "Live TPS / AVG / TTFT meter for the pi coding agent, calibrated against authoritative token counts (port of opencode-tps-meter)",
6
+ "type": "module",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi-extension",
10
+ "pi-coding-agent",
11
+ "tps",
12
+ "tokens-per-second",
13
+ "ttft",
14
+ "llm"
15
+ ],
16
+ "author": "Jack Yang",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/yangwudong/pi-tps-meter.git"
21
+ },
22
+ "homepage": "https://github.com/yangwudong/pi-tps-meter#readme",
23
+ "bugs": {
24
+ "url": "https://github.com/yangwudong/pi-tps-meter/issues"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "files": [
30
+ "extensions",
31
+ "src",
32
+ "README.md",
33
+ "CHANGELOG.md"
34
+ ],
35
+ "pi": {
36
+ "extensions": [
37
+ "./extensions/index.ts"
38
+ ],
39
+ "image": "https://raw.githubusercontent.com/yangwudong/pi-tps-meter/main/docs/screenshot-status.png"
40
+ },
41
+ "engines": {
42
+ "node": ">=22.19.0"
43
+ },
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-coding-agent": "*"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^24.0.0",
49
+ "typescript": "^5.9.0"
50
+ },
51
+ "scripts": {
52
+ "test": "node --test",
53
+ "typecheck": "tsc --noEmit"
54
+ }
55
+ }
package/src/config.ts ADDED
@@ -0,0 +1,107 @@
1
+ // Display mode persistence and tier overrides — the only module that touches the filesystem.
2
+ //
3
+ // File: <agent dir>/pi-tps-meter/config.json, where <agent dir> is $PI_CODING_AGENT_DIR or
4
+ // ~/.pi/agent, resolved the same way pi resolves its own config directory.
5
+ //
6
+ // { "mode": "status", "tiers": { "tps": { "slow": 20, "normal": 50, "fast": 100 },
7
+ // "ttft": { "fast": 10000, "ok": 20000 } } }
8
+
9
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
10
+ import { homedir } from "node:os"
11
+ import { dirname, join } from "node:path"
12
+ import type { Tiers } from "./format.ts"
13
+
14
+ export type DisplayMode = "status" | "widget" | "off"
15
+
16
+ // Also the cycle order of a bare `/tps`.
17
+ export const DISPLAY_MODES: readonly DisplayMode[] = ["status", "widget", "off"]
18
+
19
+ export type MeterConfig = { mode: DisplayMode; tiers: Tiers }
20
+
21
+ export type LoadedConfig = { config: MeterConfig; warning?: string }
22
+
23
+ export type SaveResult = { ok: true } | { ok: false; error: string }
24
+
25
+ export function isDisplayMode(value: unknown): value is DisplayMode {
26
+ return typeof value === "string" && (DISPLAY_MODES as readonly string[]).includes(value)
27
+ }
28
+
29
+ export function nextMode(mode: DisplayMode): DisplayMode {
30
+ return DISPLAY_MODES[(DISPLAY_MODES.indexOf(mode) + 1) % DISPLAY_MODES.length]
31
+ }
32
+
33
+ // The original's resolveConfig override shape: `tiers.tps` / `tiers.ttft`, field by field over
34
+ // the defaults. Values that are not finite numbers fall back to the default instead of reaching
35
+ // the tier comparisons, where a string or null would silently mis-tier every reading.
36
+ export function resolveConfig(raw: unknown): MeterConfig {
37
+ const o = isObject(raw) ? raw : {}
38
+ const tiers = isObject(o.tiers) ? o.tiers : {}
39
+ const tp = isObject(tiers.tps) ? tiers.tps : {}
40
+ const tt = isObject(tiers.ttft) ? tiers.ttft : {}
41
+ return {
42
+ mode: isDisplayMode(o.mode) ? o.mode : "status",
43
+ tiers: {
44
+ tps: { slow: num(tp.slow, 20), normal: num(tp.normal, 50), fast: num(tp.fast, 100) },
45
+ ttft: { fast: num(tt.fast, 10_000), ok: num(tt.ok, 20_000) },
46
+ },
47
+ }
48
+ }
49
+
50
+ export function agentDir(env: NodeJS.ProcessEnv = process.env): string {
51
+ const dir = env.PI_CODING_AGENT_DIR
52
+ if (!dir) return join(homedir(), ".pi", "agent")
53
+ if (dir === "~") return homedir()
54
+ return dir.startsWith("~/") ? join(homedir(), dir.slice(2)) : dir
55
+ }
56
+
57
+ export function configPath(env: NodeJS.ProcessEnv = process.env): string {
58
+ return join(agentDir(env), "pi-tps-meter", "config.json")
59
+ }
60
+
61
+ // A missing file is the normal first run. A file that does not parse falls back to the defaults
62
+ // with a warning, so a typo in hand-edited tiers is not ignored silently.
63
+ export function loadConfig(file: string = configPath()): LoadedConfig {
64
+ const read = readJson(file)
65
+ if (!read.ok) return { config: resolveConfig(undefined), warning: read.error }
66
+ return { config: resolveConfig(read.value) }
67
+ }
68
+
69
+ // Rewrites only `mode` and keeps every other key (hand-edited tiers). A file that does not parse
70
+ // as a JSON object is left untouched and reported, rather than replaced by a bare { mode }.
71
+ // Never throws: a failed save must not stop the mode switch for the running session.
72
+ export function saveMode(mode: DisplayMode, file: string = configPath()): SaveResult {
73
+ const read = readJson(file)
74
+ if (!read.ok) return read
75
+ const current = read.value === undefined ? {} : read.value
76
+ if (!isObject(current)) return { ok: false, error: `${file}: not a JSON object, left unchanged` }
77
+ try {
78
+ mkdirSync(dirname(file), { recursive: true })
79
+ writeFileSync(file, `${JSON.stringify({ ...current, mode }, null, 2)}\n`, "utf8")
80
+ return { ok: true }
81
+ } catch (err) {
82
+ return { ok: false, error: `${file}: ${errorMessage(err)}` }
83
+ }
84
+ }
85
+
86
+ type ReadResult = { ok: true; value: unknown } | { ok: false; error: string }
87
+
88
+ // The single place file text becomes data. A missing file reads as `undefined`.
89
+ function readJson(file: string): ReadResult {
90
+ try {
91
+ return { ok: true, value: existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : undefined }
92
+ } catch (err) {
93
+ return { ok: false, error: `${file}: ${errorMessage(err)}` }
94
+ }
95
+ }
96
+
97
+ function errorMessage(err: unknown): string {
98
+ return err instanceof Error ? err.message : String(err)
99
+ }
100
+
101
+ function isObject(value: unknown): value is Record<string, unknown> {
102
+ return typeof value === "object" && value !== null && !Array.isArray(value)
103
+ }
104
+
105
+ function num(value: unknown, fallback: number): number {
106
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback
107
+ }
package/src/core.ts ADDED
@@ -0,0 +1,191 @@
1
+ // Measurement core, ported from opencode-tps-meter/tui.tsx (the porting source of truth).
2
+ //
3
+ // Pure state machine: every function takes the clock as a parameter (`at` / `now`). Nothing
4
+ // here reads Date.now(), performs IO, or knows about pi — extensions/index.ts maps pi events
5
+ // onto these calls. The constants and the window / calibration / AVG arithmetic are the
6
+ // original's, unchanged; deviations are limited to the host mapping and are commented inline.
7
+
8
+ export const WINDOW_MS = 5_000
9
+ export const STALE_MS = 1_500
10
+ export const MIN_DURATION_MS = 1_000
11
+ export const MAX_CALIBRATION_RATIOS = 10
12
+
13
+ export type DeltaSample = { at: number; rawTokens: number }
14
+
15
+ export type SessionStats = {
16
+ totalOutputTokens: number
17
+ totalDurationMs: number
18
+ calibrationRatios: number[]
19
+ }
20
+
21
+ // Which stream a delta came from. All three are generated output and all are inside
22
+ // usage.output, but only text/thinking may end the TTFT wait.
23
+ export type DeltaKind = "text" | "thinking" | "toolcall"
24
+
25
+ // The original kept one map per field, keyed by sessionID / messageID, because one opencode
26
+ // TUI watches many sessions. A pi extension instance belongs to exactly one session and pi
27
+ // streams one assistant message at a time, so each map collapses to a single field (the
28
+ // original field is named on the right).
29
+ export type MeterState = {
30
+ samples: DeltaSample[] // samplesBySession
31
+ stats: SessionStats // statsBySession
32
+ turnStartedAt: number | undefined // when the current turn (one LLM request) began
33
+ streaming: boolean // between an assistant message_start and its message_end
34
+ ttftOrigin: number | undefined // messageCreatedAt (v1) / step.started (v2)
35
+ firstDeltaAt: number | undefined // firstDeltaByMessage
36
+ lastDeltaAt: number | undefined // lastDeltaByMessage
37
+ estimatedTokens: number // estimatedTokensByMessage
38
+ ttftMs: number | undefined // liveTtftBySession
39
+ }
40
+
41
+ export type MeterReading = {
42
+ tps: number | undefined
43
+ avg: number | undefined
44
+ ttftMs: number | undefined
45
+ }
46
+
47
+ // UTF-8 bytes / 4. Deliberately crude: CJK text (3 bytes per character) and code tokenize very
48
+ // differently, and the calibration factor below corrects whatever bias the content mix has.
49
+ export function estimateTokens(delta: string): number {
50
+ return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 4))
51
+ }
52
+
53
+ // Median, not mean: one odd message (reasoning the provider bills but never streams, a huge
54
+ // tool payload) must not drag the factor used for the next ten messages.
55
+ export function median(values: number[]): number {
56
+ if (values.length === 0) return 1.0
57
+ const sorted = [...values].sort((a, b) => a - b)
58
+ const mid = Math.floor(sorted.length / 2)
59
+ if (sorted.length % 2 !== 0) return sorted[mid]
60
+ return Math.round(((sorted[mid - 1] + sorted[mid]) / 2) * 1e10) / 1e10
61
+ }
62
+
63
+ // 1.0 until the first message completes: the raw byte estimate is the best guess there is.
64
+ export function calibrationFactor(ratios: number[]): number {
65
+ return ratios.length === 0 ? 1.0 : median(ratios)
66
+ }
67
+
68
+ // The span is wall clock (now - oldest sample), never a sum of inter-arrival gaps: providers
69
+ // batch several tokens into one network chunk, and summing the near-zero gaps between batched
70
+ // deltas inflates the rate. MIN_DURATION_MS keeps the first chunk of a stream from reading as
71
+ // a spike; STALE_MS blanks the reading once the stream pauses instead of letting it decay.
72
+ export function calculateLiveTps(samples: DeltaSample[], now: number, calibration: number): number | undefined {
73
+ if (samples.length === 0) return undefined
74
+ const cutoff = now - WINDOW_MS
75
+ const relevant = samples.filter((s) => s.at >= cutoff)
76
+ if (relevant.length === 0) return undefined
77
+ const last = relevant[relevant.length - 1]
78
+ if (now - last.at > STALE_MS) return undefined
79
+ const oldest = relevant[0]
80
+ const durationMs = Math.max(now - oldest.at, MIN_DURATION_MS)
81
+ const totalRaw = relevant.reduce((sum, s) => sum + s.rawTokens, 0)
82
+ return (totalRaw * calibration) / (durationMs / 1000)
83
+ }
84
+
85
+ export function createMeterState(): MeterState {
86
+ return {
87
+ samples: [],
88
+ stats: { totalOutputTokens: 0, totalDurationMs: 0, calibrationRatios: [] },
89
+ turnStartedAt: undefined,
90
+ streaming: false,
91
+ ttftOrigin: undefined,
92
+ firstDeltaAt: undefined,
93
+ lastDeltaAt: undefined,
94
+ estimatedTokens: 0,
95
+ ttftMs: undefined,
96
+ }
97
+ }
98
+
99
+ // turn_start: one pi turn is one LLM request — the counterpart of the original's
100
+ // session.step.started, i.e. "when the LLM call begins". pi's message_start cannot be the
101
+ // origin: pi emits it when the provider stream opens, after the HTTP response headers, and most
102
+ // backends only send those once queueing and prefill are done. Measured from message_start,
103
+ // TTFT reads ~0ms (observed: headers after 2.2s, message_start and the first delta in the same
104
+ // millisecond).
105
+ export function startTurn(state: MeterState, at: number): void {
106
+ state.turnStartedAt = at
107
+ }
108
+
109
+ // message_start of an assistant message: a new generation begins. The previous TTFT is cleared
110
+ // here rather than at turn_end, so the old reading stays on screen through tool runs and the
111
+ // wait for the next response. Falls back to `at` when no turn_start was seen.
112
+ export function startMessage(state: MeterState, at: number): void {
113
+ state.streaming = true
114
+ state.ttftOrigin = state.turnStartedAt ?? at
115
+ state.turnStartedAt = undefined
116
+ state.ttftMs = undefined
117
+ state.firstDeltaAt = undefined
118
+ state.lastDeltaAt = undefined
119
+ state.estimatedTokens = 0
120
+ state.samples = []
121
+ }
122
+
123
+ // One streamed chunk (the original's recordDelta). Every kind feeds the window, the span and the
124
+ // calibration estimate, because usage.output counts all of them and tool-call arguments (edit /
125
+ // write payloads) are a large share of a coding agent's output: leaving them out would inflate
126
+ // the calibration ratio (up to its 3.0 clamp) and AVG, and blank TPS while a long tool call
127
+ // streams. Only text/thinking end the TTFT wait.
128
+ // Returns true when this delta produced the message's TTFT, so the caller can show it at once.
129
+ export function recordDelta(state: MeterState, at: number, delta: string, kind: DeltaKind): boolean {
130
+ const rawTokens = estimateTokens(delta)
131
+ const cutoff = at - WINDOW_MS
132
+ state.samples = [...state.samples.filter((s) => s.at >= cutoff), { at, rawTokens }]
133
+ if (state.firstDeltaAt === undefined) state.firstDeltaAt = at
134
+ state.lastDeltaAt = at
135
+ state.estimatedTokens += rawTokens
136
+ if (kind === "toolcall" || state.ttftMs !== undefined || state.ttftOrigin === undefined) return false
137
+ state.ttftMs = Math.max(0, at - state.ttftOrigin)
138
+ return true
139
+ }
140
+
141
+ // message_end with the provider's authoritative output count (the original's
142
+ // recordMessageCompleted). pi's usage.output already includes reasoning tokens; the original
143
+ // summed tokens.output + tokens.reasoning only because opencode reports them separately.
144
+ export function completeMessage(state: MeterState, outputTokens: number): void {
145
+ const stats = state.stats
146
+ const actualTokens = outputTokens
147
+ const generationMs = state.firstDeltaAt !== undefined && state.lastDeltaAt !== undefined
148
+ ? Math.max(state.lastDeltaAt - state.firstDeltaAt, MIN_DURATION_MS) : 0
149
+
150
+ if (actualTokens > 0 && generationMs > 0) {
151
+ stats.totalOutputTokens += actualTokens
152
+ stats.totalDurationMs += generationMs
153
+
154
+ const estimated = state.estimatedTokens
155
+ if (estimated > 0) {
156
+ // Clamped so a single pathological message cannot swing the factor arbitrarily.
157
+ const ratio = Math.min(Math.max(actualTokens / estimated, 0.3), 3.0)
158
+ stats.calibrationRatios = [...stats.calibrationRatios, ratio].slice(-MAX_CALIBRATION_RATIOS)
159
+ }
160
+ }
161
+ endMessage(state)
162
+ }
163
+
164
+ // message_end of an errored or aborted message — the counterpart of the original's
165
+ // session.step.failed: its usage is partial, so it touches neither AVG nor calibration.
166
+ // A TTFT it already produced stays on screen.
167
+ export function abandonMessage(state: MeterState): void {
168
+ endMessage(state)
169
+ }
170
+
171
+ // Samples are dropped at the end of every message, as the original did on completion. In pi the
172
+ // tools run between messages; samples left in the 5s window would otherwise stretch the next
173
+ // message's window across the tool run and dilute its rate.
174
+ function endMessage(state: MeterState): void {
175
+ state.streaming = false
176
+ state.ttftOrigin = undefined
177
+ state.firstDeltaAt = undefined
178
+ state.lastDeltaAt = undefined
179
+ state.estimatedTokens = 0
180
+ state.samples = []
181
+ }
182
+
183
+ // What the meter shows at `now` (the original's MeterDisplay sync).
184
+ export function readMeter(state: MeterState, now: number): MeterReading {
185
+ const stats = state.stats
186
+ return {
187
+ tps: calculateLiveTps(state.samples, now, calibrationFactor(stats.calibrationRatios)),
188
+ avg: stats.totalDurationMs > 0 ? stats.totalOutputTokens / (stats.totalDurationMs / 1000) : undefined,
189
+ ttftMs: state.ttftMs,
190
+ }
191
+ }
package/src/format.ts ADDED
@@ -0,0 +1,104 @@
1
+ // Display formatting and tier colouring, ported from opencode-tps-meter/tui.tsx.
2
+ // Pure: no pi imports. The caller decides whether the host renders ANSI.
3
+
4
+ import type { MeterReading } from "./core.ts"
5
+
6
+ export type TpsTiers = { slow: number; normal: number; fast: number }
7
+ export type TtftTiers = { fast: number; ok: number }
8
+ export type Tiers = { tps: TpsTiers; ttft: TtftTiers }
9
+
10
+ export type SpeedTier = "slow" | "normal" | "fast" | "faster"
11
+ export type TtftTier = "fast" | "ok" | "slow"
12
+
13
+ // The original painted with opencode theme slots (error / warning / success / accent, labels in
14
+ // textMuted). A pi status line is a plain string, so each slot maps to its basic ANSI colour.
15
+ export type Hue = "red" | "yellow" | "green" | "cyan" | "muted"
16
+
17
+ // Precision follows magnitude: two decimals while the number is small, an integer once three
18
+ // digits would make the decimal noise.
19
+ export function formatTps(value: number): string | undefined {
20
+ if (!Number.isFinite(value) || value <= 0) return undefined
21
+ if (value >= 100) return Math.round(value).toString()
22
+ if (value >= 10) return value.toFixed(1)
23
+ return value.toFixed(2)
24
+ }
25
+
26
+ export function formatTtft(valueMs: number): string | undefined {
27
+ if (!Number.isFinite(valueMs) || valueMs < 0) return undefined
28
+ return `${(valueMs / 1000).toFixed(1)}s`
29
+ }
30
+
31
+ // Lower bounds are inclusive: exactly 20 TPS is "normal", exactly 10s of TTFT is "ok".
32
+ export function speedTier(tps: number, c: TpsTiers): SpeedTier {
33
+ if (tps < c.slow) return "slow"
34
+ if (tps < c.normal) return "normal"
35
+ if (tps < c.fast) return "fast"
36
+ return "faster"
37
+ }
38
+
39
+ export function ttftTier(ms: number, c: TtftTiers): TtftTier {
40
+ if (ms < c.fast) return "fast"
41
+ if (ms < c.ok) return "ok"
42
+ return "slow"
43
+ }
44
+
45
+ export function speedHue(tier: SpeedTier): Hue {
46
+ switch (tier) {
47
+ case "slow": return "red" // theme.error
48
+ case "normal": return "yellow" // theme.warning
49
+ case "fast": return "green" // theme.success
50
+ case "faster": return "cyan" // theme.accent
51
+ }
52
+ }
53
+
54
+ // TTFT is lower-is-better, so its scale runs the other way.
55
+ export function ttftHue(tier: TtftTier): Hue {
56
+ switch (tier) {
57
+ case "fast": return "green"
58
+ case "ok": return "yellow"
59
+ case "slow": return "red"
60
+ }
61
+ }
62
+
63
+ // Each code is closed by its own reset (39 = default foreground, 22 = normal intensity) rather
64
+ // than a blanket \x1b[0m, so the line never cancels styling the host wraps around it.
65
+ const SGR: Record<Hue, [open: string, close: string]> = {
66
+ red: ["\x1b[31m", "\x1b[39m"],
67
+ yellow: ["\x1b[33m", "\x1b[39m"],
68
+ green: ["\x1b[32m", "\x1b[39m"],
69
+ cyan: ["\x1b[36m", "\x1b[39m"],
70
+ muted: ["\x1b[2m", "\x1b[22m"],
71
+ }
72
+
73
+ export function paint(text: string, hue: Hue): string {
74
+ const [open, close] = SGR[hue]
75
+ return `${open}${text}${close}`
76
+ }
77
+
78
+ type Cell = { text: string; hue: Hue }
79
+
80
+ // A missing reading is "--" in the muted colour, like the original's idle state.
81
+ const DASH: Cell = { text: "--", hue: "muted" }
82
+
83
+ function tpsCell(value: number | undefined, tiers: TpsTiers): Cell {
84
+ const text = value === undefined ? undefined : formatTps(value)
85
+ return value === undefined || text === undefined ? DASH : { text, hue: speedHue(speedTier(value, tiers)) }
86
+ }
87
+
88
+ function ttftCell(valueMs: number | undefined, tiers: TtftTiers): Cell {
89
+ const text = valueMs === undefined ? undefined : formatTtft(valueMs)
90
+ return valueMs === undefined || text === undefined ? DASH : { text, hue: ttftHue(ttftTier(valueMs, tiers)) }
91
+ }
92
+
93
+ // TPS 42.5 | AVG 38.2 | TTFT 0.8s — labels muted; TPS and AVG share the speed tiers.
94
+ export function renderLine(reading: MeterReading, tiers: Tiers, color: boolean): string {
95
+ const cells: Cell[] = [
96
+ { text: "TPS ", hue: "muted" },
97
+ tpsCell(reading.tps, tiers.tps),
98
+ { text: " | AVG ", hue: "muted" },
99
+ tpsCell(reading.avg, tiers.tps),
100
+ { text: " | TTFT ", hue: "muted" },
101
+ ttftCell(reading.ttftMs, tiers.ttft),
102
+ ]
103
+ return cells.map((c) => (color ? paint(c.text, c.hue) : c.text)).join("")
104
+ }