@reedchan/statusline 1.1.2 → 1.3.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/extensions/statusline/index.ts +116 -13
- package/extensions/statusline/render.ts +123 -12
- package/package.json +1 -1
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* stepStartTime; decodeMs = completedTime - firstTokenTime; tok/s = usage.output / (decodeMs / 1000)
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { readFile,
|
|
21
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
22
22
|
import { homedir } from 'node:os'
|
|
23
23
|
import { join } from 'node:path'
|
|
24
24
|
|
|
@@ -37,8 +37,13 @@ import {
|
|
|
37
37
|
formatTps,
|
|
38
38
|
isQuietStatus,
|
|
39
39
|
pair,
|
|
40
|
+
rateFromPayload,
|
|
41
|
+
cachedRate,
|
|
42
|
+
cacheIsFresh,
|
|
43
|
+
withCachedRate,
|
|
40
44
|
row,
|
|
41
45
|
shortenPath,
|
|
46
|
+
ttftDisplay,
|
|
42
47
|
ttftMs,
|
|
43
48
|
USD,
|
|
44
49
|
type Currency,
|
|
@@ -47,11 +52,37 @@ import {
|
|
|
47
52
|
const LIVE_RENDER_MS = 200
|
|
48
53
|
const WINDOW_MS = 2000
|
|
49
54
|
const MIN_SAMPLE_MS = 200
|
|
55
|
+
/** How often the waiting TTFT slot ticks while a request is in flight. */
|
|
56
|
+
const TICK_MS = 250
|
|
50
57
|
/** Pi's own estimateTokens() heuristic, used only until a real ratio is known. */
|
|
51
58
|
const FALLBACK_TOKENS_PER_CHAR = 0.25
|
|
52
59
|
|
|
53
60
|
/** The status line's own settings file, alongside pi's other per-tool config. */
|
|
54
61
|
const CONFIG_PATH = join(homedir(), '.pi', 'agent', 'statusline.json')
|
|
62
|
+
/** Free, keyless, updated once a day — which is exactly the freshness a daily rate wants. */
|
|
63
|
+
const RATES_URL = 'https://open.er-api.com/v6/latest/USD'
|
|
64
|
+
const FETCH_TIMEOUT_MS = 5000
|
|
65
|
+
|
|
66
|
+
function today(): string {
|
|
67
|
+
const now = new Date()
|
|
68
|
+
const month = String(now.getMonth() + 1).padStart(2, '0')
|
|
69
|
+
const day = String(now.getDate()).padStart(2, '0')
|
|
70
|
+
return `${now.getFullYear()}-${month}-${day}`
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function fetchRate(code: string): Promise<number | null> {
|
|
74
|
+
const controller = new AbortController()
|
|
75
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
76
|
+
try {
|
|
77
|
+
const response = await fetch(RATES_URL, { signal: controller.signal })
|
|
78
|
+
if (!response.ok) return null
|
|
79
|
+
return rateFromPayload(await response.json(), code)
|
|
80
|
+
} catch {
|
|
81
|
+
return null
|
|
82
|
+
} finally {
|
|
83
|
+
clearTimeout(timer)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
55
86
|
|
|
56
87
|
/**
|
|
57
88
|
* Reads the display currency.
|
|
@@ -60,22 +91,34 @@ const CONFIG_PATH = join(homedir(), '.pi', 'agent', 'statusline.json')
|
|
|
60
91
|
* bug. Editing the file therefore takes effect on `/reload`.
|
|
61
92
|
*/
|
|
62
93
|
async function loadCurrency(notify: (message: string) => void): Promise<Currency> {
|
|
63
|
-
|
|
64
|
-
await stat(CONFIG_PATH)
|
|
65
|
-
} catch {
|
|
66
|
-
// No config file is the normal case, and it means USD.
|
|
67
|
-
return USD
|
|
68
|
-
}
|
|
69
|
-
let text: string
|
|
94
|
+
let text: string | null = null
|
|
70
95
|
try {
|
|
71
96
|
text = await readFile(CONFIG_PATH, 'utf8')
|
|
72
97
|
} catch {
|
|
73
|
-
|
|
98
|
+
// No config file is the normal case, and it means USD.
|
|
74
99
|
return USD
|
|
75
100
|
}
|
|
76
|
-
const { currency, problem } = currencyFromConfig(text)
|
|
101
|
+
const { currency, pending, problem } = currencyFromConfig(text)
|
|
77
102
|
if (problem !== null) notify(problem)
|
|
78
|
-
return currency
|
|
103
|
+
if (pending === null) return currency
|
|
104
|
+
|
|
105
|
+
const cached = cachedRate(text)
|
|
106
|
+
if (cached !== null && cacheIsFresh(cached.fetchedAt, today())) {
|
|
107
|
+
return { symbol: pending.symbol, perUsd: cached.perUsd }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const fetched = await fetchRate(pending.code)
|
|
111
|
+
if (fetched !== null) {
|
|
112
|
+
try {
|
|
113
|
+
await writeFile(CONFIG_PATH, withCachedRate(text, fetched, today()))
|
|
114
|
+
} catch {
|
|
115
|
+
// The session still runs on the fetched rate; only tomorrow's warm start is lost.
|
|
116
|
+
}
|
|
117
|
+
return { symbol: pending.symbol, perUsd: fetched }
|
|
118
|
+
}
|
|
119
|
+
if (cached !== null) return { symbol: pending.symbol, perUsd: cached.perUsd }
|
|
120
|
+
notify(`could not fetch a ${pending.code} rate, showing USD`)
|
|
121
|
+
return USD
|
|
79
122
|
}
|
|
80
123
|
|
|
81
124
|
/** A content block as providers stream it; every field is read defensively. */
|
|
@@ -188,6 +231,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
188
231
|
let chars = 0
|
|
189
232
|
let requestAt: number | null = null
|
|
190
233
|
let firstTokenAt: number | null = null
|
|
234
|
+
let ticker: ReturnType<typeof setInterval> | null = null
|
|
191
235
|
let windowAt = 0
|
|
192
236
|
let windowTokens = 0
|
|
193
237
|
let renderedAt = 0
|
|
@@ -205,6 +249,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
205
249
|
requestRender?.()
|
|
206
250
|
}
|
|
207
251
|
|
|
252
|
+
/** Stops the live count; a frozen TTFT needs no clock. */
|
|
253
|
+
function stopTicker(): void {
|
|
254
|
+
if (ticker !== null) {
|
|
255
|
+
clearInterval(ticker)
|
|
256
|
+
ticker = null
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Ticks the footer while a request is in flight, until the first token lands or the turn ends. */
|
|
261
|
+
function startTicker(): void {
|
|
262
|
+
if (ticker !== null) return
|
|
263
|
+
ticker = setInterval(() => {
|
|
264
|
+
if (requestAt === null || firstTokenAt !== null) {
|
|
265
|
+
stopTicker()
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
requestRender?.()
|
|
269
|
+
}, TICK_MS)
|
|
270
|
+
}
|
|
271
|
+
|
|
208
272
|
function installFooter(ctx: ExtensionContext): void {
|
|
209
273
|
ctx.ui.setFooter((tui, theme, footerData: ReadonlyFooterDataProvider) => {
|
|
210
274
|
requestRender = () => tui.requestRender()
|
|
@@ -236,7 +300,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
236
300
|
const model = ctx.model?.id ?? 'no model'
|
|
237
301
|
const row2Parts = [theme.fg('accent', model)]
|
|
238
302
|
if (ctx.thinkingLevel) row2Parts.push(pair(theme, 'Effort', ctx.thinkingLevel, 'muted'))
|
|
239
|
-
|
|
303
|
+
const waiting = ttftDisplay(requestAt, firstTokenAt, Date.now())
|
|
304
|
+
if (waiting !== null) {
|
|
305
|
+
// The clock is running: this wait has no reading yet, so the previous turn's
|
|
306
|
+
// throughput would only be mistaken for the current one.
|
|
307
|
+
row2Parts.push(pair(theme, 'TTFT', waiting.text, 'muted'))
|
|
308
|
+
} else if (reading) {
|
|
240
309
|
if (reading.ttftMs !== null)
|
|
241
310
|
row2Parts.push(pair(theme, 'TTFT', formatLatency(reading.ttftMs), 'muted'))
|
|
242
311
|
row2Parts.push(
|
|
@@ -284,10 +353,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
284
353
|
})
|
|
285
354
|
}
|
|
286
355
|
|
|
356
|
+
// A run that ends without a first token (abort, provider error) must not leave a clock counting
|
|
357
|
+
// against a request that is no longer in flight.
|
|
358
|
+
pi.on('turn_end', async () => {
|
|
359
|
+
requestAt = null
|
|
360
|
+
stopTicker()
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
pi.on('agent_end', async () => {
|
|
364
|
+
requestAt = null
|
|
365
|
+
stopTicker()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
pi.on('agent_settled', async () => {
|
|
369
|
+
requestAt = null
|
|
370
|
+
stopTicker()
|
|
371
|
+
})
|
|
372
|
+
|
|
287
373
|
pi.on('session_start', async (_event, ctx) => {
|
|
288
374
|
ratio = seedRatio(ctx)
|
|
289
375
|
reading = null
|
|
290
376
|
requestAt = null
|
|
377
|
+
stopTicker()
|
|
291
378
|
resetStream()
|
|
292
379
|
currency = await loadCurrency((message) => ctx.ui.notify(message, 'warning'))
|
|
293
380
|
installFooter(ctx)
|
|
@@ -309,6 +396,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
309
396
|
requestAt = Date.now()
|
|
310
397
|
})
|
|
311
398
|
|
|
399
|
+
pi.on('turn_start', async () => {
|
|
400
|
+
requestAt = null
|
|
401
|
+
stopTicker()
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
pi.on('before_provider_request', async () => {
|
|
405
|
+
requestAt = Date.now()
|
|
406
|
+
startTicker()
|
|
407
|
+
})
|
|
408
|
+
|
|
312
409
|
pi.on('message_update', async (event) => {
|
|
313
410
|
const delta = event.assistantMessageEvent
|
|
314
411
|
// Narrowing on the discriminant is what keeps the payload typed; membership in
|
|
@@ -322,7 +419,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
322
419
|
}
|
|
323
420
|
|
|
324
421
|
const now = Date.now()
|
|
325
|
-
if (firstTokenAt === null)
|
|
422
|
+
if (firstTokenAt === null) {
|
|
423
|
+
firstTokenAt = now
|
|
424
|
+
stopTicker()
|
|
425
|
+
}
|
|
326
426
|
chars += delta.delta.length
|
|
327
427
|
|
|
328
428
|
const tokens = chars * (ratio ?? FALLBACK_TOKENS_PER_CHAR)
|
|
@@ -357,6 +457,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
357
457
|
const tokens = output > 0 ? output : totalChars * (ratio ?? FALLBACK_TOKENS_PER_CHAR)
|
|
358
458
|
if (measured !== null && decodeMs >= MIN_SAMPLE_MS)
|
|
359
459
|
publish((tokens / decodeMs) * 1000, output > 0, measured)
|
|
460
|
+
// Null it with the stream: a request that has produced its message is no longer in flight, and
|
|
461
|
+
// a stale anchor would let the live branch count against nothing until the next turn.
|
|
462
|
+
requestAt = null
|
|
360
463
|
resetStream()
|
|
361
464
|
})
|
|
362
465
|
}
|
|
@@ -81,6 +81,36 @@ export function ttftMs(requestAt: number | null, firstTokenAt: number | null): n
|
|
|
81
81
|
return firstTokenAt > requestAt ? firstTokenAt - requestAt : null
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
/** What the TTFT slot renders, and whether the clock is still running. */
|
|
85
|
+
export interface TtftDisplay {
|
|
86
|
+
text: string
|
|
87
|
+
live: boolean
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The TTFT slot, for all three phases of a request.
|
|
92
|
+
*
|
|
93
|
+
* While the request is in flight the slot counts up (`~2s`, the `~` marking an unfinished wait, the
|
|
94
|
+
* same convention as the tok/s estimate) — a slow first byte is something you watch happen, not a
|
|
95
|
+
* number you are told about afterwards. The moment the first token lands the count freezes into the
|
|
96
|
+
* exact value. With no request in flight there is nothing to show.
|
|
97
|
+
*
|
|
98
|
+
* @param now - The clock, passed in so every branch stays a function of its arguments.
|
|
99
|
+
*/
|
|
100
|
+
export function ttftDisplay(
|
|
101
|
+
requestAt: number | null,
|
|
102
|
+
firstTokenAt: number | null,
|
|
103
|
+
now: number,
|
|
104
|
+
): TtftDisplay | null {
|
|
105
|
+
if (requestAt === null) return null
|
|
106
|
+
if (firstTokenAt === null) {
|
|
107
|
+
if (now < requestAt) return null
|
|
108
|
+
return { text: `~${formatLatency(now - requestAt)}`, live: true }
|
|
109
|
+
}
|
|
110
|
+
const measured = ttftMs(requestAt, firstTokenAt)
|
|
111
|
+
return measured === null ? null : { text: formatLatency(measured), live: false }
|
|
112
|
+
}
|
|
113
|
+
|
|
84
114
|
/** Home-relative path, or the absolute path when it is outside the home directory. */
|
|
85
115
|
export function formatCwd(cwd: string): string {
|
|
86
116
|
const home = homedir()
|
|
@@ -139,48 +169,129 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
139
169
|
* @param raw - The `currency` value from a parsed config file.
|
|
140
170
|
* @returns The currency, or null when the block could not be used.
|
|
141
171
|
*/
|
|
172
|
+
/** The identity a currency block asks for: the API code and the symbol to print. */
|
|
173
|
+
interface CurrencyIdentity {
|
|
174
|
+
code: string
|
|
175
|
+
symbol: string
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function currencyIdentity(raw: Record<string, unknown>): CurrencyIdentity | null {
|
|
179
|
+
const code = typeof raw.code === 'string' ? raw.code.trim().toUpperCase() : ''
|
|
180
|
+
const explicit = typeof raw.symbol === 'string' ? raw.symbol : ''
|
|
181
|
+
const symbol = explicit || CURRENCY_SYMBOLS[code] || code
|
|
182
|
+
return symbol === '' ? null : { code, symbol }
|
|
183
|
+
}
|
|
184
|
+
|
|
142
185
|
export function parseCurrency(raw: unknown): Currency | null {
|
|
143
186
|
if (!isRecord(raw)) return null
|
|
144
187
|
const rate = raw.perUsd
|
|
145
188
|
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) return null
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
const symbol = explicit || CURRENCY_SYMBOLS[code] || code
|
|
149
|
-
return symbol === '' ? null : { symbol, perUsd: rate }
|
|
189
|
+
const identity = currencyIdentity(raw)
|
|
190
|
+
return identity === null ? null : { symbol: identity.symbol, perUsd: rate }
|
|
150
191
|
}
|
|
151
192
|
|
|
152
|
-
/** A currency, plus
|
|
193
|
+
/** A currency, plus what to do when the config named one without a rate of its own. */
|
|
153
194
|
export interface CurrencyConfig {
|
|
154
195
|
currency: Currency
|
|
196
|
+
/** Set when the block names a code but no rate: the rate comes from a daily fetch. */
|
|
197
|
+
pending: { code: string; symbol: string } | null
|
|
155
198
|
problem: string | null
|
|
156
199
|
}
|
|
157
200
|
|
|
158
201
|
/**
|
|
159
202
|
* The display currency a config file asks for.
|
|
160
203
|
*
|
|
204
|
+
* A block with `perUsd` is a rate the user pinned, and is used as it is. A block with only a `code`
|
|
205
|
+
* asks for the market rate, which the caller fetches once a day — this returns what to fetch and
|
|
206
|
+
* the symbol to spend it with, and the caller decides what a failed fetch falls back to.
|
|
207
|
+
*
|
|
161
208
|
* @param text - The contents of the config file, or null when it does not exist. Absent is the
|
|
162
209
|
* normal case and is not a problem; a file that is there but unusable is, because the footer
|
|
163
210
|
* would otherwise keep showing dollars with nothing to explain it.
|
|
164
211
|
*/
|
|
165
212
|
export function currencyFromConfig(text: string | null): CurrencyConfig {
|
|
166
|
-
if (text === null) return { currency: USD, problem: null }
|
|
213
|
+
if (text === null) return { currency: USD, pending: null, problem: null }
|
|
167
214
|
let config: unknown
|
|
168
215
|
try {
|
|
169
216
|
config = JSON.parse(text)
|
|
170
217
|
} catch {
|
|
171
|
-
return {
|
|
218
|
+
return {
|
|
219
|
+
currency: USD,
|
|
220
|
+
pending: null,
|
|
221
|
+
problem: 'statusline.json is not valid JSON, showing USD',
|
|
222
|
+
}
|
|
172
223
|
}
|
|
173
224
|
if (!isRecord(config) || !Object.hasOwn(config, 'currency')) {
|
|
174
|
-
return { currency: USD, problem: null }
|
|
225
|
+
return { currency: USD, pending: null, problem: null }
|
|
175
226
|
}
|
|
176
|
-
|
|
177
|
-
if (currency === null) {
|
|
227
|
+
if (!isRecord(config.currency)) {
|
|
178
228
|
return {
|
|
179
229
|
currency: USD,
|
|
180
|
-
|
|
230
|
+
pending: null,
|
|
231
|
+
problem: 'statusline.json currency needs a code with a rate, or perUsd, showing USD',
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const identity = currencyIdentity(config.currency)
|
|
235
|
+
const rate = config.currency.perUsd
|
|
236
|
+
if (typeof rate === 'number' && Number.isFinite(rate) && rate > 0) {
|
|
237
|
+
return {
|
|
238
|
+
currency: { symbol: identity?.symbol ?? '$', perUsd: rate },
|
|
239
|
+
pending: null,
|
|
240
|
+
problem: null,
|
|
181
241
|
}
|
|
182
242
|
}
|
|
183
|
-
|
|
243
|
+
if (identity !== null && !Object.hasOwn(config.currency, 'perUsd')) {
|
|
244
|
+
return { currency: USD, pending: identity, problem: null }
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
currency: USD,
|
|
248
|
+
pending: null,
|
|
249
|
+
problem: 'statusline.json currency needs a code with a rate, or perUsd, showing USD',
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One rate out of an open.er-api.com payload (`{"rates": {"CNY": 7.12}}`), read defensively. */
|
|
254
|
+
export function rateFromPayload(payload: unknown, code: string): number | null {
|
|
255
|
+
if (!isRecord(payload) || !isRecord(payload.rates)) return null
|
|
256
|
+
const rate = payload.rates[code]
|
|
257
|
+
return typeof rate === 'number' && Number.isFinite(rate) && rate > 0 ? rate : null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The rate a config file cached from an earlier fetch, and the day it was fetched. */
|
|
261
|
+
export function cachedRate(text: string): { perUsd: number; fetchedAt: string } | null {
|
|
262
|
+
let config: unknown
|
|
263
|
+
try {
|
|
264
|
+
config = JSON.parse(text)
|
|
265
|
+
} catch {
|
|
266
|
+
return null
|
|
267
|
+
}
|
|
268
|
+
if (!isRecord(config)) return null
|
|
269
|
+
const perUsd = config.fetchedPerUsd
|
|
270
|
+
const fetchedAt = config.fetchedAt
|
|
271
|
+
if (typeof perUsd !== 'number' || !Number.isFinite(perUsd) || perUsd <= 0) return null
|
|
272
|
+
return typeof fetchedAt === 'string' ? { perUsd, fetchedAt } : null
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Whether a cached rate was fetched today, both dates as `YYYY-MM-DD`. */
|
|
276
|
+
export function cacheIsFresh(fetchedAt: string, today: string): boolean {
|
|
277
|
+
return fetchedAt.slice(0, 10) === today
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The config file's text with a fetched rate recorded in it, so the next session starts warm.
|
|
282
|
+
*
|
|
283
|
+
* Unknown keys are kept, and a file that does not parse is returned untouched: the extension has no
|
|
284
|
+
* business replacing a config it could not read with one it wrote.
|
|
285
|
+
*/
|
|
286
|
+
export function withCachedRate(text: string, perUsd: number, fetchedAt: string): string {
|
|
287
|
+
let config: unknown
|
|
288
|
+
try {
|
|
289
|
+
config = JSON.parse(text)
|
|
290
|
+
} catch {
|
|
291
|
+
return text
|
|
292
|
+
}
|
|
293
|
+
if (!isRecord(config)) return text
|
|
294
|
+
return `${JSON.stringify({ ...config, fetchedPerUsd: perUsd, fetchedAt }, null, 2)}\n`
|
|
184
295
|
}
|
|
185
296
|
|
|
186
297
|
/** 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.3.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",
|