@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.
- package/README.md +1 -1
- package/index.ts +173 -8
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
400
|
-
|
|
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)
|
|
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("
|
|
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