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.1",
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
- /** Where throughput metrics wait out a /reload: keyed by session file, so a reload restores. */
81
- /** One state file per session, so two concurrent sessions cannot clobber each other. */
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 = blockChars(entry.message.content)
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
- let reading: { rate: number; exact: boolean; ttftMs: number | null } | null = null
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) && typeof state.last.rate === 'number') {
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: usage?.contextWindow ?? shown?.contextWindow ?? ctx.model?.contextWindow ?? 0,
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 = window > 0 ? Math.max(0, window - used - (reserveTokens ?? 0)) : null
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. Three
624
- // groups — identity, first token, throughput — separated by a wall instead of another
625
- // dot, because a run of similar-looking pairs is what made the old footer unreadable.
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
- if (waiting !== null) {
635
- // The clock is running: this wait has no reading yet, so the previous turn's
636
- // numbers would only be mistaken for the current one.
637
- ttft.push(pair(theme, 'TTFT', waiting.text))
638
- } else if (reading) {
639
- if (reading.ttftMs !== null)
640
- ttft.push(pair(theme, 'TTFT', formatLatency(reading.ttftMs)))
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
- if (avgTtft !== null) ttft.push(pair(theme, 'Avg TTFT', formatLatency(avgTtft)))
644
-
645
- const throughput: string[] = []
646
- if (reading) {
647
- throughput.push(
648
- pair(theme, 'Last', `${reading.exact ? '' : '~'}${formatTps(reading.rate)} tok/s`),
649
- )
650
- }
651
- if (avg !== null) throughput.push(pair(theme, 'Avg', `${formatTps(avg)} tok/s`))
652
-
653
- const row2Right = [identity, ttft, throughput]
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
- // A run that ends without a first token (abort, provider error) must not leave a clock counting
697
- // against a request that is no longer in flight.
698
- pi.on('turn_end', async () => {
699
- requestAt = null
700
- stopTicker()
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
- pi.on('agent_end', async () => {
704
- requestAt = null
705
- stopTicker()
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 = blockChars(message.content)
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
- if (measured !== null && decodeMs >= MIN_SAMPLE_MS) {
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((tokens / decodeMs) * 1000, output > 0, measured)
841
- } else if (measured !== null && decodeMs > 0) {
842
- // The frozen Last must be this message's whole-message rate, never the last live
843
- // sliding-window sample: a buffered burst of chunks mid-stream reads triple.
844
- publish((tokens / decodeMs) * 1000, output > 0, measured)
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 should cost the least load-bearing of them
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 cumulative bills,
427
- * 3. The right group entirely (`row` then keeps the left alone).
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 meter = `${theme.fg('dim', 'Context window')} ${bar(theme, BAR_CELLS, fraction)} ${percent}`
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
- if (fits(withDetail, full)) return row(theme, width, withDetail, full)
479
- if (fits(meter, full)) return row(theme, width, meter, full)
480
- return row(theme, width, meter, core)
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
- /** Characters of message content: strings, text blocks, tool calls and image placeholders. */
518
- export function contentChars(content: unknown): number {
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 += 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