@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 +2 -1
- package/README_CN.md +1 -1
- package/extensions/statusline/index.ts +85 -12
- package/extensions/statusline/render.ts +1 -1
- package/package.json +1 -1
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
463
|
+
state = JSON.parse(await readFile(statePath(sessionFile), 'utf8'))
|
|
403
464
|
} catch {
|
|
404
465
|
return
|
|
405
466
|
}
|
|
406
|
-
if (!isRecord(state)
|
|
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:
|
|
438
|
-
tokens:
|
|
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 ? '
|
|
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.
|
|
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",
|