@firstpick/pi-extension-git-footer-status 0.1.3 → 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.
- package/README.md +1 -1
- package/index.ts +128 -7
- 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,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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
400
|
-
|
|
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)
|
|
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("
|
|
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