@firstpick/pi-extension-git-footer-status 0.1.3 → 0.1.5

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 +173 -8
  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
@@ -23,6 +23,15 @@ type GitSnapshot = {
23
23
  signingMismatch: boolean;
24
24
  };
25
25
 
26
+ type SigningDiagnostics = {
27
+ commitSignRequired: boolean;
28
+ signState: string;
29
+ gpgFormat: string;
30
+ signingKey: string;
31
+ };
32
+
33
+ const LIVE_TOKEN_SPEED_ROLLING_WINDOW_MS = 2000;
34
+
26
35
  // Toggle footer items on/off here.
27
36
  const FOOTER_FLAGS = {
28
37
  branch: false,
@@ -79,6 +88,21 @@ function getEntryTimestampMs(entry: { type: string; timestamp: string; message?:
79
88
  return Number.isFinite(parsed) ? parsed : null;
80
89
  }
81
90
 
91
+ function isReasonableTokenSpeed(tokensPerSecond: number): boolean {
92
+ return Number.isFinite(tokensPerSecond) && tokensPerSecond > 0 && tokensPerSecond <= 1000;
93
+ }
94
+
95
+ type LiveTokenSample = {
96
+ timestampMs: number;
97
+ tokens: number;
98
+ };
99
+
100
+ function estimateTokensFromCharCount(charCount: number): number {
101
+ // Provider tokenizers differ and streaming usage is normally only available at message end.
102
+ // chars/4 is the common rough estimate for live display; final usage uses provider counts.
103
+ return Math.max(0, Math.round(charCount / 4));
104
+ }
105
+
82
106
  function formatTokenSpeed(tokensPerSecond: number): string {
83
107
  if (tokensPerSecond < 100) {
84
108
  if (tokensPerSecond >= 10) return tokensPerSecond.toFixed(1);
@@ -267,6 +291,22 @@ async function readGitSnapshot(pi: ExtensionAPI, cwd: string): Promise<GitSnapsh
267
291
  };
268
292
  }
269
293
 
294
+ async function getSigningDiagnostics(pi: ExtensionAPI, cwd: string): Promise<SigningDiagnostics> {
295
+ const [commitSignRequiredRaw, headSignState, gpgFormatRaw, signingKeyRaw] = await Promise.all([
296
+ runGit(pi, cwd, ["config", "--bool", "--get", "commit.gpgsign"]),
297
+ runGit(pi, cwd, ["log", "-1", "--format=%G?"]),
298
+ runGit(pi, cwd, ["config", "--get", "gpg.format"]),
299
+ runGit(pi, cwd, ["config", "--get", "user.signingkey"]),
300
+ ]);
301
+
302
+ return {
303
+ commitSignRequired: commitSignRequiredRaw?.toLowerCase() === "true",
304
+ signState: headSignState?.trim().toUpperCase() || "N",
305
+ gpgFormat: gpgFormatRaw?.trim() || "(default:gpg)",
306
+ signingKey: signingKeyRaw?.trim() || "(not set)",
307
+ };
308
+ }
309
+
270
310
  function buildStatusText(ctx: ExtensionContext, snapshot: GitSnapshot): string {
271
311
  const t = ctx.ui.theme;
272
312
  const f = FOOTER_FLAGS;
@@ -297,7 +337,7 @@ function buildStatusText(ctx: ExtensionContext, snapshot: GitSnapshot): string {
297
337
  if (f.worktrees && snapshot.worktreeCount > 1) extraSection.push(t.fg("muted", `📦${snapshot.worktreeCount}`));
298
338
  if (f.tag && snapshot.headTag) extraSection.push(t.fg("accent", `🏷${snapshot.headTag}`));
299
339
  if (f.lastCommitAge && snapshot.lastCommitAge) extraSection.push(t.fg("dim", `⏱${snapshot.lastCommitAge}`));
300
- if (f.signingMismatch && snapshot.signingMismatch) extraSection.push(t.fg("warning", "🔒!"));
340
+ if (f.signingMismatch && snapshot.signingMismatch) extraSection.push(t.fg("warning", "⚠️!"));
301
341
 
302
342
  const isWorkingTreeClean =
303
343
  snapshot.ahead === 0 &&
@@ -322,6 +362,51 @@ function buildStatusText(ctx: ExtensionContext, snapshot: GitSnapshot): string {
322
362
 
323
363
  export default function gitFooterStatus(pi: ExtensionAPI) {
324
364
  let refreshing = false;
365
+ let currentAssistantStartMs: number | null = null;
366
+ let currentAssistantOutputChars = 0;
367
+ let currentAssistantEstimatedOutputTokens = 0;
368
+ let currentAssistantLiveTokenSpeed: number | null = null;
369
+ let currentAssistantTokenSamples: LiveTokenSample[] = [];
370
+ let latestMeasuredTokenSpeed: number | null = null;
371
+
372
+ const recordAssistantSpeed = (message: AssistantMessage, endMs = Date.now()): boolean => {
373
+ const outputTokens = message.usage?.output ?? 0;
374
+ if (!outputTokens || currentAssistantStartMs === null || endMs <= currentAssistantStartMs) return false;
375
+
376
+ const elapsedSeconds = (endMs - currentAssistantStartMs) / 1000;
377
+ // Filter out impossible values caused by duplicate/misordered lifecycle events.
378
+ if (elapsedSeconds < 0.05 || elapsedSeconds > 60 * 60) return false;
379
+
380
+ const speed = outputTokens / elapsedSeconds;
381
+ if (!isReasonableTokenSpeed(speed)) return false;
382
+
383
+ latestMeasuredTokenSpeed = speed;
384
+ return true;
385
+ };
386
+
387
+ const getRollingLiveTokenSpeed = (nowMs = Date.now()): number | null => {
388
+ const cutoffMs = nowMs - LIVE_TOKEN_SPEED_ROLLING_WINDOW_MS;
389
+ currentAssistantTokenSamples = currentAssistantTokenSamples.filter((sample) => sample.timestampMs >= cutoffMs);
390
+
391
+ if (currentAssistantTokenSamples.length === 0) return null;
392
+
393
+ const firstSampleMs = currentAssistantTokenSamples[0]?.timestampMs ?? nowMs;
394
+ const windowStartMs = Math.max(currentAssistantStartMs ?? firstSampleMs, cutoffMs);
395
+ const elapsedSeconds = (nowMs - windowStartMs) / 1000;
396
+ if (elapsedSeconds <= 0) return null;
397
+
398
+ const tokens = currentAssistantTokenSamples.reduce((sum, sample) => sum + sample.tokens, 0);
399
+ const speed = tokens / elapsedSeconds;
400
+ return isReasonableTokenSpeed(speed) ? speed : null;
401
+ };
402
+
403
+ const resetLiveAssistantState = () => {
404
+ currentAssistantStartMs = null;
405
+ currentAssistantOutputChars = 0;
406
+ currentAssistantEstimatedOutputTokens = 0;
407
+ currentAssistantLiveTokenSpeed = null;
408
+ currentAssistantTokenSamples = [];
409
+ };
325
410
 
326
411
  const refresh = async (ctx: ExtensionContext) => {
327
412
  if (refreshing) return;
@@ -353,7 +438,9 @@ export default function gitFooterStatus(pi: ExtensionAPI) {
353
438
  let totalCacheRead = 0;
354
439
  let totalCacheWrite = 0;
355
440
  let totalCost = 0;
356
- let latestTokenSpeed: number | null = null;
441
+ const liveOutputTokens = currentAssistantStartMs !== null ? currentAssistantEstimatedOutputTokens : 0;
442
+ let latestTokenSpeed: number | null = currentAssistantStartMs !== null ? currentAssistantLiveTokenSpeed : latestMeasuredTokenSpeed;
443
+ let historicalTokenSpeed: number | null = null;
357
444
 
358
445
  const entries = ctx.sessionManager.getEntries();
359
446
  for (let i = 0; i < entries.length; i++) {
@@ -366,7 +453,7 @@ export default function gitFooterStatus(pi: ExtensionAPI) {
366
453
  totalCacheWrite += message.usage?.cacheWrite ?? 0;
367
454
  totalCost += message.usage?.cost?.total ?? 0;
368
455
 
369
- if ((message.usage?.output ?? 0) > 0) {
456
+ if (latestMeasuredTokenSpeed === null && (message.usage?.output ?? 0) > 0) {
370
457
  const endMs = getEntryTimestampMs(entry);
371
458
  if (endMs !== null) {
372
459
  let fallbackSpeed: number | null = null;
@@ -385,10 +472,11 @@ export default function gitFooterStatus(pi: ExtensionAPI) {
385
472
  if (elapsedSeconds <= 0) continue;
386
473
 
387
474
  const speed = (message.usage?.output ?? 0) / elapsedSeconds;
475
+ if (!isReasonableTokenSpeed(speed)) continue;
388
476
 
389
477
  // Prefer user-anchored speed (best approximation of full turn latency).
390
478
  if (previous.message.role === "user") {
391
- latestTokenSpeed = speed;
479
+ historicalTokenSpeed = speed;
392
480
  break;
393
481
  }
394
482
 
@@ -396,14 +484,18 @@ export default function gitFooterStatus(pi: ExtensionAPI) {
396
484
  if (fallbackSpeed === null) fallbackSpeed = speed;
397
485
  }
398
486
 
399
- if (latestTokenSpeed === null && fallbackSpeed !== null) {
400
- latestTokenSpeed = fallbackSpeed;
487
+ if (fallbackSpeed !== null && historicalTokenSpeed === null) {
488
+ historicalTokenSpeed = fallbackSpeed;
401
489
  }
402
490
  }
403
491
  }
404
492
  }
405
493
  }
406
494
 
495
+ if (latestTokenSpeed === null && historicalTokenSpeed !== null) {
496
+ latestTokenSpeed = historicalTokenSpeed;
497
+ }
498
+
407
499
  const contextUsage = ctx.getContextUsage();
408
500
  const contextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
409
501
  const contextPercentValue = contextUsage?.percent ?? 0;
@@ -440,7 +532,10 @@ export default function gitFooterStatus(pi: ExtensionAPI) {
440
532
  const segments: string[] = [];
441
533
  if (ioItems.length > 0) segments.push(`${theme.fg("muted", "🪙")} ${ioItems.join(` ${itemSep} `)}`);
442
534
  if (cacheItems.length > 0) segments.push(`${theme.fg("muted", "💾")} ${cacheItems.join(` ${itemSep} `)}`);
443
- if (latestTokenSpeed !== null) segments.push(`⚡ ${formatTokenSpeed(latestTokenSpeed)} tok/s`);
535
+ if (latestTokenSpeed !== null) {
536
+ const livePrefix = liveOutputTokens > 0 ? `${formatTokens(liveOutputTokens)} tok @ ` : "";
537
+ segments.push(`⚡ ${livePrefix}${formatTokenSpeed(latestTokenSpeed)} tok/s`);
538
+ }
444
539
 
445
540
  const usingSubscription = ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false;
446
541
  if (totalCost || usingSubscription) {
@@ -521,7 +616,56 @@ export default function gitFooterStatus(pi: ExtensionAPI) {
521
616
  await refresh(ctx);
522
617
  });
523
618
 
524
- pi.on("turn_end", async (_event, ctx) => {
619
+ pi.on("message_start", (event) => {
620
+ if (event.message.role === "assistant") {
621
+ currentAssistantStartMs = Date.now();
622
+ currentAssistantOutputChars = 0;
623
+ currentAssistantEstimatedOutputTokens = 0;
624
+ currentAssistantLiveTokenSpeed = null;
625
+ currentAssistantTokenSamples = [];
626
+ }
627
+ });
628
+
629
+ pi.on("message_update", (event) => {
630
+ if (event.message.role !== "assistant" || currentAssistantStartMs === null) return;
631
+
632
+ const streamEvent = event.assistantMessageEvent;
633
+ if (
634
+ streamEvent.type !== "text_delta" &&
635
+ streamEvent.type !== "thinking_delta" &&
636
+ streamEvent.type !== "toolcall_delta"
637
+ ) {
638
+ return;
639
+ }
640
+
641
+ const nowMs = Date.now();
642
+ currentAssistantOutputChars += streamEvent.delta.length;
643
+
644
+ const estimatedOutputTokens = estimateTokensFromCharCount(currentAssistantOutputChars);
645
+ const newTokens = Math.max(0, estimatedOutputTokens - currentAssistantEstimatedOutputTokens);
646
+ currentAssistantEstimatedOutputTokens = estimatedOutputTokens;
647
+
648
+ if (newTokens > 0) {
649
+ currentAssistantTokenSamples.push({ timestampMs: nowMs, tokens: newTokens });
650
+ }
651
+
652
+ currentAssistantLiveTokenSpeed = getRollingLiveTokenSpeed(nowMs);
653
+ });
654
+
655
+ pi.on("message_end", (event) => {
656
+ if (event.message.role === "assistant") {
657
+ if (recordAssistantSpeed(event.message as AssistantMessage)) {
658
+ resetLiveAssistantState();
659
+ }
660
+ }
661
+ });
662
+
663
+ pi.on("turn_end", async (event, ctx) => {
664
+ // Safety net for runtimes where message_end fires before usage is populated.
665
+ if (event.message.role === "assistant") {
666
+ recordAssistantSpeed(event.message as AssistantMessage);
667
+ resetLiveAssistantState();
668
+ }
525
669
  await refresh(ctx);
526
670
  });
527
671
 
@@ -537,4 +681,25 @@ export default function gitFooterStatus(pi: ExtensionAPI) {
537
681
  ctx.ui.notify("Git footer refreshed", "info");
538
682
  },
539
683
  });
684
+
685
+ pi.registerShortcut("ctrl+shift+g", {
686
+ description: "Show git signing mismatch diagnostics",
687
+ handler: async (ctx) => {
688
+ const diagnostics = await getSigningDiagnostics(pi, ctx.cwd);
689
+ if (!diagnostics.commitSignRequired) {
690
+ ctx.ui.notify("Signing mismatch: commit.gpgsign is OFF", "info");
691
+ return;
692
+ }
693
+
694
+ if (!["N", "E"].includes(diagnostics.signState)) {
695
+ ctx.ui.notify("Signing mismatch: not currently triggered", "info");
696
+ return;
697
+ }
698
+
699
+ ctx.ui.notify(
700
+ `Signing mismatch details: commit.gpgsign=ON, last-sign-state=${diagnostics.signState}, gpg.format=${diagnostics.gpgFormat}, user.signingkey=${diagnostics.signingKey}`,
701
+ "warning",
702
+ );
703
+ },
704
+ });
540
705
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-extension-git-footer-status",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Enhanced Pi footer with git status, token usage, context usage, and model telemetry.",
5
5
  "license": "MIT",
6
6
  "keywords": [