@firstpick/pi-extension-git-footer-status 0.1.2 → 0.1.4

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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/index.ts +130 -9
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,7 +6,7 @@ Enhanced Pi footer with git health and model/token telemetry.
6
6
 
7
7
  - Shows compact runtime metrics in the footer:
8
8
  - input/output/cache tokens
9
- - token speed estimate (`tok/s`)
9
+ - live output token counter + token output speed (`tok/s`) measured from assistant streaming lifecycle events, with a session-history fallback
10
10
  - cost + context-window usage
11
11
  - current model and reasoning level
12
12
  - Shows git status context on the path line:
package/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { access } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { isAbsolute, resolve } from "node:path";
3
+ import { isAbsolute, resolve, sep } from "node:path";
4
4
  import type { AssistantMessage } from "@mariozechner/pi-ai";
5
5
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
6
6
  import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
@@ -23,6 +23,8 @@ type GitSnapshot = {
23
23
  signingMismatch: boolean;
24
24
  };
25
25
 
26
+ const LIVE_TOKEN_SPEED_ROLLING_WINDOW_MS = 2000;
27
+
26
28
  // Toggle footer items on/off here.
27
29
  const FOOTER_FLAGS = {
28
30
  branch: false,
@@ -49,7 +51,7 @@ const FOOTER_FLAGS = {
49
51
  function formatCwd(cwd: string): string {
50
52
  const home = homedir();
51
53
  if (cwd === home) return "~";
52
- if (cwd.startsWith(`${home}/`)) return `~/${cwd.slice(home.length + 1)}`;
54
+ if (cwd.startsWith(`${home}${sep}`)) return `~/${cwd.slice(home.length + 1).split(sep).join("/")}`;
53
55
  return cwd;
54
56
  }
55
57
 
@@ -79,6 +81,21 @@ function getEntryTimestampMs(entry: { type: string; timestamp: string; message?:
79
81
  return Number.isFinite(parsed) ? parsed : null;
80
82
  }
81
83
 
84
+ function isReasonableTokenSpeed(tokensPerSecond: number): boolean {
85
+ return Number.isFinite(tokensPerSecond) && tokensPerSecond > 0 && tokensPerSecond <= 1000;
86
+ }
87
+
88
+ type LiveTokenSample = {
89
+ timestampMs: number;
90
+ tokens: number;
91
+ };
92
+
93
+ function estimateTokensFromCharCount(charCount: number): number {
94
+ // Provider tokenizers differ and streaming usage is normally only available at message end.
95
+ // chars/4 is the common rough estimate for live display; final usage uses provider counts.
96
+ return Math.max(0, Math.round(charCount / 4));
97
+ }
98
+
82
99
  function formatTokenSpeed(tokensPerSecond: number): string {
83
100
  if (tokensPerSecond < 100) {
84
101
  if (tokensPerSecond >= 10) return tokensPerSecond.toFixed(1);
@@ -322,6 +339,51 @@ function buildStatusText(ctx: ExtensionContext, snapshot: GitSnapshot): string {
322
339
 
323
340
  export default function gitFooterStatus(pi: ExtensionAPI) {
324
341
  let refreshing = false;
342
+ let currentAssistantStartMs: number | null = null;
343
+ let currentAssistantOutputChars = 0;
344
+ let currentAssistantEstimatedOutputTokens = 0;
345
+ let currentAssistantLiveTokenSpeed: number | null = null;
346
+ let currentAssistantTokenSamples: LiveTokenSample[] = [];
347
+ let latestMeasuredTokenSpeed: number | null = null;
348
+
349
+ const recordAssistantSpeed = (message: AssistantMessage, endMs = Date.now()): boolean => {
350
+ const outputTokens = message.usage?.output ?? 0;
351
+ if (!outputTokens || currentAssistantStartMs === null || endMs <= currentAssistantStartMs) return false;
352
+
353
+ const elapsedSeconds = (endMs - currentAssistantStartMs) / 1000;
354
+ // Filter out impossible values caused by duplicate/misordered lifecycle events.
355
+ if (elapsedSeconds < 0.05 || elapsedSeconds > 60 * 60) return false;
356
+
357
+ const speed = outputTokens / elapsedSeconds;
358
+ if (!isReasonableTokenSpeed(speed)) return false;
359
+
360
+ latestMeasuredTokenSpeed = speed;
361
+ return true;
362
+ };
363
+
364
+ const getRollingLiveTokenSpeed = (nowMs = Date.now()): number | null => {
365
+ const cutoffMs = nowMs - LIVE_TOKEN_SPEED_ROLLING_WINDOW_MS;
366
+ currentAssistantTokenSamples = currentAssistantTokenSamples.filter((sample) => sample.timestampMs >= cutoffMs);
367
+
368
+ if (currentAssistantTokenSamples.length === 0) return null;
369
+
370
+ const firstSampleMs = currentAssistantTokenSamples[0]?.timestampMs ?? nowMs;
371
+ const windowStartMs = Math.max(currentAssistantStartMs ?? firstSampleMs, cutoffMs);
372
+ const elapsedSeconds = (nowMs - windowStartMs) / 1000;
373
+ if (elapsedSeconds <= 0) return null;
374
+
375
+ const tokens = currentAssistantTokenSamples.reduce((sum, sample) => sum + sample.tokens, 0);
376
+ const speed = tokens / elapsedSeconds;
377
+ return isReasonableTokenSpeed(speed) ? speed : null;
378
+ };
379
+
380
+ const resetLiveAssistantState = () => {
381
+ currentAssistantStartMs = null;
382
+ currentAssistantOutputChars = 0;
383
+ currentAssistantEstimatedOutputTokens = 0;
384
+ currentAssistantLiveTokenSpeed = null;
385
+ currentAssistantTokenSamples = [];
386
+ };
325
387
 
326
388
  const refresh = async (ctx: ExtensionContext) => {
327
389
  if (refreshing) return;
@@ -353,7 +415,9 @@ export default function gitFooterStatus(pi: ExtensionAPI) {
353
415
  let totalCacheRead = 0;
354
416
  let totalCacheWrite = 0;
355
417
  let totalCost = 0;
356
- let latestTokenSpeed: number | null = null;
418
+ const liveOutputTokens = currentAssistantStartMs !== null ? currentAssistantEstimatedOutputTokens : 0;
419
+ let latestTokenSpeed: number | null = currentAssistantStartMs !== null ? currentAssistantLiveTokenSpeed : latestMeasuredTokenSpeed;
420
+ let historicalTokenSpeed: number | null = null;
357
421
 
358
422
  const entries = ctx.sessionManager.getEntries();
359
423
  for (let i = 0; i < entries.length; i++) {
@@ -366,7 +430,7 @@ export default function gitFooterStatus(pi: ExtensionAPI) {
366
430
  totalCacheWrite += message.usage?.cacheWrite ?? 0;
367
431
  totalCost += message.usage?.cost?.total ?? 0;
368
432
 
369
- if ((message.usage?.output ?? 0) > 0) {
433
+ if (latestMeasuredTokenSpeed === null && (message.usage?.output ?? 0) > 0) {
370
434
  const endMs = getEntryTimestampMs(entry);
371
435
  if (endMs !== null) {
372
436
  let fallbackSpeed: number | null = null;
@@ -385,10 +449,11 @@ export default function gitFooterStatus(pi: ExtensionAPI) {
385
449
  if (elapsedSeconds <= 0) continue;
386
450
 
387
451
  const speed = (message.usage?.output ?? 0) / elapsedSeconds;
452
+ if (!isReasonableTokenSpeed(speed)) continue;
388
453
 
389
454
  // Prefer user-anchored speed (best approximation of full turn latency).
390
455
  if (previous.message.role === "user") {
391
- latestTokenSpeed = speed;
456
+ historicalTokenSpeed = speed;
392
457
  break;
393
458
  }
394
459
 
@@ -396,14 +461,18 @@ export default function gitFooterStatus(pi: ExtensionAPI) {
396
461
  if (fallbackSpeed === null) fallbackSpeed = speed;
397
462
  }
398
463
 
399
- if (latestTokenSpeed === null && fallbackSpeed !== null) {
400
- latestTokenSpeed = fallbackSpeed;
464
+ if (fallbackSpeed !== null && historicalTokenSpeed === null) {
465
+ historicalTokenSpeed = fallbackSpeed;
401
466
  }
402
467
  }
403
468
  }
404
469
  }
405
470
  }
406
471
 
472
+ if (latestTokenSpeed === null && historicalTokenSpeed !== null) {
473
+ latestTokenSpeed = historicalTokenSpeed;
474
+ }
475
+
407
476
  const contextUsage = ctx.getContextUsage();
408
477
  const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
409
478
  const contextPercentValue = contextUsage?.percent ?? 0;
@@ -440,7 +509,10 @@ export default function gitFooterStatus(pi: ExtensionAPI) {
440
509
  const segments: string[] = [];
441
510
  if (ioItems.length > 0) segments.push(`${theme.fg("muted", "🪙")} ${ioItems.join(` ${itemSep} `)}`);
442
511
  if (cacheItems.length > 0) segments.push(`${theme.fg("muted", "💾")} ${cacheItems.join(` ${itemSep} `)}`);
443
- if (latestTokenSpeed !== null) segments.push(`⚡ ${formatTokenSpeed(latestTokenSpeed)} tok/s`);
512
+ if (latestTokenSpeed !== null) {
513
+ const livePrefix = liveOutputTokens > 0 ? `${formatTokens(liveOutputTokens)} tok @ ` : "";
514
+ segments.push(`⚡ ${livePrefix}${formatTokenSpeed(latestTokenSpeed)} tok/s`);
515
+ }
444
516
 
445
517
  const usingSubscription = ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false;
446
518
  if (totalCost || usingSubscription) {
@@ -521,7 +593,56 @@ export default function gitFooterStatus(pi: ExtensionAPI) {
521
593
  await refresh(ctx);
522
594
  });
523
595
 
524
- pi.on("turn_end", async (_event, ctx) => {
596
+ pi.on("message_start", (event) => {
597
+ if (event.message.role === "assistant") {
598
+ currentAssistantStartMs = Date.now();
599
+ currentAssistantOutputChars = 0;
600
+ currentAssistantEstimatedOutputTokens = 0;
601
+ currentAssistantLiveTokenSpeed = null;
602
+ currentAssistantTokenSamples = [];
603
+ }
604
+ });
605
+
606
+ pi.on("message_update", (event) => {
607
+ if (event.message.role !== "assistant" || currentAssistantStartMs === null) return;
608
+
609
+ const streamEvent = event.assistantMessageEvent;
610
+ if (
611
+ streamEvent.type !== "text_delta" &&
612
+ streamEvent.type !== "thinking_delta" &&
613
+ streamEvent.type !== "toolcall_delta"
614
+ ) {
615
+ return;
616
+ }
617
+
618
+ const nowMs = Date.now();
619
+ currentAssistantOutputChars += streamEvent.delta.length;
620
+
621
+ const estimatedOutputTokens = estimateTokensFromCharCount(currentAssistantOutputChars);
622
+ const newTokens = Math.max(0, estimatedOutputTokens - currentAssistantEstimatedOutputTokens);
623
+ currentAssistantEstimatedOutputTokens = estimatedOutputTokens;
624
+
625
+ if (newTokens > 0) {
626
+ currentAssistantTokenSamples.push({ timestampMs: nowMs, tokens: newTokens });
627
+ }
628
+
629
+ currentAssistantLiveTokenSpeed = getRollingLiveTokenSpeed(nowMs);
630
+ });
631
+
632
+ pi.on("message_end", (event) => {
633
+ if (event.message.role === "assistant") {
634
+ if (recordAssistantSpeed(event.message as AssistantMessage)) {
635
+ resetLiveAssistantState();
636
+ }
637
+ }
638
+ });
639
+
640
+ pi.on("turn_end", async (event, ctx) => {
641
+ // Safety net for runtimes where message_end fires before usage is populated.
642
+ if (event.message.role === "assistant") {
643
+ recordAssistantSpeed(event.message as AssistantMessage);
644
+ resetLiveAssistantState();
645
+ }
525
646
  await refresh(ctx);
526
647
  });
527
648
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-extension-git-footer-status",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Enhanced Pi footer with git status, token usage, context usage, and model telemetry.",
5
5
  "license": "MIT",
6
6
  "keywords": [