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 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
- ## Usage
11
+ ## Features
12
12
 
13
- Footer shows live stats during and after streaming:
13
+ **During streaming** live animated display:
14
+ ```
15
+ ⠋ ▓▓▓▓▓▓░░░░ 42 tps
16
+ ```
14
17
 
18
+ **After message** — aggregate stats:
15
19
  ```
16
- 42 tps (during streaming, updates every 500ms)
17
- TPS: 42 avg | μ 39 | p95 61 (after message completes)
20
+ TPS: ▓▓▓▓ 42 | μ 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
+ **Color coding:**
24
+ - 🟢 Green: >50 tps (fast)
25
+ - 🟡 Yellow: 20-50 tps (medium)
26
+ - 🔴 Red: <20 tps (slow)
27
+
28
+ ## Optimizations
23
29
 
24
- No config needed. Works out of the box.
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
 
@@ -1,39 +1,55 @@
1
1
  /**
2
- * TPS Meter — Tokens Per Second
2
+ * TPS Meter v2 — Tokens Per Second
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
+ * - 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
- * Uses character count / 4 as token estimate.
12
- * Fires on message_update (text_delta) and finalizes on message_end.
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 WINDOW_MS = 60_000; // 1 minute rolling window
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 UPDATE_INTERVAL_MS = 500; // throttle status bar updates
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
- // --- State ---
23
- interface StreamSample {
24
- tokens: number;
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 lastUpdateMs = 0;
39
+ let lastTickMs = 0;
40
+ let tickTimer: ReturnType<typeof setInterval> | null = null;
41
+ let streaming = false;
31
42
 
32
- // Rolling window (last 60s)
33
- const window: StreamSample[] = [];
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 for mean and p95
36
- const allTime: number[] = []; // TPS values per assistant message
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 Math.max(1, Math.round(chars / CHARS_PER_TOKEN));
61
+ return (chars >>> 2) + ((chars & 3) > 0 ? 1 : 0); // ceil(chars/4) via bit ops
46
62
  }
47
63
 
48
- function pruneWindow(): void {
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
- while (window.length > 0 && window[0].endMs < cutoff) {
51
- window.shift();
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 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;
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 meanTps(): number {
65
- if (allTime.length === 0) return 0;
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
- 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)];
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
- function formatTps(v: number): string {
77
- return v < 10 ? v.toFixed(1) : v.toFixed(0);
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
- function statusText(): string {
81
- const avg = rollingTps();
82
- const mu = meanTps();
83
- const p95 = p95Tps();
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
- return `TPS: ${formatTps(avg)} avg | μ ${formatTps(mu)} | p95 ${formatTps(p95)}`;
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
- // --- Extension ---
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
- // Reset on new assistant message
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
- lastUpdateMs = 0;
239
+ lastTickMs = 0;
240
+ streaming = true;
241
+ spinIdx = 0;
242
+
243
+ startTick(ctx, ctx.ui.theme);
98
244
  });
99
245
 
100
- // Count tokens from stream deltas, update status bar periodically
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
- streamTokens += estimateTokens(delta.length);
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
- // Finalize: record TPS for this message, update rolling + all-time stats
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 sample
136
- const sample: StreamSample = { tokens: streamTokens, endMs: now() };
137
- window.push(sample);
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
- // Update status bar with aggregate stats
144
- const txt = statusText();
275
+ // Show final stats
276
+ const txt = renderComplete(ctx.ui.theme);
145
277
  if (txt) {
146
- ctx.ui.setStatus("tps", ctx.ui.theme.fg("accent", txt));
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
- lastUpdateMs = 0;
155
- window.length = 0;
156
- allTime.length = 0;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tps-meter",
3
- "version": "1.0.0",
3
+ "version": "2.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",