useful-pi-extensions 1.6.0 → 1.7.0

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.6.0",
3
+ "version": "1.7.0",
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.",
5
5
  "keywords": [
6
6
  "bun",
@@ -8,8 +8,8 @@ Replaces pi's footer with a labelled two-row one. Every value carries a word, so
8
8
  decoded from a symbol or remembered from a legend.
9
9
 
10
10
  ```
11
- Context █████████▍░░░░░░░░░░ 47% 471k / 1.0M Input 194k · Output 89k · Cache hit 99.9% · Cost $0.229 · Today $1.63
12
- ~/.pi (master) deepseek-flash · Effort high · TTFT 482ms · 729 tok/s
11
+ Context █████████▍░░░░░░░░░░ 47% 471k / 1.0M Input 194k · Output 89k | Cache hit 99.9% | Cost $0.229 · Today $1.63
12
+ ~/.pi (master) deepseek-flash · Effort high | TTFT 482ms · Avg TTFT 612ms | Last 729 tok/s · Avg 512 tok/s
13
13
  LSP Active: typescript
14
14
  ```
15
15
 
@@ -7,8 +7,8 @@
7
7
  替换 pi 的 footer,改成带文字标签的两行。每个值都带一个词,不需要靠符号猜、也不需要记图例。
8
8
 
9
9
  ```
10
- Context █████████▍░░░░░░░░░░ 47% 471k / 1.0M Input 194k · Output 89k · Cache hit 99.9% · Cost $0.229 · Today $1.63
11
- ~/.pi (master) deepseek-flash · Effort high · TTFT 482ms · 729 tok/s
10
+ Context █████████▍░░░░░░░░░░ 47% 471k / 1.0M Input 194k · Output 89k | Cache hit 99.9% | Cost $0.229 · Today $1.63
11
+ ~/.pi (master) deepseek-flash · Effort high | TTFT 482ms · Avg TTFT 612ms | Last 729 tok/s · Avg 512 tok/s
12
12
  LSP Active: typescript
13
13
  ```
14
14
 
@@ -37,6 +37,7 @@ import {
37
37
  formatCwd,
38
38
  formatLatency,
39
39
  formatTps,
40
+ avgMs,
40
41
  isQuietStatus,
41
42
  pair,
42
43
  cachedRates,
@@ -65,6 +66,8 @@ const CONFIG_PATH = join(homedir(), '.pi', 'agent', 'statusline.json')
65
66
  const RATES_URL = 'https://open.er-api.com/v6/latest/USD'
66
67
  /** Every session on this machine, for the day-cost total that spans projects and models. */
67
68
  const SESSIONS_DIR = join(homedir(), '.pi', 'agent', 'sessions')
69
+ /** Where throughput metrics wait out a /reload: keyed by session file, so a reload restores. */
70
+ const STATE_PATH = join(homedir(), '.pi', 'agent', 'statusline-state.json')
68
71
  const FETCH_TIMEOUT_MS = 5000
69
72
 
70
73
  function today(): string {
@@ -110,6 +113,11 @@ function isRecord(value: unknown): value is Record<string, unknown> {
110
113
  return typeof value === 'object' && value !== null && !Array.isArray(value)
111
114
  }
112
115
 
116
+ /** Coerces a persisted counter back to a non-negative finite number, or 0. */
117
+ function num(value: unknown): number {
118
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
119
+ }
120
+
113
121
  /** Today's provider cost of one session-file line, or null when the line bills nothing today. */
114
122
  function entryCost(line: string, since: number): number | null {
115
123
  if (!line.includes('"usage"')) return null
@@ -326,6 +334,8 @@ export default function (pi: ExtensionAPI) {
326
334
  let firstTokenAt: number | null = null
327
335
  let totalDecodeMs = 0
328
336
  let totalMeasuredOutput = 0
337
+ let totalTtftMs = 0
338
+ let ttftCount = 0
329
339
  let todayBase = 0
330
340
  let ticker: ReturnType<typeof setInterval> | null = null
331
341
  let windowAt = 0
@@ -365,6 +375,48 @@ export default function (pi: ExtensionAPI) {
365
375
  }, TICK_MS)
366
376
  }
367
377
 
378
+ /**
379
+ * Writes the throughput metrics so a /reload can restore them.
380
+ *
381
+ * Decode timing only exists in live stream events — pi records nothing per message — so without
382
+ * this file the session's averages reset to zero every time the extension re-loads.
383
+ */
384
+ function persist(sessionFile: string | null): void {
385
+ if (sessionFile === null) return
386
+ const state = {
387
+ sessionFile,
388
+ totalDecodeMs,
389
+ totalMeasuredOutput,
390
+ totalTtftMs,
391
+ ttftCount,
392
+ last: reading,
393
+ }
394
+ void writeFile(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`).catch(() => {})
395
+ }
396
+
397
+ /** Restores what persist wrote, but only for this exact session file. */
398
+ async function restore(sessionFile: string | null): Promise<void> {
399
+ if (sessionFile === null) return
400
+ let state: unknown
401
+ try {
402
+ state = JSON.parse(await readFile(STATE_PATH, 'utf8'))
403
+ } catch {
404
+ return
405
+ }
406
+ if (!isRecord(state) || state.sessionFile !== sessionFile) return
407
+ totalDecodeMs = num(state.totalDecodeMs)
408
+ totalMeasuredOutput = num(state.totalMeasuredOutput)
409
+ totalTtftMs = num(state.totalTtftMs)
410
+ ttftCount = num(state.ttftCount)
411
+ if (isRecord(state.last) && typeof state.last.rate === 'number') {
412
+ reading = {
413
+ rate: state.last.rate,
414
+ exact: state.last.exact === true,
415
+ ttftMs: typeof state.last.ttftMs === 'number' ? state.last.ttftMs : null,
416
+ }
417
+ }
418
+ }
419
+
368
420
  function installFooter(ctx: ExtensionContext): void {
369
421
  ctx.ui.setFooter((tui, theme, footerData: ReadonlyFooterDataProvider) => {
370
422
  requestRender = () => tui.requestRender()
@@ -394,19 +446,31 @@ export default function (pi: ExtensionAPI) {
394
446
  currency,
395
447
  )
396
448
 
397
- // Row 2: model and the latest turn's timing on the right, path on the left.
449
+ // Row 2: model and the latest turn's timing on the right, path on the left. Three
450
+ // groups — identity, first token, throughput — separated by a wall instead of another
451
+ // dot, because a run of similar-looking pairs is what made the old footer unreadable.
398
452
  const model = ctx.model?.id ?? 'no model'
399
- const row2Parts = [theme.fg('accent', model)]
400
- if (ctx.thinkingLevel) row2Parts.push(pair(theme, 'Effort', ctx.thinkingLevel, 'muted'))
453
+ const separator = theme.fg('dim', ' · ')
454
+ const wall = theme.fg('dim', ' | ')
455
+ const identity = [theme.fg('accent', model)]
456
+ if (ctx.thinkingLevel) identity.push(pair(theme, 'Effort', ctx.thinkingLevel, 'muted'))
457
+
458
+ const ttft: string[] = []
401
459
  const waiting = ttftDisplay(requestAt, firstTokenAt, Date.now())
402
460
  if (waiting !== null) {
403
461
  // The clock is running: this wait has no reading yet, so the previous turn's
404
- // throughput would only be mistaken for the current one.
405
- row2Parts.push(pair(theme, 'TTFT', waiting.text, 'muted'))
462
+ // numbers would only be mistaken for the current one.
463
+ ttft.push(pair(theme, 'TTFT', waiting.text, 'muted'))
406
464
  } else if (reading) {
407
465
  if (reading.ttftMs !== null)
408
- row2Parts.push(pair(theme, 'TTFT', formatLatency(reading.ttftMs), 'muted'))
409
- row2Parts.push(
466
+ ttft.push(pair(theme, 'TTFT', formatLatency(reading.ttftMs), 'muted'))
467
+ }
468
+ const avgTtft = avgMs(totalTtftMs, ttftCount)
469
+ if (avgTtft !== null) ttft.push(pair(theme, 'Avg TTFT', formatLatency(avgTtft), 'muted'))
470
+
471
+ const throughput: string[] = []
472
+ if (reading) {
473
+ throughput.push(
410
474
  pair(
411
475
  theme,
412
476
  'Last',
@@ -415,9 +479,12 @@ export default function (pi: ExtensionAPI) {
415
479
  ),
416
480
  )
417
481
  }
418
- if (avg !== null) row2Parts.push(pair(theme, 'Avg', `${formatTps(avg)} tok/s`, 'muted'))
419
- const row2Right = row2Parts.join(theme.fg('dim', ' · '))
482
+ if (avg !== null) throughput.push(pair(theme, 'Avg', `${formatTps(avg)} tok/s`, 'muted'))
420
483
 
484
+ const row2Right = [identity, ttft, throughput]
485
+ .filter((group) => group.length > 0)
486
+ .map((group) => group.join(separator))
487
+ .join(wall)
421
488
  const branch = footerData.getGitBranch()
422
489
  const path = formatCwd(ctx.cwd)
423
490
  const branchSuffix = branch ? ` (${branch})` : ''
@@ -481,6 +548,7 @@ export default function (pi: ExtensionAPI) {
481
548
  resetStream()
482
549
  currency = await loadCurrency((message) => ctx.ui.notify(message, 'warning'))
483
550
  const file = ctx.sessionManager.getSessionFile()
551
+ await restore(file ?? null)
484
552
  todayBase = await sumOtherTodaysCost(file ?? null, startOfToday())
485
553
  installFooter(ctx)
486
554
  })
@@ -546,7 +614,7 @@ export default function (pi: ExtensionAPI) {
546
614
  publish(rate, false, ttftMs(requestAt, firstTokenAt))
547
615
  })
548
616
 
549
- pi.on('message_end', async (event) => {
617
+ pi.on('message_end', async (event, ctx) => {
550
618
  if (event.message.role !== 'assistant') return
551
619
 
552
620
  const message = event.message as { content: unknown; usage?: { output?: number } }
@@ -566,6 +634,9 @@ export default function (pi: ExtensionAPI) {
566
634
  // below once produced an Avg of 4324 tok/s.
567
635
  totalDecodeMs += decodeMs
568
636
  if (output > 0) totalMeasuredOutput += output
637
+ totalTtftMs += measured
638
+ ttftCount += 1
639
+ persist(ctx.sessionManager.getSessionFile() ?? null)
569
640
  publish((tokens / decodeMs) * 1000, output > 0, measured)
570
641
  }
571
642
  // Null it with the stream: a request that has produced its message is no longer in flight, and
@@ -296,6 +296,11 @@ export function avgTokPerSec(outputTokens: number, decodeMs: number): number | n
296
296
  return outputTokens / (decodeMs / 1000)
297
297
  }
298
298
 
299
+ /** Mean of `count` durations totalling `totalMs`; null when nothing was measured. */
300
+ export function avgMs(totalMs: number, count: number): number | null {
301
+ return count > 0 ? totalMs / count : null
302
+ }
303
+
299
304
  /**
300
305
  * The config file's text with the day's rates recorded in it, so the next session starts warm.
301
306
  *
@@ -435,18 +440,28 @@ export function contextRow(
435
440
  const volumes: string[] = []
436
441
  if (parts.input > 0) volumes.push(pair(theme, 'Input', formatTokens(parts.input)))
437
442
  if (parts.output > 0) volumes.push(pair(theme, 'Output', formatTokens(parts.output)))
438
- const outcomes: string[] = []
443
+ const hit: string[] = []
439
444
  if (parts.cacheHitRate !== null) {
440
- outcomes.push(pair(theme, 'Cache hit', `${parts.cacheHitRate.toFixed(1)}%`))
445
+ hit.push(pair(theme, 'Cache hit', `${parts.cacheHitRate.toFixed(1)}%`))
441
446
  }
442
- if (parts.cost > 0) outcomes.push(pair(theme, 'Cost', formatCost(parts.cost, currency)))
447
+ const money: string[] = []
448
+ if (parts.cost > 0) money.push(pair(theme, 'Cost', formatCost(parts.cost, currency)))
443
449
  if (parts.todayCost > 0) {
444
- outcomes.push(pair(theme, 'Today', formatCost(parts.todayCost, currency)))
450
+ money.push(pair(theme, 'Today', formatCost(parts.todayCost, currency)))
445
451
  }
446
452
 
447
453
  const separator = theme.fg('dim', ' · ')
448
- const full = [...volumes, ...outcomes].join(separator)
449
- const core = outcomes.join(separator)
454
+ // Groups, not a flat run of dots: volumes | cache | money. A wall between different kinds of
455
+ // number reads faster than another dot between similar-looking ones.
456
+ const groups: string[] = []
457
+ if (volumes.length > 0) groups.push(volumes.join(separator))
458
+ if (hit.length > 0) groups.push(hit.join(separator))
459
+ if (money.length > 0) groups.push(money.join(separator))
460
+ const full = groups.join(theme.fg('dim', ' | '))
461
+ const core =
462
+ hit.length > 0 && money.length > 0
463
+ ? hit.join(separator) + theme.fg('dim', ' | ') + money.join(separator)
464
+ : [...hit, ...money].join(separator)
450
465
  const fits = (left: string, right: string): boolean =>
451
466
  right === '' || visibleWidth(left) + 2 + visibleWidth(right) <= width
452
467