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 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
- ## Features
11
+ ## Display
12
12
 
13
- **During streaming** — live animated display:
13
+ **Streaming** — live sparkline updates every 200ms:
14
14
  ```
15
- ▓▓▓▓▓▓░░░░ 42 tps
15
+ ▁▂▃▅▇▆▅▃▂▁ 42 tps
16
16
  ```
17
17
 
18
- **After message** — aggregate stats:
18
+ **Complete** — aggregate stats with trend:
19
19
  ```
20
- TPS: ▓▓▓▓ 42 | μ 39 | p95 61
20
+ TPS ▁▂▃▅▇▆▅▃▂▁ 42 avg | μ 39 | p95 61
21
21
  ```
22
22
 
23
- **Color coding:**
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
- - Single shared timer (no per-event timers)
31
- - Fixed-size circular buffer (zero allocations in hot path)
32
+ - Fixed ring buffers (zero allocations)
32
33
  - Bitwise token estimation
33
- - 200ms update throttle during streaming
34
- - Insertion sort for p95 (fast for ≤500 elements)
34
+ - Single shared timer
35
+ - Insertion sort for p95
35
36
 
36
37
  ## Author
37
38
 
@@ -1,55 +1,60 @@
1
1
  /**
2
- * TPS Meter v2 — Tokens Per Second
2
+ * TPS Meter v3 — Tokens Per Second with Sparkline Trend
3
3
  *
4
4
  * Footer display:
5
- * Streaming: ▓▓▓▓▓▓░░░░ 42 tps
6
- * Complete: TPS: ▓▓▓▓ 42 avg | μ 39 | p95 61
5
+ * Streaming: ▁▂▃▅▇▆▅▃▂▁ 42 tps
6
+ * Complete: TPS ▁▂▃▅▇▆▅▃▂▁ 42 avg | μ 39 | p95 61
7
7
  *
8
8
  * Features:
9
- * - Live sparkline bar during streaming (10-char width)
10
- * - Color-coded: green (>50), yellow (20-50), red (<20)
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
- * 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
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; // max samples in rolling window
24
+ const WINDOW_SIZE = 60;
25
25
  const WINDOW_MS = 60_000;
26
- const CHARS_PER_TOKEN = 4;
27
- const STREAM_INTERVAL_MS = 200; // update freq during streaming
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
- // 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;
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 (ring buffer)
49
- const allTimeBuf = new Float64Array(ALLTIME_CAP);
50
- let allTimeLen = 0;
51
- let allTimeHead = 0;
52
- let allTimeSum = 0;
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 estimateTokens(chars: number): number {
61
- return (chars >>> 2) + ((chars & 3) > 0 ? 1 : 0); // ceil(chars/4) via bit ops
65
+ function tokEst(ch: number): number {
66
+ return (ch >>> 2) + ((ch & 3) > 0 ? 1 : 0);
62
67
  }
63
68
 
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++;
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 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++;
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 rollingAvg(): number {
85
- if (windowLen === 0) return 0;
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 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++) {
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 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++;
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 allTimeMean(): number {
114
- return allTimeLen === 0 ? 0 : allTimeSum / allTimeLen;
110
+ function atMean(): number {
111
+ return atLen === 0 ? 0 : atSum / atLen;
115
112
  }
116
113
 
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];
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 && temp[j] > val) {
132
- temp[j + 1] = temp[j];
123
+ while (j >= 0 && tmp[j] > v) {
124
+ tmp[j + 1] = tmp[j];
133
125
  j--;
134
126
  }
135
- temp[j + 1] = val;
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
- return v < 10 ? v.toFixed(1) : v < 100 ? v.toFixed(0) : `${Math.round(v)}`;
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
- // 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);
151
- }
138
+ // --- Sparkline rendering ---
152
139
 
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));
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 bar;
172
+ return result;
170
173
  }
171
174
 
172
- // Streaming spinner frames
175
+ // --- Spinner ---
176
+
173
177
  const SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
174
- let spinIdx = 0;
175
- function spinFrame(): string {
176
- const s = SPIN[spinIdx];
177
- spinIdx = (spinIdx + 1) % SPIN.length;
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
- // --- Status rendering ---
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 renderStreaming(theme: any): string {
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 spin = spinFrame();
187
- const bar = sparkBar(tps, theme);
196
+ const s = spin();
197
+ const sp = sparkline(theme);
188
198
  const num = speedColor(tps, `${fmt(tps)} tps`, theme);
189
- return `${theme.fg("accent", spin)} ${bar} ${num}`;
199
+ return `${theme.fg("accent", s)} ${sp} ${num}`;
190
200
  }
191
201
 
192
- function renderComplete(theme: any): string {
193
- const avg = rollingAvg();
194
- const mu = allTimeMean();
195
- const p95 = allTimeP95();
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 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);
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: ${bar} ${avgStr} | ${muStr} | ${p95Str}`;
213
+ return `TPS ${sp} ${a} avg | ${m} | ${p}`;
204
214
  }
205
215
 
206
- // --- Tick loop (single timer for all updates) ---
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", renderStreaming(theme));
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
- spinIdx = 0;
242
-
248
+ spinI = 0;
243
249
  startTick(ctx, ctx.ui.theme);
244
250
  });
245
251
 
246
- // Accumulate tokens from stream deltas
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 delta = evt.delta as string;
254
- streamChars += delta.length;
255
- streamTokens = estimateTokens(streamChars);
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 circular buffers
272
- windowPush(tps, now());
273
- allTimePush(tps);
273
+ // Record to all buffers
274
+ winPush(tps, now());
275
+ atPush(tps);
276
+ sparkPush(tps);
274
277
 
275
- // Show final stats
276
- const txt = renderComplete(ctx.ui.theme);
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
- windowLen = 0;
290
- windowHead = 0;
291
- allTimeLen = 0;
292
- allTimeHead = 0;
293
- allTimeSum = 0;
294
- spinIdx = 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;
295
297
  ctx.ui.setStatus("tps", undefined);
296
298
  });
297
299
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tps-meter",
3
- "version": "2.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",