@reedchan/statusline 1.4.0 → 1.6.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
@@ -8,7 +8,7 @@ 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.1% 471k / 1.0M Input 194k · Output 89k · Cache hit 99.9% · Cost $0.229
11
+ Context █████████▍░░░░░░░░░░ 47% 471k / 1.0M Input 194k · Output 89k · Cache hit 99.9% · Cost $0.229 · Today $1.63
12
12
  ~/.pi (master) deepseek-flash · Effort high · TTFT 482ms · 729 tok/s
13
13
  LSP Active: typescript
14
14
  ```
@@ -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 | Meaning |
23
- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
24
- | `Context` + meter + `47.1%` | 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
- | Effort | The active thinking level |
30
- | `TTFT` | Time from request dispatch to the first streamed token |
31
- | `tok/s` | Decode throughput, i.e. output tokens per second of decode time |
32
- | Last line | Other extensions' `ctx.ui.setStatus()` entries, so they do not silently disappear |
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
 
@@ -53,29 +54,34 @@ Configuration lives at the top of [`render.ts`](render.ts):
53
54
  ## Currency
54
55
 
55
56
  pi prices every model in USD and its `cost` field carries no unit at all, so the footer cannot know
56
- what you were actually billed. What a session cost in RMB is set by whoever sold you the credit, not
57
- by a market feed, which is why the rate is configured rather than fetched.
58
-
59
- Create `~/.pi/agent/statusline.json`:
57
+ what you were actually billed. The currency and the rate live in `~/.pi/agent/statusline.json`:
60
58
 
61
59
  ```json
62
60
  {
63
61
  "currency": {
64
- "code": "CNY",
65
- "perUsd": 7.12
62
+ "code": "CNY"
66
63
  }
67
64
  }
68
65
  ```
69
66
 
70
- - `code` picks the symbol (`CNY` and `RMB` give `¥`; the table also knows `USD`, `EUR`, `GBP`, `JPY`,
71
- `HKD`, `TWD`, `SGD`, `KRW` and `INR`). A code the table does not know is printed as it is.
72
- - `symbol` overrides the table, for a currency it does not list or a different separator.
73
- - `perUsd` is how many units of that currency one dollar buys the rate you were actually charged.
74
- It has to be a positive number; anything else is refused and the footer stays in USD.
67
+ Two ways to set the rate:
68
+
69
+ - **Follow the market.** A `code` with no rate fetches the day's table from open.er-api.com on the
70
+ first session of each day and caches the whole table in the file (`rates` + `fetchedAt`), so the
71
+ next session starts warm and switching codes is instant and offline.
72
+ - **Pin it.** Add `"perUsd": 7.12` and the footer always uses that rate, never touching the network
73
+ — the right choice when your platform bills in RMB directly, because a domestic price list is not
74
+ the dollar list times a market rate.
75
+
76
+ `code` picks the symbol: `¥` for `CNY` and `RMB`, `JP¥` for `JPY` so the two never trade places, and
77
+ the table also knows `USD`, `EUR`, `GBP`, `HKD`, `TWD`, `SGD`, `KRW`, `INR`, `AUD`, `CAD`, `NZD` and
78
+ `CHF`; an unknown code is printed as it is. `symbol` overrides all of that.
75
79
 
76
- With the file above, the same session reads `Cost ¥1.63` instead of `Cost $0.229`. The file is read
77
- once per session, so edit it and `/reload`. A file that is there but unusable says so in a
78
- notification, rather than silently showing dollars with nothing to explain why.
80
+ Switching is editing `code`, or `make currency CODE=JPY` from a checkout. The file is read once per
81
+ session, so finish with `/reload`. A file that is there but unusable says so in a notification,
82
+ rather than silently showing dollars with nothing to explain why.
83
+
84
+ ## Metrics
79
85
 
80
86
  ## Metrics
81
87
 
package/README_CN.md CHANGED
@@ -7,7 +7,7 @@
7
7
  替换 pi 的 footer,改成带文字标签的两行。每个值都带一个词,不需要靠符号猜、也不需要记图例。
8
8
 
9
9
  ```
10
- Context █████████▍░░░░░░░░░░ 47.1% 471k / 1.0M Input 194k · Output 89k · Cache hit 99.9% · Cost $0.229
10
+ Context █████████▍░░░░░░░░░░ 47% 471k / 1.0M Input 194k · Output 89k · Cache hit 99.9% · Cost $0.229 · Today $1.63
11
11
  ~/.pi (master) deepseek-flash · Effort high · TTFT 482ms · 729 tok/s
12
12
  LSP Active: typescript
13
13
  ```
@@ -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 数 |
@@ -51,27 +52,31 @@ LSP Active: typescript
51
52
  ## 币种
52
53
 
53
54
  pi 里所有模型价格都是美元,而且它的 `cost` 字段**不带任何单位**,所以 footer 无从知道你实际是按什么币种付的。
54
- 这次会话折合多少人民币,取决于卖你额度那家给的汇率,而不是市场汇率——所以汇率是**配置**出来的,不是联网抓的。
55
-
56
- 创建 `~/.pi/agent/statusline.json`:
55
+ 币种与汇率放在 `~/.pi/agent/statusline.json`:
57
56
 
58
57
  ```json
59
58
  {
60
59
  "currency": {
61
- "code": "CNY",
62
- "perUsd": 7.12
60
+ "code": "CNY"
63
61
  }
64
62
  }
65
63
  ```
66
64
 
67
- - `code` 决定符号(`CNY` 与 `RMB` 得到 `¥`;符号表还认 `USD`、`EUR`、`GBP`、`JPY`、`HKD`、`TWD`、
68
- `SGD`、`KRW`、`INR`)。表里没有的代码就原样打印。
69
- - `symbol` 可覆盖符号表,用于表里没列的币种,或想换分隔方式。
70
- - `perUsd` 是一美元等于多少该币种,也就是**你实际被收的汇率**。必须是正数;
71
- 否则整块被拒绝,footer 继续显示美元。
65
+ 汇率有两种设法:
66
+
67
+ - **跟随市场。** 只写 `code` 不写汇率时,每天第一个会话会从 open.er-api.com 拉取当日汇率表,
68
+ 并把整张表缓存进文件(`rates` + `fetchedAt`),下个会话热启动;切换币种即时生效、离线也能切。
69
+ - **钉死。** 加上 `"perUsd": 7.12`,footer 永远用这个汇率、绝不联网——如果你的平台直接按人民币计费,
70
+ 这是正确选择:国内价目表不是美元价目表乘以市场汇率。
71
+
72
+ `code` 决定符号:`CNY` 和 `RMB` 得到 `¥`,`JPY` 得到 `JP¥`(两者永不混用),符号表还认 `USD`、
73
+ `EUR`、`GBP`、`HKD`、`TWD`、`SGD`、`KRW`、`INR`、`AUD`、`CAD`、`NZD`、`CHF`;表里没有的代码原样打印。
74
+ `symbol` 可覆盖这一切。
72
75
 
73
- 配置成上面这样,同一个会话就从 `Cost $0.229` 变成 `Cost ¥1.63`。该文件每个会话只读一次,
74
- 改完需 `/reload`。文件存在但不可用时会给出通知,而不是静默继续显示美元。
76
+ 切换就是改 `code`,或者在检出里执行 `make currency CODE=JPY`。该文件每个会话只读一次,改完需 `/reload`。
77
+ 文件存在但不可用时会给出通知,而不是静默继续显示美元。
78
+
79
+ ## 指标口径
75
80
 
76
81
  ## 指标口径
77
82
 
@@ -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
 
@@ -62,6 +63,8 @@ const FALLBACK_TOKENS_PER_CHAR = 0.25
62
63
  const CONFIG_PATH = join(homedir(), '.pi', 'agent', 'statusline.json')
63
64
  /** Free, keyless, and one request returns every currency — so the cache serves instant switching. */
64
65
  const RATES_URL = 'https://open.er-api.com/v6/latest/USD'
66
+ /** Every session on this machine, for the day-cost total that spans projects and models. */
67
+ const SESSIONS_DIR = join(homedir(), '.pi', 'agent', 'sessions')
65
68
  const FETCH_TIMEOUT_MS = 5000
66
69
 
67
70
  function today(): string {
@@ -97,6 +100,78 @@ async function fetchRates(): Promise<Record<string, number> | null> {
97
100
  * Called once per session on purpose: a footer that stat'ed a file on every frame would be its own
98
101
  * bug. Editing the file therefore takes effect on `/reload`.
99
102
  */
103
+ function startOfToday(): number {
104
+ const start = new Date()
105
+ start.setHours(0, 0, 0, 0)
106
+ return start.getTime()
107
+ }
108
+
109
+ function isRecord(value: unknown): value is Record<string, unknown> {
110
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
111
+ }
112
+
113
+ /** Today's provider cost of one session-file line, or null when the line bills nothing today. */
114
+ function entryCost(line: string, since: number): number | null {
115
+ if (!line.includes('"usage"')) return null
116
+ let entry: unknown
117
+ try {
118
+ entry = JSON.parse(line)
119
+ } catch {
120
+ return null
121
+ }
122
+ if (!isRecord(entry) || typeof entry.timestamp !== 'string') return null
123
+ if (Date.parse(entry.timestamp) < since) return null
124
+ if (!isRecord(entry.usage) || !isRecord(entry.usage.cost)) return null
125
+ const total = entry.usage.cost.total
126
+ return typeof total === 'number' && Number.isFinite(total) ? total : null
127
+ }
128
+
129
+ /**
130
+ * Today's cost across every other session on this machine.
131
+ *
132
+ * Files not touched today are skipped unread, and this session's own file is skipped because its
133
+ * cost is already live in totals.cost — counting both would double a resumed session.
134
+ */
135
+ async function sumOtherTodaysCost(currentFile: string | null, since: number): Promise<number> {
136
+ let dirs: string[] = []
137
+ try {
138
+ dirs = await readdir(SESSIONS_DIR)
139
+ } catch {
140
+ return 0
141
+ }
142
+ const lists = await Promise.all(
143
+ dirs.map(async (dir) => {
144
+ const dirPath = join(SESSIONS_DIR, dir)
145
+ let names: string[] = []
146
+ try {
147
+ names = await readdir(dirPath)
148
+ } catch {
149
+ return []
150
+ }
151
+ return names
152
+ .map((name) => join(dirPath, name))
153
+ .filter((path) => path.endsWith('.jsonl') && path !== currentFile)
154
+ }),
155
+ )
156
+ const costs = await Promise.all(
157
+ lists.flat().map(async (path) => {
158
+ try {
159
+ if ((await stat(path)).mtimeMs < since) return 0
160
+ } catch {
161
+ return 0
162
+ }
163
+ const text = await readFile(path, 'utf8').catch(() => '')
164
+ let total = 0
165
+ for (const line of text.split('\n')) {
166
+ const cost = entryCost(line, since)
167
+ if (cost !== null) total += cost
168
+ }
169
+ return total
170
+ }),
171
+ )
172
+ return costs.reduce((sum, value) => sum + value, 0)
173
+ }
174
+
100
175
  async function loadCurrency(notify: (message: string) => void): Promise<Currency> {
101
176
  let text: string | null = null
102
177
  try {
@@ -167,6 +242,7 @@ interface Totals {
167
242
  cacheWrite: number
168
243
  cost: number
169
244
  cacheHitRate: number | null
245
+ todayCost: number
170
246
  }
171
247
 
172
248
  /** The usage fields this footer totals, read structurally so no cast is needed. */
@@ -178,7 +254,7 @@ type UsageTotals = {
178
254
  cost: { total: number }
179
255
  }
180
256
 
181
- function collectTotals(ctx: ExtensionContext): Totals {
257
+ function collectTotals(ctx: ExtensionContext, since: number): Totals {
182
258
  const totals: Totals = {
183
259
  input: 0,
184
260
  output: 0,
@@ -186,29 +262,38 @@ function collectTotals(ctx: ExtensionContext): Totals {
186
262
  cacheWrite: 0,
187
263
  cost: 0,
188
264
  cacheHitRate: null,
265
+ todayCost: 0,
189
266
  }
190
267
  // `usage` stays optional here even though an assistant message always carries it:
191
268
  // a truncated or hand-edited session file is the case this guard is for.
192
- const add = (usage: UsageTotals | undefined, assistant: boolean): void => {
269
+ const add = (usage: UsageTotals | undefined, assistant: boolean, isToday: boolean): void => {
193
270
  if (!usage) return
194
271
  totals.input += usage.input
195
272
  totals.output += usage.output
196
273
  totals.cacheRead += usage.cacheRead
197
274
  totals.cacheWrite += usage.cacheWrite
198
275
  totals.cost += usage.cost.total
276
+ if (isToday) totals.todayCost += usage.cost.total
199
277
  if (!assistant) return
200
278
  const prompt = usage.input + usage.cacheRead + usage.cacheWrite
201
279
  if (prompt > 0) totals.cacheHitRate = (usage.cacheRead / prompt) * 100
202
280
  }
203
281
  for (const entry of ctx.sessionManager.getEntries()) {
282
+ const stamp =
283
+ 'timestamp' in entry && typeof entry.timestamp === 'string'
284
+ ? Date.parse(entry.timestamp)
285
+ : Number.NaN
286
+ const isToday = Number.isFinite(stamp) && stamp >= since
204
287
  if (entry.type === 'message') {
205
288
  const { message } = entry
206
- if (message.role === 'assistant') add(message.usage, true)
207
- else if (message.role === 'toolResult') add(message.usage, false)
289
+ if (message.role === 'assistant') add(message.usage, true, isToday)
290
+ else if (message.role === 'toolResult') add(message.usage, false, isToday)
208
291
  continue
209
292
  }
210
293
  // Compaction and branch summaries bill a model call of their own.
211
- if (entry.type === 'compaction' || entry.type === 'branch_summary') add(entry.usage, false)
294
+ if (entry.type === 'compaction' || entry.type === 'branch_summary') {
295
+ add(entry.usage, false, isToday)
296
+ }
212
297
  }
213
298
  return totals
214
299
  }
@@ -240,6 +325,8 @@ export default function (pi: ExtensionAPI) {
240
325
  let requestAt: number | null = null
241
326
  let firstTokenAt: number | null = null
242
327
  let totalDecodeMs = 0
328
+ let totalMeasuredOutput = 0
329
+ let todayBase = 0
243
330
  let ticker: ReturnType<typeof setInterval> | null = null
244
331
  let windowAt = 0
245
332
  let windowTokens = 0
@@ -289,8 +376,8 @@ export default function (pi: ExtensionAPI) {
289
376
  },
290
377
  render(width: number): string[] {
291
378
  const usage = ctx.getContextUsage()
292
- const totals = collectTotals(ctx)
293
- const avg = avgTokPerSec(totals.output, totalDecodeMs)
379
+ const totals = collectTotals(ctx, startOfToday())
380
+ const avg = avgTokPerSec(totalMeasuredOutput, totalDecodeMs)
294
381
  const row1 = contextRow(
295
382
  theme,
296
383
  width,
@@ -302,6 +389,7 @@ export default function (pi: ExtensionAPI) {
302
389
  output: totals.output,
303
390
  cacheHitRate: totals.cacheHitRate,
304
391
  cost: totals.cost,
392
+ todayCost: todayBase + totals.todayCost,
305
393
  },
306
394
  currency,
307
395
  )
@@ -388,9 +476,12 @@ export default function (pi: ExtensionAPI) {
388
476
  reading = null
389
477
  requestAt = null
390
478
  totalDecodeMs = 0
479
+ totalMeasuredOutput = 0
391
480
  stopTicker()
392
481
  resetStream()
393
482
  currency = await loadCurrency((message) => ctx.ui.notify(message, 'warning'))
483
+ const file = ctx.sessionManager.getSessionFile()
484
+ todayBase = await sumOtherTodaysCost(file ?? null, startOfToday())
394
485
  installFooter(ctx)
395
486
  })
396
487
 
@@ -470,8 +561,11 @@ export default function (pi: ExtensionAPI) {
470
561
  const measured = ttftMs(requestAt, firstTokenAt)
471
562
  const tokens = output > 0 ? output : totalChars * (ratio ?? FALLBACK_TOKENS_PER_CHAR)
472
563
  if (measured !== null && decodeMs >= MIN_SAMPLE_MS) {
473
- // Session-average numerator lives in collectTotals; this is its denominator.
564
+ // The average's numerator and denominator must cover the same messages: totals.output spans
565
+ // the whole session (a reload replays none of it), so pairing it with the partial denominator
566
+ // below once produced an Avg of 4324 tok/s.
474
567
  totalDecodeMs += decodeMs
568
+ if (output > 0) totalMeasuredOutput += output
475
569
  publish((tokens / decodeMs) * 1000, output > 0, measured)
476
570
  }
477
571
  // Null it with the stream: a request that has produced its message is no longer in flight, and
@@ -143,13 +143,18 @@ export const USD: Currency = { symbol: '$', perUsd: 1 }
143
143
 
144
144
  /** Currencies the config can name by `code` alone. Anything else needs an explicit `symbol`. */
145
145
  const CURRENCY_SYMBOLS: Record<string, string> = {
146
+ AUD: 'A$',
147
+ CAD: 'CA$',
148
+ CHF: 'CHF',
146
149
  CNY: '¥',
147
150
  EUR: '€',
148
151
  GBP: '£',
149
152
  HKD: 'HK$',
150
153
  INR: '₹',
151
- JPY: '¥',
154
+ // ¥ is CNY's by default; JPY wears the prefixed form so the two never trade places.
155
+ JPY: 'JP¥',
152
156
  KRW: '₩',
157
+ NZD: 'NZ$',
153
158
  RMB: '¥',
154
159
  SGD: 'S$',
155
160
  TWD: 'NT$',
@@ -392,6 +397,8 @@ export interface ContextRowParts {
392
397
  output: number
393
398
  cacheHitRate: number | null
394
399
  cost: number
400
+ /** Today's spend across every session on this machine, or 0 to hide the slot. */
401
+ todayCost: number
395
402
  }
396
403
 
397
404
  /**
@@ -416,7 +423,8 @@ export function contextRow(
416
423
  const fraction = parts.percent === null ? 0 : parts.percent / 100
417
424
  const percent = theme.fg(
418
425
  percentColor(parts.percent),
419
- parts.percent === null ? '?' : `${parts.percent.toFixed(1)}%`,
426
+ // Whole percents: the meter carries the precision, and a decimal here is noise.
427
+ parts.percent === null ? '?' : `${Math.round(parts.percent)}%`,
420
428
  )
421
429
  const meter = `${theme.fg('dim', 'Context')} ${bar(theme, BAR_CELLS, fraction)} ${percent}`
422
430
  const detail =
@@ -432,6 +440,9 @@ export function contextRow(
432
440
  outcomes.push(pair(theme, 'Cache hit', `${parts.cacheHitRate.toFixed(1)}%`))
433
441
  }
434
442
  if (parts.cost > 0) outcomes.push(pair(theme, 'Cost', formatCost(parts.cost, currency)))
443
+ if (parts.todayCost > 0) {
444
+ outcomes.push(pair(theme, 'Today', formatCost(parts.todayCost, currency)))
445
+ }
435
446
 
436
447
  const separator = theme.fg('dim', ' · ')
437
448
  const full = [...volumes, ...outcomes].join(separator)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reedchan/statusline",
3
- "version": "1.4.0",
3
+ "version": "1.6.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",