pi-tps-meter 1.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -9
- package/extensions/tps-meter.ts +228 -88
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-tps-meter
|
|
2
2
|
|
|
3
|
-
Tokens per second meter for [pi CLI](https://pi.dev).
|
|
3
|
+
Tokens per second meter for [pi CLI](https://pi.dev) with sparkline trend visualization.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -8,20 +8,31 @@ Tokens per second meter for [pi CLI](https://pi.dev).
|
|
|
8
8
|
pi install npm:pi-tps-meter
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Display
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
**Streaming** — live sparkline updates every 200ms:
|
|
14
|
+
```
|
|
15
|
+
⠹ ▁▂▃▅▇▆▅▃▂▁ 42 tps
|
|
16
|
+
```
|
|
14
17
|
|
|
18
|
+
**Complete** — aggregate stats with trend:
|
|
15
19
|
```
|
|
16
|
-
|
|
17
|
-
TPS: 42 avg | μ 39 | p95 61 (after message completes)
|
|
20
|
+
TPS ▁▂▃▅▇▆▅▃▂▁ 42 avg | μ 39 | p95 61
|
|
18
21
|
```
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
**Sparkline** uses Unicode block characters `▁▂▃▄▅▆▇█` — each char = one recent message's TPS. Left = oldest, right = newest. Color-coded by speed.
|
|
24
|
+
|
|
25
|
+
**Colors:**
|
|
26
|
+
- 🟢 Green: >50 tps (fast)
|
|
27
|
+
- 🟡 Yellow: 20-50 tps (medium)
|
|
28
|
+
- 🔴 Red: <20 tps (slow)
|
|
29
|
+
|
|
30
|
+
## Optimizations
|
|
23
31
|
|
|
24
|
-
|
|
32
|
+
- Fixed ring buffers (zero allocations)
|
|
33
|
+
- Bitwise token estimation
|
|
34
|
+
- Single shared timer
|
|
35
|
+
- Insertion sort for p95
|
|
25
36
|
|
|
26
37
|
## Author
|
|
27
38
|
|
package/extensions/tps-meter.ts
CHANGED
|
@@ -1,39 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TPS Meter — Tokens Per Second
|
|
2
|
+
* TPS Meter v3 — Tokens Per Second with Sparkline Trend
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Footer display:
|
|
5
|
+
* Streaming: ⠹ ▁▂▃▅▇▆▅▃▂▁ 42 tps
|
|
6
|
+
* Complete: TPS ▁▂▃▅▇▆▅▃▂▁ 42 avg | μ 39 | p95 61
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
8
|
+
* Features:
|
|
9
|
+
* - Real sparkline: ▁▂▃▄▅▆▇█ showing TPS trend over last 12 messages
|
|
10
|
+
* - Color-coded sparkline by speed
|
|
11
|
+
* - Animated spinner during streaming
|
|
12
|
+
* - Rolling 60s window for avg, all-time for μ and p95
|
|
10
13
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
14
|
+
* Optimizations:
|
|
15
|
+
* - Fixed ring buffer (no allocations)
|
|
16
|
+
* - Bitwise token estimation
|
|
17
|
+
* - Single shared timer
|
|
18
|
+
* - Insertion sort for p95
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
22
|
|
|
17
23
|
// --- Config ---
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
24
|
+
const WINDOW_SIZE = 60;
|
|
25
|
+
const WINDOW_MS = 60_000;
|
|
26
|
+
const STREAM_INTERVAL_MS = 200;
|
|
27
|
+
const SPARK_LEN = 12;
|
|
28
|
+
const ALLTIME_CAP = 500;
|
|
29
|
+
const FAST = 50;
|
|
30
|
+
const MED = 20;
|
|
21
31
|
|
|
22
|
-
// ---
|
|
23
|
-
|
|
24
|
-
tokens: number;
|
|
25
|
-
endMs: number; // when this sample was recorded
|
|
26
|
-
}
|
|
32
|
+
// --- Sparkline chars (8 levels) ---
|
|
33
|
+
const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
27
34
|
|
|
35
|
+
// --- State ---
|
|
28
36
|
let streamStartMs = 0;
|
|
37
|
+
let streamChars = 0;
|
|
29
38
|
let streamTokens = 0;
|
|
30
|
-
let
|
|
39
|
+
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
|
40
|
+
let streaming = false;
|
|
31
41
|
|
|
32
|
-
// Rolling window (
|
|
33
|
-
const
|
|
42
|
+
// Rolling window (circular buffer)
|
|
43
|
+
const winBuf = new Float64Array(WINDOW_SIZE * 2);
|
|
44
|
+
let winLen = 0;
|
|
45
|
+
let winHead = 0;
|
|
34
46
|
|
|
35
|
-
// All-time
|
|
36
|
-
const
|
|
47
|
+
// All-time stats (circular buffer)
|
|
48
|
+
const atBuf = new Float64Array(ALLTIME_CAP);
|
|
49
|
+
let atLen = 0;
|
|
50
|
+
let atHead = 0;
|
|
51
|
+
let atSum = 0;
|
|
52
|
+
|
|
53
|
+
// Sparkline history (ring buffer of TPS values, last N messages)
|
|
54
|
+
const sparkBuf = new Float64Array(SPARK_LEN);
|
|
55
|
+
let sparkLen = 0;
|
|
56
|
+
let sparkHead = 0;
|
|
57
|
+
let sparkMax = 1; // track max for normalization
|
|
37
58
|
|
|
38
59
|
// --- Helpers ---
|
|
39
60
|
|
|
@@ -41,119 +62,238 @@ function now(): number {
|
|
|
41
62
|
return Date.now();
|
|
42
63
|
}
|
|
43
64
|
|
|
44
|
-
function
|
|
45
|
-
return
|
|
65
|
+
function tokEst(ch: number): number {
|
|
66
|
+
return (ch >>> 2) + ((ch & 3) > 0 ? 1 : 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function winPush(tps: number, ms: number): void {
|
|
70
|
+
const b = winHead * 2;
|
|
71
|
+
winBuf[b] = tps;
|
|
72
|
+
winBuf[b + 1] = ms;
|
|
73
|
+
winHead = (winHead + 1) % WINDOW_SIZE;
|
|
74
|
+
if (winLen < WINDOW_SIZE) winLen++;
|
|
46
75
|
}
|
|
47
76
|
|
|
48
|
-
function
|
|
77
|
+
function atPush(tps: number): void {
|
|
78
|
+
atSum += tps;
|
|
79
|
+
if (atLen >= ALLTIME_CAP) atSum -= atBuf[atHead];
|
|
80
|
+
atBuf[atHead] = tps;
|
|
81
|
+
atHead = (atHead + 1) % ALLTIME_CAP;
|
|
82
|
+
if (atLen < ALLTIME_CAP) atLen++;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sparkPush(tps: number): void {
|
|
86
|
+
sparkBuf[sparkHead] = tps;
|
|
87
|
+
sparkHead = (sparkHead + 1) % SPARK_LEN;
|
|
88
|
+
if (sparkLen < SPARK_LEN) sparkLen++;
|
|
89
|
+
if (tps > sparkMax) sparkMax = tps;
|
|
90
|
+
// Decay max slowly so sparkline adapts
|
|
91
|
+
if (sparkMax > 10) sparkMax *= 0.99;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function winAvg(): number {
|
|
95
|
+
if (winLen === 0) return 0;
|
|
49
96
|
const cutoff = now() - WINDOW_MS;
|
|
50
|
-
|
|
51
|
-
|
|
97
|
+
let sum = 0;
|
|
98
|
+
let n = 0;
|
|
99
|
+
const oldest = winLen < WINDOW_SIZE ? 0 : winHead;
|
|
100
|
+
for (let i = 0; i < winLen; i++) {
|
|
101
|
+
const idx = (oldest + i) % WINDOW_SIZE;
|
|
102
|
+
const b = idx * 2;
|
|
103
|
+
if (winBuf[b + 1] < cutoff) continue;
|
|
104
|
+
sum += winBuf[b];
|
|
105
|
+
n++;
|
|
52
106
|
}
|
|
107
|
+
return n === 0 ? 0 : sum / n;
|
|
53
108
|
}
|
|
54
109
|
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
if (window.length === 0) return 0;
|
|
58
|
-
const totalTokens = window.reduce((s, w) => s + w.tokens, 0);
|
|
59
|
-
const spanMs = window[window.length - 1].endMs - window[0].endMs;
|
|
60
|
-
if (spanMs < 100) return 0; // too short to measure
|
|
61
|
-
return (totalTokens / spanMs) * 1000;
|
|
110
|
+
function atMean(): number {
|
|
111
|
+
return atLen === 0 ? 0 : atSum / atLen;
|
|
62
112
|
}
|
|
63
113
|
|
|
64
|
-
function
|
|
65
|
-
if (
|
|
66
|
-
|
|
114
|
+
function atP95(): number {
|
|
115
|
+
if (atLen === 0) return 0;
|
|
116
|
+
const tmp = new Float64Array(atLen);
|
|
117
|
+
const oldest = atLen < ALLTIME_CAP ? 0 : atHead;
|
|
118
|
+
for (let i = 0; i < atLen; i++) tmp[i] = atBuf[(oldest + i) % ALLTIME_CAP];
|
|
119
|
+
// Insertion sort
|
|
120
|
+
for (let i = 1; i < tmp.length; i++) {
|
|
121
|
+
const v = tmp[i];
|
|
122
|
+
let j = i - 1;
|
|
123
|
+
while (j >= 0 && tmp[j] > v) {
|
|
124
|
+
tmp[j + 1] = tmp[j];
|
|
125
|
+
j--;
|
|
126
|
+
}
|
|
127
|
+
tmp[j + 1] = v;
|
|
128
|
+
}
|
|
129
|
+
return tmp[Math.ceil(tmp.length * 0.95) - 1] || 0;
|
|
67
130
|
}
|
|
68
131
|
|
|
69
|
-
function
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return sorted[Math.max(0, idx)];
|
|
132
|
+
function fmt(v: number): string {
|
|
133
|
+
if (v < 10) return v.toFixed(1);
|
|
134
|
+
if (v < 100) return v.toFixed(0);
|
|
135
|
+
return `${Math.round(v)}`;
|
|
74
136
|
}
|
|
75
137
|
|
|
76
|
-
|
|
77
|
-
|
|
138
|
+
// --- Sparkline rendering ---
|
|
139
|
+
|
|
140
|
+
function sparkline(theme: any): string {
|
|
141
|
+
if (sparkLen === 0) return theme.fg("dim", "▁".repeat(SPARK_LEN));
|
|
142
|
+
|
|
143
|
+
// Read ring buffer in order (oldest first)
|
|
144
|
+
const vals = new Float64Array(SPARK_LEN);
|
|
145
|
+
const oldest = sparkLen < SPARK_LEN ? 0 : sparkHead;
|
|
146
|
+
for (let i = 0; i < sparkLen; i++) {
|
|
147
|
+
vals[i] = sparkBuf[(oldest + i) % SPARK_LEN];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Normalize against local max
|
|
151
|
+
let localMax = 1;
|
|
152
|
+
for (let i = 0; i < sparkLen; i++) {
|
|
153
|
+
if (vals[i] > localMax) localMax = vals[i];
|
|
154
|
+
}
|
|
155
|
+
// Also consider historical max
|
|
156
|
+
if (sparkMax > localMax) localMax = sparkMax;
|
|
157
|
+
|
|
158
|
+
let result = "";
|
|
159
|
+
for (let i = 0; i < SPARK_LEN; i++) {
|
|
160
|
+
const v = vals[i];
|
|
161
|
+
const norm = Math.min(7, Math.round((v / localMax) * 7));
|
|
162
|
+
const ch = BLOCKS[norm];
|
|
163
|
+
|
|
164
|
+
// Color each block by speed
|
|
165
|
+
let colored: string;
|
|
166
|
+
if (v >= FAST) colored = theme.fg("success", ch);
|
|
167
|
+
else if (v >= MED) colored = theme.fg("warning", ch);
|
|
168
|
+
else colored = theme.fg("error", ch);
|
|
169
|
+
|
|
170
|
+
result += colored;
|
|
171
|
+
}
|
|
172
|
+
return result;
|
|
78
173
|
}
|
|
79
174
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
175
|
+
// --- Spinner ---
|
|
176
|
+
|
|
177
|
+
const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
178
|
+
let spinI = 0;
|
|
179
|
+
function spin(): string {
|
|
180
|
+
const s = SPIN[spinI];
|
|
181
|
+
spinI = (spinI + 1) % SPIN.length;
|
|
182
|
+
return s;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function speedColor(tps: number, text: string, theme: any): string {
|
|
186
|
+
if (tps >= FAST) return theme.fg("success", text);
|
|
187
|
+
if (tps >= MED) return theme.fg("warning", text);
|
|
188
|
+
return theme.fg("error", text);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- Rendering ---
|
|
192
|
+
|
|
193
|
+
function renderLive(theme: any): string {
|
|
194
|
+
const elapsed = (now() - streamStartMs) / 1000;
|
|
195
|
+
const tps = elapsed > 0.3 ? streamTokens / elapsed : 0;
|
|
196
|
+
const s = spin();
|
|
197
|
+
const sp = sparkline(theme);
|
|
198
|
+
const num = speedColor(tps, `${fmt(tps)} tps`, theme);
|
|
199
|
+
return `${theme.fg("accent", s)} ${sp} ${num}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function renderFinal(theme: any): string {
|
|
203
|
+
const avg = winAvg();
|
|
204
|
+
const mu = atMean();
|
|
205
|
+
const p95 = atP95();
|
|
84
206
|
if (avg === 0 && mu === 0) return "";
|
|
85
|
-
|
|
207
|
+
|
|
208
|
+
const sp = sparkline(theme);
|
|
209
|
+
const a = speedColor(avg, fmt(avg), theme);
|
|
210
|
+
const m = speedColor(mu, `μ ${fmt(mu)}`, theme);
|
|
211
|
+
const p = speedColor(p95, `p95 ${fmt(p95)}`, theme);
|
|
212
|
+
|
|
213
|
+
return `TPS ${sp} ${a} avg | ${m} | ${p}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- Single shared timer ---
|
|
217
|
+
|
|
218
|
+
function startTick(ctx: any, theme: any): void {
|
|
219
|
+
if (tickTimer) return;
|
|
220
|
+
tickTimer = setInterval(() => {
|
|
221
|
+
if (!streaming) {
|
|
222
|
+
stopTick();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
ctx.ui.setStatus("tps", renderLive(theme));
|
|
226
|
+
}, STREAM_INTERVAL_MS);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function stopTick(): void {
|
|
230
|
+
if (tickTimer) {
|
|
231
|
+
clearInterval(tickTimer);
|
|
232
|
+
tickTimer = null;
|
|
233
|
+
}
|
|
86
234
|
}
|
|
87
235
|
|
|
88
|
-
//
|
|
236
|
+
// ============================
|
|
237
|
+
// Extension
|
|
238
|
+
// ============================
|
|
89
239
|
|
|
90
240
|
export default function tpsMeter(pi: ExtensionAPI): void {
|
|
91
241
|
|
|
92
|
-
|
|
93
|
-
pi.on("message_start", async (event) => {
|
|
242
|
+
pi.on("message_start", async (event, ctx) => {
|
|
94
243
|
if (event.message.role !== "assistant") return;
|
|
95
244
|
streamStartMs = now();
|
|
245
|
+
streamChars = 0;
|
|
96
246
|
streamTokens = 0;
|
|
97
|
-
|
|
247
|
+
streaming = true;
|
|
248
|
+
spinI = 0;
|
|
249
|
+
startTick(ctx, ctx.ui.theme);
|
|
98
250
|
});
|
|
99
251
|
|
|
100
|
-
|
|
101
|
-
pi.on("message_update", async (event, ctx) => {
|
|
252
|
+
pi.on("message_update", async (event) => {
|
|
102
253
|
if (event.message.role !== "assistant") return;
|
|
103
254
|
if (!event.assistantMessageEvent) return;
|
|
104
|
-
|
|
105
255
|
const evt = event.assistantMessageEvent;
|
|
106
|
-
|
|
107
|
-
// Count text and thinking deltas
|
|
108
256
|
if (evt.type === "text_delta" || evt.type === "thinking_delta") {
|
|
109
|
-
const
|
|
110
|
-
|
|
257
|
+
const d = evt.delta as string;
|
|
258
|
+
streamChars += d.length;
|
|
259
|
+
streamTokens = tokEst(streamChars);
|
|
111
260
|
}
|
|
112
|
-
|
|
113
|
-
// Throttle status bar updates
|
|
114
|
-
const t = now();
|
|
115
|
-
if (t - lastUpdateMs < UPDATE_INTERVAL_MS) return;
|
|
116
|
-
lastUpdateMs = t;
|
|
117
|
-
|
|
118
|
-
// Live TPS during streaming
|
|
119
|
-
const elapsed = (t - streamStartMs) / 1000;
|
|
120
|
-
if (elapsed < 0.3) return; // avoid flicker at start
|
|
121
|
-
|
|
122
|
-
const liveTps = streamTokens / elapsed;
|
|
123
|
-
ctx.ui.setStatus("tps", ctx.ui.theme.fg("accent", `⚡ ${formatTps(liveTps)} tps`));
|
|
124
261
|
});
|
|
125
262
|
|
|
126
|
-
// Finalize: record TPS for this message, update rolling + all-time stats
|
|
127
263
|
pi.on("message_end", async (event, ctx) => {
|
|
128
264
|
if (event.message.role !== "assistant") return;
|
|
265
|
+
streaming = false;
|
|
266
|
+
stopTick();
|
|
129
267
|
|
|
130
268
|
const elapsed = (now() - streamStartMs) / 1000;
|
|
131
269
|
if (elapsed < 0.1 || streamTokens === 0) return;
|
|
132
270
|
|
|
133
271
|
const tps = streamTokens / elapsed;
|
|
134
272
|
|
|
135
|
-
// Record
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
273
|
+
// Record to all buffers
|
|
274
|
+
winPush(tps, now());
|
|
275
|
+
atPush(tps);
|
|
276
|
+
sparkPush(tps);
|
|
139
277
|
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
// Update status bar with aggregate stats
|
|
144
|
-
const txt = statusText();
|
|
145
|
-
if (txt) {
|
|
146
|
-
ctx.ui.setStatus("tps", ctx.ui.theme.fg("accent", txt));
|
|
147
|
-
}
|
|
278
|
+
const txt = renderFinal(ctx.ui.theme);
|
|
279
|
+
if (txt) ctx.ui.setStatus("tps", txt);
|
|
148
280
|
});
|
|
149
281
|
|
|
150
|
-
// Clear on session start
|
|
151
282
|
pi.on("session_start", async (_event, ctx) => {
|
|
283
|
+
streaming = false;
|
|
284
|
+
stopTick();
|
|
152
285
|
streamStartMs = 0;
|
|
286
|
+
streamChars = 0;
|
|
153
287
|
streamTokens = 0;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
288
|
+
winLen = 0;
|
|
289
|
+
winHead = 0;
|
|
290
|
+
atLen = 0;
|
|
291
|
+
atHead = 0;
|
|
292
|
+
atSum = 0;
|
|
293
|
+
sparkLen = 0;
|
|
294
|
+
sparkHead = 0;
|
|
295
|
+
sparkMax = 1;
|
|
296
|
+
spinI = 0;
|
|
157
297
|
ctx.ui.setStatus("tps", undefined);
|
|
158
298
|
});
|
|
159
299
|
}
|