@reedchan/statusline 1.5.0 → 1.7.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 +14 -13
- package/README_CN.md +3 -2
- package/extensions/statusline/index.ts +184 -19
- package/extensions/statusline/render.ts +27 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@ Replaces pi's footer with a labelled two-row one. Every value carries a word, so
|
|
|
8
8
|
decoded from a symbol or remembered from a legend.
|
|
9
9
|
|
|
10
10
|
```
|
|
11
|
-
Context █████████▍░░░░░░░░░░ 47
|
|
12
|
-
~/.pi (master)
|
|
11
|
+
Context █████████▍░░░░░░░░░░ 47% 471k / 1.0M Input 194k · Output 89k | Cache hit 99.9% | Cost $0.229 · Today $1.63
|
|
12
|
+
~/.pi (master) deepseek-flash · Effort high | TTFT 482ms · Avg TTFT 612ms | Last 729 tok/s · Avg 512 tok/s
|
|
13
13
|
LSP Active: typescript
|
|
14
14
|
```
|
|
15
15
|
|
|
@@ -19,17 +19,18 @@ columns only the meter is left. Every step is a whole value — a number is neve
|
|
|
19
19
|
|
|
20
20
|
## What each part is
|
|
21
21
|
|
|
22
|
-
| Part
|
|
23
|
-
|
|
|
24
|
-
| `Context` + meter + `47
|
|
25
|
-
| `471k / 1.0M`
|
|
26
|
-
| `Input` / `Output`
|
|
27
|
-
| `Cache hit`
|
|
28
|
-
| `Cost`
|
|
29
|
-
|
|
|
30
|
-
|
|
|
31
|
-
| `
|
|
32
|
-
|
|
|
22
|
+
| Part | Meaning |
|
|
23
|
+
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
24
|
+
| `Context` + meter + `47%` | Share of the model's context window in use. The fill turns `warning` above 70% and `error` above 90%, the same thresholds pi's shipped footer uses, and the percentage changes color with it |
|
|
25
|
+
| `471k / 1.0M` | Absolute context tokens over the window size. The first thing dropped when the terminal is narrow |
|
|
26
|
+
| `Input` / `Output` | Session prompt and completion tokens. `Input` counts the prompt tokens that were neither read from nor written to cache, because pi reports those two separately in the same `usage` object |
|
|
27
|
+
| `Cache hit` | The latest turn's cache hit rate, `cacheRead / (input + cacheRead + cacheWrite)` |
|
|
28
|
+
| `Cost` | Session cost, in USD unless a config file names another currency (see below) |
|
|
29
|
+
| `Today` | Today's running cost across every project, session and model on this machine |
|
|
30
|
+
| Effort | The active thinking level |
|
|
31
|
+
| `TTFT` | Time from request dispatch to the first streamed token |
|
|
32
|
+
| `tok/s` | Decode throughput, i.e. output tokens per second of decode time |
|
|
33
|
+
| Last line | Other extensions' `ctx.ui.setStatus()` entries, so they do not silently disappear |
|
|
33
34
|
|
|
34
35
|
## The meter
|
|
35
36
|
|
package/README_CN.md
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
替换 pi 的 footer,改成带文字标签的两行。每个值都带一个词,不需要靠符号猜、也不需要记图例。
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
Context █████████▍░░░░░░░░░░ 47
|
|
11
|
-
~/.pi (master)
|
|
10
|
+
Context █████████▍░░░░░░░░░░ 47% 471k / 1.0M Input 194k · Output 89k | Cache hit 99.9% | Cost $0.229 · Today $1.63
|
|
11
|
+
~/.pi (master) deepseek-flash · Effort high | TTFT 482ms · Avg TTFT 612ms | Last 729 tok/s · Avg 512 tok/s
|
|
12
12
|
LSP Active: typescript
|
|
13
13
|
```
|
|
14
14
|
|
|
@@ -25,6 +25,7 @@ LSP Active: typescript
|
|
|
25
25
|
| `Input` / `Output` | 会话的输入与输出 token 量。`Input` 指既没命中缓存、也没写入缓存的那部分 prompt token,因为 pi 把这两种情况在同一个 `usage` 对象里分开报 |
|
|
26
26
|
| `Cache hit` | 最近一轮的缓存命中率,`cacheRead / (input + cacheRead + cacheWrite)` |
|
|
27
27
|
| `Cost` | 会话花费,默认美元;配置了其他币种则换算显示(见下) |
|
|
28
|
+
| `Today` | 当天跨项目、跨会话、跨模型的累计花费 |
|
|
28
29
|
| Effort | 当前思考等级 |
|
|
29
30
|
| `TTFT` | 从发出请求到第一个流式 token 的耗时 |
|
|
30
31
|
| `tok/s` | 解码吞吐,即每秒解码时间产出的输出 token 数 |
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
* stepStartTime; decodeMs = completedTime - firstTokenTime; tok/s = usage.output / (decodeMs / 1000)
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { readFile, writeFile } from 'node:fs/promises'
|
|
21
|
+
import { readFile, readdir, writeFile } from 'node:fs/promises'
|
|
22
|
+
import { stat } from 'node:fs/promises'
|
|
22
23
|
import { homedir } from 'node:os'
|
|
23
24
|
import { join } from 'node:path'
|
|
24
25
|
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
formatCwd,
|
|
37
38
|
formatLatency,
|
|
38
39
|
formatTps,
|
|
40
|
+
avgMs,
|
|
39
41
|
isQuietStatus,
|
|
40
42
|
pair,
|
|
41
43
|
cachedRates,
|
|
@@ -62,6 +64,10 @@ const FALLBACK_TOKENS_PER_CHAR = 0.25
|
|
|
62
64
|
const CONFIG_PATH = join(homedir(), '.pi', 'agent', 'statusline.json')
|
|
63
65
|
/** Free, keyless, and one request returns every currency — so the cache serves instant switching. */
|
|
64
66
|
const RATES_URL = 'https://open.er-api.com/v6/latest/USD'
|
|
67
|
+
/** Every session on this machine, for the day-cost total that spans projects and models. */
|
|
68
|
+
const SESSIONS_DIR = join(homedir(), '.pi', 'agent', 'sessions')
|
|
69
|
+
/** 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')
|
|
65
71
|
const FETCH_TIMEOUT_MS = 5000
|
|
66
72
|
|
|
67
73
|
function today(): string {
|
|
@@ -97,6 +103,83 @@ async function fetchRates(): Promise<Record<string, number> | null> {
|
|
|
97
103
|
* Called once per session on purpose: a footer that stat'ed a file on every frame would be its own
|
|
98
104
|
* bug. Editing the file therefore takes effect on `/reload`.
|
|
99
105
|
*/
|
|
106
|
+
function startOfToday(): number {
|
|
107
|
+
const start = new Date()
|
|
108
|
+
start.setHours(0, 0, 0, 0)
|
|
109
|
+
return start.getTime()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
113
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Coerces a persisted counter back to a non-negative finite number, or 0. */
|
|
117
|
+
function num(value: unknown): number {
|
|
118
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Today's provider cost of one session-file line, or null when the line bills nothing today. */
|
|
122
|
+
function entryCost(line: string, since: number): number | null {
|
|
123
|
+
if (!line.includes('"usage"')) return null
|
|
124
|
+
let entry: unknown
|
|
125
|
+
try {
|
|
126
|
+
entry = JSON.parse(line)
|
|
127
|
+
} catch {
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
if (!isRecord(entry) || typeof entry.timestamp !== 'string') return null
|
|
131
|
+
if (Date.parse(entry.timestamp) < since) return null
|
|
132
|
+
if (!isRecord(entry.usage) || !isRecord(entry.usage.cost)) return null
|
|
133
|
+
const total = entry.usage.cost.total
|
|
134
|
+
return typeof total === 'number' && Number.isFinite(total) ? total : null
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Today's cost across every other session on this machine.
|
|
139
|
+
*
|
|
140
|
+
* Files not touched today are skipped unread, and this session's own file is skipped because its
|
|
141
|
+
* cost is already live in totals.cost — counting both would double a resumed session.
|
|
142
|
+
*/
|
|
143
|
+
async function sumOtherTodaysCost(currentFile: string | null, since: number): Promise<number> {
|
|
144
|
+
let dirs: string[] = []
|
|
145
|
+
try {
|
|
146
|
+
dirs = await readdir(SESSIONS_DIR)
|
|
147
|
+
} catch {
|
|
148
|
+
return 0
|
|
149
|
+
}
|
|
150
|
+
const lists = await Promise.all(
|
|
151
|
+
dirs.map(async (dir) => {
|
|
152
|
+
const dirPath = join(SESSIONS_DIR, dir)
|
|
153
|
+
let names: string[] = []
|
|
154
|
+
try {
|
|
155
|
+
names = await readdir(dirPath)
|
|
156
|
+
} catch {
|
|
157
|
+
return []
|
|
158
|
+
}
|
|
159
|
+
return names
|
|
160
|
+
.map((name) => join(dirPath, name))
|
|
161
|
+
.filter((path) => path.endsWith('.jsonl') && path !== currentFile)
|
|
162
|
+
}),
|
|
163
|
+
)
|
|
164
|
+
const costs = await Promise.all(
|
|
165
|
+
lists.flat().map(async (path) => {
|
|
166
|
+
try {
|
|
167
|
+
if ((await stat(path)).mtimeMs < since) return 0
|
|
168
|
+
} catch {
|
|
169
|
+
return 0
|
|
170
|
+
}
|
|
171
|
+
const text = await readFile(path, 'utf8').catch(() => '')
|
|
172
|
+
let total = 0
|
|
173
|
+
for (const line of text.split('\n')) {
|
|
174
|
+
const cost = entryCost(line, since)
|
|
175
|
+
if (cost !== null) total += cost
|
|
176
|
+
}
|
|
177
|
+
return total
|
|
178
|
+
}),
|
|
179
|
+
)
|
|
180
|
+
return costs.reduce((sum, value) => sum + value, 0)
|
|
181
|
+
}
|
|
182
|
+
|
|
100
183
|
async function loadCurrency(notify: (message: string) => void): Promise<Currency> {
|
|
101
184
|
let text: string | null = null
|
|
102
185
|
try {
|
|
@@ -167,6 +250,7 @@ interface Totals {
|
|
|
167
250
|
cacheWrite: number
|
|
168
251
|
cost: number
|
|
169
252
|
cacheHitRate: number | null
|
|
253
|
+
todayCost: number
|
|
170
254
|
}
|
|
171
255
|
|
|
172
256
|
/** The usage fields this footer totals, read structurally so no cast is needed. */
|
|
@@ -178,7 +262,7 @@ type UsageTotals = {
|
|
|
178
262
|
cost: { total: number }
|
|
179
263
|
}
|
|
180
264
|
|
|
181
|
-
function collectTotals(ctx: ExtensionContext): Totals {
|
|
265
|
+
function collectTotals(ctx: ExtensionContext, since: number): Totals {
|
|
182
266
|
const totals: Totals = {
|
|
183
267
|
input: 0,
|
|
184
268
|
output: 0,
|
|
@@ -186,29 +270,38 @@ function collectTotals(ctx: ExtensionContext): Totals {
|
|
|
186
270
|
cacheWrite: 0,
|
|
187
271
|
cost: 0,
|
|
188
272
|
cacheHitRate: null,
|
|
273
|
+
todayCost: 0,
|
|
189
274
|
}
|
|
190
275
|
// `usage` stays optional here even though an assistant message always carries it:
|
|
191
276
|
// a truncated or hand-edited session file is the case this guard is for.
|
|
192
|
-
const add = (usage: UsageTotals | undefined, assistant: boolean): void => {
|
|
277
|
+
const add = (usage: UsageTotals | undefined, assistant: boolean, isToday: boolean): void => {
|
|
193
278
|
if (!usage) return
|
|
194
279
|
totals.input += usage.input
|
|
195
280
|
totals.output += usage.output
|
|
196
281
|
totals.cacheRead += usage.cacheRead
|
|
197
282
|
totals.cacheWrite += usage.cacheWrite
|
|
198
283
|
totals.cost += usage.cost.total
|
|
284
|
+
if (isToday) totals.todayCost += usage.cost.total
|
|
199
285
|
if (!assistant) return
|
|
200
286
|
const prompt = usage.input + usage.cacheRead + usage.cacheWrite
|
|
201
287
|
if (prompt > 0) totals.cacheHitRate = (usage.cacheRead / prompt) * 100
|
|
202
288
|
}
|
|
203
289
|
for (const entry of ctx.sessionManager.getEntries()) {
|
|
290
|
+
const stamp =
|
|
291
|
+
'timestamp' in entry && typeof entry.timestamp === 'string'
|
|
292
|
+
? Date.parse(entry.timestamp)
|
|
293
|
+
: Number.NaN
|
|
294
|
+
const isToday = Number.isFinite(stamp) && stamp >= since
|
|
204
295
|
if (entry.type === 'message') {
|
|
205
296
|
const { message } = entry
|
|
206
|
-
if (message.role === 'assistant') add(message.usage, true)
|
|
207
|
-
else if (message.role === 'toolResult') add(message.usage, false)
|
|
297
|
+
if (message.role === 'assistant') add(message.usage, true, isToday)
|
|
298
|
+
else if (message.role === 'toolResult') add(message.usage, false, isToday)
|
|
208
299
|
continue
|
|
209
300
|
}
|
|
210
301
|
// Compaction and branch summaries bill a model call of their own.
|
|
211
|
-
if (entry.type === 'compaction' || entry.type === 'branch_summary')
|
|
302
|
+
if (entry.type === 'compaction' || entry.type === 'branch_summary') {
|
|
303
|
+
add(entry.usage, false, isToday)
|
|
304
|
+
}
|
|
212
305
|
}
|
|
213
306
|
return totals
|
|
214
307
|
}
|
|
@@ -240,6 +333,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
240
333
|
let requestAt: number | null = null
|
|
241
334
|
let firstTokenAt: number | null = null
|
|
242
335
|
let totalDecodeMs = 0
|
|
336
|
+
let totalMeasuredOutput = 0
|
|
337
|
+
let totalTtftMs = 0
|
|
338
|
+
let ttftCount = 0
|
|
339
|
+
let todayBase = 0
|
|
243
340
|
let ticker: ReturnType<typeof setInterval> | null = null
|
|
244
341
|
let windowAt = 0
|
|
245
342
|
let windowTokens = 0
|
|
@@ -278,6 +375,48 @@ export default function (pi: ExtensionAPI) {
|
|
|
278
375
|
}, TICK_MS)
|
|
279
376
|
}
|
|
280
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Writes the throughput metrics so a /reload can restore them.
|
|
380
|
+
*
|
|
381
|
+
* Decode timing only exists in live stream events — pi records nothing per message — so without
|
|
382
|
+
* this file the session's averages reset to zero every time the extension re-loads.
|
|
383
|
+
*/
|
|
384
|
+
function persist(sessionFile: string | null): void {
|
|
385
|
+
if (sessionFile === null) return
|
|
386
|
+
const state = {
|
|
387
|
+
sessionFile,
|
|
388
|
+
totalDecodeMs,
|
|
389
|
+
totalMeasuredOutput,
|
|
390
|
+
totalTtftMs,
|
|
391
|
+
ttftCount,
|
|
392
|
+
last: reading,
|
|
393
|
+
}
|
|
394
|
+
void writeFile(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`).catch(() => {})
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Restores what persist wrote, but only for this exact session file. */
|
|
398
|
+
async function restore(sessionFile: string | null): Promise<void> {
|
|
399
|
+
if (sessionFile === null) return
|
|
400
|
+
let state: unknown
|
|
401
|
+
try {
|
|
402
|
+
state = JSON.parse(await readFile(STATE_PATH, 'utf8'))
|
|
403
|
+
} catch {
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
if (!isRecord(state) || state.sessionFile !== sessionFile) return
|
|
407
|
+
totalDecodeMs = num(state.totalDecodeMs)
|
|
408
|
+
totalMeasuredOutput = num(state.totalMeasuredOutput)
|
|
409
|
+
totalTtftMs = num(state.totalTtftMs)
|
|
410
|
+
ttftCount = num(state.ttftCount)
|
|
411
|
+
if (isRecord(state.last) && typeof state.last.rate === 'number') {
|
|
412
|
+
reading = {
|
|
413
|
+
rate: state.last.rate,
|
|
414
|
+
exact: state.last.exact === true,
|
|
415
|
+
ttftMs: typeof state.last.ttftMs === 'number' ? state.last.ttftMs : null,
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
281
420
|
function installFooter(ctx: ExtensionContext): void {
|
|
282
421
|
ctx.ui.setFooter((tui, theme, footerData: ReadonlyFooterDataProvider) => {
|
|
283
422
|
requestRender = () => tui.requestRender()
|
|
@@ -289,8 +428,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
289
428
|
},
|
|
290
429
|
render(width: number): string[] {
|
|
291
430
|
const usage = ctx.getContextUsage()
|
|
292
|
-
const totals = collectTotals(ctx)
|
|
293
|
-
const avg = avgTokPerSec(
|
|
431
|
+
const totals = collectTotals(ctx, startOfToday())
|
|
432
|
+
const avg = avgTokPerSec(totalMeasuredOutput, totalDecodeMs)
|
|
294
433
|
const row1 = contextRow(
|
|
295
434
|
theme,
|
|
296
435
|
width,
|
|
@@ -302,23 +441,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
302
441
|
output: totals.output,
|
|
303
442
|
cacheHitRate: totals.cacheHitRate,
|
|
304
443
|
cost: totals.cost,
|
|
444
|
+
todayCost: todayBase + totals.todayCost,
|
|
305
445
|
},
|
|
306
446
|
currency,
|
|
307
447
|
)
|
|
308
448
|
|
|
309
|
-
// Row 2: model and the latest turn's timing on the right, path on the left.
|
|
449
|
+
// Row 2: model and the latest turn's timing on the right, path on the left. Three
|
|
450
|
+
// groups — identity, first token, throughput — separated by a wall instead of another
|
|
451
|
+
// dot, because a run of similar-looking pairs is what made the old footer unreadable.
|
|
310
452
|
const model = ctx.model?.id ?? 'no model'
|
|
311
|
-
const
|
|
312
|
-
|
|
453
|
+
const separator = theme.fg('dim', ' · ')
|
|
454
|
+
const wall = theme.fg('dim', ' | ')
|
|
455
|
+
const identity = [theme.fg('accent', model)]
|
|
456
|
+
if (ctx.thinkingLevel) identity.push(pair(theme, 'Effort', ctx.thinkingLevel, 'muted'))
|
|
457
|
+
|
|
458
|
+
const ttft: string[] = []
|
|
313
459
|
const waiting = ttftDisplay(requestAt, firstTokenAt, Date.now())
|
|
314
460
|
if (waiting !== null) {
|
|
315
461
|
// The clock is running: this wait has no reading yet, so the previous turn's
|
|
316
|
-
//
|
|
317
|
-
|
|
462
|
+
// numbers would only be mistaken for the current one.
|
|
463
|
+
ttft.push(pair(theme, 'TTFT', waiting.text, 'muted'))
|
|
318
464
|
} else if (reading) {
|
|
319
465
|
if (reading.ttftMs !== null)
|
|
320
|
-
|
|
321
|
-
|
|
466
|
+
ttft.push(pair(theme, 'TTFT', formatLatency(reading.ttftMs), 'muted'))
|
|
467
|
+
}
|
|
468
|
+
const avgTtft = avgMs(totalTtftMs, ttftCount)
|
|
469
|
+
if (avgTtft !== null) ttft.push(pair(theme, 'Avg TTFT', formatLatency(avgTtft), 'muted'))
|
|
470
|
+
|
|
471
|
+
const throughput: string[] = []
|
|
472
|
+
if (reading) {
|
|
473
|
+
throughput.push(
|
|
322
474
|
pair(
|
|
323
475
|
theme,
|
|
324
476
|
'Last',
|
|
@@ -327,9 +479,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
327
479
|
),
|
|
328
480
|
)
|
|
329
481
|
}
|
|
330
|
-
if (avg !== null)
|
|
331
|
-
const row2Right = row2Parts.join(theme.fg('dim', ' · '))
|
|
482
|
+
if (avg !== null) throughput.push(pair(theme, 'Avg', `${formatTps(avg)} tok/s`, 'muted'))
|
|
332
483
|
|
|
484
|
+
const row2Right = [identity, ttft, throughput]
|
|
485
|
+
.filter((group) => group.length > 0)
|
|
486
|
+
.map((group) => group.join(separator))
|
|
487
|
+
.join(wall)
|
|
333
488
|
const branch = footerData.getGitBranch()
|
|
334
489
|
const path = formatCwd(ctx.cwd)
|
|
335
490
|
const branchSuffix = branch ? ` (${branch})` : ''
|
|
@@ -388,9 +543,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
388
543
|
reading = null
|
|
389
544
|
requestAt = null
|
|
390
545
|
totalDecodeMs = 0
|
|
546
|
+
totalMeasuredOutput = 0
|
|
391
547
|
stopTicker()
|
|
392
548
|
resetStream()
|
|
393
549
|
currency = await loadCurrency((message) => ctx.ui.notify(message, 'warning'))
|
|
550
|
+
const file = ctx.sessionManager.getSessionFile()
|
|
551
|
+
await restore(file ?? null)
|
|
552
|
+
todayBase = await sumOtherTodaysCost(file ?? null, startOfToday())
|
|
394
553
|
installFooter(ctx)
|
|
395
554
|
})
|
|
396
555
|
|
|
@@ -455,7 +614,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
455
614
|
publish(rate, false, ttftMs(requestAt, firstTokenAt))
|
|
456
615
|
})
|
|
457
616
|
|
|
458
|
-
pi.on('message_end', async (event) => {
|
|
617
|
+
pi.on('message_end', async (event, ctx) => {
|
|
459
618
|
if (event.message.role !== 'assistant') return
|
|
460
619
|
|
|
461
620
|
const message = event.message as { content: unknown; usage?: { output?: number } }
|
|
@@ -470,8 +629,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
470
629
|
const measured = ttftMs(requestAt, firstTokenAt)
|
|
471
630
|
const tokens = output > 0 ? output : totalChars * (ratio ?? FALLBACK_TOKENS_PER_CHAR)
|
|
472
631
|
if (measured !== null && decodeMs >= MIN_SAMPLE_MS) {
|
|
473
|
-
//
|
|
632
|
+
// The average's numerator and denominator must cover the same messages: totals.output spans
|
|
633
|
+
// the whole session (a reload replays none of it), so pairing it with the partial denominator
|
|
634
|
+
// below once produced an Avg of 4324 tok/s.
|
|
474
635
|
totalDecodeMs += decodeMs
|
|
636
|
+
if (output > 0) totalMeasuredOutput += output
|
|
637
|
+
totalTtftMs += measured
|
|
638
|
+
ttftCount += 1
|
|
639
|
+
persist(ctx.sessionManager.getSessionFile() ?? null)
|
|
475
640
|
publish((tokens / decodeMs) * 1000, output > 0, measured)
|
|
476
641
|
}
|
|
477
642
|
// Null it with the stream: a request that has produced its message is no longer in flight, and
|
|
@@ -296,6 +296,11 @@ export function avgTokPerSec(outputTokens: number, decodeMs: number): number | n
|
|
|
296
296
|
return outputTokens / (decodeMs / 1000)
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
/** Mean of `count` durations totalling `totalMs`; null when nothing was measured. */
|
|
300
|
+
export function avgMs(totalMs: number, count: number): number | null {
|
|
301
|
+
return count > 0 ? totalMs / count : null
|
|
302
|
+
}
|
|
303
|
+
|
|
299
304
|
/**
|
|
300
305
|
* The config file's text with the day's rates recorded in it, so the next session starts warm.
|
|
301
306
|
*
|
|
@@ -397,6 +402,8 @@ export interface ContextRowParts {
|
|
|
397
402
|
output: number
|
|
398
403
|
cacheHitRate: number | null
|
|
399
404
|
cost: number
|
|
405
|
+
/** Today's spend across every session on this machine, or 0 to hide the slot. */
|
|
406
|
+
todayCost: number
|
|
400
407
|
}
|
|
401
408
|
|
|
402
409
|
/**
|
|
@@ -421,7 +428,8 @@ export function contextRow(
|
|
|
421
428
|
const fraction = parts.percent === null ? 0 : parts.percent / 100
|
|
422
429
|
const percent = theme.fg(
|
|
423
430
|
percentColor(parts.percent),
|
|
424
|
-
|
|
431
|
+
// Whole percents: the meter carries the precision, and a decimal here is noise.
|
|
432
|
+
parts.percent === null ? '?' : `${Math.round(parts.percent)}%`,
|
|
425
433
|
)
|
|
426
434
|
const meter = `${theme.fg('dim', 'Context')} ${bar(theme, BAR_CELLS, fraction)} ${percent}`
|
|
427
435
|
const detail =
|
|
@@ -432,15 +440,28 @@ export function contextRow(
|
|
|
432
440
|
const volumes: string[] = []
|
|
433
441
|
if (parts.input > 0) volumes.push(pair(theme, 'Input', formatTokens(parts.input)))
|
|
434
442
|
if (parts.output > 0) volumes.push(pair(theme, 'Output', formatTokens(parts.output)))
|
|
435
|
-
const
|
|
443
|
+
const hit: string[] = []
|
|
436
444
|
if (parts.cacheHitRate !== null) {
|
|
437
|
-
|
|
445
|
+
hit.push(pair(theme, 'Cache hit', `${parts.cacheHitRate.toFixed(1)}%`))
|
|
446
|
+
}
|
|
447
|
+
const money: string[] = []
|
|
448
|
+
if (parts.cost > 0) money.push(pair(theme, 'Cost', formatCost(parts.cost, currency)))
|
|
449
|
+
if (parts.todayCost > 0) {
|
|
450
|
+
money.push(pair(theme, 'Today', formatCost(parts.todayCost, currency)))
|
|
438
451
|
}
|
|
439
|
-
if (parts.cost > 0) outcomes.push(pair(theme, 'Cost', formatCost(parts.cost, currency)))
|
|
440
452
|
|
|
441
453
|
const separator = theme.fg('dim', ' · ')
|
|
442
|
-
|
|
443
|
-
|
|
454
|
+
// Groups, not a flat run of dots: volumes | cache | money. A wall between different kinds of
|
|
455
|
+
// number reads faster than another dot between similar-looking ones.
|
|
456
|
+
const groups: string[] = []
|
|
457
|
+
if (volumes.length > 0) groups.push(volumes.join(separator))
|
|
458
|
+
if (hit.length > 0) groups.push(hit.join(separator))
|
|
459
|
+
if (money.length > 0) groups.push(money.join(separator))
|
|
460
|
+
const full = groups.join(theme.fg('dim', ' | '))
|
|
461
|
+
const core =
|
|
462
|
+
hit.length > 0 && money.length > 0
|
|
463
|
+
? hit.join(separator) + theme.fg('dim', ' | ') + money.join(separator)
|
|
464
|
+
: [...hit, ...money].join(separator)
|
|
444
465
|
const fits = (left: string, right: string): boolean =>
|
|
445
466
|
right === '' || visibleWidth(left) + 2 + visibleWidth(right) <= width
|
|
446
467
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reedchan/statusline",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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",
|