useful-pi-extensions 1.11.1 → 1.11.3
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "useful-pi-extensions",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.3",
|
|
4
4
|
"description": "A small collection of pi extensions, installed with one command — a labelled status line with context pressure, cache, cost, effort, TTFT and tokens/sec, plus an opt-in context breakdown panel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -32,16 +32,17 @@ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'
|
|
|
32
32
|
import {
|
|
33
33
|
avgTokPerSec,
|
|
34
34
|
breakdownPanel,
|
|
35
|
+
contentChars,
|
|
35
36
|
contextRow,
|
|
36
37
|
currencyFromConfig,
|
|
37
38
|
detailFromConfig,
|
|
38
39
|
formatCwd,
|
|
39
40
|
formatLatency,
|
|
40
41
|
formatTps,
|
|
42
|
+
frozenThroughput,
|
|
41
43
|
avgMs,
|
|
42
44
|
isQuietStatus,
|
|
43
45
|
messagesTokens,
|
|
44
|
-
pair,
|
|
45
46
|
cachedRates,
|
|
46
47
|
ratesFromPayload,
|
|
47
48
|
cacheIsFresh,
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
row,
|
|
52
53
|
shortenPath,
|
|
53
54
|
splitToolTokens,
|
|
55
|
+
timingRow,
|
|
54
56
|
ttftDisplay,
|
|
55
57
|
ttftMs,
|
|
56
58
|
USD,
|
|
@@ -67,7 +69,6 @@ const TICK_MS = 250
|
|
|
67
69
|
/** Pi's own estimateTokens() heuristic, used only until a real ratio is known. */
|
|
68
70
|
const FALLBACK_TOKENS_PER_CHAR = 0.25
|
|
69
71
|
|
|
70
|
-
/** The status line's own settings file, alongside pi's other per-tool config. */
|
|
71
72
|
/** Everything this extension keeps lives in one folder, not loose files relying on name prefixes. */
|
|
72
73
|
const STATUSLINE_DIR = join(homedir(), '.pi', 'agent', 'statusline')
|
|
73
74
|
const CONFIG_PATH = join(STATUSLINE_DIR, 'config.json')
|
|
@@ -77,8 +78,10 @@ const LEGACY_CONFIG = join(homedir(), '.pi', 'agent', 'statusline.json')
|
|
|
77
78
|
const RATES_URL = 'https://open.er-api.com/v6/latest/USD'
|
|
78
79
|
/** Every session on this machine, for the day-cost total that spans projects and models. */
|
|
79
80
|
const SESSIONS_DIR = join(homedir(), '.pi', 'agent', 'sessions')
|
|
80
|
-
/**
|
|
81
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Where throughput metrics wait out a /reload: one state file per session, keyed by session file,
|
|
83
|
+
* so a reload restores its own and two concurrent sessions cannot clobber each other.
|
|
84
|
+
*/
|
|
82
85
|
const STATE_DIR = join(STATUSLINE_DIR, 'state')
|
|
83
86
|
const LEGACY_STATE = join(homedir(), '.pi', 'agent', 'statusline-state.json')
|
|
84
87
|
/** The 1.8 single-file state: one bucket every session shared, also migrated to per-session. */
|
|
@@ -119,17 +122,6 @@ async function fetchRates(): Promise<Record<string, number> | null> {
|
|
|
119
122
|
}
|
|
120
123
|
}
|
|
121
124
|
|
|
122
|
-
/**
|
|
123
|
-
* Reads the display currency.
|
|
124
|
-
*
|
|
125
|
-
* `perUsd` is a pinned rate and wins untouched; a code without one resolves through the cached rate
|
|
126
|
-
* table — today's table answers instantly, a stale one answers while a fresh one is fetched, and
|
|
127
|
-
* only a first-ever failure speaks up. The whole table is cached because one request returns every
|
|
128
|
-
* currency, which is also what makes switching codes instant and offline.
|
|
129
|
-
*
|
|
130
|
-
* Called once per session on purpose: a footer that stat'ed a file on every frame would be its own
|
|
131
|
-
* bug. Editing the file therefore takes effect on `/reload`.
|
|
132
|
-
*/
|
|
133
125
|
function startOfToday(): number {
|
|
134
126
|
const start = new Date()
|
|
135
127
|
start.setHours(0, 0, 0, 0)
|
|
@@ -182,7 +174,6 @@ async function pruneState(): Promise<void> {
|
|
|
182
174
|
const filePath = join(STATE_DIR, name)
|
|
183
175
|
const stats = await stat(filePath).catch(() => null)
|
|
184
176
|
if (stats !== null && stats.mtimeMs < cutoff) await unlink(filePath).catch(() => {})
|
|
185
|
-
return null
|
|
186
177
|
}),
|
|
187
178
|
)
|
|
188
179
|
} catch {
|
|
@@ -252,6 +243,17 @@ async function sumOtherTodaysCost(currentFile: string | null, since: number): Pr
|
|
|
252
243
|
return costs.reduce((sum, value) => sum + value, 0)
|
|
253
244
|
}
|
|
254
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Reads the display currency.
|
|
248
|
+
*
|
|
249
|
+
* `perUsd` is a pinned rate and wins untouched; a code without one resolves through the cached rate
|
|
250
|
+
* table — today's table answers instantly, a stale one answers while a fresh one is fetched, and
|
|
251
|
+
* only a first-ever failure speaks up. The whole table is cached because one request returns every
|
|
252
|
+
* currency, which is also what makes switching codes instant and offline.
|
|
253
|
+
*
|
|
254
|
+
* Called once per session on purpose: a footer that stat'ed a file on every frame would be its own
|
|
255
|
+
* bug. Editing the file therefore takes effect on `/reload`.
|
|
256
|
+
*/
|
|
255
257
|
async function loadCurrency(notify: (message: string) => void): Promise<Currency> {
|
|
256
258
|
let text: string | null = null
|
|
257
259
|
try {
|
|
@@ -286,37 +288,6 @@ async function loadCurrency(notify: (message: string) => void): Promise<Currency
|
|
|
286
288
|
return USD
|
|
287
289
|
}
|
|
288
290
|
|
|
289
|
-
/** A content block as providers stream it; every field is read defensively. */
|
|
290
|
-
type ContentBlock = {
|
|
291
|
-
type?: unknown
|
|
292
|
-
text?: unknown
|
|
293
|
-
thinking?: unknown
|
|
294
|
-
name?: unknown
|
|
295
|
-
arguments?: unknown
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function isContentBlock(value: unknown): value is ContentBlock {
|
|
299
|
-
return typeof value === 'object' && value !== null
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
function blockChars(content: unknown): number {
|
|
303
|
-
if (typeof content === 'string') return content.length
|
|
304
|
-
if (!Array.isArray(content)) return 0
|
|
305
|
-
|
|
306
|
-
let chars = 0
|
|
307
|
-
for (const raw of content as unknown[]) {
|
|
308
|
-
if (!isContentBlock(raw)) continue
|
|
309
|
-
const { type, text, thinking, name, arguments: args } = raw
|
|
310
|
-
if (type === 'text' && typeof text === 'string') chars += text.length
|
|
311
|
-
else if (type === 'thinking' && typeof thinking === 'string') chars += thinking.length
|
|
312
|
-
else if (type === 'toolCall') {
|
|
313
|
-
const nameLength = typeof name === 'string' ? name.length : 0
|
|
314
|
-
chars += nameLength + JSON.stringify(args ?? {}).length
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
return chars
|
|
318
|
-
}
|
|
319
|
-
|
|
320
291
|
interface Totals {
|
|
321
292
|
input: number
|
|
322
293
|
output: number
|
|
@@ -380,13 +351,18 @@ function collectTotals(ctx: ExtensionContext, since: number): Totals {
|
|
|
380
351
|
return totals
|
|
381
352
|
}
|
|
382
353
|
|
|
354
|
+
/** Chars for the tokens-per-char ratio: images never stream as text, so they must not dilute it. */
|
|
355
|
+
function ratioChars(content: unknown): number {
|
|
356
|
+
return contentChars(content, 0)
|
|
357
|
+
}
|
|
358
|
+
|
|
383
359
|
/** Tokens per character, averaged over recent assistant messages with provider usage. */
|
|
384
360
|
function seedRatio(ctx: ExtensionContext): number | null {
|
|
385
361
|
const samples: number[] = []
|
|
386
362
|
for (const entry of ctx.sessionManager.getBranch().toReversed()) {
|
|
387
363
|
if (entry.type !== 'message' || entry.message.role !== 'assistant') continue
|
|
388
364
|
const output = entry.message.usage.output
|
|
389
|
-
const chars =
|
|
365
|
+
const chars = ratioChars(entry.message.content)
|
|
390
366
|
if (output <= 0 || chars <= 0) continue
|
|
391
367
|
samples.push(output / chars)
|
|
392
368
|
if (samples.length >= 5) break
|
|
@@ -400,8 +376,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
400
376
|
let requestRender: (() => void) | null = null
|
|
401
377
|
let currency: Currency = USD
|
|
402
378
|
|
|
403
|
-
// Latest turn reading: { rate, exact, ttftMs }
|
|
404
|
-
|
|
379
|
+
// Latest turn reading: { rate, exact, ttftMs }. rate is null when the message arrived as one
|
|
380
|
+
// burst: the honest TTFT stays, the unmeasurable throughput goes.
|
|
381
|
+
let reading: { rate: number | null; exact: boolean; ttftMs: number | null } | null = null
|
|
405
382
|
|
|
406
383
|
// Breakdown-line inputs, refreshed once per turn rather than per frame. The prompt and the
|
|
407
384
|
// tool list change only across turns (before_agent_start re-captures both), and the message
|
|
@@ -495,7 +472,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
495
472
|
renderedAt = 0
|
|
496
473
|
}
|
|
497
474
|
|
|
498
|
-
function publish(rate: number, exact: boolean, ttft: number | null): void {
|
|
475
|
+
function publish(rate: number | null, exact: boolean, ttft: number | null): void {
|
|
499
476
|
reading = { rate, exact, ttftMs: ttft }
|
|
500
477
|
requestRender?.()
|
|
501
478
|
}
|
|
@@ -520,6 +497,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
520
497
|
}, TICK_MS)
|
|
521
498
|
}
|
|
522
499
|
|
|
500
|
+
/**
|
|
501
|
+
* Drops the in-flight anchor and its clock. A run that ends without a first token (abort,
|
|
502
|
+
* provider error) must not leave a clock counting against a request that is no longer in flight.
|
|
503
|
+
*/
|
|
504
|
+
function clearInFlightRequest(): void {
|
|
505
|
+
requestAt = null
|
|
506
|
+
stopTicker()
|
|
507
|
+
}
|
|
508
|
+
|
|
523
509
|
/**
|
|
524
510
|
* Writes the throughput metrics so a /reload can restore them.
|
|
525
511
|
*
|
|
@@ -555,9 +541,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
555
541
|
totalMeasuredOutput = num(state.totalMeasuredOutput)
|
|
556
542
|
totalTtftMs = num(state.totalTtftMs)
|
|
557
543
|
ttftCount = num(state.ttftCount)
|
|
558
|
-
if (isRecord(state.last)
|
|
544
|
+
if (isRecord(state.last)) {
|
|
559
545
|
reading = {
|
|
560
|
-
rate: state.last.rate,
|
|
546
|
+
rate: typeof state.last.rate === 'number' ? state.last.rate : null,
|
|
561
547
|
exact: state.last.exact === true,
|
|
562
548
|
ttftMs: typeof state.last.ttftMs === 'number' ? state.last.ttftMs : null,
|
|
563
549
|
}
|
|
@@ -584,13 +570,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
584
570
|
const shown = trusted ?? lastKnownUsage
|
|
585
571
|
const totals = collectTotals(ctx, startOfToday())
|
|
586
572
|
const avg = avgTokPerSec(totalMeasuredOutput, totalDecodeMs)
|
|
573
|
+
const contextWindow =
|
|
574
|
+
usage?.contextWindow ?? shown?.contextWindow ?? ctx.model?.contextWindow ?? 0
|
|
587
575
|
const row1 = contextRow(
|
|
588
576
|
theme,
|
|
589
577
|
width,
|
|
590
578
|
{
|
|
591
579
|
percent: shown?.percent ?? 0,
|
|
592
580
|
tokens: shown?.tokens ?? 0,
|
|
593
|
-
window:
|
|
581
|
+
window: contextWindow,
|
|
594
582
|
input: totals.input,
|
|
595
583
|
output: totals.output,
|
|
596
584
|
cacheHitRate: totals.cacheHitRate,
|
|
@@ -604,10 +592,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
604
592
|
// row. The concise footer renders by default; ctrl+q or /breakdown opens the panel.
|
|
605
593
|
let breakdown: string[] | null = null
|
|
606
594
|
if (detailVisible) {
|
|
607
|
-
const window =
|
|
608
|
-
usage?.contextWindow ?? shown?.contextWindow ?? ctx.model?.contextWindow ?? 0
|
|
609
595
|
const used = shown?.tokens ?? 0
|
|
610
|
-
const free =
|
|
596
|
+
const free =
|
|
597
|
+
contextWindow > 0 ? Math.max(0, contextWindow - used - (reserveTokens ?? 0)) : null
|
|
611
598
|
breakdown = breakdownPanel(theme, width, {
|
|
612
599
|
messages: messagesFor(ctx),
|
|
613
600
|
promptParts,
|
|
@@ -615,45 +602,35 @@ export default function (pi: ExtensionAPI) {
|
|
|
615
602
|
reserve: reserveTokens,
|
|
616
603
|
free,
|
|
617
604
|
usedPercent: shown?.percent ?? null,
|
|
618
|
-
window,
|
|
605
|
+
window: contextWindow,
|
|
619
606
|
used,
|
|
620
607
|
})
|
|
621
608
|
}
|
|
622
609
|
|
|
623
|
-
// Row 2: model and the latest turn's timing on the right, path on the left.
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
const model = ctx.model?.id ?? 'no model'
|
|
627
|
-
const separator = theme.fg('dim', ' · ')
|
|
628
|
-
const wall = theme.fg('dim', ' | ')
|
|
629
|
-
const identity = [theme.fg('accent', model)]
|
|
630
|
-
if (ctx.thinkingLevel) identity.push(pair(theme, 'Effort', ctx.thinkingLevel))
|
|
631
|
-
|
|
632
|
-
const ttft: string[] = []
|
|
610
|
+
// Row 2: model and the latest turn's timing on the right, path on the left. The right
|
|
611
|
+
// side assembles through timingRow's drop ladder, so a narrow terminal loses whole
|
|
612
|
+
// slots — never a half-rendered one.
|
|
633
613
|
const waiting = ttftDisplay(requestAt, firstTokenAt, Date.now())
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
614
|
+
// The clock is running: this wait has no reading yet, so the previous turn's numbers
|
|
615
|
+
// would only be mistaken for the current one.
|
|
616
|
+
const ttft =
|
|
617
|
+
waiting !== null
|
|
618
|
+
? waiting.text
|
|
619
|
+
: reading && reading.ttftMs !== null
|
|
620
|
+
? formatLatency(reading.ttftMs)
|
|
621
|
+
: null
|
|
642
622
|
const avgTtft = avgMs(totalTtftMs, ttftCount)
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
.filter((group) => group.length > 0)
|
|
655
|
-
.map((group) => group.join(separator))
|
|
656
|
-
.join(wall)
|
|
623
|
+
const row2Right = timingRow(theme, width, {
|
|
624
|
+
model: ctx.model?.id ?? 'no model',
|
|
625
|
+
effort: ctx.thinkingLevel ?? null,
|
|
626
|
+
ttft,
|
|
627
|
+
avgTtft: avgTtft !== null ? formatLatency(avgTtft) : null,
|
|
628
|
+
last:
|
|
629
|
+
reading && reading.rate !== null
|
|
630
|
+
? `${reading.exact ? '' : '~'}${formatTps(reading.rate)} tok/s`
|
|
631
|
+
: null,
|
|
632
|
+
avg: avg !== null ? `${formatTps(avg)} tok/s` : null,
|
|
633
|
+
})
|
|
657
634
|
const branch = footerData.getGitBranch()
|
|
658
635
|
const path = formatCwd(ctx.cwd)
|
|
659
636
|
const branchSuffix = branch ? ` (${branch})` : ''
|
|
@@ -693,22 +670,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
693
670
|
})
|
|
694
671
|
}
|
|
695
672
|
|
|
696
|
-
//
|
|
697
|
-
//
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
673
|
+
// TTFT's request anchor, from the agent loop in pi's docs: turn_start opens the LLM call and
|
|
674
|
+
// before_provider_request is the last thing pi does before the wire. message_start is not that
|
|
675
|
+
// moment, and anchoring there made a first token that arrived early read as a clamped 0ms.
|
|
676
|
+
pi.on('turn_start', clearInFlightRequest)
|
|
677
|
+
pi.on('before_provider_request', () => {
|
|
678
|
+
requestAt = Date.now()
|
|
679
|
+
startTicker()
|
|
701
680
|
})
|
|
702
681
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
pi.on('agent_settled', async () => {
|
|
709
|
-
requestAt = null
|
|
710
|
-
stopTicker()
|
|
711
|
-
})
|
|
682
|
+
// Every way a run can retire retires the in-flight request with it.
|
|
683
|
+
pi.on('turn_end', clearInFlightRequest)
|
|
684
|
+
pi.on('agent_end', clearInFlightRequest)
|
|
685
|
+
pi.on('agent_settled', clearInFlightRequest)
|
|
712
686
|
|
|
713
687
|
// The prompt and the tool list are inputs to the breakdown line, and both are settled by the
|
|
714
688
|
// time this fires: earlier handlers have chained their changes, so what is captured here is
|
|
@@ -758,27 +732,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
758
732
|
resetStream()
|
|
759
733
|
})
|
|
760
734
|
|
|
761
|
-
// TTFT's request anchor, from the agent loop in pi's docs: turn_start opens the LLM call and
|
|
762
|
-
// before_provider_request is the last thing pi does before the wire. message_start is not that
|
|
763
|
-
// moment, and anchoring there made a first token that arrived early read as a clamped 0ms.
|
|
764
|
-
pi.on('turn_start', async () => {
|
|
765
|
-
requestAt = null
|
|
766
|
-
})
|
|
767
|
-
|
|
768
|
-
pi.on('before_provider_request', async () => {
|
|
769
|
-
requestAt = Date.now()
|
|
770
|
-
})
|
|
771
|
-
|
|
772
|
-
pi.on('turn_start', async () => {
|
|
773
|
-
requestAt = null
|
|
774
|
-
stopTicker()
|
|
775
|
-
})
|
|
776
|
-
|
|
777
|
-
pi.on('before_provider_request', async () => {
|
|
778
|
-
requestAt = Date.now()
|
|
779
|
-
startTicker()
|
|
780
|
-
})
|
|
781
|
-
|
|
782
735
|
pi.on('message_update', async (event) => {
|
|
783
736
|
const delta = event.assistantMessageEvent
|
|
784
737
|
// Narrowing on the discriminant is what keeps the payload typed; membership in
|
|
@@ -819,7 +772,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
819
772
|
|
|
820
773
|
const message = event.message as { content: unknown; usage?: { output?: number } }
|
|
821
774
|
const output = message.usage?.output ?? 0
|
|
822
|
-
const totalChars =
|
|
775
|
+
const totalChars = ratioChars(message.content)
|
|
823
776
|
if (output > 0 && totalChars > 0) {
|
|
824
777
|
const sample = output / totalChars
|
|
825
778
|
ratio = ratio === null ? sample : (ratio + sample) / 2
|
|
@@ -828,7 +781,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
828
781
|
const decodeMs = firstTokenAt !== null ? Date.now() - firstTokenAt : 0
|
|
829
782
|
const measured = ttftMs(requestAt, firstTokenAt)
|
|
830
783
|
const tokens = output > 0 ? output : totalChars * (ratio ?? FALLBACK_TOKENS_PER_CHAR)
|
|
831
|
-
|
|
784
|
+
const rate = frozenThroughput(decodeMs, tokens, MIN_SAMPLE_MS)
|
|
785
|
+
if (measured !== null && rate !== null) {
|
|
832
786
|
// The average's numerator and denominator must cover the same messages: totals.output spans
|
|
833
787
|
// the whole session (a reload replays none of it), so pairing it with the partial denominator
|
|
834
788
|
// below once produced an Avg of 4324 tok/s.
|
|
@@ -837,11 +791,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
837
791
|
totalTtftMs += measured
|
|
838
792
|
ttftCount += 1
|
|
839
793
|
persist(ctx.sessionManager.getSessionFile() ?? null)
|
|
840
|
-
publish(
|
|
841
|
-
} else if (measured !== null
|
|
842
|
-
//
|
|
843
|
-
//
|
|
844
|
-
|
|
794
|
+
publish(rate, output > 0, measured)
|
|
795
|
+
} else if (measured !== null) {
|
|
796
|
+
// A sub-floor window is a burst arrival: the message landed in one or two chunks and there
|
|
797
|
+
// is no decode phase to measure — dividing by those milliseconds once printed Last 82000
|
|
798
|
+
// tok/s. Keep the honest TTFT, publish no rate; the live sliding-window sample is no
|
|
799
|
+
// fallback either, a buffered burst mid-stream reads triple there too.
|
|
800
|
+
publish(null, false, measured)
|
|
801
|
+
persist(ctx.sessionManager.getSessionFile() ?? null)
|
|
845
802
|
}
|
|
846
803
|
// Null it with the stream: a request that has produced its message is no longer in flight, and
|
|
847
804
|
// a stale anchor would let the live branch count against nothing until the next turn.
|
|
@@ -85,6 +85,29 @@ export function ttftMs(requestAt: number | null, firstTokenAt: number | null): n
|
|
|
85
85
|
return firstTokenAt > requestAt ? firstTokenAt - requestAt : null
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* The frozen Last throughput for a finished message: its whole-message rate, output tokens over the
|
|
90
|
+
* entire decode window.
|
|
91
|
+
*
|
|
92
|
+
* A decode window below the sample floor is a burst arrival — the whole message landed in one or
|
|
93
|
+
* two chunks, so there is no decode phase to measure. Dividing by those couple of milliseconds once
|
|
94
|
+
* printed `Last 82000 tok/s` for an 82-token greeting; like the negative TTFT above, an
|
|
95
|
+
* unmeasurable rate reports absence rather than a confident fake.
|
|
96
|
+
*
|
|
97
|
+
* @param decodeMs - Milliseconds from the first streamed delta to message end.
|
|
98
|
+
* @param tokens - Output tokens for the message (provider-reported when available).
|
|
99
|
+
* @param minSampleMs - The shortest window considered a real decode phase.
|
|
100
|
+
* @returns The tokens/second rate, or null when the window is too short to measure.
|
|
101
|
+
*/
|
|
102
|
+
export function frozenThroughput(
|
|
103
|
+
decodeMs: number,
|
|
104
|
+
tokens: number,
|
|
105
|
+
minSampleMs: number,
|
|
106
|
+
): number | null {
|
|
107
|
+
if (decodeMs < minSampleMs) return null
|
|
108
|
+
return (tokens / decodeMs) * 1000
|
|
109
|
+
}
|
|
110
|
+
|
|
88
111
|
/** What the TTFT slot renders, and whether the clock is still running. */
|
|
89
112
|
export interface TtftDisplay {
|
|
90
113
|
text: string
|
|
@@ -169,15 +192,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
169
192
|
return typeof value === 'object' && value !== null
|
|
170
193
|
}
|
|
171
194
|
|
|
172
|
-
/**
|
|
173
|
-
* Validates a `currency` block.
|
|
174
|
-
*
|
|
175
|
-
* A rate that is missing, zero, negative or not a number is refused rather than defaulted: a
|
|
176
|
-
* confident wrong amount is worse than the unconverted one the caller falls back to.
|
|
177
|
-
*
|
|
178
|
-
* @param raw - The `currency` value from a parsed config file.
|
|
179
|
-
* @returns The currency, or null when the block could not be used.
|
|
180
|
-
*/
|
|
181
195
|
/** The identity a currency block asks for: the API code and the symbol to print. */
|
|
182
196
|
interface CurrencyIdentity {
|
|
183
197
|
code: string
|
|
@@ -191,6 +205,15 @@ function currencyIdentity(raw: Record<string, unknown>): CurrencyIdentity | null
|
|
|
191
205
|
return symbol === '' ? null : { code, symbol }
|
|
192
206
|
}
|
|
193
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Validates a `currency` block.
|
|
210
|
+
*
|
|
211
|
+
* A rate that is missing, zero, negative or not a number is refused rather than defaulted: a
|
|
212
|
+
* confident wrong amount is worse than the unconverted one the caller falls back to.
|
|
213
|
+
*
|
|
214
|
+
* @param raw - The `currency` value from a parsed config file.
|
|
215
|
+
* @returns The currency, or null when the block could not be used.
|
|
216
|
+
*/
|
|
194
217
|
export function parseCurrency(raw: unknown): Currency | null {
|
|
195
218
|
if (!isRecord(raw)) return null
|
|
196
219
|
const rate = raw.perUsd
|
|
@@ -326,7 +349,6 @@ export function withCachedRates(
|
|
|
326
349
|
return `${JSON.stringify({ ...config, rates, fetchedAt }, null, 2)}\n`
|
|
327
350
|
}
|
|
328
351
|
|
|
329
|
-
/** Two decimals, with padding zeros trimmed so `$0.30` renders as `$0.3`. */
|
|
330
352
|
/**
|
|
331
353
|
* Two decimals, with the padding zeros trimmed so `$0.30` renders as `$0.3`.
|
|
332
354
|
*
|
|
@@ -419,12 +441,13 @@ export interface ContextRowParts {
|
|
|
419
441
|
* Row 1: context pressure on the left, session totals on the right.
|
|
420
442
|
*
|
|
421
443
|
* Space is given up in a fixed order rather than all at once, because this row carries numbers that
|
|
422
|
-
* appear nowhere else in the footer. A narrower terminal
|
|
423
|
-
* visibly rather than silently dropping the whole group:
|
|
444
|
+
* appear nowhere else in the footer. A narrower terminal costs, in turn:
|
|
424
445
|
*
|
|
425
446
|
* 1. The absolute `tokens / window` detail,
|
|
426
|
-
* 2. The input/output volumes, which are informative but not
|
|
427
|
-
* 3. The
|
|
447
|
+
* 2. The input/output volumes, which are informative but not bills,
|
|
448
|
+
* 3. The cache-hit rate, which is diagnostic rather than money,
|
|
449
|
+
* 4. Today's running total (the session's own bill outlives it),
|
|
450
|
+
* 5. The bar's own cells, down to a stub — the percent survives everything.
|
|
428
451
|
*
|
|
429
452
|
* @param currency - Used for the cost; defaults to USD so the row renders without a config file.
|
|
430
453
|
*/
|
|
@@ -441,7 +464,9 @@ export function contextRow(
|
|
|
441
464
|
parts.percent === null ? '--' : `${Math.round(parts.percent)}%`,
|
|
442
465
|
)
|
|
443
466
|
// The title is Claude Code's: "Context window", so the two panels read the same at a glance.
|
|
444
|
-
const
|
|
467
|
+
const meterWith = (cells: number): string =>
|
|
468
|
+
`${theme.fg('dim', 'Context window')} ${bar(theme, cells, fraction)} ${percent}`
|
|
469
|
+
const meter = meterWith(BAR_CELLS)
|
|
445
470
|
const detail =
|
|
446
471
|
parts.window > 0 ? `${formatTokens(parts.tokens)} / ${formatTokens(parts.window)}` : ''
|
|
447
472
|
const withDetail = detail === '' ? meter : `${meter} ${theme.fg('dim', detail)}`
|
|
@@ -475,9 +500,97 @@ export function contextRow(
|
|
|
475
500
|
const fits = (left: string, right: string): boolean =>
|
|
476
501
|
right === '' || visibleWidth(left) + 2 + visibleWidth(right) <= width
|
|
477
502
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
503
|
+
const moneyOnly = money.join(separator)
|
|
504
|
+
// The session's own bill outlives today's running total.
|
|
505
|
+
const ownBill = money.length > 0 ? (money[0] ?? '') : ''
|
|
506
|
+
|
|
507
|
+
// The ladder, highest fidelity first: the detail, the volumes, the cache diagnostics and
|
|
508
|
+
// today's total each give up their seat before the bar itself shrinks — twenty cells of bar
|
|
509
|
+
// are a luxury the bills should not have to pay for.
|
|
510
|
+
const compact = 10
|
|
511
|
+
const rungs: Array<{ left: string; right: string }> = [
|
|
512
|
+
{ left: withDetail, right: full },
|
|
513
|
+
{ left: meter, right: full },
|
|
514
|
+
{ left: meter, right: core },
|
|
515
|
+
{ left: meter, right: moneyOnly },
|
|
516
|
+
{ left: meterWith(compact), right: moneyOnly },
|
|
517
|
+
{ left: meterWith(compact), right: ownBill },
|
|
518
|
+
]
|
|
519
|
+
for (const { left, right } of rungs) {
|
|
520
|
+
if (right === '' ? visibleWidth(left) <= width : fits(left, right)) {
|
|
521
|
+
return row(theme, width, left, right)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Floor: the percent is the one number this row exists to show — the bar shrinks to a stub
|
|
526
|
+
// before the percent is ever truncated, and past that the label goes, never the number.
|
|
527
|
+
const stub = Math.min(compact, width - 22)
|
|
528
|
+
if (stub >= 4) return row(theme, width, meterWith(stub), '')
|
|
529
|
+
if (width >= 18) {
|
|
530
|
+
return row(theme, width, `${theme.fg('dim', 'Context window')} ${percent}`, '')
|
|
531
|
+
}
|
|
532
|
+
return truncateToWidth(percent, width, theme.fg('dim', '…'))
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** The row-2 right side, the caller pre-formatting each slot's display text. */
|
|
536
|
+
export interface TimingRowParts {
|
|
537
|
+
model: string
|
|
538
|
+
/** Thinking level label, or null to hide the slot. */
|
|
539
|
+
effort: string | null
|
|
540
|
+
/** The live TTFT clock or the frozen reading, or null to hide the slot. */
|
|
541
|
+
ttft: string | null
|
|
542
|
+
/** Session-average TTFT, or null to hide the slot. */
|
|
543
|
+
avgTtft: string | null
|
|
544
|
+
/** Last turn's decode rate, or null to hide the slot. */
|
|
545
|
+
last: string | null
|
|
546
|
+
/** Session-average decode rate, or null to hide the slot. */
|
|
547
|
+
avg: string | null
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Row 2's right side: the model, the effort level, and the latest turn's timing.
|
|
552
|
+
*
|
|
553
|
+
* Like row 1, space is given up in a fixed order rather than half rendered. A narrower terminal
|
|
554
|
+
* costs, in turn: the throughput average, the TTFT average, the effort level, the last rate —
|
|
555
|
+
* aggregates before live numbers, static config before measured history. The model and the TTFT
|
|
556
|
+
* clock are the floor; past them the line truncates, because a model id is the one string here with
|
|
557
|
+
* no drop-in substitute.
|
|
558
|
+
*
|
|
559
|
+
* @returns The assembled right side, at most `width` cells wide.
|
|
560
|
+
*/
|
|
561
|
+
export function timingRow(theme: Theme, width: number, parts: TimingRowParts): string {
|
|
562
|
+
const separator = theme.fg('dim', ' · ')
|
|
563
|
+
const wall = theme.fg('dim', ' | ')
|
|
564
|
+
const slots = {
|
|
565
|
+
model: theme.fg('accent', parts.model),
|
|
566
|
+
effort: parts.effort !== null ? pair(theme, 'Effort', parts.effort) : null,
|
|
567
|
+
ttft: parts.ttft !== null ? pair(theme, 'TTFT', parts.ttft) : null,
|
|
568
|
+
avgTtft: parts.avgTtft !== null ? pair(theme, 'Avg TTFT', parts.avgTtft) : null,
|
|
569
|
+
last: parts.last !== null ? pair(theme, 'Last', parts.last) : null,
|
|
570
|
+
avg: parts.avg !== null ? pair(theme, 'Avg', parts.avg) : null,
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
type Droppable = 'effort' | 'ttft' | 'avgTtft' | 'last' | 'avg'
|
|
574
|
+
const hidden = new Set<Droppable>()
|
|
575
|
+
const assemble = (): string =>
|
|
576
|
+
[
|
|
577
|
+
[slots.model, hidden.has('effort') ? null : slots.effort],
|
|
578
|
+
[hidden.has('ttft') ? null : slots.ttft, hidden.has('avgTtft') ? null : slots.avgTtft],
|
|
579
|
+
[hidden.has('last') ? null : slots.last, hidden.has('avg') ? null : slots.avg],
|
|
580
|
+
]
|
|
581
|
+
.map((group) => group.filter((slot): slot is string => slot !== null))
|
|
582
|
+
.filter((group) => group.length > 0)
|
|
583
|
+
.map((group) => group.join(separator))
|
|
584
|
+
.join(wall)
|
|
585
|
+
|
|
586
|
+
const ladder: Droppable[] = ['avg', 'avgTtft', 'effort', 'last']
|
|
587
|
+
let right = assemble()
|
|
588
|
+
for (const key of ladder) {
|
|
589
|
+
if (visibleWidth(right) <= width) break
|
|
590
|
+
hidden.add(key)
|
|
591
|
+
right = assemble()
|
|
592
|
+
}
|
|
593
|
+
return truncateToWidth(right, width, theme.fg('dim', '…'))
|
|
481
594
|
}
|
|
482
595
|
|
|
483
596
|
/** `label value` where the key is quiet and the value is not. */
|
|
@@ -514,8 +627,14 @@ export function tokensFromChars(chars: number): number {
|
|
|
514
627
|
/** Images carry this many estimated characters each, pi's own ESTIMATED_IMAGE_CHARS. */
|
|
515
628
|
const IMAGE_CHARS = 4800
|
|
516
629
|
|
|
517
|
-
/**
|
|
518
|
-
|
|
630
|
+
/**
|
|
631
|
+
* Characters of message content: strings, text blocks, tool calls and image placeholders.
|
|
632
|
+
*
|
|
633
|
+
* @param imageChars - What one image block counts for. The breakdown panel pays pi's full estimate;
|
|
634
|
+
* the stream ratio passes 0, because images never arrive as streamed text and would dilute a
|
|
635
|
+
* tokens-per-character reading with phantom characters.
|
|
636
|
+
*/
|
|
637
|
+
export function contentChars(content: unknown, imageChars = IMAGE_CHARS): number {
|
|
519
638
|
if (typeof content === 'string') return content.length
|
|
520
639
|
if (!Array.isArray(content)) return 0
|
|
521
640
|
let chars = 0
|
|
@@ -524,7 +643,7 @@ export function contentChars(content: unknown): number {
|
|
|
524
643
|
if (raw.type === 'text' && typeof raw.text === 'string') chars += raw.text.length
|
|
525
644
|
else if (raw.type === 'thinking' && typeof raw.thinking === 'string')
|
|
526
645
|
chars += raw.thinking.length
|
|
527
|
-
else if (raw.type === 'image') chars +=
|
|
646
|
+
else if (raw.type === 'image') chars += imageChars
|
|
528
647
|
else if (raw.type === 'toolCall') {
|
|
529
648
|
const name = typeof raw.name === 'string' ? raw.name.length : 0
|
|
530
649
|
let args = 0
|