@reedchan/statusline 1.7.0 → 1.9.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 CHANGED
@@ -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 per-session state files that survive /reload) sits in that one folder:
58
59
 
59
60
  ```json
60
61
  {
package/README_CN.md CHANGED
@@ -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,9 @@
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'
22
- import { stat } from 'node:fs/promises'
21
+ import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises'
23
22
  import { homedir } from 'node:os'
24
- import { join } from 'node:path'
23
+ import { basename, dirname, join } from 'node:path'
25
24
 
26
25
  import type {
27
26
  ExtensionAPI,
@@ -61,13 +60,21 @@ const TICK_MS = 250
61
60
  const FALLBACK_TOKENS_PER_CHAR = 0.25
62
61
 
63
62
  /** The status line's own settings file, alongside pi's other per-tool config. */
64
- const CONFIG_PATH = join(homedir(), '.pi', 'agent', 'statusline.json')
63
+ /** Everything this extension keeps lives in one folder, not loose files relying on name prefixes. */
64
+ const STATUSLINE_DIR = join(homedir(), '.pi', 'agent', 'statusline')
65
+ const CONFIG_PATH = join(STATUSLINE_DIR, 'config.json')
66
+ /** Pre-1.8 locations, migrated out of on first start. */
67
+ const LEGACY_CONFIG = join(homedir(), '.pi', 'agent', 'statusline.json')
65
68
  /** Free, keyless, and one request returns every currency — so the cache serves instant switching. */
66
69
  const RATES_URL = 'https://open.er-api.com/v6/latest/USD'
67
70
  /** Every session on this machine, for the day-cost total that spans projects and models. */
68
71
  const SESSIONS_DIR = join(homedir(), '.pi', 'agent', 'sessions')
69
72
  /** 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')
73
+ /** One state file per session, so two concurrent sessions cannot clobber each other. */
74
+ const STATE_DIR = join(STATUSLINE_DIR, 'state')
75
+ const LEGACY_STATE = join(homedir(), '.pi', 'agent', 'statusline-state.json')
76
+ /** The 1.8 single-file state: one bucket every session shared, also migrated to per-session. */
77
+ const LEGACY_SHARED_STATE = join(STATUSLINE_DIR, 'state.json')
71
78
  const FETCH_TIMEOUT_MS = 5000
72
79
 
73
80
  function today(): string {
@@ -118,6 +125,50 @@ function num(value: unknown): number {
118
125
  return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
119
126
  }
120
127
 
128
+ /** Moves a pre-1.8 flat file into the extension folder; missing files are the normal case. */
129
+ async function migrateLegacyFile(legacy: string, current: string): Promise<void> {
130
+ try {
131
+ await mkdir(dirname(current), { recursive: true })
132
+ await rename(legacy, current)
133
+ } catch {
134
+ // Nothing at the old path, or already migrated: either way the current file rules.
135
+ }
136
+ }
137
+
138
+ /** Per-session state path: the session file's name is unique and stable across reloads. */
139
+ function statePath(sessionFile: string): string {
140
+ return join(STATE_DIR, `${basename(sessionFile)}.json`)
141
+ }
142
+
143
+ /** Moves a shared-bucket state file to its per-session name; garbage in, ignored out. */
144
+ async function migrateLegacyState(legacy: string): Promise<void> {
145
+ try {
146
+ const parsed: unknown = JSON.parse(await readFile(legacy, 'utf8'))
147
+ if (!isRecord(parsed) || typeof parsed.sessionFile !== 'string') return
148
+ await mkdir(STATE_DIR, { recursive: true })
149
+ await rename(legacy, statePath(parsed.sessionFile))
150
+ } catch {
151
+ // Nothing to migrate, or the file was not ours: leave it alone.
152
+ }
153
+ }
154
+
155
+ /** Drops per-session state untouched for a week; older files restore nothing useful. */
156
+ async function pruneState(): Promise<void> {
157
+ try {
158
+ const files = await readdir(STATE_DIR)
159
+ const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000
160
+ await Promise.all(
161
+ files.map(async (name) => {
162
+ const filePath = join(STATE_DIR, name)
163
+ const stats = await stat(filePath).catch(() => null)
164
+ if (stats !== null && stats.mtimeMs < cutoff) await unlink(filePath).catch(() => {})
165
+ }),
166
+ )
167
+ } catch {
168
+ // No state dir yet: nothing to prune.
169
+ }
170
+ }
171
+
121
172
  /** Today's provider cost of one session-file line, or null when the line bills nothing today. */
122
173
  function entryCost(line: string, since: number): number | null {
123
174
  if (!line.includes('"usage"')) return null
@@ -191,6 +242,8 @@ async function loadCurrency(notify: (message: string) => void): Promise<Currency
191
242
  const { currency, pending, problem } = currencyFromConfig(text)
192
243
  if (problem !== null) notify(problem)
193
244
  if (pending === null) return currency
245
+ // Costs are already USD; a rates fetch could only ever return 1.
246
+ if (pending.code === 'USD') return USD
194
247
 
195
248
  const cached = cachedRates(text)
196
249
  const known = cached?.rates[pending.code]
@@ -337,6 +390,12 @@ export default function (pi: ExtensionAPI) {
337
390
  let totalTtftMs = 0
338
391
  let ttftCount = 0
339
392
  let todayBase = 0
393
+ /** Last context usage pi reported with real numbers, shown when pi's current answer is stale. */
394
+ let lastKnownUsage: {
395
+ tokens: number | null
396
+ percent: number | null
397
+ contextWindow: number
398
+ } | null = null
340
399
  let ticker: ReturnType<typeof setInterval> | null = null
341
400
  let windowAt = 0
342
401
  let windowTokens = 0
@@ -391,19 +450,21 @@ export default function (pi: ExtensionAPI) {
391
450
  ttftCount,
392
451
  last: reading,
393
452
  }
394
- void writeFile(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`).catch(() => {})
453
+ void mkdir(STATE_DIR, { recursive: true })
454
+ .then(() => writeFile(statePath(sessionFile), `${JSON.stringify(state, null, 2)}\n`))
455
+ .catch(() => {})
395
456
  }
396
457
 
397
- /** Restores what persist wrote, but only for this exact session file. */
458
+ /** Restores this session's own state file, written by persist before the reload. */
398
459
  async function restore(sessionFile: string | null): Promise<void> {
399
460
  if (sessionFile === null) return
400
461
  let state: unknown
401
462
  try {
402
- state = JSON.parse(await readFile(STATE_PATH, 'utf8'))
463
+ state = JSON.parse(await readFile(statePath(sessionFile), 'utf8'))
403
464
  } catch {
404
465
  return
405
466
  }
406
- if (!isRecord(state) || state.sessionFile !== sessionFile) return
467
+ if (!isRecord(state)) return
407
468
  totalDecodeMs = num(state.totalDecodeMs)
408
469
  totalMeasuredOutput = num(state.totalMeasuredOutput)
409
470
  totalTtftMs = num(state.totalTtftMs)
@@ -428,15 +489,22 @@ export default function (pi: ExtensionAPI) {
428
489
  },
429
490
  render(width: number): string[] {
430
491
  const usage = ctx.getContextUsage()
492
+ // pi nulls tokens and percent when the last usage predates a compaction and no response
493
+ // has landed since: the numbers it could hand over are stale, not zero. Show the last
494
+ // reading we trusted instead; a brand-new session starts at 0, which is near the truth.
495
+ const trusted =
496
+ usage !== undefined && usage.tokens !== null && usage.percent !== null ? usage : null
497
+ if (trusted !== null) lastKnownUsage = trusted
498
+ const shown = trusted ?? lastKnownUsage
431
499
  const totals = collectTotals(ctx, startOfToday())
432
500
  const avg = avgTokPerSec(totalMeasuredOutput, totalDecodeMs)
433
501
  const row1 = contextRow(
434
502
  theme,
435
503
  width,
436
504
  {
437
- percent: usage?.percent ?? null,
438
- tokens: usage?.tokens ?? 0,
439
- window: usage?.contextWindow ?? ctx.model?.contextWindow ?? 0,
505
+ percent: shown?.percent ?? 0,
506
+ tokens: shown?.tokens ?? 0,
507
+ window: usage?.contextWindow ?? shown?.contextWindow ?? ctx.model?.contextWindow ?? 0,
440
508
  input: totals.input,
441
509
  output: totals.output,
442
510
  cacheHitRate: totals.cacheHitRate,
@@ -547,6 +615,11 @@ export default function (pi: ExtensionAPI) {
547
615
  stopTicker()
548
616
  resetStream()
549
617
  currency = await loadCurrency((message) => ctx.ui.notify(message, 'warning'))
618
+ lastKnownUsage = null
619
+ await migrateLegacyFile(LEGACY_CONFIG, CONFIG_PATH)
620
+ await migrateLegacyState(LEGACY_STATE)
621
+ await migrateLegacyState(LEGACY_SHARED_STATE)
622
+ void pruneState()
550
623
  const file = ctx.sessionManager.getSessionFile()
551
624
  await restore(file ?? null)
552
625
  todayBase = await sumOtherTodaysCost(file ?? null, startOfToday())
@@ -429,7 +429,7 @@ export function contextRow(
429
429
  const percent = theme.fg(
430
430
  percentColor(parts.percent),
431
431
  // Whole percents: the meter carries the precision, and a decimal here is noise.
432
- parts.percent === null ? '?' : `${Math.round(parts.percent)}%`,
432
+ parts.percent === null ? '--' : `${Math.round(parts.percent)}%`,
433
433
  )
434
434
  const meter = `${theme.fg('dim', 'Context')} ${bar(theme, BAR_CELLS, fraction)} ${percent}`
435
435
  const detail =
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reedchan/statusline",
3
- "version": "1.7.0",
3
+ "version": "1.9.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",