@jack-yang/pi-tps-meter 0.2.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/CHANGELOG.md +20 -0
- package/README.md +10 -8
- package/extensions/index.ts +172 -18
- package/package.json +4 -3
- package/src/config.ts +6 -5
- package/src/footer.ts +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.0] - 2026-11-04
|
|
4
|
+
|
|
5
|
+
First stable release; the behaviour is that of 0.3.0, reviewed before freezing it.
|
|
6
|
+
|
|
7
|
+
- Declare `@earendil-works/pi-tui` as a peer dependency: footer mode imports it at runtime (pi
|
|
8
|
+
supplies it, as it does `@earendil-works/pi-coding-agent`).
|
|
9
|
+
- Footer mode no longer deep-copies all of pi's settings on every frame to read the
|
|
10
|
+
auto-compaction flag; it re-reads the setting at most once a second.
|
|
11
|
+
|
|
12
|
+
## [0.3.0] - 2026-11-04
|
|
13
|
+
|
|
14
|
+
- New default display `footer`: the meter sits at the right end of pi's footer line with the
|
|
15
|
+
working directory, in line with the model name below it, without taking an extra line. pi's
|
|
16
|
+
own `FooterComponent` still renders the whole footer (the stats and status lines are
|
|
17
|
+
byte-for-byte pi's); the meter is merged into its first line. On a narrow terminal the
|
|
18
|
+
directory is shortened first, so the reading stays whole.
|
|
19
|
+
- If pi's footer cannot be wrapped (for example a future pi changes its internals), pi's own
|
|
20
|
+
footer is restored and the meter falls back to `right`, with a one-time notice.
|
|
21
|
+
- `/tps` cycles footer → right → status → widget → off; a saved `mode` still wins.
|
|
22
|
+
|
|
3
23
|
## [0.2.0] - 2026-11-03
|
|
4
24
|
|
|
5
25
|
- New default display `right`: one line below the editor, right-aligned so it ends in the last
|
package/README.md
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
⠔⠀⠈⠀⠤⠔ TPS 42.5 | AVG 38.2 | TTFT 0.8s
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
默认显示在 footer 的目录那一行右端(`/tps footer`),和下一行的模型名右对齐,不额外占行;其他扩展的 footer 状态(比如 pi-lens 的 `LSP Inactive`)也挤不动它。下图是模型思考时的样子,读数左边是思考粒子流:
|
|
16
16
|
|
|
17
|
-

|
|
18
18
|
|
|
19
19
|
- **TPS**:流式输出时的实时速度,基于 5 秒滑动窗口,约 10Hz 刷新;超过 1.5 秒没有新 token 时显示 `--`。
|
|
20
20
|
- **AVG**:会话级累计平均。所有已完成消息的权威 token 数之和,除以各消息生成时长之和。
|
|
@@ -62,15 +62,16 @@ pi -e npm:@jack-yang/pi-tps-meter # 只在本次启动试用,不写
|
|
|
62
62
|
|
|
63
63
|
| 命令 | 作用 |
|
|
64
64
|
|---|---|
|
|
65
|
-
| `/tps` | 循环切换 right → status → widget → off |
|
|
66
|
-
| `/tps
|
|
65
|
+
| `/tps` | 循环切换 footer → right → status → widget → off |
|
|
66
|
+
| `/tps footer` | footer 的目录那一行右端(默认),不额外占行 |
|
|
67
|
+
| `/tps right` | 编辑器下方单独一行,右对齐,紧贴 footer |
|
|
67
68
|
| `/tps status` | footer 的扩展状态行,和其他扩展共用一行,按扩展名排序 |
|
|
68
69
|
| `/tps widget` | 编辑器上方的单行组件 |
|
|
69
70
|
| `/tps off` | 完全隐藏。计量仍在后台进行,切回来立即显示当前读数 |
|
|
70
71
|
|
|
71
72
|
模式写入配置文件,下次启动沿用。
|
|
72
73
|
|
|
73
|
-
|
|
74
|
+
`footer` 模式是怎么做的:pi 的 footer 没有给扩展留定位的口子(扩展状态只能左对齐拼在最后一行),所以这里用 `setFooter` 挂了一个包装层:里面跑的就是 pi 自己导出的 `FooterComponent`,统计行、状态行都由 pi 自己渲染,实测和原装 footer 逐字符、连颜色都一致;只是在目录那一行右端补上读数。终端变窄时先截短目录(和 pi 一样用 `...`),读数保持完整;中文目录名按双宽字符计算。如果将来某个 pi 版本改了 footer 内部导致包装失败,会自动恢复 pi 原装 footer、退回 `right` 模式并提示一次,不会把 footer 弄坏。
|
|
74
75
|
|
|
75
76
|
如果也嫌 pi-lens 的 `LSP Inactive` 占地方,pi-lens 自己有开关:在 `~/.pi-lens/config.json` 里设 `ui.compactLspStatus: true`(压成一个图标)或 `ui.hideLspStatus: true`(不显示)。
|
|
76
77
|
|
|
@@ -80,7 +81,7 @@ pi -e npm:@jack-yang/pi-tps-meter # 只在本次启动试用,不写
|
|
|
80
81
|
|
|
81
82
|
```json
|
|
82
83
|
{
|
|
83
|
-
"mode": "
|
|
84
|
+
"mode": "footer",
|
|
84
85
|
"tiers": {
|
|
85
86
|
"tps": { "slow": 20, "normal": 50, "fast": 100 },
|
|
86
87
|
"ttft": { "fast": 10000, "ok": 20000 }
|
|
@@ -91,7 +92,7 @@ pi -e npm:@jack-yang/pi-tps-meter # 只在本次启动试用,不写
|
|
|
91
92
|
|
|
92
93
|
| 键 | 默认值 | 说明 |
|
|
93
94
|
|---|---|---|
|
|
94
|
-
| `mode` | `"
|
|
95
|
+
| `mode` | `"footer"` | `footer` / `right` / `status` / `widget` / `off`。`/tps` 只改写这个键,其他内容原样保留 |
|
|
95
96
|
| `tiers.tps` | `{ slow: 20, normal: 50, fast: 100 }` | TPS 与 AVG 的分档阈值(tok/s) |
|
|
96
97
|
| `tiers.ttft` | `{ fast: 10000, ok: 20000 }` | TTFT 分档阈值(毫秒) |
|
|
97
98
|
| `spinner.theme` | `"tech"` | 粒子流配色:`"tech"`(青 → 蓝 → 紫)、`"red"`(KITT 风格红);`"none"` 关闭粒子流 |
|
|
@@ -147,13 +148,14 @@ TTFT 从 `turn_start` 开始计时(在 pi 里,一个 turn 就是一次模型
|
|
|
147
148
|
- toolcall delta 计入 TPS 和校准,因为 pi 的 `usage.output` 包含工具调用参数的 token;原版只统计文本和推理。TTFT 仍然只认文本和思考。
|
|
148
149
|
- 权威 token 数直接用 `usage.output`:pi 的 output 已经包含推理 token,原版需要把 output 和 reasoning 相加。
|
|
149
150
|
- 思考粒子流已移植(配色预设、渐变、FIFO 流动、配置项都和原版一致),并做了原版想做但受限于 opencode 做不到的一点:每个粒子单独着色,真正从亮到暗渐隐(原版受 opentui 限制,整串只能用一种颜色)。右对齐时粒子流放在读数左边、方向镜像,读数本身不会移动。
|
|
151
|
+
- 显示位置:原版在 opencode 输入框右侧的 slot 里;pi 没有对应的位置,默认放在 footer 目录行右端(包装 pi 自己的 footer,不替换),另有 `right` / `status` / `widget` 可选。
|
|
150
152
|
- 颜色用 ANSI 基本色:红 31、黄 33、绿 32、青 36,标签用暗色;原版用的是 opencode 主题槽位。
|
|
151
153
|
|
|
152
154
|
## 已知限制
|
|
153
155
|
|
|
154
156
|
- **不流式输出的推理**:有些模型按推理 token 计费,但不把推理内容流式发出来。这时校准比率会顶到 3.0 上限,AVG 也会偏高(原版同样如此)。
|
|
155
157
|
- **一次性返回工具参数的 provider**(如 Gemini):消息末尾会出现一个短暂的 TPS 尖峰,消息结束后立即清除。
|
|
156
|
-
- **`
|
|
158
|
+
- **`footer` 和其他替换 footer 的扩展互斥**:pi 同一时间只有一个 footer,后设置的生效。如果还开着 awesome-pi-themes 的 look-pack 这类扩展,改用 `/tps right`。`status` 模式也依赖 pi 的默认 footer(look-pack 不渲染扩展状态)。
|
|
157
159
|
- **AVG 和校准只在当前会话内累计**:`/new`、`/resume`、`/fork`、`/reload` 会重新开始;不会从历史记录里重建。
|
|
158
160
|
- **TTFT 包含客户端准备时间**:包括请求前的上下文处理(实测约 7ms)和 provider 层的自动重试等待,也就是用户实际等待的时间。
|
|
159
161
|
- **时间戳取自 pi 把事件派发给扩展的时刻**:其他扩展的慢 handler 会推迟派发,从而影响读数。
|
package/extensions/index.ts
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Port of opencode-tps-meter: the measurement core (src/core.ts), the formatting (src/format.ts)
|
|
4
4
|
// and the thinking particle stream (src/spinner.ts) are the original's. This file is the replaced
|
|
5
|
-
// host layer: pi events in;
|
|
6
|
-
// above the editor
|
|
5
|
+
// host layer: pi events in; the meter out on pi's footer line (by wrapping pi's own footer), a
|
|
6
|
+
// right-aligned line below the editor, a footer status, or a line above the editor.
|
|
7
7
|
|
|
8
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext, ReadonlyFooterDataProvider } from "@earendil-works/pi-coding-agent"
|
|
9
9
|
import { DISPLAY_MODES, isDisplayMode, loadConfig, nextMode, resolveConfig, saveMode, type DisplayMode, type MeterConfig } from "../src/config.ts"
|
|
10
10
|
import { abandonMessage, completeMessage, createMeterState, readMeter, recordDelta, startMessage, startTurn, type DeltaKind, type MeterState } from "../src/core.ts"
|
|
11
|
+
import { mergeFooterLine } from "../src/footer.ts"
|
|
11
12
|
import { alignRight, renderLine, withSpinner } from "../src/format.ts"
|
|
12
13
|
import { advanceSpinner, createSpinner, spinnerCells, type Rgb, type SpinnerState } from "../src/spinner.ts"
|
|
13
14
|
|
|
@@ -21,12 +22,61 @@ const REFRESH_MS = 100
|
|
|
21
22
|
const SPINNER_MUTED: Rgb = { r: 128, g: 132, b: 142 }
|
|
22
23
|
|
|
23
24
|
type UI = ExtensionContext["ui"]
|
|
25
|
+
type Model = NonNullable<ExtensionContext["model"]>
|
|
26
|
+
|
|
27
|
+
// The part of pi's AgentSession that pi's FooterComponent reads, rebuilt from what an extension
|
|
28
|
+
// can see. pi does not hand extensions its session, but its footer only needs these members.
|
|
29
|
+
export type SessionView = {
|
|
30
|
+
readonly sessionManager: ExtensionContext["sessionManager"]
|
|
31
|
+
readonly model: ExtensionContext["model"]
|
|
32
|
+
readonly state: { model: ExtensionContext["model"]; thinkingLevel: string }
|
|
33
|
+
readonly routedModel: { model: Model; thinkingLevel?: string } | undefined
|
|
34
|
+
readonly modelRuntime: { isUsingSubscription(provider: string): boolean }
|
|
35
|
+
getContextUsage(): ReturnType<ExtensionContext["getContextUsage"]>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type FooterLike = { render(width: number): string[]; invalidate(): void; dispose?(): void; setAutoCompactEnabled(enabled: boolean): void }
|
|
39
|
+
|
|
40
|
+
// pi runtime pieces the footer mode needs; loaded on demand (and injected in tests, which run on
|
|
41
|
+
// plain Node without pi).
|
|
42
|
+
export type FooterKit = {
|
|
43
|
+
createFooter(session: SessionView, footerData: ReadonlyFooterDataProvider): FooterLike
|
|
44
|
+
visibleWidth(text: string): number
|
|
45
|
+
truncateToWidth(text: string, width: number, ellipsis?: string): string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type Deps = { loadFooterKit?: () => Promise<FooterKit> }
|
|
49
|
+
|
|
50
|
+
async function loadPiFooterKit(): Promise<FooterKit> {
|
|
51
|
+
const [core, tuiLib] = await Promise.all([import("@earendil-works/pi-coding-agent"), import("@earendil-works/pi-tui")])
|
|
52
|
+
type Session = ConstructorParameters<typeof core.FooterComponent>[0]
|
|
53
|
+
return {
|
|
54
|
+
createFooter: (session, footerData) => {
|
|
55
|
+
// SAFETY: FooterComponent is typed to take pi's whole AgentSession, which extensions never
|
|
56
|
+
// get. In pi 1.5.0 (dist/modes/interactive/components/footer.js) it reads exactly the members
|
|
57
|
+
// SessionView provides: sessionManager.{getCwd, getEntries, getEntryCount, getLeafId,
|
|
58
|
+
// getSessionId, getSessionName}, model, state.{model, thinkingLevel}, routedModel,
|
|
59
|
+
// modelRuntime.isUsingSubscription and getContextUsage(). If a later pi reads anything else,
|
|
60
|
+
// render() throws, the wrapper catches it, and fallBack() restores pi's own footer.
|
|
61
|
+
const agentSession = session as unknown as Session
|
|
62
|
+
return new core.FooterComponent(agentSession, footerData)
|
|
63
|
+
},
|
|
64
|
+
visibleWidth: tuiLib.visibleWidth,
|
|
65
|
+
truncateToWidth: tuiLib.truncateToWidth,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
24
68
|
|
|
25
69
|
export default function tpsMeter(pi: ExtensionAPI): void {
|
|
70
|
+
createTpsMeter(pi)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createTpsMeter(pi: ExtensionAPI, deps: Deps = {}): void {
|
|
74
|
+
const loadFooterKit = deps.loadFooterKit ?? loadPiFooterKit
|
|
26
75
|
let state: MeterState = createMeterState()
|
|
27
76
|
// Defaults until session_start reads the file: the factory may run in invocations that never
|
|
28
77
|
// start a session, so it does no IO and starts no timers.
|
|
29
78
|
let config: MeterConfig = resolveConfig(undefined)
|
|
79
|
+
let ctxRef: ExtensionContext | undefined
|
|
30
80
|
let ui: UI | undefined
|
|
31
81
|
let tui = false
|
|
32
82
|
let ticker: ReturnType<typeof setInterval> | undefined
|
|
@@ -34,18 +84,22 @@ export default function tpsMeter(pi: ExtensionAPI): void {
|
|
|
34
84
|
// What is on screen right now, so unchanged readings cost no re-render.
|
|
35
85
|
let shownMode: DisplayMode | undefined
|
|
36
86
|
let shownLine: string | undefined
|
|
37
|
-
// The mounted
|
|
38
|
-
let
|
|
87
|
+
// The mounted component's host (widget or footer), told to redraw when the line changes.
|
|
88
|
+
let host: { requestRender(): void } | undefined
|
|
89
|
+
// Bumped on every mode change, so a footer mount still loading for an older mode is dropped.
|
|
90
|
+
let mountEpoch = 0
|
|
39
91
|
|
|
40
92
|
// Handlers re-bind from their own ctx; the ticker only ever uses the latest binding.
|
|
41
93
|
const bind = (ctx: ExtensionContext): void => {
|
|
94
|
+
ctxRef = ctx
|
|
42
95
|
ui = ctx.hasUI ? ctx.ui : undefined
|
|
43
96
|
// The TUI renders ANSI and components; RPC clients get plain text lines.
|
|
44
97
|
tui = ctx.mode === "tui"
|
|
45
98
|
}
|
|
46
99
|
|
|
47
100
|
// 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.
|
|
101
|
+
// just be noise at 10Hz). A fresh stream starts each time thinking starts. Next to a
|
|
102
|
+
// right-aligned reading it goes on the left, so the reading itself never moves.
|
|
49
103
|
const decorate = (target: UI, line: string, now: number): string => {
|
|
50
104
|
if (!tui || !config.spinner.enabled || !state.thinking) {
|
|
51
105
|
spinner = undefined
|
|
@@ -54,29 +108,126 @@ export default function tpsMeter(pi: ExtensionAPI): void {
|
|
|
54
108
|
if (spinner === undefined) spinner = createSpinner(config.spinner.cells, now, Math.random)
|
|
55
109
|
else advanceSpinner(spinner, config.spinner, now, Math.random)
|
|
56
110
|
const paint = (text: string, c: Rgb) => target.theme.style(text, { fg: { kind: "rgb", r: c.r, g: c.g, b: c.b } })
|
|
57
|
-
|
|
111
|
+
const side = config.mode === "right" || config.mode === "footer" ? "before" : "after"
|
|
112
|
+
return withSpinner(line, spinnerCells(spinner, config.spinner, SPINNER_MUTED), paint, side)
|
|
58
113
|
}
|
|
59
114
|
|
|
60
115
|
// A component rather than a string widget: it right-aligns itself to the width of every render,
|
|
61
116
|
// so it stays in the corner across terminal resizes, and an update is only a redraw request.
|
|
62
117
|
const mountRight = (target: UI): void => {
|
|
63
|
-
target.setWidget(KEY, (
|
|
64
|
-
|
|
118
|
+
target.setWidget(KEY, (h) => {
|
|
119
|
+
host = h
|
|
65
120
|
return { render: (width: number) => [alignRight(shownLine ?? "", width)], invalidate() {} }
|
|
66
121
|
}, { placement: "belowEditor" })
|
|
67
122
|
}
|
|
68
123
|
|
|
124
|
+
// pi's own footer, fed a SessionView, with the meter merged into its first (directory) line.
|
|
125
|
+
// Everything else on the footer stays pi's rendering.
|
|
126
|
+
const sessionView: SessionView = {
|
|
127
|
+
// Only read inside a mounted footer, which exists only after bind(); a throw here is caught
|
|
128
|
+
// by the footer's render() and ends in fallBack().
|
|
129
|
+
get sessionManager() {
|
|
130
|
+
if (!ctxRef) throw new Error("no session bound")
|
|
131
|
+
return ctxRef.sessionManager
|
|
132
|
+
},
|
|
133
|
+
get model() { return ctxRef?.model },
|
|
134
|
+
get state() { return { model: ctxRef?.model, thinkingLevel: pi.getThinkingLevel() } },
|
|
135
|
+
// As pi's AgentSession.routedModel: under a virtual model, the physical model of the latest
|
|
136
|
+
// successful response.
|
|
137
|
+
get routedModel() {
|
|
138
|
+
const model = ctxRef?.model
|
|
139
|
+
if (!ctxRef || !model || model.api !== "pi-virtual") return undefined
|
|
140
|
+
const branch = ctxRef.sessionManager.getBranch()
|
|
141
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
142
|
+
const entry = branch[i]
|
|
143
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue
|
|
144
|
+
const m = entry.message
|
|
145
|
+
if (m.stopReason === "error" || m.stopReason === "aborted") continue
|
|
146
|
+
const physical = ctxRef.modelRegistry.find(m.provider, m.model)
|
|
147
|
+
return physical ? { model: physical, thinkingLevel: m.thinkingLevel } : undefined
|
|
148
|
+
}
|
|
149
|
+
return undefined
|
|
150
|
+
},
|
|
151
|
+
// As pi's ModelRuntime.isUsingSubscription: OAuth credentials for a subscription provider.
|
|
152
|
+
// The footer asks this about the current model's provider; ModelRegistry.isUsingOAuth takes
|
|
153
|
+
// the model and reads its provider.
|
|
154
|
+
modelRuntime: {
|
|
155
|
+
isUsingSubscription: (provider) => {
|
|
156
|
+
const registry = ctxRef?.modelRegistry
|
|
157
|
+
const model = ctxRef?.model
|
|
158
|
+
if (!registry || model?.provider !== provider || !registry.isUsingOAuth(model)) return false
|
|
159
|
+
return registry.getProvider(provider)?.auth.oauth?.isSubscription === true
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
getContextUsage: () => ctxRef?.getContextUsage(),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// If pi's footer cannot be wrapped (an older or newer pi changed it), never leave the user
|
|
166
|
+
// without a footer: fall back to the line below the editor (leaving footer mode is what restores
|
|
167
|
+
// pi's own footer, in render()) and say so once. A mount that is no longer current (the mode
|
|
168
|
+
// changed, or the session was replaced, since it started) is ignored, so a stale UI is never used.
|
|
169
|
+
const fallBack = (target: UI, epoch: number, reason: unknown): void => {
|
|
170
|
+
if (epoch !== mountEpoch || ui !== target || config.mode !== "footer") return
|
|
171
|
+
config = { ...config, mode: "right" }
|
|
172
|
+
target.notify(`tps-meter: footer mode unavailable here (${reason instanceof Error ? reason.message : String(reason)}), using right`, "warning")
|
|
173
|
+
render()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// pi feeds its own footer the auto-compaction flag whenever it changes; ours has to read the
|
|
177
|
+
// setting. pi.getSettings() deep-copies all settings, and the footer renders on every frame, so
|
|
178
|
+
// the flag is re-read at most once a second (a toggle in /settings shows up within that).
|
|
179
|
+
let autoCompact = { value: true, readAt: Number.NEGATIVE_INFINITY }
|
|
180
|
+
const autoCompactEnabled = (): boolean => {
|
|
181
|
+
const now = Date.now()
|
|
182
|
+
if (now - autoCompact.readAt >= 1_000) autoCompact = { value: pi.getSettings().compaction?.enabled ?? true, readAt: now }
|
|
183
|
+
return autoCompact.value
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const mountFooter = (target: UI, epoch: number): void => {
|
|
187
|
+
loadFooterKit().then((kit) => {
|
|
188
|
+
if (epoch !== mountEpoch || ui !== target) return
|
|
189
|
+
target.setFooter((h, theme, footerData) => {
|
|
190
|
+
host = h
|
|
191
|
+
const inner = kit.createFooter(sessionView, footerData)
|
|
192
|
+
const unsubscribe = footerData.onBranchChange(() => h.requestRender())
|
|
193
|
+
const fns = { visibleWidth: kit.visibleWidth, truncate: (text: string, width: number) => kit.truncateToWidth(text, width, theme.fg("dim", "...")) }
|
|
194
|
+
return {
|
|
195
|
+
render(width: number): string[] {
|
|
196
|
+
try {
|
|
197
|
+
inner.setAutoCompactEnabled(autoCompactEnabled())
|
|
198
|
+
const lines = inner.render(width)
|
|
199
|
+
if (lines.length > 0 && shownLine) lines[0] = mergeFooterLine(lines[0], shownLine, width, fns)
|
|
200
|
+
return lines
|
|
201
|
+
} catch (err) {
|
|
202
|
+
// Not from inside pi's render pass: swap the footer once this frame is done.
|
|
203
|
+
queueMicrotask(() => fallBack(target, epoch, err))
|
|
204
|
+
return []
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
invalidate() { inner.invalidate() },
|
|
208
|
+
dispose() {
|
|
209
|
+
unsubscribe()
|
|
210
|
+
inner.dispose?.()
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
}, (err: unknown) => fallBack(target, epoch, err))
|
|
215
|
+
}
|
|
216
|
+
|
|
69
217
|
const render = (): void => {
|
|
70
218
|
if (!ui) return
|
|
71
219
|
const mode = config.mode
|
|
72
220
|
if (mode !== shownMode) {
|
|
73
221
|
// Exactly one surface (or none) shows the meter: clear the one being left.
|
|
74
222
|
if (shownMode === "status") ui.setStatus(KEY, undefined)
|
|
75
|
-
if (shownMode === "widget" || shownMode === "right") ui.setWidget(KEY, undefined)
|
|
76
|
-
|
|
223
|
+
if (shownMode === "widget" || shownMode === "right" || (shownMode === "footer" && !tui)) ui.setWidget(KEY, undefined)
|
|
224
|
+
if (shownMode === "footer" && tui) ui.setFooter(undefined)
|
|
225
|
+
host = undefined
|
|
226
|
+
mountEpoch++
|
|
77
227
|
shownMode = mode
|
|
78
228
|
shownLine = undefined
|
|
79
229
|
if (mode === "right" && tui) mountRight(ui)
|
|
230
|
+
if (mode === "footer" && tui) mountFooter(ui, mountEpoch)
|
|
80
231
|
}
|
|
81
232
|
if (mode === "off") return
|
|
82
233
|
const now = Date.now()
|
|
@@ -85,8 +236,9 @@ export default function tpsMeter(pi: ExtensionAPI): void {
|
|
|
85
236
|
shownLine = line
|
|
86
237
|
if (mode === "status") ui.setStatus(KEY, line)
|
|
87
238
|
else if (mode === "widget") ui.setWidget(KEY, [line])
|
|
88
|
-
// RPC
|
|
89
|
-
|
|
239
|
+
// RPC has no custom footers or component widgets; there the line goes out as plain text
|
|
240
|
+
// below the editor.
|
|
241
|
+
else if (tui) host?.requestRender()
|
|
90
242
|
else ui.setWidget(KEY, [line], { placement: "belowEditor" })
|
|
91
243
|
}
|
|
92
244
|
|
|
@@ -162,16 +314,18 @@ export default function tpsMeter(pi: ExtensionAPI): void {
|
|
|
162
314
|
})
|
|
163
315
|
|
|
164
316
|
pi.on("session_shutdown", () => {
|
|
165
|
-
// pi resets extension UI itself when it reloads or replaces the runtime
|
|
166
|
-
//
|
|
317
|
+
// pi resets extension UI itself when it reloads or replaces the runtime (including a custom
|
|
318
|
+
// footer); the timer and the references to this session are the only things left to drop.
|
|
167
319
|
stopTicker()
|
|
320
|
+
mountEpoch++
|
|
168
321
|
ui = undefined
|
|
169
|
-
|
|
322
|
+
ctxRef = undefined
|
|
323
|
+
host = undefined
|
|
170
324
|
spinner = undefined
|
|
171
325
|
})
|
|
172
326
|
|
|
173
327
|
pi.registerCommand("tps", {
|
|
174
|
-
description: "TPS meter display: /tps cycles right → status → widget → off, /tps right|status|widget|off sets it",
|
|
328
|
+
description: "TPS meter display: /tps cycles footer → right → status → widget → off, /tps footer|right|status|widget|off sets it",
|
|
175
329
|
getArgumentCompletions: (prefix) => {
|
|
176
330
|
// Only strict prefixes: pi's editor makes Enter apply an open argument completion instead
|
|
177
331
|
// of submitting, so a fully typed mode must close the popup or `/tps off` needs two Enters.
|
|
@@ -183,7 +337,7 @@ export default function tpsMeter(pi: ExtensionAPI): void {
|
|
|
183
337
|
bind(ctx)
|
|
184
338
|
const arg = args.trim().toLowerCase()
|
|
185
339
|
if (arg && !isDisplayMode(arg)) {
|
|
186
|
-
ctx.ui.notify(`tps-meter: unknown mode "${arg}" (use right | status | widget | off)`, "error")
|
|
340
|
+
ctx.ui.notify(`tps-meter: unknown mode "${arg}" (use footer | right | status | widget | off)`, "error")
|
|
187
341
|
return
|
|
188
342
|
}
|
|
189
343
|
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": "1.0.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,13 +36,14 @@
|
|
|
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-footer.png"
|
|
40
40
|
},
|
|
41
41
|
"engines": {
|
|
42
42
|
"node": ">=22.19.0"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
45
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
46
|
+
"@earendil-works/pi-tui": "*"
|
|
46
47
|
},
|
|
47
48
|
"devDependencies": {
|
|
48
49
|
"@types/node": "^24.0.0",
|
package/src/config.ts
CHANGED
|
@@ -13,12 +13,13 @@ import { dirname, join } from "node:path"
|
|
|
13
13
|
import type { Tiers } from "./format.ts"
|
|
14
14
|
import { parseHex, THEME_PRESETS, type Rgb, type SpinnerConfig } from "./spinner.ts"
|
|
15
15
|
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
16
|
+
// footer: the right end of pi's own footer line with the working directory (pi's footer is wrapped,
|
|
17
|
+
// not replaced); right: one line below the editor, right-aligned; status: pi's footer status line;
|
|
18
|
+
// widget: a line above the editor.
|
|
19
|
+
export type DisplayMode = "footer" | "right" | "status" | "widget" | "off"
|
|
19
20
|
|
|
20
21
|
// Also the cycle order of a bare `/tps`.
|
|
21
|
-
export const DISPLAY_MODES: readonly DisplayMode[] = ["right", "status", "widget", "off"]
|
|
22
|
+
export const DISPLAY_MODES: readonly DisplayMode[] = ["footer", "right", "status", "widget", "off"]
|
|
22
23
|
|
|
23
24
|
export type MeterConfig = { mode: DisplayMode; tiers: Tiers; spinner: SpinnerConfig }
|
|
24
25
|
|
|
@@ -49,7 +50,7 @@ export function resolveConfig(raw: unknown): MeterConfig {
|
|
|
49
50
|
const custom = hexColors(sp.colors)
|
|
50
51
|
const preset = hexColors(THEME_PRESETS[theme] ?? THEME_PRESETS.tech)
|
|
51
52
|
return {
|
|
52
|
-
mode: isDisplayMode(o.mode) ? o.mode : "
|
|
53
|
+
mode: isDisplayMode(o.mode) ? o.mode : "footer",
|
|
53
54
|
tiers: {
|
|
54
55
|
tps: { slow: num(tp.slow, 20), normal: num(tp.normal, 50), fast: num(tp.fast, 100) },
|
|
55
56
|
ttft: { fast: num(tt.fast, 10_000), ok: num(tt.ok, 20_000) },
|
package/src/footer.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Puts the meter on the right end of the first line of pi's own footer (the working directory
|
|
2
|
+
// line), the way pi itself right-aligns the model name on the line below. Pure: the terminal
|
|
3
|
+
// width functions are injected (pi-tui's in pi, simple ones in tests), so this runs on plain Node.
|
|
4
|
+
|
|
5
|
+
export type WidthFns = {
|
|
6
|
+
// Terminal columns of a string: ANSI codes take none, CJK characters take two.
|
|
7
|
+
visibleWidth(text: string): number
|
|
8
|
+
// Cut a string to at most `width` columns; the caller decides the ellipsis (pi uses a dim "...").
|
|
9
|
+
truncate(text: string, width: number): string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Columns kept free between the directory and the meter.
|
|
13
|
+
export const FOOTER_GAP = 2
|
|
14
|
+
|
|
15
|
+
// The meter is the point of this line, so on a narrow terminal the directory gives way first
|
|
16
|
+
// (truncated with an ellipsis, as pi itself does); the meter is only dropped when even it alone
|
|
17
|
+
// would not fit. Returns exactly `width` columns, padded between the two parts.
|
|
18
|
+
export function mergeFooterLine(left: string, meter: string, width: number, fns: WidthFns): string {
|
|
19
|
+
const meterWidth = fns.visibleWidth(meter)
|
|
20
|
+
if (meter === "" || meterWidth > width) return left
|
|
21
|
+
const room = width - meterWidth - FOOTER_GAP
|
|
22
|
+
let head = left
|
|
23
|
+
if (room <= 0) head = ""
|
|
24
|
+
else if (fns.visibleWidth(left) > room) head = fns.truncate(left, room)
|
|
25
|
+
const pad = width - fns.visibleWidth(head) - meterWidth
|
|
26
|
+
return head + " ".repeat(Math.max(0, pad)) + meter
|
|
27
|
+
}
|