useful-pi-extensions 0.1.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 CHANGED
@@ -2,17 +2,21 @@
2
2
 
3
3
  English | [中文](README_CN.md)
4
4
 
5
- A small collection of [pi](https://pi.dev) extensions, installed with one command.
5
+ A small collection of [pi](https://pi.dev) extensions, installed with one command. Every extension
6
+ in it is also published on its own, so you can take the collection or just the piece you want.
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)
6
10
 
7
11
  ```sh
8
- pi install git:github.com/reedchan7/useful-pi-extensions
12
+ pi install npm:useful-pi-extensions
9
13
  ```
10
14
 
11
15
  ## What is in here
12
16
 
13
- | Extension | What it does |
14
- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
- | [`statusline`](extensions/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 |
16
20
 
17
21
  Only one extension owns the footer, so `statusline` is a complete replacement rather than an
18
22
  addition. If you want pi's stock footer back, remove this package and `/reload`.
@@ -26,11 +30,14 @@ addition. If you want pi's stock footer back, remove this package and `/reload`.
26
30
  ## Install
27
31
 
28
32
  ```sh
29
- # from the repository
30
- pi install git:github.com/reedchan7/useful-pi-extensions
33
+ # the collection: every extension in this repository
34
+ pi install npm:useful-pi-extensions
35
+
36
+ # one extension on its own
37
+ pi install npm:@reedchan/statusline
31
38
 
32
- # pinned to a tag
33
- pi install git:github.com/reedchan7/useful-pi-extensions@v0.1.0
39
+ # from the repository, pinned to a tag
40
+ pi install git:github.com/reedchan7/useful-pi-extensions@v1.0.0
34
41
 
35
42
  # from a local checkout (development)
36
43
  pi install /absolute/path/to/useful-pi-extensions
@@ -38,6 +45,10 @@ pi install /absolute/path/to/useful-pi-extensions
38
45
 
39
46
  Then reload pi in the running session with `/reload`, or start a new one.
40
47
 
48
+ > **Install one surface, not both.** The collection and the individual package ship the same
49
+ > extension file, so installing `useful-pi-extensions` _and_ `@reedchan/statusline` loads it twice.
50
+ > Nothing breaks, but there is nothing to gain either.
51
+
41
52
  > **Removing a loose copy first.** If you were running the extension as a single file in
42
53
  > `~/.pi/agent/extensions/`, delete that file before installing this package. Both would load, and
43
54
  > two footers race for the same slot — the later one wins, which decides nothing useful.
@@ -50,17 +61,26 @@ Then reload pi in the running session with `/reload`, or start a new one.
50
61
 
51
62
  ## Layout
52
63
 
64
+ Two things are published from this repository: the collection, which is the repository root, and
65
+ each extension under `packages/`, on its own.
66
+
53
67
  ```
54
- extensions/ # each subdirectory is one extension; pi discovers <name>/index.ts
55
- statusline/
56
- index.ts # pi entry point: events and footer wiring
57
- render.ts # pure helpers: number formatting, the meter, row layout
58
- render.test.ts # unit tests for render.ts
68
+ package.json # the collection package, published as useful-pi-extensions
69
+ packages/
70
+ statusline/ # published as @reedchan/statusline
71
+ package.json
72
+ extensions/statusline/
73
+ index.ts # pi entry point: events and footer wiring
74
+ render.ts # pure helpers: number formatting, the meter, row layout
75
+ render.test.ts # unit tests for render.ts
76
+ tools/ # repository tooling: docs pairing, commit lint, publishing
59
77
  ```
60
78
 
61
- `package.json` declares `"pi": { "extensions": ["./extensions"] }` and carries the `pi-package`
62
- keyword so the package is discoverable. pi resolves the manifest, finds every
63
- `extensions/*/index.ts`, and loads it — no build step, no bundling.
79
+ The collection declares `"pi": { "extensions": ["packages/*/extensions"] }` and the individual
80
+ package declares `"./extensions"`. pi resolves the glob, finds every `extensions/<name>/index.ts`
81
+ and loads it — no build step, no bundling. Adding an extension is therefore adding a directory under
82
+ `packages/`: no manifest needs editing, because the collection's glob picks the new package up on
83
+ its own, and `make publish` discovers it the same way.
64
84
 
65
85
  ## Development
66
86
 
@@ -79,7 +99,7 @@ make help # list every target
79
99
  | Lint | `bun run lint` | oxlint in type-aware mode with `--deny-warnings`, so a warning fails the run |
80
100
  | Types | `bun run typecheck` | `--strict` plus `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes` and `verbatimModuleSyntax`, which is what keeps every file loadable by pi without a transform |
81
101
  | Docs | `bun run docs:check` | every `README.md` has a `README_CN.md` with the same heading sequence and a working language switcher |
82
- | Tests | `bun test` | the unit tests under `extensions/*/` |
102
+ | Tests | `bun test` | the unit tests under `packages/*/` |
83
103
 
84
104
  Commits follow [Conventional Commits](https://www.conventionalcommits.org/): the `commit-msg` hook
85
105
  rejects a subject that is not `type(scope): summary`, and the pre-commit hook formats and lints only
package/README_CN.md CHANGED
@@ -2,17 +2,21 @@
2
2
 
3
3
  [English](README.md) | 中文
4
4
 
5
- 一组 [pi](https://pi.dev) 扩展,一行命令即可安装。
5
+ 一组 [pi](https://pi.dev) 扩展,一行命令即可安装。其中的每个扩展也会单独发布,
6
+ 所以你要么整套装,要么只取其中一件。
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)
6
10
 
7
11
  ```sh
8
- pi install git:github.com/reedchan7/useful-pi-extensions
12
+ pi install npm:useful-pi-extensions
9
13
  ```
10
14
 
11
15
  ## 包含什么
12
16
 
13
- | 扩展 | 作用 |
14
- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
15
- | [`statusline`](extensions/statusline/README_CN.md) | 替换 pi 的 footer,改成带文字标签的两行:上下文压力用固定宽度仪表显示,外加缓存与花费、模型与思考等级,以及最近一轮的 TTFT 和解码速度(tok/s) |
17
+ | 扩展 | 作用 |
18
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
19
+ | [`@reedchan/statusline`](packages/statusline/README_CN.md) | 替换 pi 的 footer,改成带文字标签的两行:上下文压力用固定宽度仪表显示,外加本次会话的输入/输出 token 量、缓存命中率与花费(币种可配置)、模型与思考等级,以及最近一轮的 TTFT 和解码速度(tok/s) |
16
20
 
17
21
  footer 只能有一个扩展占用,所以 `statusline` 是**替换**而不是叠加。想回到 pi 原生 footer,移除本包再 `/reload`。
18
22
 
@@ -25,11 +29,14 @@ footer 只能有一个扩展占用,所以 `statusline` 是**替换**而不是
25
29
  ## 安装
26
30
 
27
31
  ```sh
28
- # 从仓库安装
29
- pi install git:github.com/reedchan7/useful-pi-extensions
32
+ # 合集:本仓库的全部扩展
33
+ pi install npm:useful-pi-extensions
34
+
35
+ # 只要其中一个扩展
36
+ pi install npm:@reedchan/statusline
30
37
 
31
- # 锁定标签
32
- pi install git:github.com/reedchan7/useful-pi-extensions@v0.1.0
38
+ # 从仓库安装并锁定标签
39
+ pi install git:github.com/reedchan7/useful-pi-extensions@v1.0.0
33
40
 
34
41
  # 从本地检出安装(开发用)
35
42
  pi install /absolute/path/to/useful-pi-extensions
@@ -37,6 +44,10 @@ pi install /absolute/path/to/useful-pi-extensions
37
44
 
38
45
  然后在运行中的会话里执行 `/reload`,或直接新开一个会话。
39
46
 
47
+ > **只装一个入口,不要两个都装。** 合集与单独包包含的是同一个扩展文件,
48
+ > 同时安装 `useful-pi-extensions` 与 `@reedchan/statusline` 会让它被加载两次。
49
+ > 这不会出错,但也没有任何好处。
50
+
40
51
  > **先删掉散装的那份。** 如果你之前把扩展作为单文件放在 `~/.pi/agent/extensions/`,
41
52
  > 安装本包之前请先删掉那个文件。两份会同时加载,两个 footer 抢同一个位置——
42
53
  > 后加载的赢,而谁后加载没有意义。
@@ -49,16 +60,24 @@ pi install /absolute/path/to/useful-pi-extensions
49
60
 
50
61
  ## 目录结构
51
62
 
63
+ 本仓库发布两样东西:合集,即仓库根目录;以及 `packages/` 下每个扩展各自成包。
64
+
52
65
  ```
53
- extensions/ # 每个子目录是一个扩展;pi 发现 <name>/index.ts
54
- statusline/
55
- index.ts # pi 入口:事件与 footer 接线
56
- render.ts # 纯函数:数字格式化、仪表、行布局
57
- render.test.ts # render.ts 的单元测试
66
+ package.json # 合集包,发布为 useful-pi-extensions
67
+ packages/
68
+ statusline/ # 发布为 @reedchan/statusline
69
+ package.json
70
+ extensions/statusline/
71
+ index.ts # pi 入口:事件与 footer 接线
72
+ render.ts # 纯函数:数字格式化、仪表、行布局
73
+ render.test.ts # render.ts 的单元测试
74
+ tools/ # 仓库工具:文档配对、提交信息校验、发布
58
75
  ```
59
76
 
60
- `package.json` 声明 `"pi": { "extensions": ["./extensions"] }`,并带 `pi-package` 关键字以便被发现。
61
- pi 读取该清单,找到每个 `extensions/*/index.ts` 并加载——无构建、无打包。
77
+ 合集声明 `"pi": { "extensions": ["packages/*/extensions"] }`,单独包声明 `"./extensions"`。
78
+ pi 解析该 glob,找到每个 `extensions/<name>/index.ts` 并加载——无构建、无打包。
79
+ 因此新增一个扩展就是新增一个 `packages/` 下的目录:**两个清单都不用改**,
80
+ 合集的 glob 会自动发现新包,`make publish` 也用同一套发现逻辑。
62
81
 
63
82
  ## 开发
64
83
 
@@ -77,7 +96,7 @@ make help # 列出所有目标
77
96
  | Lint | `bun run lint` | oxlint 类型感知模式 + `--deny-warnings`,即 **warning 也判失败** |
78
97
  | 类型 | `bun run typecheck` | `--strict` 以及 `noUncheckedIndexedAccess`、`exactOptionalPropertyTypes`、`verbatimModuleSyntax`,这正是保证每个文件无需转译就能被 pi 直接加载的原因 |
79
98
  | 文档 | `bun run docs:check` | 每个 `README.md` 都有 `README_CN.md`,标题层级序列一致且语言切换链接可用 |
80
- | 单测 | `bun test` | `extensions/*/` 下的单元测试 |
99
+ | 单测 | `bun test` | `packages/*/` 下的单元测试 |
81
100
 
82
101
  提交遵循 [Conventional Commits](https://www.conventionalcommits.org/):`commit-msg` 钩子会拒绝不符合
83
102
  `type(scope): summary` 的标题,pre-commit 钩子只对暂存文件做格式化与 lint。工具链在 `package.json`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "useful-pi-extensions",
3
- "version": "0.1.0",
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,13 +19,17 @@
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"
25
26
  },
27
+ "workspaces": [
28
+ "packages/*"
29
+ ],
26
30
  "files": [
27
- "extensions",
28
- "!extensions/**/*.test.ts",
31
+ "packages/*/extensions/**",
32
+ "!packages/*/extensions/**/*.test.ts",
29
33
  "README.md",
30
34
  "README_CN.md",
31
35
  "LICENSE"
@@ -38,7 +42,7 @@
38
42
  "fmt:check": "oxfmt --check .",
39
43
  "lint": "oxlint --deny-warnings .",
40
44
  "lint:fix": "oxlint --fix .",
41
- "prepublishOnly": "bun run check",
45
+ "publish:changed": "bun tools/publish-changed/src/index.ts",
42
46
  "test": "bun test",
43
47
  "test:changed": "git rev-parse --verify --quiet origin/main >/dev/null && bun test --changed=origin/main || bun test",
44
48
  "typecheck": "tsc -p tsconfig.json"
@@ -53,16 +57,12 @@
53
57
  "oxlint-tsgolint": "7.0.2002",
54
58
  "typescript": "7.0.2"
55
59
  },
56
- "peerDependencies": {
57
- "@earendil-works/pi-coding-agent": "*",
58
- "@earendil-works/pi-tui": "*"
59
- },
60
60
  "engines": {
61
61
  "bun": ">=1.4.0"
62
62
  },
63
63
  "pi": {
64
64
  "extensions": [
65
- "./extensions"
65
+ "packages/*/extensions"
66
66
  ],
67
67
  "image": "https://github.com/user-attachments/assets/7ae64213-cb05-4440-835e-a633796a87f9"
68
68
  }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 reedchan7
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -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 Cache 38M · Hit 100.0% · Cost $0.56
10
- ~/.pi (master) deepseek-flash · Effort high · TTFT 482ms · 729 tok/s
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. Dropped first when the terminal is narrow |
20
- | `Cache` / `Hit` / `Cost` | Cumulative prompt-cache reads, the latest turn's cache hit rate (`cacheRead / (input + cacheRead + cacheWrite)`), and session cost |
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
@@ -81,7 +116,11 @@ So the rate excludes prefill and time-to-first-token, which is the industry-stan
81
116
  ## Install
82
117
 
83
118
  ```sh
84
- pi install git:github.com/reedchan7/useful-pi-extensions
119
+ # on its own
120
+ pi install npm:@reedchan/statusline
121
+
122
+ # or as part of the collection, which installs every extension in the repository
123
+ pi install npm:useful-pi-extensions
85
124
  ```
86
125
 
87
126
  Then `/reload`.
@@ -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 Cache 38M · Hit 100.0% · Cost $0.56
9
- ~/.pi (master) deepseek-flash · Effort high · TTFT 482ms · 729 tok/s
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
- | `Cache` / `Hit` / `Cost` | 累计 prompt cache 读取量、最近一轮缓存命中率(`cacheRead / (input + cacheRead + cacheWrite)`)、会话花费 |
20
- | Effort | 当前思考等级 |
21
- | `TTFT` | 从发出请求到第一个流式 token 的耗时 |
22
- | `tok/s` | 解码吞吐,即每秒解码时间产出的输出 token 数 |
23
- | 最后一行 | 其他扩展通过 `ctx.ui.setStatus()` 设的状态,否则它们会静默消失 |
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 契约
@@ -71,7 +104,11 @@ tok/s = usage.output / (decodeMs / 1000)
71
104
  ## 安装
72
105
 
73
106
  ```sh
74
- pi install git:github.com/reedchan7/useful-pi-extensions
107
+ # 单独安装
108
+ pi install npm:@reedchan/statusline
109
+
110
+ # 或作为合集的一部分安装,会装上本仓库的全部扩展
111
+ pi install npm:useful-pi-extensions
75
112
  ```
76
113
 
77
114
  然后 `/reload`。
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * Design notes:
7
7
  *
8
- * - Every value carries a word ("Context", "Cache", "Cost", "Effort"), not a symbol: icon-only labels
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
- bar,
30
- BAR_CELLS,
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 percent = usage?.percent ?? null
188
- const fraction = percent === null ? 0 : percent / 100
189
-
190
- // Row 1 right: cache and cost, words instead of symbols.
191
- const row1Parts: string[] = []
192
- if (totals.cacheRead > 0)
193
- row1Parts.push(pair(theme, 'Cache', formatTokens(totals.cacheRead)))
194
- if (totals.cacheHitRate !== null) {
195
- row1Parts.push(pair(theme, 'Hit', `${totals.cacheHitRate.toFixed(1)}%`))
196
- }
197
- if (totals.cost > 0) row1Parts.push(pair(theme, 'Cost', formatCost(totals.cost)))
198
- const row1Right = row1Parts.join(theme.fg('dim', ' · '))
199
-
200
- // Row 1 left: fixed-size meter, percentage as the hero, absolute figures
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, and the status-noise filter.
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
- export function formatCost(cost: number): string {
84
- return `$${cost.toFixed(3).replace(/0$/, '')}`
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)}`