useful-pi-extensions 1.0.0 → 1.1.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 +6 -3
- package/README_CN.md +6 -3
- package/package.json +2 -1
- package/packages/statusline/README.md +39 -4
- package/packages/statusline/README_CN.md +44 -11
- package/packages/statusline/extensions/statusline/index.ts +43 -36
- package/packages/statusline/extensions/statusline/render.ts +160 -4
package/README.md
CHANGED
|
@@ -5,15 +5,18 @@ English | [中文](README_CN.md)
|
|
|
5
5
|
A small collection of [pi](https://pi.dev) extensions, installed with one command. Every extension
|
|
6
6
|
in it is also published on its own, so you can take the collection or just the piece you want.
|
|
7
7
|
|
|
8
|
+
- `useful-pi-extensions`, the collection — [npm](https://www.npmjs.com/package/useful-pi-extensions) · [pi packages gallery](https://pi.dev/packages/useful-pi-extensions)
|
|
9
|
+
- `@reedchan/statusline` — [npm](https://www.npmjs.com/package/@reedchan/statusline) · [pi packages gallery](https://pi.dev/packages/@reedchan/statusline)
|
|
10
|
+
|
|
8
11
|
```sh
|
|
9
12
|
pi install npm:useful-pi-extensions
|
|
10
13
|
```
|
|
11
14
|
|
|
12
15
|
## What is in here
|
|
13
16
|
|
|
14
|
-
| Extension | What it does
|
|
15
|
-
| ------------------------------------------------------- |
|
|
16
|
-
| [`@reedchan/statusline`](packages/statusline/README.md) | Replaces pi's footer with a labelled two-row one: 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 |
|
|
17
|
+
| Extension | What it does |
|
|
18
|
+
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
19
|
+
| [`@reedchan/statusline`](packages/statusline/README.md) | Replaces pi's footer with a labelled two-row one: context pressure as a fixed-size meter, the session's input/output tokens, cache hit rate and cost (in whatever currency you configure), the model and effort level, and the latest turn's TTFT and decode throughput in tokens/second |
|
|
17
20
|
|
|
18
21
|
Only one extension owns the footer, so `statusline` is a complete replacement rather than an
|
|
19
22
|
addition. If you want pi's stock footer back, remove this package and `/reload`.
|
package/README_CN.md
CHANGED
|
@@ -5,15 +5,18 @@
|
|
|
5
5
|
一组 [pi](https://pi.dev) 扩展,一行命令即可安装。其中的每个扩展也会单独发布,
|
|
6
6
|
所以你要么整套装,要么只取其中一件。
|
|
7
7
|
|
|
8
|
+
- `useful-pi-extensions`(合集)— [npm](https://www.npmjs.com/package/useful-pi-extensions) · [pi packages 画廊](https://pi.dev/packages/useful-pi-extensions)
|
|
9
|
+
- `@reedchan/statusline` — [npm](https://www.npmjs.com/package/@reedchan/statusline) · [pi packages 画廊](https://pi.dev/packages/@reedchan/statusline)
|
|
10
|
+
|
|
8
11
|
```sh
|
|
9
12
|
pi install npm:useful-pi-extensions
|
|
10
13
|
```
|
|
11
14
|
|
|
12
15
|
## 包含什么
|
|
13
16
|
|
|
14
|
-
| 扩展 | 作用
|
|
15
|
-
| ---------------------------------------------------------- |
|
|
16
|
-
| [`@reedchan/statusline`](packages/statusline/README_CN.md) | 替换 pi 的 footer
|
|
17
|
+
| 扩展 | 作用 |
|
|
18
|
+
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
19
|
+
| [`@reedchan/statusline`](packages/statusline/README_CN.md) | 替换 pi 的 footer,改成带文字标签的两行:上下文压力用固定宽度仪表显示,外加本次会话的输入/输出 token 量、缓存命中率与花费(币种可配置)、模型与思考等级,以及最近一轮的 TTFT 和解码速度(tok/s) |
|
|
17
20
|
|
|
18
21
|
footer 只能有一个扩展占用,所以 `statusline` 是**替换**而不是叠加。想回到 pi 原生 footer,移除本包再 `/reload`。
|
|
19
22
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "useful-pi-extensions",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A small collection of pi extensions, installed with one command — a labelled status line with context pressure, cache, cost, effort, TTFT and tokens/sec.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"url": "https://github.com/reedchan7/useful-pi-extensions/issues"
|
|
20
20
|
},
|
|
21
21
|
"license": "MIT",
|
|
22
|
+
"author": "Reed Chan <xinyuchen77@gmail.com>",
|
|
22
23
|
"repository": {
|
|
23
24
|
"type": "git",
|
|
24
25
|
"url": "git+https://github.com/reedchan7/useful-pi-extensions.git"
|
|
@@ -2,22 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
English | [中文](README_CN.md)
|
|
4
4
|
|
|
5
|
+
[npm](https://www.npmjs.com/package/@reedchan/statusline) · [pi packages gallery](https://pi.dev/packages/@reedchan/statusline) · [repository](https://github.com/reedchan7/useful-pi-extensions)
|
|
6
|
+
|
|
5
7
|
Replaces pi's footer with a labelled two-row one. Every value carries a word, so nothing has to be
|
|
6
8
|
decoded from a symbol or remembered from a legend.
|
|
7
9
|
|
|
8
10
|
```
|
|
9
|
-
Context █████████▍░░░░░░░░░░ 47.1% 471k / 1.0M
|
|
10
|
-
~/.pi (master)
|
|
11
|
+
Context █████████▍░░░░░░░░░░ 47.1% 471k / 1.0M Input 194k · Output 89k · Cache hit 99.9% · Cost $0.229
|
|
12
|
+
~/.pi (master) deepseek-flash · Effort high · TTFT 482ms · 729 tok/s
|
|
11
13
|
LSP Active: typescript
|
|
12
14
|
```
|
|
13
15
|
|
|
16
|
+
A narrower terminal gives the row up in a fixed order rather than all at once: the `471k / 1.0M`
|
|
17
|
+
detail goes first, then the input/output volumes, leaving the hit rate and the bill. Below about 60
|
|
18
|
+
columns only the meter is left. Every step is a whole value — a number is never shown cut in half.
|
|
19
|
+
|
|
14
20
|
## What each part is
|
|
15
21
|
|
|
16
22
|
| Part | Meaning |
|
|
17
23
|
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
18
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 |
|
|
19
|
-
| `471k / 1.0M` | Absolute context tokens over the window size.
|
|
20
|
-
| `
|
|
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) |
|
|
21
29
|
| Effort | The active thinking level |
|
|
22
30
|
| `TTFT` | Time from request dispatch to the first streamed token |
|
|
23
31
|
| `tok/s` | Decode throughput, i.e. output tokens per second of decode time |
|
|
@@ -42,6 +50,33 @@ Configuration lives at the top of [`render.ts`](render.ts):
|
|
|
42
50
|
| `BAR_TRACK` | `░` | Set to `""` for a trackless meter, or `"─"` for a hairline one |
|
|
43
51
|
| `QUIET_STATUS` | pi-lens | `[statusKey, pattern]` pairs whose matching text is hidden as "nothing to report" |
|
|
44
52
|
|
|
53
|
+
## Currency
|
|
54
|
+
|
|
55
|
+
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`:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"currency": {
|
|
64
|
+
"code": "CNY",
|
|
65
|
+
"perUsd": 7.12
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
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.
|
|
75
|
+
|
|
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.
|
|
79
|
+
|
|
45
80
|
## Metrics
|
|
46
81
|
|
|
47
82
|
Throughput and latency follow the
|
|
@@ -2,25 +2,33 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 中文
|
|
4
4
|
|
|
5
|
+
[npm](https://www.npmjs.com/package/@reedchan/statusline) · [pi packages 画廊](https://pi.dev/packages/@reedchan/statusline) · [仓库](https://github.com/reedchan7/useful-pi-extensions)
|
|
6
|
+
|
|
5
7
|
替换 pi 的 footer,改成带文字标签的两行。每个值都带一个词,不需要靠符号猜、也不需要记图例。
|
|
6
8
|
|
|
7
9
|
```
|
|
8
|
-
Context █████████▍░░░░░░░░░░ 47.1% 471k / 1.0M
|
|
9
|
-
~/.pi (master)
|
|
10
|
+
Context █████████▍░░░░░░░░░░ 47.1% 471k / 1.0M Input 194k · Output 89k · Cache hit 99.9% · Cost $0.229
|
|
11
|
+
~/.pi (master) deepseek-flash · Effort high · TTFT 482ms · 729 tok/s
|
|
10
12
|
LSP Active: typescript
|
|
11
13
|
```
|
|
12
14
|
|
|
15
|
+
终端变窄时,这一行是**按固定顺序**逐项舍弃的,而不是整体消失:先丢 `471k / 1.0M`,
|
|
16
|
+
再丢输入/输出量,最后保留命中率与花费。窄于约 60 列时就只剩仪表了。
|
|
17
|
+
每一步丢的都是完整的一项,绝不出现被截掉一半的数字。
|
|
18
|
+
|
|
13
19
|
## 每一项是什么
|
|
14
20
|
|
|
15
|
-
| 部分 | 含义
|
|
16
|
-
| -------------------------- |
|
|
17
|
-
| `Context` + 仪表 + `47.1%` | 已占模型上下文窗口的比例。填充超过 70% 转 `warning`、超过 90% 转 `error`(与 pi 原生 footer 同一套阈值),百分比同步变色
|
|
18
|
-
| `471k / 1.0M` | 上下文已用 token /
|
|
19
|
-
| `
|
|
20
|
-
|
|
|
21
|
-
| `
|
|
22
|
-
|
|
|
23
|
-
|
|
|
21
|
+
| 部分 | 含义 |
|
|
22
|
+
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
23
|
+
| `Context` + 仪表 + `47.1%` | 已占模型上下文窗口的比例。填充超过 70% 转 `warning`、超过 90% 转 `error`(与 pi 原生 footer 同一套阈值),百分比同步变色 |
|
|
24
|
+
| `471k / 1.0M` | 上下文已用 token / 窗口大小。终端变窄时**第一个被舍弃** |
|
|
25
|
+
| `Input` / `Output` | 会话的输入与输出 token 量。`Input` 指既没命中缓存、也没写入缓存的那部分 prompt token,因为 pi 把这两种情况在同一个 `usage` 对象里分开报 |
|
|
26
|
+
| `Cache hit` | 最近一轮的缓存命中率,`cacheRead / (input + cacheRead + cacheWrite)` |
|
|
27
|
+
| `Cost` | 会话花费,默认美元;配置了其他币种则换算显示(见下) |
|
|
28
|
+
| Effort | 当前思考等级 |
|
|
29
|
+
| `TTFT` | 从发出请求到第一个流式 token 的耗时 |
|
|
30
|
+
| `tok/s` | 解码吞吐,即每秒解码时间产出的输出 token 数 |
|
|
31
|
+
| 最后一行 | 其他扩展通过 `ctx.ui.setStatus()` 设的状态,否则它们会静默消失 |
|
|
24
32
|
|
|
25
33
|
## 关于这个仪表
|
|
26
34
|
|
|
@@ -40,6 +48,31 @@ LSP Active: typescript
|
|
|
40
48
|
| `BAR_TRACK` | `░` | 设为 `""` 得到无轨道仪表,设为 `"─"` 得到细线轨道 |
|
|
41
49
|
| `QUIET_STATUS` | pi-lens | `[状态键, 正则]` 列表,匹配到的文本视为"无事发生"而隐藏 |
|
|
42
50
|
|
|
51
|
+
## 币种
|
|
52
|
+
|
|
53
|
+
pi 里所有模型价格都是美元,而且它的 `cost` 字段**不带任何单位**,所以 footer 无从知道你实际是按什么币种付的。
|
|
54
|
+
这次会话折合多少人民币,取决于卖你额度那家给的汇率,而不是市场汇率——所以汇率是**配置**出来的,不是联网抓的。
|
|
55
|
+
|
|
56
|
+
创建 `~/.pi/agent/statusline.json`:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"currency": {
|
|
61
|
+
"code": "CNY",
|
|
62
|
+
"perUsd": 7.12
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- `code` 决定符号(`CNY` 与 `RMB` 得到 `¥`;符号表还认 `USD`、`EUR`、`GBP`、`JPY`、`HKD`、`TWD`、
|
|
68
|
+
`SGD`、`KRW`、`INR`)。表里没有的代码就原样打印。
|
|
69
|
+
- `symbol` 可覆盖符号表,用于表里没列的币种,或想换分隔方式。
|
|
70
|
+
- `perUsd` 是一美元等于多少该币种,也就是**你实际被收的汇率**。必须是正数;
|
|
71
|
+
否则整块被拒绝,footer 继续显示美元。
|
|
72
|
+
|
|
73
|
+
配置成上面这样,同一个会话就从 `Cost $0.229` 变成 `Cost ¥1.63`。该文件每个会话只读一次,
|
|
74
|
+
改完需 `/reload`。文件存在但不可用时会给出通知,而不是静默继续显示美元。
|
|
75
|
+
|
|
43
76
|
## 指标口径
|
|
44
77
|
|
|
45
78
|
吞吐与延迟遵循 [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) 的 turn-metrics 契约
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Design notes:
|
|
7
7
|
*
|
|
8
|
-
* - Every value carries a word ("Context", "Cache", "Cost", "Effort"), not a
|
|
9
|
-
* are unreadable at a glance.
|
|
8
|
+
* - Every value carries a word ("Context", "Input", "Output", "Cache hit", "Cost", "Effort"), not a
|
|
9
|
+
* symbol: icon-only labels are unreadable at a glance.
|
|
10
10
|
* - Keys render dim and values render brighter, so a key/value pair reads as one unit instead of as a
|
|
11
11
|
* run of equal-weight tokens.
|
|
12
12
|
* - The meter is a FIXED 20 cells and never stretches to the terminal, so it reads as an instrument
|
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
* stepStartTime; decodeMs = completedTime - firstTokenTime; tok/s = usage.output / (decodeMs / 1000)
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { homedir } from 'node:os'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
|
|
21
24
|
import type {
|
|
22
25
|
ExtensionAPI,
|
|
23
26
|
ExtensionContext,
|
|
@@ -26,18 +29,17 @@ import type {
|
|
|
26
29
|
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'
|
|
27
30
|
|
|
28
31
|
import {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
formatCost,
|
|
32
|
+
contextRow,
|
|
33
|
+
currencyFromConfig,
|
|
32
34
|
formatCwd,
|
|
33
35
|
formatLatency,
|
|
34
|
-
formatTokens,
|
|
35
36
|
formatTps,
|
|
36
37
|
isQuietStatus,
|
|
37
38
|
pair,
|
|
38
|
-
percentColor,
|
|
39
39
|
row,
|
|
40
40
|
shortenPath,
|
|
41
|
+
USD,
|
|
42
|
+
type Currency,
|
|
41
43
|
} from './render.ts'
|
|
42
44
|
|
|
43
45
|
const LIVE_RENDER_MS = 200
|
|
@@ -46,6 +48,23 @@ const MIN_SAMPLE_MS = 200
|
|
|
46
48
|
/** Pi's own estimateTokens() heuristic, used only until a real ratio is known. */
|
|
47
49
|
const FALLBACK_TOKENS_PER_CHAR = 0.25
|
|
48
50
|
|
|
51
|
+
/** The status line's own settings file, alongside pi's other per-tool config. */
|
|
52
|
+
const CONFIG_PATH = join(homedir(), '.pi', 'agent', 'statusline.json')
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reads the display currency.
|
|
56
|
+
*
|
|
57
|
+
* Called once per session on purpose: a footer that stat'ed a file on every frame would be its own
|
|
58
|
+
* bug. Editing the file therefore takes effect on `/reload`.
|
|
59
|
+
*/
|
|
60
|
+
async function loadCurrency(notify: (message: string) => void): Promise<Currency> {
|
|
61
|
+
const file = Bun.file(CONFIG_PATH)
|
|
62
|
+
const text = (await file.exists()) ? await file.text() : null
|
|
63
|
+
const { currency, problem } = currencyFromConfig(text)
|
|
64
|
+
if (problem !== null) notify(problem)
|
|
65
|
+
return currency
|
|
66
|
+
}
|
|
67
|
+
|
|
49
68
|
/** A content block as providers stream it; every field is read defensively. */
|
|
50
69
|
type ContentBlock = {
|
|
51
70
|
type?: unknown
|
|
@@ -148,6 +167,7 @@ function seedRatio(ctx: ExtensionContext): number | null {
|
|
|
148
167
|
export default function (pi: ExtensionAPI) {
|
|
149
168
|
let ratio: number | null = null
|
|
150
169
|
let requestRender: (() => void) | null = null
|
|
170
|
+
let currency: Currency = USD
|
|
151
171
|
|
|
152
172
|
// Latest turn reading: { rate, exact, ttftMs }
|
|
153
173
|
let reading: { rate: number; exact: boolean; ttftMs: number | null } | null = null
|
|
@@ -184,31 +204,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
184
204
|
render(width: number): string[] {
|
|
185
205
|
const usage = ctx.getContextUsage()
|
|
186
206
|
const totals = collectTotals(ctx)
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
// as quiet secondary detail. Air to the right of the meter is deliberate.
|
|
202
|
-
const pctColor = percentColor(percent)
|
|
203
|
-
const pctText = theme.fg(pctColor, percent === null ? '?' : `${percent.toFixed(1)}%`)
|
|
204
|
-
const window = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0
|
|
205
|
-
const detail =
|
|
206
|
-
window > 0 ? `${formatTokens(usage?.tokens ?? 0)} / ${formatTokens(window)}` : ''
|
|
207
|
-
const meter = `${theme.fg('dim', 'Context')} ${bar(theme, BAR_CELLS, fraction)} ${pctText}`
|
|
208
|
-
const withDetail = detail ? `${meter} ${theme.fg('dim', detail)}` : meter
|
|
209
|
-
// Shed the secondary detail before the cache/cost group gets squeezed.
|
|
210
|
-
const row1Left =
|
|
211
|
-
visibleWidth(withDetail) + 2 + visibleWidth(row1Right) <= width ? withDetail : meter
|
|
207
|
+
const row1 = contextRow(
|
|
208
|
+
theme,
|
|
209
|
+
width,
|
|
210
|
+
{
|
|
211
|
+
percent: usage?.percent ?? null,
|
|
212
|
+
tokens: usage?.tokens ?? 0,
|
|
213
|
+
window: usage?.contextWindow ?? ctx.model?.contextWindow ?? 0,
|
|
214
|
+
input: totals.input,
|
|
215
|
+
output: totals.output,
|
|
216
|
+
cacheHitRate: totals.cacheHitRate,
|
|
217
|
+
cost: totals.cost,
|
|
218
|
+
},
|
|
219
|
+
currency,
|
|
220
|
+
)
|
|
212
221
|
|
|
213
222
|
// Row 2: model and the latest turn's timing on the right, path on the left.
|
|
214
223
|
const model = ctx.model?.id ?? 'no model'
|
|
@@ -245,10 +254,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
245
254
|
}
|
|
246
255
|
const row2Left = pwdPlain ? theme.fg('muted', pwdPlain) : ''
|
|
247
256
|
|
|
248
|
-
const lines = [
|
|
249
|
-
row(theme, width, row1Left, row1Right),
|
|
250
|
-
row(theme, width, row2Left, row2Right),
|
|
251
|
-
]
|
|
257
|
+
const lines = [row1, row(theme, width, row2Left, row2Right)]
|
|
252
258
|
|
|
253
259
|
// Other extensions' status entries still belong on screen, minus the
|
|
254
260
|
// ones that only report that nothing is happening.
|
|
@@ -269,6 +275,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
269
275
|
ratio = seedRatio(ctx)
|
|
270
276
|
reading = null
|
|
271
277
|
resetStream()
|
|
278
|
+
currency = await loadCurrency((message) => ctx.ui.notify(message, 'warning'))
|
|
272
279
|
installFooter(ctx)
|
|
273
280
|
})
|
|
274
281
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure rendering helpers for the status line: number formatting, the context meter, the two-group
|
|
3
|
-
* row layout,
|
|
3
|
+
* row layout, the status-noise filter, and the display currency.
|
|
4
4
|
*
|
|
5
5
|
* Nothing here touches pi APIs, the filesystem, or the clock, so every export is a function of its
|
|
6
|
-
* arguments alone and is covered by render.test.ts.
|
|
6
|
+
* arguments alone and is covered by render.test.ts. The config file is read by the caller and
|
|
7
|
+
* handed in as text, which is what keeps the parsing testable here.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import { homedir } from 'node:os'
|
|
@@ -79,9 +80,103 @@ export function formatCwd(cwd: string): string {
|
|
|
79
80
|
}
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* A display currency: the symbol to print, and how many of its units one USD buys.
|
|
85
|
+
*
|
|
86
|
+
* Pi prices every model in USD and never names a unit — its own model type documents `cost` as
|
|
87
|
+
* "cost per million tokens" — so a conversion rate cannot be derived from pi and has to come from
|
|
88
|
+
* the person paying. What a session cost in RMB is set by whoever sold the credit, not by a market
|
|
89
|
+
* feed, which is why the rate is configured rather than fetched. See README.md for the file.
|
|
90
|
+
*/
|
|
91
|
+
export interface Currency {
|
|
92
|
+
symbol: string
|
|
93
|
+
perUsd: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What the footer shows until the config file names something else. */
|
|
97
|
+
export const USD: Currency = { symbol: '$', perUsd: 1 }
|
|
98
|
+
|
|
99
|
+
/** Currencies the config can name by `code` alone. Anything else needs an explicit `symbol`. */
|
|
100
|
+
const CURRENCY_SYMBOLS: Record<string, string> = {
|
|
101
|
+
CNY: '¥',
|
|
102
|
+
EUR: '€',
|
|
103
|
+
GBP: '£',
|
|
104
|
+
HKD: 'HK$',
|
|
105
|
+
INR: '₹',
|
|
106
|
+
JPY: '¥',
|
|
107
|
+
KRW: '₩',
|
|
108
|
+
RMB: '¥',
|
|
109
|
+
SGD: 'S$',
|
|
110
|
+
TWD: 'NT$',
|
|
111
|
+
USD: '$',
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
115
|
+
return typeof value === 'object' && value !== null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Validates a `currency` block.
|
|
120
|
+
*
|
|
121
|
+
* A rate that is missing, zero, negative or not a number is refused rather than defaulted: a
|
|
122
|
+
* confident wrong amount is worse than the unconverted one the caller falls back to.
|
|
123
|
+
*
|
|
124
|
+
* @param raw - The `currency` value from a parsed config file.
|
|
125
|
+
* @returns The currency, or null when the block could not be used.
|
|
126
|
+
*/
|
|
127
|
+
export function parseCurrency(raw: unknown): Currency | null {
|
|
128
|
+
if (!isRecord(raw)) return null
|
|
129
|
+
const rate = raw.perUsd
|
|
130
|
+
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) return null
|
|
131
|
+
const code = typeof raw.code === 'string' ? raw.code.trim().toUpperCase() : ''
|
|
132
|
+
const explicit = typeof raw.symbol === 'string' ? raw.symbol : ''
|
|
133
|
+
const symbol = explicit || CURRENCY_SYMBOLS[code] || code
|
|
134
|
+
return symbol === '' ? null : { symbol, perUsd: rate }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A currency, plus the message to show when the config named one that could not be used. */
|
|
138
|
+
export interface CurrencyConfig {
|
|
139
|
+
currency: Currency
|
|
140
|
+
problem: string | null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The display currency a config file asks for.
|
|
145
|
+
*
|
|
146
|
+
* @param text - The contents of the config file, or null when it does not exist. Absent is the
|
|
147
|
+
* normal case and is not a problem; a file that is there but unusable is, because the footer
|
|
148
|
+
* would otherwise keep showing dollars with nothing to explain it.
|
|
149
|
+
*/
|
|
150
|
+
export function currencyFromConfig(text: string | null): CurrencyConfig {
|
|
151
|
+
if (text === null) return { currency: USD, problem: null }
|
|
152
|
+
let config: unknown
|
|
153
|
+
try {
|
|
154
|
+
config = JSON.parse(text)
|
|
155
|
+
} catch {
|
|
156
|
+
return { currency: USD, problem: 'statusline.json is not valid JSON, showing USD' }
|
|
157
|
+
}
|
|
158
|
+
if (!isRecord(config) || !Object.hasOwn(config, 'currency')) {
|
|
159
|
+
return { currency: USD, problem: null }
|
|
160
|
+
}
|
|
161
|
+
const currency = parseCurrency(config.currency)
|
|
162
|
+
if (currency === null) {
|
|
163
|
+
return {
|
|
164
|
+
currency: USD,
|
|
165
|
+
problem: 'statusline.json currency needs a positive perUsd, showing USD',
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { currency, problem: null }
|
|
169
|
+
}
|
|
170
|
+
|
|
82
171
|
/** Three decimals, with one trailing zero trimmed so `$0.380` renders as `$0.38`. */
|
|
83
|
-
|
|
84
|
-
|
|
172
|
+
/**
|
|
173
|
+
* Three decimals, with the padding zeros trimmed so `$0.380` renders as `$0.38`.
|
|
174
|
+
*
|
|
175
|
+
* All of them, not just one: a converted amount lands on `¥17.800` often enough that a single
|
|
176
|
+
* trailing zero would show up as `¥17.80` beside `¥2.706` and read as a different precision.
|
|
177
|
+
*/
|
|
178
|
+
export function formatCost(cost: number, currency: Currency = USD): string {
|
|
179
|
+
return `${currency.symbol}${(cost * currency.perUsd).toFixed(3).replace(/\.?0+$/, '')}`
|
|
85
180
|
}
|
|
86
181
|
|
|
87
182
|
/**
|
|
@@ -144,6 +239,67 @@ export function row(theme: Theme, width: number, left: string, right: string): s
|
|
|
144
239
|
return truncateToWidth(keep, width, theme.fg('dim', '…'))
|
|
145
240
|
}
|
|
146
241
|
|
|
242
|
+
/** The row-1 figures, already totalled by the caller. */
|
|
243
|
+
export interface ContextRowParts {
|
|
244
|
+
percent: number | null
|
|
245
|
+
tokens: number
|
|
246
|
+
window: number
|
|
247
|
+
input: number
|
|
248
|
+
output: number
|
|
249
|
+
cacheHitRate: number | null
|
|
250
|
+
cost: number
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Row 1: context pressure on the left, session totals on the right.
|
|
255
|
+
*
|
|
256
|
+
* Space is given up in a fixed order rather than all at once, because this row carries numbers that
|
|
257
|
+
* appear nowhere else in the footer. A narrower terminal should cost the least load-bearing of them
|
|
258
|
+
* visibly rather than silently dropping the whole group:
|
|
259
|
+
*
|
|
260
|
+
* 1. The absolute `tokens / window` detail,
|
|
261
|
+
* 2. The input/output volumes, which are informative but not cumulative bills,
|
|
262
|
+
* 3. The right group entirely (`row` then keeps the left alone).
|
|
263
|
+
*
|
|
264
|
+
* @param currency - Used for the cost; defaults to USD so the row renders without a config file.
|
|
265
|
+
*/
|
|
266
|
+
export function contextRow(
|
|
267
|
+
theme: Theme,
|
|
268
|
+
width: number,
|
|
269
|
+
parts: ContextRowParts,
|
|
270
|
+
currency: Currency = USD,
|
|
271
|
+
): string {
|
|
272
|
+
const fraction = parts.percent === null ? 0 : parts.percent / 100
|
|
273
|
+
const percent = theme.fg(
|
|
274
|
+
percentColor(parts.percent),
|
|
275
|
+
parts.percent === null ? '?' : `${parts.percent.toFixed(1)}%`,
|
|
276
|
+
)
|
|
277
|
+
const meter = `${theme.fg('dim', 'Context')} ${bar(theme, BAR_CELLS, fraction)} ${percent}`
|
|
278
|
+
const detail =
|
|
279
|
+
parts.window > 0 ? `${formatTokens(parts.tokens)} / ${formatTokens(parts.window)}` : ''
|
|
280
|
+
const withDetail = detail === '' ? meter : `${meter} ${theme.fg('dim', detail)}`
|
|
281
|
+
|
|
282
|
+
// Volume, then the numbers that describe what the session cost.
|
|
283
|
+
const volumes: string[] = []
|
|
284
|
+
if (parts.input > 0) volumes.push(pair(theme, 'Input', formatTokens(parts.input)))
|
|
285
|
+
if (parts.output > 0) volumes.push(pair(theme, 'Output', formatTokens(parts.output)))
|
|
286
|
+
const outcomes: string[] = []
|
|
287
|
+
if (parts.cacheHitRate !== null) {
|
|
288
|
+
outcomes.push(pair(theme, 'Cache hit', `${parts.cacheHitRate.toFixed(1)}%`))
|
|
289
|
+
}
|
|
290
|
+
if (parts.cost > 0) outcomes.push(pair(theme, 'Cost', formatCost(parts.cost, currency)))
|
|
291
|
+
|
|
292
|
+
const separator = theme.fg('dim', ' · ')
|
|
293
|
+
const full = [...volumes, ...outcomes].join(separator)
|
|
294
|
+
const core = outcomes.join(separator)
|
|
295
|
+
const fits = (left: string, right: string): boolean =>
|
|
296
|
+
right === '' || visibleWidth(left) + 2 + visibleWidth(right) <= width
|
|
297
|
+
|
|
298
|
+
if (fits(withDetail, full)) return row(theme, width, withDetail, full)
|
|
299
|
+
if (fits(meter, full)) return row(theme, width, meter, full)
|
|
300
|
+
return row(theme, width, meter, core)
|
|
301
|
+
}
|
|
302
|
+
|
|
147
303
|
/** `label value` where the key is quiet and the value is not. */
|
|
148
304
|
export function pair(theme: Theme, key: string, value: string, color: ThemeColor = 'text'): string {
|
|
149
305
|
return `${theme.fg('dim', key)} ${theme.fg(color, value)}`
|