pi-tps-meter 2.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 +12 -11
- package/extensions/tps-meter.ts +164 -162
- 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) with sparkline visualization.
|
|
3
|
+
Tokens per second meter for [pi CLI](https://pi.dev) with sparkline trend visualization.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -8,30 +8,31 @@ Tokens per second meter for [pi CLI](https://pi.dev) with sparkline visualizatio
|
|
|
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
14
|
```
|
|
15
|
-
|
|
15
|
+
⠹ ▁▂▃▅▇▆▅▃▂▁ 42 tps
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
**
|
|
18
|
+
**Complete** — aggregate stats with trend:
|
|
19
19
|
```
|
|
20
|
-
TPS
|
|
20
|
+
TPS ▁▂▃▅▇▆▅▃▂▁ 42 avg | μ 39 | p95 61
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
**Color
|
|
23
|
+
**Sparkline** uses Unicode block characters `▁▂▃▄▅▆▇█` — each char = one recent message's TPS. Left = oldest, right = newest. Color-coded by speed.
|
|
24
|
+
|
|
25
|
+
**Colors:**
|
|
24
26
|
- 🟢 Green: >50 tps (fast)
|
|
25
27
|
- 🟡 Yellow: 20-50 tps (medium)
|
|
26
28
|
- 🔴 Red: <20 tps (slow)
|
|
27
29
|
|
|
28
30
|
## Optimizations
|
|
29
31
|
|
|
30
|
-
-
|
|
31
|
-
- Fixed-size circular buffer (zero allocations in hot path)
|
|
32
|
+
- Fixed ring buffers (zero allocations)
|
|
32
33
|
- Bitwise token estimation
|
|
33
|
-
-
|
|
34
|
-
- Insertion sort for p95
|
|
34
|
+
- Single shared timer
|
|
35
|
+
- Insertion sort for p95
|
|
35
36
|
|
|
36
37
|
## Author
|
|
37
38
|
|
package/extensions/tps-meter.ts
CHANGED
|
@@ -1,55 +1,60 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* TPS Meter
|
|
2
|
+
* TPS Meter v3 — Tokens Per Second with Sparkline Trend
|
|
3
3
|
*
|
|
4
4
|
* Footer display:
|
|
5
|
-
* Streaming:
|
|
6
|
-
* Complete: TPS
|
|
5
|
+
* Streaming: ⠹ ▁▂▃▅▇▆▅▃▂▁ 42 tps
|
|
6
|
+
* Complete: TPS ▁▂▃▅▇▆▅▃▂▁ 42 avg | μ 39 | p95 61
|
|
7
7
|
*
|
|
8
8
|
* Features:
|
|
9
|
-
* -
|
|
10
|
-
* - Color-coded
|
|
9
|
+
* - Real sparkline: ▁▂▃▄▅▆▇█ showing TPS trend over last 12 messages
|
|
10
|
+
* - Color-coded sparkline by speed
|
|
11
|
+
* - Animated spinner during streaming
|
|
11
12
|
* - Rolling 60s window for avg, all-time for μ and p95
|
|
12
|
-
* - Token estimate: chars / 4
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
16
|
-
* -
|
|
17
|
-
* -
|
|
18
|
-
* -
|
|
14
|
+
* Optimizations:
|
|
15
|
+
* - Fixed ring buffer (no allocations)
|
|
16
|
+
* - Bitwise token estimation
|
|
17
|
+
* - Single shared timer
|
|
18
|
+
* - Insertion sort for p95
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
22
22
|
|
|
23
23
|
// --- Config ---
|
|
24
|
-
const WINDOW_SIZE = 60;
|
|
24
|
+
const WINDOW_SIZE = 60;
|
|
25
25
|
const WINDOW_MS = 60_000;
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const SPARK_WIDTH = 10; // sparkline bar width
|
|
26
|
+
const STREAM_INTERVAL_MS = 200;
|
|
27
|
+
const SPARK_LEN = 12;
|
|
29
28
|
const ALLTIME_CAP = 500;
|
|
30
|
-
|
|
31
|
-
// --- Speed thresholds ---
|
|
32
29
|
const FAST = 50;
|
|
33
30
|
const MED = 20;
|
|
34
31
|
|
|
32
|
+
// --- Sparkline chars (8 levels) ---
|
|
33
|
+
const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
34
|
+
|
|
35
35
|
// --- State ---
|
|
36
36
|
let streamStartMs = 0;
|
|
37
37
|
let streamChars = 0;
|
|
38
38
|
let streamTokens = 0;
|
|
39
|
-
let lastTickMs = 0;
|
|
40
39
|
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
|
41
40
|
let streaming = false;
|
|
42
41
|
|
|
43
|
-
//
|
|
44
|
-
const
|
|
45
|
-
let
|
|
46
|
-
let
|
|
42
|
+
// Rolling window (circular buffer)
|
|
43
|
+
const winBuf = new Float64Array(WINDOW_SIZE * 2);
|
|
44
|
+
let winLen = 0;
|
|
45
|
+
let winHead = 0;
|
|
47
46
|
|
|
48
|
-
// All-time stats (
|
|
49
|
-
const
|
|
50
|
-
let
|
|
51
|
-
let
|
|
52
|
-
let
|
|
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
|
|
53
58
|
|
|
54
59
|
// --- Helpers ---
|
|
55
60
|
|
|
@@ -57,153 +62,158 @@ function now(): number {
|
|
|
57
62
|
return Date.now();
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
function
|
|
61
|
-
return (
|
|
65
|
+
function tokEst(ch: number): number {
|
|
66
|
+
return (ch >>> 2) + ((ch & 3) > 0 ? 1 : 0);
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (windowLen < WINDOW_SIZE) windowLen++;
|
|
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++;
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
allTimeBuf[allTimeHead] = tps;
|
|
80
|
-
allTimeHead = (allTimeHead + 1) % ALLTIME_CAP;
|
|
81
|
-
if (allTimeLen < ALLTIME_CAP) allTimeLen++;
|
|
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++;
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
function
|
|
85
|
-
|
|
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;
|
|
86
96
|
const cutoff = now() - WINDOW_MS;
|
|
87
|
-
let
|
|
88
|
-
let
|
|
89
|
-
|
|
90
|
-
let
|
|
91
|
-
|
|
92
|
-
// Walk circular buffer
|
|
93
|
-
const oldest = windowLen < WINDOW_SIZE ? 0 : windowHead;
|
|
94
|
-
for (let i = 0; i < windowLen; i++) {
|
|
97
|
+
let sum = 0;
|
|
98
|
+
let n = 0;
|
|
99
|
+
const oldest = winLen < WINDOW_SIZE ? 0 : winHead;
|
|
100
|
+
for (let i = 0; i < winLen; i++) {
|
|
95
101
|
const idx = (oldest + i) % WINDOW_SIZE;
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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++;
|
|
102
|
+
const b = idx * 2;
|
|
103
|
+
if (winBuf[b + 1] < cutoff) continue;
|
|
104
|
+
sum += winBuf[b];
|
|
105
|
+
n++;
|
|
106
106
|
}
|
|
107
|
-
|
|
108
|
-
if (count === 0) return 0;
|
|
109
|
-
// Return average TPS of recent samples
|
|
110
|
-
return totalTokens / count;
|
|
107
|
+
return n === 0 ? 0 : sum / n;
|
|
111
108
|
}
|
|
112
109
|
|
|
113
|
-
function
|
|
114
|
-
return
|
|
110
|
+
function atMean(): number {
|
|
111
|
+
return atLen === 0 ? 0 : atSum / atLen;
|
|
115
112
|
}
|
|
116
113
|
|
|
117
|
-
function
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
for (let i =
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Insertion sort (fast for small arrays)
|
|
128
|
-
for (let i = 1; i < temp.length; i++) {
|
|
129
|
-
const val = temp[i];
|
|
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];
|
|
130
122
|
let j = i - 1;
|
|
131
|
-
while (j >= 0 &&
|
|
132
|
-
|
|
123
|
+
while (j >= 0 && tmp[j] > v) {
|
|
124
|
+
tmp[j + 1] = tmp[j];
|
|
133
125
|
j--;
|
|
134
126
|
}
|
|
135
|
-
|
|
127
|
+
tmp[j + 1] = v;
|
|
136
128
|
}
|
|
137
|
-
|
|
138
|
-
const idx = Math.ceil(temp.length * 0.95) - 1;
|
|
139
|
-
return temp[Math.max(0, idx)];
|
|
129
|
+
return tmp[Math.ceil(tmp.length * 0.95) - 1] || 0;
|
|
140
130
|
}
|
|
141
131
|
|
|
142
132
|
function fmt(v: number): string {
|
|
143
|
-
|
|
133
|
+
if (v < 10) return v.toFixed(1);
|
|
134
|
+
if (v < 100) return v.toFixed(0);
|
|
135
|
+
return `${Math.round(v)}`;
|
|
144
136
|
}
|
|
145
137
|
|
|
146
|
-
//
|
|
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);
|
|
151
|
-
}
|
|
138
|
+
// --- Sparkline rendering ---
|
|
152
139
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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;
|
|
168
171
|
}
|
|
169
|
-
return
|
|
172
|
+
return result;
|
|
170
173
|
}
|
|
171
174
|
|
|
172
|
-
//
|
|
175
|
+
// --- Spinner ---
|
|
176
|
+
|
|
173
177
|
const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
174
|
-
let
|
|
175
|
-
function
|
|
176
|
-
const s = SPIN[
|
|
177
|
-
|
|
178
|
+
let spinI = 0;
|
|
179
|
+
function spin(): string {
|
|
180
|
+
const s = SPIN[spinI];
|
|
181
|
+
spinI = (spinI + 1) % SPIN.length;
|
|
178
182
|
return s;
|
|
179
183
|
}
|
|
180
184
|
|
|
181
|
-
|
|
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 ---
|
|
182
192
|
|
|
183
|
-
function
|
|
193
|
+
function renderLive(theme: any): string {
|
|
184
194
|
const elapsed = (now() - streamStartMs) / 1000;
|
|
185
195
|
const tps = elapsed > 0.3 ? streamTokens / elapsed : 0;
|
|
186
|
-
const
|
|
187
|
-
const
|
|
196
|
+
const s = spin();
|
|
197
|
+
const sp = sparkline(theme);
|
|
188
198
|
const num = speedColor(tps, `${fmt(tps)} tps`, theme);
|
|
189
|
-
return `${theme.fg("accent",
|
|
199
|
+
return `${theme.fg("accent", s)} ${sp} ${num}`;
|
|
190
200
|
}
|
|
191
201
|
|
|
192
|
-
function
|
|
193
|
-
const avg =
|
|
194
|
-
const mu =
|
|
195
|
-
const p95 =
|
|
202
|
+
function renderFinal(theme: any): string {
|
|
203
|
+
const avg = winAvg();
|
|
204
|
+
const mu = atMean();
|
|
205
|
+
const p95 = atP95();
|
|
196
206
|
if (avg === 0 && mu === 0) return "";
|
|
197
207
|
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
const
|
|
201
|
-
const
|
|
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);
|
|
202
212
|
|
|
203
|
-
return `TPS
|
|
213
|
+
return `TPS ${sp} ${a} avg | ${m} | ${p}`;
|
|
204
214
|
}
|
|
205
215
|
|
|
206
|
-
// ---
|
|
216
|
+
// --- Single shared timer ---
|
|
207
217
|
|
|
208
218
|
function startTick(ctx: any, theme: any): void {
|
|
209
219
|
if (tickTimer) return;
|
|
@@ -212,7 +222,7 @@ function startTick(ctx: any, theme: any): void {
|
|
|
212
222
|
stopTick();
|
|
213
223
|
return;
|
|
214
224
|
}
|
|
215
|
-
ctx.ui.setStatus("tps",
|
|
225
|
+
ctx.ui.setStatus("tps", renderLive(theme));
|
|
216
226
|
}, STREAM_INTERVAL_MS);
|
|
217
227
|
}
|
|
218
228
|
|
|
@@ -229,37 +239,29 @@ function stopTick(): void {
|
|
|
229
239
|
|
|
230
240
|
export default function tpsMeter(pi: ExtensionAPI): void {
|
|
231
241
|
|
|
232
|
-
// New assistant message — start counting
|
|
233
242
|
pi.on("message_start", async (event, ctx) => {
|
|
234
243
|
if (event.message.role !== "assistant") return;
|
|
235
|
-
|
|
236
244
|
streamStartMs = now();
|
|
237
245
|
streamChars = 0;
|
|
238
246
|
streamTokens = 0;
|
|
239
|
-
lastTickMs = 0;
|
|
240
247
|
streaming = true;
|
|
241
|
-
|
|
242
|
-
|
|
248
|
+
spinI = 0;
|
|
243
249
|
startTick(ctx, ctx.ui.theme);
|
|
244
250
|
});
|
|
245
251
|
|
|
246
|
-
|
|
247
|
-
pi.on("message_update", async (event, ctx) => {
|
|
252
|
+
pi.on("message_update", async (event) => {
|
|
248
253
|
if (event.message.role !== "assistant") return;
|
|
249
254
|
if (!event.assistantMessageEvent) return;
|
|
250
|
-
|
|
251
255
|
const evt = event.assistantMessageEvent;
|
|
252
256
|
if (evt.type === "text_delta" || evt.type === "thinking_delta") {
|
|
253
|
-
const
|
|
254
|
-
streamChars +=
|
|
255
|
-
streamTokens =
|
|
257
|
+
const d = evt.delta as string;
|
|
258
|
+
streamChars += d.length;
|
|
259
|
+
streamTokens = tokEst(streamChars);
|
|
256
260
|
}
|
|
257
261
|
});
|
|
258
262
|
|
|
259
|
-
// Message done — finalize stats
|
|
260
263
|
pi.on("message_end", async (event, ctx) => {
|
|
261
264
|
if (event.message.role !== "assistant") return;
|
|
262
|
-
|
|
263
265
|
streaming = false;
|
|
264
266
|
stopTick();
|
|
265
267
|
|
|
@@ -268,30 +270,30 @@ export default function tpsMeter(pi: ExtensionAPI): void {
|
|
|
268
270
|
|
|
269
271
|
const tps = streamTokens / elapsed;
|
|
270
272
|
|
|
271
|
-
// Record to
|
|
272
|
-
|
|
273
|
-
|
|
273
|
+
// Record to all buffers
|
|
274
|
+
winPush(tps, now());
|
|
275
|
+
atPush(tps);
|
|
276
|
+
sparkPush(tps);
|
|
274
277
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (txt) {
|
|
278
|
-
ctx.ui.setStatus("tps", txt);
|
|
279
|
-
}
|
|
278
|
+
const txt = renderFinal(ctx.ui.theme);
|
|
279
|
+
if (txt) ctx.ui.setStatus("tps", txt);
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
-
// Clear on session start
|
|
283
282
|
pi.on("session_start", async (_event, ctx) => {
|
|
284
283
|
streaming = false;
|
|
285
284
|
stopTick();
|
|
286
285
|
streamStartMs = 0;
|
|
287
286
|
streamChars = 0;
|
|
288
287
|
streamTokens = 0;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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;
|
|
295
297
|
ctx.ui.setStatus("tps", undefined);
|
|
296
298
|
});
|
|
297
299
|
}
|