pi-tps-meter 1.0.0 → 2.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 +19 -9
- package/extensions/tps-meter.ts +218 -80
- 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 visualization.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -8,20 +8,30 @@ Tokens per second meter for [pi CLI](https://pi.dev).
|
|
|
8
8
|
pi install npm:pi-tps-meter
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Features
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
**During streaming** — live animated display:
|
|
14
|
+
```
|
|
15
|
+
⠋ ▓▓▓▓▓▓░░░░ 42 tps
|
|
16
|
+
```
|
|
14
17
|
|
|
18
|
+
**After message** — aggregate stats:
|
|
15
19
|
```
|
|
16
|
-
|
|
17
|
-
TPS: 42 avg | μ 39 | p95 61 (after message completes)
|
|
20
|
+
TPS: ▓▓▓▓ 42 | μ 39 | p95 61
|
|
18
21
|
```
|
|
19
22
|
|
|
20
|
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
+
**Color coding:**
|
|
24
|
+
- 🟢 Green: >50 tps (fast)
|
|
25
|
+
- 🟡 Yellow: 20-50 tps (medium)
|
|
26
|
+
- 🔴 Red: <20 tps (slow)
|
|
27
|
+
|
|
28
|
+
## Optimizations
|
|
23
29
|
|
|
24
|
-
|
|
30
|
+
- Single shared timer (no per-event timers)
|
|
31
|
+
- Fixed-size circular buffer (zero allocations in hot path)
|
|
32
|
+
- Bitwise token estimation
|
|
33
|
+
- 200ms update throttle during streaming
|
|
34
|
+
- Insertion sort for p95 (fast for ≤500 elements)
|
|
25
35
|
|
|
26
36
|
## Author
|
|
27
37
|
|
package/extensions/tps-meter.ts
CHANGED
|
@@ -1,39 +1,55 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TPS Meter — Tokens Per Second
|
|
2
|
+
* TPS Meter v2 — Tokens Per Second
|
|
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
|
+
* - Live sparkline bar during streaming (10-char width)
|
|
10
|
+
* - Color-coded: green (>50), yellow (20-50), red (<20)
|
|
11
|
+
* - Rolling 60s window for avg, all-time for μ and p95
|
|
12
|
+
* - Token estimate: chars / 4
|
|
10
13
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
14
|
+
* Resource optimization:
|
|
15
|
+
* - Single shared timer (no per-event timers)
|
|
16
|
+
* - Fixed-size circular buffer for window samples
|
|
17
|
+
* - Minimal allocations in hot path
|
|
18
|
+
* - Throttled updates: 200ms streaming, 0ms on finalize
|
|
13
19
|
*/
|
|
14
20
|
|
|
15
21
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
22
|
|
|
17
23
|
// --- Config ---
|
|
18
|
-
const
|
|
24
|
+
const WINDOW_SIZE = 60; // max samples in rolling window
|
|
25
|
+
const WINDOW_MS = 60_000;
|
|
19
26
|
const CHARS_PER_TOKEN = 4;
|
|
20
|
-
const
|
|
27
|
+
const STREAM_INTERVAL_MS = 200; // update freq during streaming
|
|
28
|
+
const SPARK_WIDTH = 10; // sparkline bar width
|
|
29
|
+
const ALLTIME_CAP = 500;
|
|
21
30
|
|
|
22
|
-
// ---
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
endMs: number; // when this sample was recorded
|
|
26
|
-
}
|
|
31
|
+
// --- Speed thresholds ---
|
|
32
|
+
const FAST = 50;
|
|
33
|
+
const MED = 20;
|
|
27
34
|
|
|
35
|
+
// --- State ---
|
|
28
36
|
let streamStartMs = 0;
|
|
37
|
+
let streamChars = 0;
|
|
29
38
|
let streamTokens = 0;
|
|
30
|
-
let
|
|
39
|
+
let lastTickMs = 0;
|
|
40
|
+
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
|
41
|
+
let streaming = false;
|
|
31
42
|
|
|
32
|
-
//
|
|
33
|
-
const
|
|
43
|
+
// Circular buffer for rolling window (fixed size, no allocations)
|
|
44
|
+
const windowBuf = new Float64Array(WINDOW_SIZE * 2); // [tps, endMs] pairs
|
|
45
|
+
let windowLen = 0;
|
|
46
|
+
let windowHead = 0;
|
|
34
47
|
|
|
35
|
-
// All-time
|
|
36
|
-
const
|
|
48
|
+
// All-time stats (ring buffer)
|
|
49
|
+
const allTimeBuf = new Float64Array(ALLTIME_CAP);
|
|
50
|
+
let allTimeLen = 0;
|
|
51
|
+
let allTimeHead = 0;
|
|
52
|
+
let allTimeSum = 0;
|
|
37
53
|
|
|
38
54
|
// --- Helpers ---
|
|
39
55
|
|
|
@@ -42,118 +58,240 @@ function now(): number {
|
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
function estimateTokens(chars: number): number {
|
|
45
|
-
return
|
|
61
|
+
return (chars >>> 2) + ((chars & 3) > 0 ? 1 : 0); // ceil(chars/4) via bit ops
|
|
46
62
|
}
|
|
47
63
|
|
|
48
|
-
|
|
64
|
+
// Circular buffer push
|
|
65
|
+
function windowPush(tps: number, endMs: number): void {
|
|
66
|
+
const base = windowHead * 2;
|
|
67
|
+
windowBuf[base] = tps;
|
|
68
|
+
windowBuf[base + 1] = endMs;
|
|
69
|
+
windowHead = (windowHead + 1) % WINDOW_SIZE;
|
|
70
|
+
if (windowLen < WINDOW_SIZE) windowLen++;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function allTimePush(tps: number): void {
|
|
74
|
+
allTimeSum += tps;
|
|
75
|
+
// Subtract old value if buffer is full
|
|
76
|
+
if (allTimeLen >= ALLTIME_CAP) {
|
|
77
|
+
allTimeSum -= allTimeBuf[allTimeHead];
|
|
78
|
+
}
|
|
79
|
+
allTimeBuf[allTimeHead] = tps;
|
|
80
|
+
allTimeHead = (allTimeHead + 1) % ALLTIME_CAP;
|
|
81
|
+
if (allTimeLen < ALLTIME_CAP) allTimeLen++;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rollingAvg(): number {
|
|
85
|
+
if (windowLen === 0) return 0;
|
|
49
86
|
const cutoff = now() - WINDOW_MS;
|
|
50
|
-
|
|
51
|
-
|
|
87
|
+
let totalTokens = 0;
|
|
88
|
+
let firstMs = Infinity;
|
|
89
|
+
let lastMs = 0;
|
|
90
|
+
let count = 0;
|
|
91
|
+
|
|
92
|
+
// Walk circular buffer
|
|
93
|
+
const oldest = windowLen < WINDOW_SIZE ? 0 : windowHead;
|
|
94
|
+
for (let i = 0; i < windowLen; i++) {
|
|
95
|
+
const idx = (oldest + i) % WINDOW_SIZE;
|
|
96
|
+
const base = idx * 2;
|
|
97
|
+
const endMs = windowBuf[base + 1];
|
|
98
|
+
if (endMs < cutoff) continue;
|
|
99
|
+
const tps = windowBuf[base];
|
|
100
|
+
// Estimate tokens from tps (we don't store tokens directly)
|
|
101
|
+
// Approximate: tps * timeBetweenSamples ≈ tokens
|
|
102
|
+
totalTokens += tps; // we'll divide by count for avg
|
|
103
|
+
if (endMs < firstMs) firstMs = endMs;
|
|
104
|
+
if (endMs > lastMs) lastMs = endMs;
|
|
105
|
+
count++;
|
|
52
106
|
}
|
|
107
|
+
|
|
108
|
+
if (count === 0) return 0;
|
|
109
|
+
// Return average TPS of recent samples
|
|
110
|
+
return totalTokens / count;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function allTimeMean(): number {
|
|
114
|
+
return allTimeLen === 0 ? 0 : allTimeSum / allTimeLen;
|
|
53
115
|
}
|
|
54
116
|
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
117
|
+
function allTimeP95(): number {
|
|
118
|
+
if (allTimeLen === 0) return 0;
|
|
119
|
+
|
|
120
|
+
// Copy valid entries to temp array for sorting
|
|
121
|
+
const temp = new Float64Array(allTimeLen);
|
|
122
|
+
const oldest = allTimeLen < ALLTIME_CAP ? 0 : allTimeHead;
|
|
123
|
+
for (let i = 0; i < allTimeLen; i++) {
|
|
124
|
+
temp[i] = allTimeBuf[(oldest + i) % ALLTIME_CAP];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Insertion sort (fast for small arrays)
|
|
128
|
+
for (let i = 1; i < temp.length; i++) {
|
|
129
|
+
const val = temp[i];
|
|
130
|
+
let j = i - 1;
|
|
131
|
+
while (j >= 0 && temp[j] > val) {
|
|
132
|
+
temp[j + 1] = temp[j];
|
|
133
|
+
j--;
|
|
134
|
+
}
|
|
135
|
+
temp[j + 1] = val;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const idx = Math.ceil(temp.length * 0.95) - 1;
|
|
139
|
+
return temp[Math.max(0, idx)];
|
|
62
140
|
}
|
|
63
141
|
|
|
64
|
-
function
|
|
65
|
-
|
|
66
|
-
return allTime.reduce((a, b) => a + b, 0) / allTime.length;
|
|
142
|
+
function fmt(v: number): string {
|
|
143
|
+
return v < 10 ? v.toFixed(1) : v < 100 ? v.toFixed(0) : `${Math.round(v)}`;
|
|
67
144
|
}
|
|
68
145
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return
|
|
146
|
+
// Speed-based color
|
|
147
|
+
function speedColor(tps: number, text: string, theme: any): string {
|
|
148
|
+
if (tps >= FAST) return theme.fg("success", text);
|
|
149
|
+
if (tps >= MED) return theme.fg("warning", text);
|
|
150
|
+
return theme.fg("error", text);
|
|
74
151
|
}
|
|
75
152
|
|
|
76
|
-
|
|
77
|
-
|
|
153
|
+
// Sparkline bar: ▓ fast, ▒ medium, ░ slow
|
|
154
|
+
function sparkBar(tps: number, theme: any): string {
|
|
155
|
+
if (tps <= 0) return theme.fg("dim", "░".repeat(SPARK_WIDTH));
|
|
156
|
+
|
|
157
|
+
// Map 0-100 tps to 0-10 width
|
|
158
|
+
const fill = Math.min(SPARK_WIDTH, Math.round((tps / 100) * SPARK_WIDTH));
|
|
159
|
+
const empty = SPARK_WIDTH - fill;
|
|
160
|
+
|
|
161
|
+
let bar = "";
|
|
162
|
+
if (tps >= FAST) {
|
|
163
|
+
bar = theme.fg("success", "▓".repeat(fill)) + theme.fg("dim", "░".repeat(empty));
|
|
164
|
+
} else if (tps >= MED) {
|
|
165
|
+
bar = theme.fg("warning", "▓".repeat(fill)) + theme.fg("dim", "░".repeat(empty));
|
|
166
|
+
} else {
|
|
167
|
+
bar = theme.fg("error", "▓".repeat(fill)) + theme.fg("dim", "░".repeat(empty));
|
|
168
|
+
}
|
|
169
|
+
return bar;
|
|
78
170
|
}
|
|
79
171
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
172
|
+
// Streaming spinner frames
|
|
173
|
+
const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
174
|
+
let spinIdx = 0;
|
|
175
|
+
function spinFrame(): string {
|
|
176
|
+
const s = SPIN[spinIdx];
|
|
177
|
+
spinIdx = (spinIdx + 1) % SPIN.length;
|
|
178
|
+
return s;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- Status rendering ---
|
|
182
|
+
|
|
183
|
+
function renderStreaming(theme: any): string {
|
|
184
|
+
const elapsed = (now() - streamStartMs) / 1000;
|
|
185
|
+
const tps = elapsed > 0.3 ? streamTokens / elapsed : 0;
|
|
186
|
+
const spin = spinFrame();
|
|
187
|
+
const bar = sparkBar(tps, theme);
|
|
188
|
+
const num = speedColor(tps, `${fmt(tps)} tps`, theme);
|
|
189
|
+
return `${theme.fg("accent", spin)} ${bar} ${num}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function renderComplete(theme: any): string {
|
|
193
|
+
const avg = rollingAvg();
|
|
194
|
+
const mu = allTimeMean();
|
|
195
|
+
const p95 = allTimeP95();
|
|
84
196
|
if (avg === 0 && mu === 0) return "";
|
|
85
|
-
|
|
197
|
+
|
|
198
|
+
const bar = sparkBar(avg, theme);
|
|
199
|
+
const avgStr = speedColor(avg, fmt(avg), theme);
|
|
200
|
+
const muStr = speedColor(mu, `μ ${fmt(mu)}`, theme);
|
|
201
|
+
const p95Str = speedColor(p95, `p95 ${fmt(p95)}`, theme);
|
|
202
|
+
|
|
203
|
+
return `TPS: ${bar} ${avgStr} | ${muStr} | ${p95Str}`;
|
|
86
204
|
}
|
|
87
205
|
|
|
88
|
-
// ---
|
|
206
|
+
// --- Tick loop (single timer for all updates) ---
|
|
207
|
+
|
|
208
|
+
function startTick(ctx: any, theme: any): void {
|
|
209
|
+
if (tickTimer) return;
|
|
210
|
+
tickTimer = setInterval(() => {
|
|
211
|
+
if (!streaming) {
|
|
212
|
+
stopTick();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
ctx.ui.setStatus("tps", renderStreaming(theme));
|
|
216
|
+
}, STREAM_INTERVAL_MS);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function stopTick(): void {
|
|
220
|
+
if (tickTimer) {
|
|
221
|
+
clearInterval(tickTimer);
|
|
222
|
+
tickTimer = null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ============================
|
|
227
|
+
// Extension
|
|
228
|
+
// ============================
|
|
89
229
|
|
|
90
230
|
export default function tpsMeter(pi: ExtensionAPI): void {
|
|
91
231
|
|
|
92
|
-
//
|
|
93
|
-
pi.on("message_start", async (event) => {
|
|
232
|
+
// New assistant message — start counting
|
|
233
|
+
pi.on("message_start", async (event, ctx) => {
|
|
94
234
|
if (event.message.role !== "assistant") return;
|
|
235
|
+
|
|
95
236
|
streamStartMs = now();
|
|
237
|
+
streamChars = 0;
|
|
96
238
|
streamTokens = 0;
|
|
97
|
-
|
|
239
|
+
lastTickMs = 0;
|
|
240
|
+
streaming = true;
|
|
241
|
+
spinIdx = 0;
|
|
242
|
+
|
|
243
|
+
startTick(ctx, ctx.ui.theme);
|
|
98
244
|
});
|
|
99
245
|
|
|
100
|
-
//
|
|
246
|
+
// Accumulate tokens from stream deltas
|
|
101
247
|
pi.on("message_update", async (event, ctx) => {
|
|
102
248
|
if (event.message.role !== "assistant") return;
|
|
103
249
|
if (!event.assistantMessageEvent) return;
|
|
104
250
|
|
|
105
251
|
const evt = event.assistantMessageEvent;
|
|
106
|
-
|
|
107
|
-
// Count text and thinking deltas
|
|
108
252
|
if (evt.type === "text_delta" || evt.type === "thinking_delta") {
|
|
109
253
|
const delta = evt.delta as string;
|
|
110
|
-
|
|
254
|
+
streamChars += delta.length;
|
|
255
|
+
streamTokens = estimateTokens(streamChars);
|
|
111
256
|
}
|
|
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
257
|
});
|
|
125
258
|
|
|
126
|
-
//
|
|
259
|
+
// Message done — finalize stats
|
|
127
260
|
pi.on("message_end", async (event, ctx) => {
|
|
128
261
|
if (event.message.role !== "assistant") return;
|
|
129
262
|
|
|
263
|
+
streaming = false;
|
|
264
|
+
stopTick();
|
|
265
|
+
|
|
130
266
|
const elapsed = (now() - streamStartMs) / 1000;
|
|
131
267
|
if (elapsed < 0.1 || streamTokens === 0) return;
|
|
132
268
|
|
|
133
269
|
const tps = streamTokens / elapsed;
|
|
134
270
|
|
|
135
|
-
// Record
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
allTime.push(tps);
|
|
139
|
-
|
|
140
|
-
// Cap all-time history (keep last 500 measurements)
|
|
141
|
-
if (allTime.length > 500) allTime.splice(0, allTime.length - 500);
|
|
271
|
+
// Record to circular buffers
|
|
272
|
+
windowPush(tps, now());
|
|
273
|
+
allTimePush(tps);
|
|
142
274
|
|
|
143
|
-
//
|
|
144
|
-
const txt =
|
|
275
|
+
// Show final stats
|
|
276
|
+
const txt = renderComplete(ctx.ui.theme);
|
|
145
277
|
if (txt) {
|
|
146
|
-
ctx.ui.setStatus("tps",
|
|
278
|
+
ctx.ui.setStatus("tps", txt);
|
|
147
279
|
}
|
|
148
280
|
});
|
|
149
281
|
|
|
150
282
|
// Clear on session start
|
|
151
283
|
pi.on("session_start", async (_event, ctx) => {
|
|
284
|
+
streaming = false;
|
|
285
|
+
stopTick();
|
|
152
286
|
streamStartMs = 0;
|
|
287
|
+
streamChars = 0;
|
|
153
288
|
streamTokens = 0;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
289
|
+
windowLen = 0;
|
|
290
|
+
windowHead = 0;
|
|
291
|
+
allTimeLen = 0;
|
|
292
|
+
allTimeHead = 0;
|
|
293
|
+
allTimeSum = 0;
|
|
294
|
+
spinIdx = 0;
|
|
157
295
|
ctx.ui.setStatus("tps", undefined);
|
|
158
296
|
});
|
|
159
297
|
}
|