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 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
- ## Usage
11
+ ## Display
12
12
 
13
- Footer shows live stats during and after streaming:
13
+ **Streaming** live sparkline updates every 200ms:
14
+ ```
15
+ ⠹ ▁▂▃▅▇▆▅▃▂▁ 42 tps
16
+ ```
14
17
 
18
+ **Complete** — aggregate stats with trend:
15
19
  ```
16
- 42 tps (during streaming, updates every 500ms)
17
- TPS: 42 avg | μ 39 | p95 61 (after message completes)
20
+ TPS ▁▂▃▅▇▆▅▃▂▁ 42 avg | μ 39 | p95 61
18
21
  ```
19
22
 
20
- - **avg** — rolling average over last 60 seconds
21
- - **μ** — all-time mean
22
- - **p95** — 95th percentile of all measurements
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
- No config needed. Works out of the box.
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
 
@@ -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
- * Displays in footer status bar next to caveman level:
5
- * TPS: 42.1 avg | μ 38.7 | p95 61.2
4
+ * Footer display:
5
+ * Streaming: ▁▂▃▅▇▆▅▃▂▁ 42 tps
6
+ * Complete: TPS ▁▂▃▅▇▆▅▃▂▁ 42 avg | μ 39 | p95 61
6
7
  *
7
- * - Rolling avg: last 60s window
8
- * - μ (mean): all-time average
9
- * - p95: 95th percentile of all recorded TPS values
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
- * Uses character count / 4 as token estimate.
12
- * Fires on message_update (text_delta) and finalizes on message_end.
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 WINDOW_MS = 60_000; // 1 minute rolling window
19
- const CHARS_PER_TOKEN = 4;
20
- const UPDATE_INTERVAL_MS = 500; // throttle status bar updates
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
- // --- State ---
23
- interface StreamSample {
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 lastUpdateMs = 0;
39
+ let tickTimer: ReturnType<typeof setInterval> | null = null;
40
+ let streaming = false;
31
41
 
32
- // Rolling window (last 60s)
33
- const window: StreamSample[] = [];
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 for mean and p95
36
- const allTime: number[] = []; // TPS values per assistant message
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 estimateTokens(chars: number): number {
45
- return Math.max(1, Math.round(chars / CHARS_PER_TOKEN));
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 pruneWindow(): void {
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
- while (window.length > 0 && window[0].endMs < cutoff) {
51
- window.shift();
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 rollingTps(): number {
56
- pruneWindow();
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 meanTps(): number {
65
- if (allTime.length === 0) return 0;
66
- return allTime.reduce((a, b) => a + b, 0) / allTime.length;
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 p95Tps(): number {
70
- if (allTime.length === 0) return 0;
71
- const sorted = [...allTime].sort((a, b) => a - b);
72
- const idx = Math.ceil(sorted.length * 0.95) - 1;
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
- function formatTps(v: number): string {
77
- return v < 10 ? v.toFixed(1) : v.toFixed(0);
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
- function statusText(): string {
81
- const avg = rollingTps();
82
- const mu = meanTps();
83
- const p95 = p95Tps();
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
- return `TPS: ${formatTps(avg)} avg | μ ${formatTps(mu)} | p95 ${formatTps(p95)}`;
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
- // --- Extension ---
236
+ // ============================
237
+ // Extension
238
+ // ============================
89
239
 
90
240
  export default function tpsMeter(pi: ExtensionAPI): void {
91
241
 
92
- // Reset on new assistant message
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
- lastUpdateMs = 0;
247
+ streaming = true;
248
+ spinI = 0;
249
+ startTick(ctx, ctx.ui.theme);
98
250
  });
99
251
 
100
- // Count tokens from stream deltas, update status bar periodically
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 delta = evt.delta as string;
110
- streamTokens += estimateTokens(delta.length);
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 sample
136
- const sample: StreamSample = { tokens: streamTokens, endMs: now() };
137
- window.push(sample);
138
- allTime.push(tps);
273
+ // Record to all buffers
274
+ winPush(tps, now());
275
+ atPush(tps);
276
+ sparkPush(tps);
139
277
 
140
- // Cap all-time history (keep last 500 measurements)
141
- if (allTime.length > 500) allTime.splice(0, allTime.length - 500);
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
- lastUpdateMs = 0;
155
- window.length = 0;
156
- allTime.length = 0;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tps-meter",
3
- "version": "1.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "Tokens per second meter for pi CLI — live TPS, rolling avg, mean, p95",
5
5
  "author": "Venkata Sai Chirasani",
6
6
  "license": "MIT",