@reedchan/statusline 1.2.0 → 1.4.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.
@@ -18,7 +18,7 @@
18
18
  * stepStartTime; decodeMs = completedTime - firstTokenTime; tok/s = usage.output / (decodeMs / 1000)
19
19
  */
20
20
 
21
- import { readFile, stat } from 'node:fs/promises'
21
+ import { readFile, writeFile } from 'node:fs/promises'
22
22
  import { homedir } from 'node:os'
23
23
  import { join } from 'node:path'
24
24
 
@@ -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,6 +38,10 @@ import {
37
38
  formatTps,
38
39
  isQuietStatus,
39
40
  pair,
41
+ cachedRates,
42
+ ratesFromPayload,
43
+ cacheIsFresh,
44
+ withCachedRates,
40
45
  row,
41
46
  shortenPath,
42
47
  ttftDisplay,
@@ -55,30 +60,73 @@ const FALLBACK_TOKENS_PER_CHAR = 0.25
55
60
 
56
61
  /** The status line's own settings file, alongside pi's other per-tool config. */
57
62
  const CONFIG_PATH = join(homedir(), '.pi', 'agent', 'statusline.json')
63
+ /** Free, keyless, and one request returns every currency — so the cache serves instant switching. */
64
+ const RATES_URL = 'https://open.er-api.com/v6/latest/USD'
65
+ const FETCH_TIMEOUT_MS = 5000
66
+
67
+ function today(): string {
68
+ const now = new Date()
69
+ const month = String(now.getMonth() + 1).padStart(2, '0')
70
+ const day = String(now.getDate()).padStart(2, '0')
71
+ return `${now.getFullYear()}-${month}-${day}`
72
+ }
73
+
74
+ async function fetchRates(): Promise<Record<string, number> | null> {
75
+ const controller = new AbortController()
76
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
77
+ try {
78
+ const response = await fetch(RATES_URL, { signal: controller.signal })
79
+ if (!response.ok) return null
80
+ const rates = ratesFromPayload(await response.json())
81
+ return Object.keys(rates).length === 0 ? null : rates
82
+ } catch {
83
+ return null
84
+ } finally {
85
+ clearTimeout(timer)
86
+ }
87
+ }
58
88
 
59
89
  /**
60
90
  * Reads the display currency.
61
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
+ *
62
97
  * Called once per session on purpose: a footer that stat'ed a file on every frame would be its own
63
98
  * bug. Editing the file therefore takes effect on `/reload`.
64
99
  */
65
100
  async function loadCurrency(notify: (message: string) => void): Promise<Currency> {
66
- try {
67
- await stat(CONFIG_PATH)
68
- } catch {
69
- // No config file is the normal case, and it means USD.
70
- return USD
71
- }
72
- let text: string
101
+ let text: string | null = null
73
102
  try {
74
103
  text = await readFile(CONFIG_PATH, 'utf8')
75
104
  } catch {
76
- notify('statusline.json exists but could not be read, showing USD')
105
+ // No config file is the normal case, and it means USD.
77
106
  return USD
78
107
  }
79
- const { currency, problem } = currencyFromConfig(text)
108
+ const { currency, pending, problem } = currencyFromConfig(text)
80
109
  if (problem !== null) notify(problem)
81
- return currency
110
+ if (pending === null) return currency
111
+
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 }
116
+ }
117
+
118
+ const fetched = await fetchRates()
119
+ if (fetched !== null) {
120
+ try {
121
+ await writeFile(CONFIG_PATH, withCachedRates(text, fetched, today()))
122
+ } catch {
123
+ // The session still runs on the fetched rate; only tomorrow's warm start is lost.
124
+ }
125
+ return { symbol: pending.symbol, perUsd: fetched[pending.code] ?? USD.perUsd }
126
+ }
127
+ if (known !== undefined) return { symbol: pending.symbol, perUsd: known }
128
+ notify(`could not fetch a ${pending.code} rate, showing USD`)
129
+ return USD
82
130
  }
83
131
 
84
132
  /** A content block as providers stream it; every field is read defensively. */
@@ -191,6 +239,7 @@ export default function (pi: ExtensionAPI) {
191
239
  let chars = 0
192
240
  let requestAt: number | null = null
193
241
  let firstTokenAt: number | null = null
242
+ let totalDecodeMs = 0
194
243
  let ticker: ReturnType<typeof setInterval> | null = null
195
244
  let windowAt = 0
196
245
  let windowTokens = 0
@@ -241,6 +290,7 @@ export default function (pi: ExtensionAPI) {
241
290
  render(width: number): string[] {
242
291
  const usage = ctx.getContextUsage()
243
292
  const totals = collectTotals(ctx)
293
+ const avg = avgTokPerSec(totals.output, totalDecodeMs)
244
294
  const row1 = contextRow(
245
295
  theme,
246
296
  width,
@@ -269,12 +319,15 @@ export default function (pi: ExtensionAPI) {
269
319
  if (reading.ttftMs !== null)
270
320
  row2Parts.push(pair(theme, 'TTFT', formatLatency(reading.ttftMs), 'muted'))
271
321
  row2Parts.push(
272
- theme.fg(
273
- reading.exact ? 'success' : 'dim',
322
+ pair(
323
+ theme,
324
+ 'Last',
274
325
  `${reading.exact ? '' : '~'}${formatTps(reading.rate)} tok/s`,
326
+ reading.exact ? 'success' : 'dim',
275
327
  ),
276
328
  )
277
329
  }
330
+ if (avg !== null) row2Parts.push(pair(theme, 'Avg', `${formatTps(avg)} tok/s`, 'muted'))
278
331
  const row2Right = row2Parts.join(theme.fg('dim', ' · '))
279
332
 
280
333
  const branch = footerData.getGitBranch()
@@ -334,6 +387,7 @@ export default function (pi: ExtensionAPI) {
334
387
  ratio = seedRatio(ctx)
335
388
  reading = null
336
389
  requestAt = null
390
+ totalDecodeMs = 0
337
391
  stopTicker()
338
392
  resetStream()
339
393
  currency = await loadCurrency((message) => ctx.ui.notify(message, 'warning'))
@@ -415,8 +469,11 @@ export default function (pi: ExtensionAPI) {
415
469
  const decodeMs = firstTokenAt !== null ? Date.now() - firstTokenAt : 0
416
470
  const measured = ttftMs(requestAt, firstTokenAt)
417
471
  const tokens = output > 0 ? output : totalChars * (ratio ?? FALLBACK_TOKENS_PER_CHAR)
418
- 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
419
475
  publish((tokens / decodeMs) * 1000, output > 0, measured)
476
+ }
420
477
  // Null it with the stream: a request that has produced its message is no longer in flight, and
421
478
  // a stale anchor would let the live branch count against nothing until the next turn.
422
479
  requestAt = null
@@ -169,48 +169,147 @@ function isRecord(value: unknown): value is Record<string, unknown> {
169
169
  * @param raw - The `currency` value from a parsed config file.
170
170
  * @returns The currency, or null when the block could not be used.
171
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
+
172
185
  export function parseCurrency(raw: unknown): Currency | null {
173
186
  if (!isRecord(raw)) return null
174
187
  const rate = raw.perUsd
175
188
  if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) return null
176
- const code = typeof raw.code === 'string' ? raw.code.trim().toUpperCase() : ''
177
- const explicit = typeof raw.symbol === 'string' ? raw.symbol : ''
178
- const symbol = explicit || CURRENCY_SYMBOLS[code] || code
179
- return symbol === '' ? null : { symbol, perUsd: rate }
189
+ const identity = currencyIdentity(raw)
190
+ return identity === null ? null : { symbol: identity.symbol, perUsd: rate }
180
191
  }
181
192
 
182
- /** A currency, plus the message to show when the config named one that could not be used. */
193
+ /** A currency, plus what to do when the config named one without a rate of its own. */
183
194
  export interface CurrencyConfig {
184
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
185
198
  problem: string | null
186
199
  }
187
200
 
188
201
  /**
189
202
  * The display currency a config file asks for.
190
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
+ *
191
208
  * @param text - The contents of the config file, or null when it does not exist. Absent is the
192
209
  * normal case and is not a problem; a file that is there but unusable is, because the footer
193
210
  * would otherwise keep showing dollars with nothing to explain it.
194
211
  */
195
212
  export function currencyFromConfig(text: string | null): CurrencyConfig {
196
- if (text === null) return { currency: USD, problem: null }
213
+ if (text === null) return { currency: USD, pending: null, problem: null }
197
214
  let config: unknown
198
215
  try {
199
216
  config = JSON.parse(text)
200
217
  } catch {
201
- return { currency: USD, problem: 'statusline.json is not valid JSON, showing USD' }
218
+ return {
219
+ currency: USD,
220
+ pending: null,
221
+ problem: 'statusline.json is not valid JSON, showing USD',
222
+ }
202
223
  }
203
224
  if (!isRecord(config) || !Object.hasOwn(config, 'currency')) {
204
- return { currency: USD, problem: null }
225
+ return { currency: USD, pending: null, problem: null }
205
226
  }
206
- const currency = parseCurrency(config.currency)
207
- if (currency === null) {
227
+ if (!isRecord(config.currency)) {
208
228
  return {
209
229
  currency: USD,
210
- problem: 'statusline.json currency needs a positive perUsd, showing USD',
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,
211
241
  }
212
242
  }
213
- return { currency, problem: null }
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
+ /** The day's rates out of an open.er-api.com payload, positive and finite only. */
254
+ export function ratesFromPayload(payload: unknown): Record<string, number> {
255
+ if (!isRecord(payload) || !isRecord(payload.rates)) return {}
256
+ const rates: Record<string, number> = {}
257
+ for (const [code, rate] of Object.entries(payload.rates)) {
258
+ if (typeof rate === 'number' && Number.isFinite(rate) && rate > 0) rates[code] = rate
259
+ }
260
+ return rates
261
+ }
262
+
263
+ /** The rates a config file cached from an earlier fetch, and the day they were fetched. */
264
+ export function cachedRates(
265
+ text: string,
266
+ ): { rates: Record<string, number>; fetchedAt: string } | null {
267
+ let config: unknown
268
+ try {
269
+ config = JSON.parse(text)
270
+ } catch {
271
+ return null
272
+ }
273
+ if (!isRecord(config) || !isRecord(config.rates)) return null
274
+ const fetchedAt = config.fetchedAt
275
+ if (typeof fetchedAt !== 'string') return null
276
+ const rates: Record<string, number> = {}
277
+ for (const [code, rate] of Object.entries(config.rates)) {
278
+ if (typeof rate === 'number' && Number.isFinite(rate) && rate > 0) rates[code] = rate
279
+ }
280
+ return Object.keys(rates).length === 0 ? null : { rates, fetchedAt }
281
+ }
282
+
283
+ /** Whether a cached rate was fetched today, both dates as `YYYY-MM-DD`. */
284
+ export function cacheIsFresh(fetchedAt: string, today: string): boolean {
285
+ return fetchedAt.slice(0, 10) === today
286
+ }
287
+
288
+ /** Session-average output tokens per second of measured decode time; null before any measurement. */
289
+ export function avgTokPerSec(outputTokens: number, decodeMs: number): number | null {
290
+ if (decodeMs <= 0) return null
291
+ return outputTokens / (decodeMs / 1000)
292
+ }
293
+
294
+ /**
295
+ * The config file's text with the day's rates recorded in it, so the next session starts warm.
296
+ *
297
+ * Unknown keys are kept, and a file that does not parse is returned untouched: the extension has no
298
+ * business replacing a config it could not read with one it wrote.
299
+ */
300
+ export function withCachedRates(
301
+ text: string,
302
+ rates: Record<string, number>,
303
+ fetchedAt: string,
304
+ ): string {
305
+ let config: unknown
306
+ try {
307
+ config = JSON.parse(text)
308
+ } catch {
309
+ return text
310
+ }
311
+ if (!isRecord(config)) return text
312
+ return `${JSON.stringify({ ...config, rates, fetchedAt }, null, 2)}\n`
214
313
  }
215
314
 
216
315
  /** 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.2.0",
3
+ "version": "1.4.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",