@reedchan/statusline 1.3.0 → 1.5.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 +19 -14
- package/README_CN.md +16 -12
- package/extensions/statusline/index.ts +33 -16
- package/extensions/statusline/render.ts +38 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -53,29 +53,34 @@ Configuration lives at the top of [`render.ts`](render.ts):
|
|
|
53
53
|
## Currency
|
|
54
54
|
|
|
55
55
|
pi prices every model in USD and its `cost` field carries no unit at all, so the footer cannot know
|
|
56
|
-
what you were actually billed.
|
|
57
|
-
by a market feed, which is why the rate is configured rather than fetched.
|
|
58
|
-
|
|
59
|
-
Create `~/.pi/agent/statusline.json`:
|
|
56
|
+
what you were actually billed. The currency and the rate live in `~/.pi/agent/statusline.json`:
|
|
60
57
|
|
|
61
58
|
```json
|
|
62
59
|
{
|
|
63
60
|
"currency": {
|
|
64
|
-
"code": "CNY"
|
|
65
|
-
"perUsd": 7.12
|
|
61
|
+
"code": "CNY"
|
|
66
62
|
}
|
|
67
63
|
}
|
|
68
64
|
```
|
|
69
65
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
-
|
|
73
|
-
|
|
74
|
-
|
|
66
|
+
Two ways to set the rate:
|
|
67
|
+
|
|
68
|
+
- **Follow the market.** A `code` with no rate fetches the day's table from open.er-api.com on the
|
|
69
|
+
first session of each day and caches the whole table in the file (`rates` + `fetchedAt`), so the
|
|
70
|
+
next session starts warm and switching codes is instant and offline.
|
|
71
|
+
- **Pin it.** Add `"perUsd": 7.12` and the footer always uses that rate, never touching the network
|
|
72
|
+
— the right choice when your platform bills in RMB directly, because a domestic price list is not
|
|
73
|
+
the dollar list times a market rate.
|
|
74
|
+
|
|
75
|
+
`code` picks the symbol: `¥` for `CNY` and `RMB`, `JP¥` for `JPY` so the two never trade places, and
|
|
76
|
+
the table also knows `USD`, `EUR`, `GBP`, `HKD`, `TWD`, `SGD`, `KRW`, `INR`, `AUD`, `CAD`, `NZD` and
|
|
77
|
+
`CHF`; an unknown code is printed as it is. `symbol` overrides all of that.
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
Switching is editing `code`, or `make currency CODE=JPY` from a checkout. The file is read once per
|
|
80
|
+
session, so finish with `/reload`. A file that is there but unusable says so in a notification,
|
|
81
|
+
rather than silently showing dollars with nothing to explain why.
|
|
82
|
+
|
|
83
|
+
## Metrics
|
|
79
84
|
|
|
80
85
|
## Metrics
|
|
81
86
|
|
package/README_CN.md
CHANGED
|
@@ -51,27 +51,31 @@ LSP Active: typescript
|
|
|
51
51
|
## 币种
|
|
52
52
|
|
|
53
53
|
pi 里所有模型价格都是美元,而且它的 `cost` 字段**不带任何单位**,所以 footer 无从知道你实际是按什么币种付的。
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
创建 `~/.pi/agent/statusline.json`:
|
|
54
|
+
币种与汇率放在 `~/.pi/agent/statusline.json`:
|
|
57
55
|
|
|
58
56
|
```json
|
|
59
57
|
{
|
|
60
58
|
"currency": {
|
|
61
|
-
"code": "CNY"
|
|
62
|
-
"perUsd": 7.12
|
|
59
|
+
"code": "CNY"
|
|
63
60
|
}
|
|
64
61
|
}
|
|
65
62
|
```
|
|
66
63
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
- `
|
|
70
|
-
|
|
71
|
-
|
|
64
|
+
汇率有两种设法:
|
|
65
|
+
|
|
66
|
+
- **跟随市场。** 只写 `code` 不写汇率时,每天第一个会话会从 open.er-api.com 拉取当日汇率表,
|
|
67
|
+
并把整张表缓存进文件(`rates` + `fetchedAt`),下个会话热启动;切换币种即时生效、离线也能切。
|
|
68
|
+
- **钉死。** 加上 `"perUsd": 7.12`,footer 永远用这个汇率、绝不联网——如果你的平台直接按人民币计费,
|
|
69
|
+
这是正确选择:国内价目表不是美元价目表乘以市场汇率。
|
|
70
|
+
|
|
71
|
+
`code` 决定符号:`CNY` 和 `RMB` 得到 `¥`,`JPY` 得到 `JP¥`(两者永不混用),符号表还认 `USD`、
|
|
72
|
+
`EUR`、`GBP`、`HKD`、`TWD`、`SGD`、`KRW`、`INR`、`AUD`、`CAD`、`NZD`、`CHF`;表里没有的代码原样打印。
|
|
73
|
+
`symbol` 可覆盖这一切。
|
|
72
74
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
+
切换就是改 `code`,或者在检出里执行 `make currency CODE=JPY`。该文件每个会话只读一次,改完需 `/reload`。
|
|
76
|
+
文件存在但不可用时会给出通知,而不是静默继续显示美元。
|
|
77
|
+
|
|
78
|
+
## 指标口径
|
|
75
79
|
|
|
76
80
|
## 指标口径
|
|
77
81
|
|
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'
|
|
31
31
|
|
|
32
32
|
import {
|
|
33
|
+
avgTokPerSec,
|
|
33
34
|
contextRow,
|
|
34
35
|
currencyFromConfig,
|
|
35
36
|
formatCwd,
|
|
@@ -37,10 +38,10 @@ import {
|
|
|
37
38
|
formatTps,
|
|
38
39
|
isQuietStatus,
|
|
39
40
|
pair,
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
cachedRates,
|
|
42
|
+
ratesFromPayload,
|
|
42
43
|
cacheIsFresh,
|
|
43
|
-
|
|
44
|
+
withCachedRates,
|
|
44
45
|
row,
|
|
45
46
|
shortenPath,
|
|
46
47
|
ttftDisplay,
|
|
@@ -59,7 +60,7 @@ const FALLBACK_TOKENS_PER_CHAR = 0.25
|
|
|
59
60
|
|
|
60
61
|
/** The status line's own settings file, alongside pi's other per-tool config. */
|
|
61
62
|
const CONFIG_PATH = join(homedir(), '.pi', 'agent', 'statusline.json')
|
|
62
|
-
/** Free, keyless,
|
|
63
|
+
/** Free, keyless, and one request returns every currency — so the cache serves instant switching. */
|
|
63
64
|
const RATES_URL = 'https://open.er-api.com/v6/latest/USD'
|
|
64
65
|
const FETCH_TIMEOUT_MS = 5000
|
|
65
66
|
|
|
@@ -70,13 +71,14 @@ function today(): string {
|
|
|
70
71
|
return `${now.getFullYear()}-${month}-${day}`
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
async function
|
|
74
|
+
async function fetchRates(): Promise<Record<string, number> | null> {
|
|
74
75
|
const controller = new AbortController()
|
|
75
76
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
76
77
|
try {
|
|
77
78
|
const response = await fetch(RATES_URL, { signal: controller.signal })
|
|
78
79
|
if (!response.ok) return null
|
|
79
|
-
|
|
80
|
+
const rates = ratesFromPayload(await response.json())
|
|
81
|
+
return Object.keys(rates).length === 0 ? null : rates
|
|
80
82
|
} catch {
|
|
81
83
|
return null
|
|
82
84
|
} finally {
|
|
@@ -87,6 +89,11 @@ async function fetchRate(code: string): Promise<number | null> {
|
|
|
87
89
|
/**
|
|
88
90
|
* Reads the display currency.
|
|
89
91
|
*
|
|
92
|
+
* `perUsd` is a pinned rate and wins untouched; a code without one resolves through the cached rate
|
|
93
|
+
* table — today's table answers instantly, a stale one answers while a fresh one is fetched, and
|
|
94
|
+
* only a first-ever failure speaks up. The whole table is cached because one request returns every
|
|
95
|
+
* currency, which is also what makes switching codes instant and offline.
|
|
96
|
+
*
|
|
90
97
|
* Called once per session on purpose: a footer that stat'ed a file on every frame would be its own
|
|
91
98
|
* bug. Editing the file therefore takes effect on `/reload`.
|
|
92
99
|
*/
|
|
@@ -102,21 +109,22 @@ async function loadCurrency(notify: (message: string) => void): Promise<Currency
|
|
|
102
109
|
if (problem !== null) notify(problem)
|
|
103
110
|
if (pending === null) return currency
|
|
104
111
|
|
|
105
|
-
const cached =
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
const cached = cachedRates(text)
|
|
113
|
+
const known = cached?.rates[pending.code]
|
|
114
|
+
if (cached !== null && known !== undefined && cacheIsFresh(cached.fetchedAt, today())) {
|
|
115
|
+
return { symbol: pending.symbol, perUsd: known }
|
|
108
116
|
}
|
|
109
117
|
|
|
110
|
-
const fetched = await
|
|
118
|
+
const fetched = await fetchRates()
|
|
111
119
|
if (fetched !== null) {
|
|
112
120
|
try {
|
|
113
|
-
await writeFile(CONFIG_PATH,
|
|
121
|
+
await writeFile(CONFIG_PATH, withCachedRates(text, fetched, today()))
|
|
114
122
|
} catch {
|
|
115
123
|
// The session still runs on the fetched rate; only tomorrow's warm start is lost.
|
|
116
124
|
}
|
|
117
|
-
return { symbol: pending.symbol, perUsd: fetched }
|
|
125
|
+
return { symbol: pending.symbol, perUsd: fetched[pending.code] ?? USD.perUsd }
|
|
118
126
|
}
|
|
119
|
-
if (
|
|
127
|
+
if (known !== undefined) return { symbol: pending.symbol, perUsd: known }
|
|
120
128
|
notify(`could not fetch a ${pending.code} rate, showing USD`)
|
|
121
129
|
return USD
|
|
122
130
|
}
|
|
@@ -231,6 +239,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
231
239
|
let chars = 0
|
|
232
240
|
let requestAt: number | null = null
|
|
233
241
|
let firstTokenAt: number | null = null
|
|
242
|
+
let totalDecodeMs = 0
|
|
234
243
|
let ticker: ReturnType<typeof setInterval> | null = null
|
|
235
244
|
let windowAt = 0
|
|
236
245
|
let windowTokens = 0
|
|
@@ -281,6 +290,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
281
290
|
render(width: number): string[] {
|
|
282
291
|
const usage = ctx.getContextUsage()
|
|
283
292
|
const totals = collectTotals(ctx)
|
|
293
|
+
const avg = avgTokPerSec(totals.output, totalDecodeMs)
|
|
284
294
|
const row1 = contextRow(
|
|
285
295
|
theme,
|
|
286
296
|
width,
|
|
@@ -309,12 +319,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
309
319
|
if (reading.ttftMs !== null)
|
|
310
320
|
row2Parts.push(pair(theme, 'TTFT', formatLatency(reading.ttftMs), 'muted'))
|
|
311
321
|
row2Parts.push(
|
|
312
|
-
|
|
313
|
-
|
|
322
|
+
pair(
|
|
323
|
+
theme,
|
|
324
|
+
'Last',
|
|
314
325
|
`${reading.exact ? '' : '~'}${formatTps(reading.rate)} tok/s`,
|
|
326
|
+
reading.exact ? 'success' : 'dim',
|
|
315
327
|
),
|
|
316
328
|
)
|
|
317
329
|
}
|
|
330
|
+
if (avg !== null) row2Parts.push(pair(theme, 'Avg', `${formatTps(avg)} tok/s`, 'muted'))
|
|
318
331
|
const row2Right = row2Parts.join(theme.fg('dim', ' · '))
|
|
319
332
|
|
|
320
333
|
const branch = footerData.getGitBranch()
|
|
@@ -374,6 +387,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
374
387
|
ratio = seedRatio(ctx)
|
|
375
388
|
reading = null
|
|
376
389
|
requestAt = null
|
|
390
|
+
totalDecodeMs = 0
|
|
377
391
|
stopTicker()
|
|
378
392
|
resetStream()
|
|
379
393
|
currency = await loadCurrency((message) => ctx.ui.notify(message, 'warning'))
|
|
@@ -455,8 +469,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
455
469
|
const decodeMs = firstTokenAt !== null ? Date.now() - firstTokenAt : 0
|
|
456
470
|
const measured = ttftMs(requestAt, firstTokenAt)
|
|
457
471
|
const tokens = output > 0 ? output : totalChars * (ratio ?? FALLBACK_TOKENS_PER_CHAR)
|
|
458
|
-
if (measured !== null && decodeMs >= MIN_SAMPLE_MS)
|
|
472
|
+
if (measured !== null && decodeMs >= MIN_SAMPLE_MS) {
|
|
473
|
+
// Session-average numerator lives in collectTotals; this is its denominator.
|
|
474
|
+
totalDecodeMs += decodeMs
|
|
459
475
|
publish((tokens / decodeMs) * 1000, output > 0, measured)
|
|
476
|
+
}
|
|
460
477
|
// Null it with the stream: a request that has produced its message is no longer in flight, and
|
|
461
478
|
// a stale anchor would let the live branch count against nothing until the next turn.
|
|
462
479
|
requestAt = null
|
|
@@ -143,13 +143,18 @@ export const USD: Currency = { symbol: '$', perUsd: 1 }
|
|
|
143
143
|
|
|
144
144
|
/** Currencies the config can name by `code` alone. Anything else needs an explicit `symbol`. */
|
|
145
145
|
const CURRENCY_SYMBOLS: Record<string, string> = {
|
|
146
|
+
AUD: 'A$',
|
|
147
|
+
CAD: 'CA$',
|
|
148
|
+
CHF: 'CHF',
|
|
146
149
|
CNY: '¥',
|
|
147
150
|
EUR: '€',
|
|
148
151
|
GBP: '£',
|
|
149
152
|
HKD: 'HK$',
|
|
150
153
|
INR: '₹',
|
|
151
|
-
|
|
154
|
+
// ¥ is CNY's by default; JPY wears the prefixed form so the two never trade places.
|
|
155
|
+
JPY: 'JP¥',
|
|
152
156
|
KRW: '₩',
|
|
157
|
+
NZD: 'NZ$',
|
|
153
158
|
RMB: '¥',
|
|
154
159
|
SGD: 'S$',
|
|
155
160
|
TWD: 'NT$',
|
|
@@ -250,26 +255,34 @@ export function currencyFromConfig(text: string | null): CurrencyConfig {
|
|
|
250
255
|
}
|
|
251
256
|
}
|
|
252
257
|
|
|
253
|
-
/**
|
|
254
|
-
export function
|
|
255
|
-
if (!isRecord(payload) || !isRecord(payload.rates)) return
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
+
/** The day's rates out of an open.er-api.com payload, positive and finite only. */
|
|
259
|
+
export function ratesFromPayload(payload: unknown): Record<string, number> {
|
|
260
|
+
if (!isRecord(payload) || !isRecord(payload.rates)) return {}
|
|
261
|
+
const rates: Record<string, number> = {}
|
|
262
|
+
for (const [code, rate] of Object.entries(payload.rates)) {
|
|
263
|
+
if (typeof rate === 'number' && Number.isFinite(rate) && rate > 0) rates[code] = rate
|
|
264
|
+
}
|
|
265
|
+
return rates
|
|
258
266
|
}
|
|
259
267
|
|
|
260
|
-
/** The
|
|
261
|
-
export function
|
|
268
|
+
/** The rates a config file cached from an earlier fetch, and the day they were fetched. */
|
|
269
|
+
export function cachedRates(
|
|
270
|
+
text: string,
|
|
271
|
+
): { rates: Record<string, number>; fetchedAt: string } | null {
|
|
262
272
|
let config: unknown
|
|
263
273
|
try {
|
|
264
274
|
config = JSON.parse(text)
|
|
265
275
|
} catch {
|
|
266
276
|
return null
|
|
267
277
|
}
|
|
268
|
-
if (!isRecord(config)) return null
|
|
269
|
-
const perUsd = config.fetchedPerUsd
|
|
278
|
+
if (!isRecord(config) || !isRecord(config.rates)) return null
|
|
270
279
|
const fetchedAt = config.fetchedAt
|
|
271
|
-
if (typeof
|
|
272
|
-
|
|
280
|
+
if (typeof fetchedAt !== 'string') return null
|
|
281
|
+
const rates: Record<string, number> = {}
|
|
282
|
+
for (const [code, rate] of Object.entries(config.rates)) {
|
|
283
|
+
if (typeof rate === 'number' && Number.isFinite(rate) && rate > 0) rates[code] = rate
|
|
284
|
+
}
|
|
285
|
+
return Object.keys(rates).length === 0 ? null : { rates, fetchedAt }
|
|
273
286
|
}
|
|
274
287
|
|
|
275
288
|
/** Whether a cached rate was fetched today, both dates as `YYYY-MM-DD`. */
|
|
@@ -277,13 +290,23 @@ export function cacheIsFresh(fetchedAt: string, today: string): boolean {
|
|
|
277
290
|
return fetchedAt.slice(0, 10) === today
|
|
278
291
|
}
|
|
279
292
|
|
|
293
|
+
/** Session-average output tokens per second of measured decode time; null before any measurement. */
|
|
294
|
+
export function avgTokPerSec(outputTokens: number, decodeMs: number): number | null {
|
|
295
|
+
if (decodeMs <= 0) return null
|
|
296
|
+
return outputTokens / (decodeMs / 1000)
|
|
297
|
+
}
|
|
298
|
+
|
|
280
299
|
/**
|
|
281
|
-
* The config file's text with
|
|
300
|
+
* The config file's text with the day's rates recorded in it, so the next session starts warm.
|
|
282
301
|
*
|
|
283
302
|
* Unknown keys are kept, and a file that does not parse is returned untouched: the extension has no
|
|
284
303
|
* business replacing a config it could not read with one it wrote.
|
|
285
304
|
*/
|
|
286
|
-
export function
|
|
305
|
+
export function withCachedRates(
|
|
306
|
+
text: string,
|
|
307
|
+
rates: Record<string, number>,
|
|
308
|
+
fetchedAt: string,
|
|
309
|
+
): string {
|
|
287
310
|
let config: unknown
|
|
288
311
|
try {
|
|
289
312
|
config = JSON.parse(text)
|
|
@@ -291,7 +314,7 @@ export function withCachedRate(text: string, perUsd: number, fetchedAt: string):
|
|
|
291
314
|
return text
|
|
292
315
|
}
|
|
293
316
|
if (!isRecord(config)) return text
|
|
294
|
-
return `${JSON.stringify({ ...config,
|
|
317
|
+
return `${JSON.stringify({ ...config, rates, fetchedAt }, null, 2)}\n`
|
|
295
318
|
}
|
|
296
319
|
|
|
297
320
|
/** Three decimals, with one trailing zero trimmed so `$0.380` renders as `$0.38`. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reedchan/statusline",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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",
|