@reedchan/statusline 1.6.0 → 1.8.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/README.md +4 -3
- package/README_CN.md +3 -3
- package/extensions/statusline/index.ts +120 -16
- package/extensions/statusline/render.ts +22 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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
|
|
12
|
-
~/.pi (master)
|
|
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
|
|
|
@@ -54,7 +54,8 @@ Configuration lives at the top of [`render.ts`](render.ts):
|
|
|
54
54
|
## Currency
|
|
55
55
|
|
|
56
56
|
pi prices every model in USD and its `cost` field carries no unit at all, so the footer cannot know
|
|
57
|
-
what you were actually billed. The currency and the rate live in `~/.pi/agent/statusline.json
|
|
57
|
+
what you were actually billed. The currency and the rate live in `~/.pi/agent/statusline/config.json` — everything this
|
|
58
|
+
extension keeps (config plus the reload-proof state file) sits in that one folder:
|
|
58
59
|
|
|
59
60
|
```json
|
|
60
61
|
{
|
package/README_CN.md
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
替换 pi 的 footer,改成带文字标签的两行。每个值都带一个词,不需要靠符号猜、也不需要记图例。
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
Context █████████▍░░░░░░░░░░ 47% 471k / 1.0M Input 194k · Output 89k
|
|
11
|
-
~/.pi (master)
|
|
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
|
|
|
@@ -52,7 +52,7 @@ LSP Active: typescript
|
|
|
52
52
|
## 币种
|
|
53
53
|
|
|
54
54
|
pi 里所有模型价格都是美元,而且它的 `cost` 字段**不带任何单位**,所以 footer 无从知道你实际是按什么币种付的。
|
|
55
|
-
币种与汇率放在 `~/.pi/agent/statusline.json
|
|
55
|
+
币种与汇率放在 `~/.pi/agent/statusline/config.json`——本扩展的所有文件(配置加状态)都集中在这个文件夹里:
|
|
56
56
|
|
|
57
57
|
```json
|
|
58
58
|
{
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
* stepStartTime; decodeMs = completedTime - firstTokenTime; tok/s = usage.output / (decodeMs / 1000)
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { readFile, readdir, writeFile } from 'node:fs/promises'
|
|
21
|
+
import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
|
|
22
22
|
import { stat } from 'node:fs/promises'
|
|
23
23
|
import { homedir } from 'node:os'
|
|
24
|
-
import { join } from 'node:path'
|
|
24
|
+
import { dirname, join } from 'node:path'
|
|
25
25
|
|
|
26
26
|
import type {
|
|
27
27
|
ExtensionAPI,
|
|
@@ -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,
|
|
@@ -60,11 +61,18 @@ const TICK_MS = 250
|
|
|
60
61
|
const FALLBACK_TOKENS_PER_CHAR = 0.25
|
|
61
62
|
|
|
62
63
|
/** The status line's own settings file, alongside pi's other per-tool config. */
|
|
63
|
-
|
|
64
|
+
/** Everything this extension keeps lives in one folder, not loose files relying on name prefixes. */
|
|
65
|
+
const STATUSLINE_DIR = join(homedir(), '.pi', 'agent', 'statusline')
|
|
66
|
+
const CONFIG_PATH = join(STATUSLINE_DIR, 'config.json')
|
|
67
|
+
/** Pre-1.8 locations, migrated out of on first start. */
|
|
68
|
+
const LEGACY_CONFIG = join(homedir(), '.pi', 'agent', 'statusline.json')
|
|
64
69
|
/** Free, keyless, and one request returns every currency — so the cache serves instant switching. */
|
|
65
70
|
const RATES_URL = 'https://open.er-api.com/v6/latest/USD'
|
|
66
71
|
/** Every session on this machine, for the day-cost total that spans projects and models. */
|
|
67
72
|
const SESSIONS_DIR = join(homedir(), '.pi', 'agent', 'sessions')
|
|
73
|
+
/** Where throughput metrics wait out a /reload: keyed by session file, so a reload restores. */
|
|
74
|
+
const STATE_PATH = join(STATUSLINE_DIR, 'state.json')
|
|
75
|
+
const LEGACY_STATE = join(homedir(), '.pi', 'agent', 'statusline-state.json')
|
|
68
76
|
const FETCH_TIMEOUT_MS = 5000
|
|
69
77
|
|
|
70
78
|
function today(): string {
|
|
@@ -110,6 +118,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
110
118
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
111
119
|
}
|
|
112
120
|
|
|
121
|
+
/** Coerces a persisted counter back to a non-negative finite number, or 0. */
|
|
122
|
+
function num(value: unknown): number {
|
|
123
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Moves a pre-1.8 flat file into the extension folder; missing files are the normal case. */
|
|
127
|
+
async function migrateLegacyFile(legacy: string, current: string): Promise<void> {
|
|
128
|
+
try {
|
|
129
|
+
await mkdir(dirname(current), { recursive: true })
|
|
130
|
+
await rename(legacy, current)
|
|
131
|
+
} catch {
|
|
132
|
+
// Nothing at the old path, or already migrated: either way the current file rules.
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
113
136
|
/** Today's provider cost of one session-file line, or null when the line bills nothing today. */
|
|
114
137
|
function entryCost(line: string, since: number): number | null {
|
|
115
138
|
if (!line.includes('"usage"')) return null
|
|
@@ -183,6 +206,8 @@ async function loadCurrency(notify: (message: string) => void): Promise<Currency
|
|
|
183
206
|
const { currency, pending, problem } = currencyFromConfig(text)
|
|
184
207
|
if (problem !== null) notify(problem)
|
|
185
208
|
if (pending === null) return currency
|
|
209
|
+
// Costs are already USD; a rates fetch could only ever return 1.
|
|
210
|
+
if (pending.code === 'USD') return USD
|
|
186
211
|
|
|
187
212
|
const cached = cachedRates(text)
|
|
188
213
|
const known = cached?.rates[pending.code]
|
|
@@ -326,7 +351,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
326
351
|
let firstTokenAt: number | null = null
|
|
327
352
|
let totalDecodeMs = 0
|
|
328
353
|
let totalMeasuredOutput = 0
|
|
354
|
+
let totalTtftMs = 0
|
|
355
|
+
let ttftCount = 0
|
|
329
356
|
let todayBase = 0
|
|
357
|
+
/** Last context usage pi reported with real numbers, shown when pi's current answer is stale. */
|
|
358
|
+
let lastKnownUsage: {
|
|
359
|
+
tokens: number | null
|
|
360
|
+
percent: number | null
|
|
361
|
+
contextWindow: number
|
|
362
|
+
} | null = null
|
|
330
363
|
let ticker: ReturnType<typeof setInterval> | null = null
|
|
331
364
|
let windowAt = 0
|
|
332
365
|
let windowTokens = 0
|
|
@@ -365,6 +398,48 @@ export default function (pi: ExtensionAPI) {
|
|
|
365
398
|
}, TICK_MS)
|
|
366
399
|
}
|
|
367
400
|
|
|
401
|
+
/**
|
|
402
|
+
* Writes the throughput metrics so a /reload can restore them.
|
|
403
|
+
*
|
|
404
|
+
* Decode timing only exists in live stream events — pi records nothing per message — so without
|
|
405
|
+
* this file the session's averages reset to zero every time the extension re-loads.
|
|
406
|
+
*/
|
|
407
|
+
function persist(sessionFile: string | null): void {
|
|
408
|
+
if (sessionFile === null) return
|
|
409
|
+
const state = {
|
|
410
|
+
sessionFile,
|
|
411
|
+
totalDecodeMs,
|
|
412
|
+
totalMeasuredOutput,
|
|
413
|
+
totalTtftMs,
|
|
414
|
+
ttftCount,
|
|
415
|
+
last: reading,
|
|
416
|
+
}
|
|
417
|
+
void writeFile(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`).catch(() => {})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Restores what persist wrote, but only for this exact session file. */
|
|
421
|
+
async function restore(sessionFile: string | null): Promise<void> {
|
|
422
|
+
if (sessionFile === null) return
|
|
423
|
+
let state: unknown
|
|
424
|
+
try {
|
|
425
|
+
state = JSON.parse(await readFile(STATE_PATH, 'utf8'))
|
|
426
|
+
} catch {
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
if (!isRecord(state) || state.sessionFile !== sessionFile) return
|
|
430
|
+
totalDecodeMs = num(state.totalDecodeMs)
|
|
431
|
+
totalMeasuredOutput = num(state.totalMeasuredOutput)
|
|
432
|
+
totalTtftMs = num(state.totalTtftMs)
|
|
433
|
+
ttftCount = num(state.ttftCount)
|
|
434
|
+
if (isRecord(state.last) && typeof state.last.rate === 'number') {
|
|
435
|
+
reading = {
|
|
436
|
+
rate: state.last.rate,
|
|
437
|
+
exact: state.last.exact === true,
|
|
438
|
+
ttftMs: typeof state.last.ttftMs === 'number' ? state.last.ttftMs : null,
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
368
443
|
function installFooter(ctx: ExtensionContext): void {
|
|
369
444
|
ctx.ui.setFooter((tui, theme, footerData: ReadonlyFooterDataProvider) => {
|
|
370
445
|
requestRender = () => tui.requestRender()
|
|
@@ -376,15 +451,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
376
451
|
},
|
|
377
452
|
render(width: number): string[] {
|
|
378
453
|
const usage = ctx.getContextUsage()
|
|
454
|
+
// pi nulls tokens and percent when the last usage predates a compaction and no response
|
|
455
|
+
// has landed since: the numbers it could hand over are stale, not zero. Show the last
|
|
456
|
+
// reading we trusted instead; a brand-new session starts at 0, which is near the truth.
|
|
457
|
+
const trusted =
|
|
458
|
+
usage !== undefined && usage.tokens !== null && usage.percent !== null ? usage : null
|
|
459
|
+
if (trusted !== null) lastKnownUsage = trusted
|
|
460
|
+
const shown = trusted ?? lastKnownUsage
|
|
379
461
|
const totals = collectTotals(ctx, startOfToday())
|
|
380
462
|
const avg = avgTokPerSec(totalMeasuredOutput, totalDecodeMs)
|
|
381
463
|
const row1 = contextRow(
|
|
382
464
|
theme,
|
|
383
465
|
width,
|
|
384
466
|
{
|
|
385
|
-
percent:
|
|
386
|
-
tokens:
|
|
387
|
-
window: usage?.contextWindow ?? ctx.model?.contextWindow ?? 0,
|
|
467
|
+
percent: shown?.percent ?? 0,
|
|
468
|
+
tokens: shown?.tokens ?? 0,
|
|
469
|
+
window: usage?.contextWindow ?? shown?.contextWindow ?? ctx.model?.contextWindow ?? 0,
|
|
388
470
|
input: totals.input,
|
|
389
471
|
output: totals.output,
|
|
390
472
|
cacheHitRate: totals.cacheHitRate,
|
|
@@ -394,19 +476,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
394
476
|
currency,
|
|
395
477
|
)
|
|
396
478
|
|
|
397
|
-
// Row 2: model and the latest turn's timing on the right, path on the left.
|
|
479
|
+
// Row 2: model and the latest turn's timing on the right, path on the left. Three
|
|
480
|
+
// groups — identity, first token, throughput — separated by a wall instead of another
|
|
481
|
+
// dot, because a run of similar-looking pairs is what made the old footer unreadable.
|
|
398
482
|
const model = ctx.model?.id ?? 'no model'
|
|
399
|
-
const
|
|
400
|
-
|
|
483
|
+
const separator = theme.fg('dim', ' · ')
|
|
484
|
+
const wall = theme.fg('dim', ' | ')
|
|
485
|
+
const identity = [theme.fg('accent', model)]
|
|
486
|
+
if (ctx.thinkingLevel) identity.push(pair(theme, 'Effort', ctx.thinkingLevel, 'muted'))
|
|
487
|
+
|
|
488
|
+
const ttft: string[] = []
|
|
401
489
|
const waiting = ttftDisplay(requestAt, firstTokenAt, Date.now())
|
|
402
490
|
if (waiting !== null) {
|
|
403
491
|
// The clock is running: this wait has no reading yet, so the previous turn's
|
|
404
|
-
//
|
|
405
|
-
|
|
492
|
+
// numbers would only be mistaken for the current one.
|
|
493
|
+
ttft.push(pair(theme, 'TTFT', waiting.text, 'muted'))
|
|
406
494
|
} else if (reading) {
|
|
407
495
|
if (reading.ttftMs !== null)
|
|
408
|
-
|
|
409
|
-
|
|
496
|
+
ttft.push(pair(theme, 'TTFT', formatLatency(reading.ttftMs), 'muted'))
|
|
497
|
+
}
|
|
498
|
+
const avgTtft = avgMs(totalTtftMs, ttftCount)
|
|
499
|
+
if (avgTtft !== null) ttft.push(pair(theme, 'Avg TTFT', formatLatency(avgTtft), 'muted'))
|
|
500
|
+
|
|
501
|
+
const throughput: string[] = []
|
|
502
|
+
if (reading) {
|
|
503
|
+
throughput.push(
|
|
410
504
|
pair(
|
|
411
505
|
theme,
|
|
412
506
|
'Last',
|
|
@@ -415,9 +509,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
415
509
|
),
|
|
416
510
|
)
|
|
417
511
|
}
|
|
418
|
-
if (avg !== null)
|
|
419
|
-
const row2Right = row2Parts.join(theme.fg('dim', ' · '))
|
|
512
|
+
if (avg !== null) throughput.push(pair(theme, 'Avg', `${formatTps(avg)} tok/s`, 'muted'))
|
|
420
513
|
|
|
514
|
+
const row2Right = [identity, ttft, throughput]
|
|
515
|
+
.filter((group) => group.length > 0)
|
|
516
|
+
.map((group) => group.join(separator))
|
|
517
|
+
.join(wall)
|
|
421
518
|
const branch = footerData.getGitBranch()
|
|
422
519
|
const path = formatCwd(ctx.cwd)
|
|
423
520
|
const branchSuffix = branch ? ` (${branch})` : ''
|
|
@@ -480,7 +577,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
480
577
|
stopTicker()
|
|
481
578
|
resetStream()
|
|
482
579
|
currency = await loadCurrency((message) => ctx.ui.notify(message, 'warning'))
|
|
580
|
+
lastKnownUsage = null
|
|
581
|
+
await migrateLegacyFile(LEGACY_CONFIG, CONFIG_PATH)
|
|
582
|
+
await migrateLegacyFile(LEGACY_STATE, STATE_PATH)
|
|
483
583
|
const file = ctx.sessionManager.getSessionFile()
|
|
584
|
+
await restore(file ?? null)
|
|
484
585
|
todayBase = await sumOtherTodaysCost(file ?? null, startOfToday())
|
|
485
586
|
installFooter(ctx)
|
|
486
587
|
})
|
|
@@ -546,7 +647,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
546
647
|
publish(rate, false, ttftMs(requestAt, firstTokenAt))
|
|
547
648
|
})
|
|
548
649
|
|
|
549
|
-
pi.on('message_end', async (event) => {
|
|
650
|
+
pi.on('message_end', async (event, ctx) => {
|
|
550
651
|
if (event.message.role !== 'assistant') return
|
|
551
652
|
|
|
552
653
|
const message = event.message as { content: unknown; usage?: { output?: number } }
|
|
@@ -566,6 +667,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
566
667
|
// below once produced an Avg of 4324 tok/s.
|
|
567
668
|
totalDecodeMs += decodeMs
|
|
568
669
|
if (output > 0) totalMeasuredOutput += output
|
|
670
|
+
totalTtftMs += measured
|
|
671
|
+
ttftCount += 1
|
|
672
|
+
persist(ctx.sessionManager.getSessionFile() ?? null)
|
|
569
673
|
publish((tokens / decodeMs) * 1000, output > 0, measured)
|
|
570
674
|
}
|
|
571
675
|
// 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
|
*
|
|
@@ -424,7 +429,7 @@ export function contextRow(
|
|
|
424
429
|
const percent = theme.fg(
|
|
425
430
|
percentColor(parts.percent),
|
|
426
431
|
// Whole percents: the meter carries the precision, and a decimal here is noise.
|
|
427
|
-
parts.percent === null ? '
|
|
432
|
+
parts.percent === null ? '--' : `${Math.round(parts.percent)}%`,
|
|
428
433
|
)
|
|
429
434
|
const meter = `${theme.fg('dim', 'Context')} ${bar(theme, BAR_CELLS, fraction)} ${percent}`
|
|
430
435
|
const detail =
|
|
@@ -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
|
|
443
|
+
const hit: string[] = []
|
|
439
444
|
if (parts.cacheHitRate !== null) {
|
|
440
|
-
|
|
445
|
+
hit.push(pair(theme, 'Cache hit', `${parts.cacheHitRate.toFixed(1)}%`))
|
|
441
446
|
}
|
|
442
|
-
|
|
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
|
-
|
|
450
|
+
money.push(pair(theme, 'Today', formatCost(parts.todayCost, currency)))
|
|
445
451
|
}
|
|
446
452
|
|
|
447
453
|
const separator = theme.fg('dim', ' · ')
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reedchan/statusline",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Replaces pi's footer with a labelled two-row status line: context pressure as a fixed-size meter, cache and cost, the model and effort level, and the latest turn's TTFT and decode throughput in tokens/second.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|