useful-pi-extensions 0.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/LICENSE +21 -0
- package/README.md +92 -0
- package/README_CN.md +89 -0
- package/extensions/statusline/README.md +87 -0
- package/extensions/statusline/README_CN.md +77 -0
- package/extensions/statusline/index.ts +331 -0
- package/extensions/statusline/render.ts +157 -0
- package/package.json +69 -0
package/LICENSE
ADDED
|
@@ -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.
|
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# useful-pi-extensions
|
|
2
|
+
|
|
3
|
+
English | [中文](README_CN.md)
|
|
4
|
+
|
|
5
|
+
A small collection of [pi](https://pi.dev) extensions, installed with one command.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pi install git:github.com/reedchan7/useful-pi-extensions
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What is in here
|
|
12
|
+
|
|
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 |
|
|
16
|
+
|
|
17
|
+
Only one extension owns the footer, so `statusline` is a complete replacement rather than an
|
|
18
|
+
addition. If you want pi's stock footer back, remove this package and `/reload`.
|
|
19
|
+
|
|
20
|
+
## Screenshots
|
|
21
|
+
|
|
22
|
+
### statusline
|
|
23
|
+
|
|
24
|
+
<img width="897" height="45" alt="Warp 2026-09-18 16 20 27" src="https://github.com/user-attachments/assets/7ae64213-cb05-4440-835e-a633796a87f9" />
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
# from the repository
|
|
30
|
+
pi install git:github.com/reedchan7/useful-pi-extensions
|
|
31
|
+
|
|
32
|
+
# pinned to a tag
|
|
33
|
+
pi install git:github.com/reedchan7/useful-pi-extensions@v0.1.0
|
|
34
|
+
|
|
35
|
+
# from a local checkout (development)
|
|
36
|
+
pi install /absolute/path/to/useful-pi-extensions
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then reload pi in the running session with `/reload`, or start a new one.
|
|
40
|
+
|
|
41
|
+
> **Removing a loose copy first.** If you were running the extension as a single file in
|
|
42
|
+
> `~/.pi/agent/extensions/`, delete that file before installing this package. Both would load, and
|
|
43
|
+
> two footers race for the same slot — the later one wins, which decides nothing useful.
|
|
44
|
+
|
|
45
|
+
## Requirements
|
|
46
|
+
|
|
47
|
+
- pi 0.85 or newer (`pi --version`)
|
|
48
|
+
- No runtime dependencies. `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` are
|
|
49
|
+
peer dependencies supplied by pi itself.
|
|
50
|
+
|
|
51
|
+
## Layout
|
|
52
|
+
|
|
53
|
+
```
|
|
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
|
|
59
|
+
```
|
|
60
|
+
|
|
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.
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
make install # bun install
|
|
69
|
+
make check # every gate: format, lint, types, docs pairing, tests
|
|
70
|
+
make hooks # install the git hooks (run once, needs a git checkout)
|
|
71
|
+
make help # list every target
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`make check` runs the same gates as CI:
|
|
75
|
+
|
|
76
|
+
| Gate | Command | What it enforces |
|
|
77
|
+
| ------ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
78
|
+
| Format | `bun run fmt:check` | oxfmt: 100 columns, single quotes, no semicolons |
|
|
79
|
+
| Lint | `bun run lint` | oxlint in type-aware mode with `--deny-warnings`, so a warning fails the run |
|
|
80
|
+
| Types | `bun run typecheck` | `--strict` plus `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes` and `verbatimModuleSyntax`, which is what keeps every file loadable by pi without a transform |
|
|
81
|
+
| 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/*/` |
|
|
83
|
+
|
|
84
|
+
Commits follow [Conventional Commits](https://www.conventionalcommits.org/): the `commit-msg` hook
|
|
85
|
+
rejects a subject that is not `type(scope): summary`, and the pre-commit hook formats and lints only
|
|
86
|
+
the staged files. The toolchain is pinned to exact versions in `package.json` (`bunfig.toml` sets
|
|
87
|
+
`install.exact = true`). The `@earendil-works/*` packages are deliberately left at `*`: they are the
|
|
88
|
+
host API pi supplies, and tracking it is what surfaces an upstream break early.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT. See [LICENSE](LICENSE).
|
package/README_CN.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# useful-pi-extensions
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
一组 [pi](https://pi.dev) 扩展,一行命令即可安装。
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pi install git:github.com/reedchan7/useful-pi-extensions
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 包含什么
|
|
12
|
+
|
|
13
|
+
| 扩展 | 作用 |
|
|
14
|
+
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
15
|
+
| [`statusline`](extensions/statusline/README_CN.md) | 替换 pi 的 footer,改成带文字标签的两行:上下文压力用固定宽度仪表显示,外加缓存与花费、模型与思考等级,以及最近一轮的 TTFT 和解码速度(tok/s) |
|
|
16
|
+
|
|
17
|
+
footer 只能有一个扩展占用,所以 `statusline` 是**替换**而不是叠加。想回到 pi 原生 footer,移除本包再 `/reload`。
|
|
18
|
+
|
|
19
|
+
## 效果图
|
|
20
|
+
|
|
21
|
+
### statusline
|
|
22
|
+
|
|
23
|
+
<img width="897" height="45" alt="Warp 2026-09-18 16 20 27" src="https://github.com/user-attachments/assets/7ae64213-cb05-4440-835e-a633796a87f9" />
|
|
24
|
+
|
|
25
|
+
## 安装
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
# 从仓库安装
|
|
29
|
+
pi install git:github.com/reedchan7/useful-pi-extensions
|
|
30
|
+
|
|
31
|
+
# 锁定标签
|
|
32
|
+
pi install git:github.com/reedchan7/useful-pi-extensions@v0.1.0
|
|
33
|
+
|
|
34
|
+
# 从本地检出安装(开发用)
|
|
35
|
+
pi install /absolute/path/to/useful-pi-extensions
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
然后在运行中的会话里执行 `/reload`,或直接新开一个会话。
|
|
39
|
+
|
|
40
|
+
> **先删掉散装的那份。** 如果你之前把扩展作为单文件放在 `~/.pi/agent/extensions/`,
|
|
41
|
+
> 安装本包之前请先删掉那个文件。两份会同时加载,两个 footer 抢同一个位置——
|
|
42
|
+
> 后加载的赢,而谁后加载没有意义。
|
|
43
|
+
|
|
44
|
+
## 环境要求
|
|
45
|
+
|
|
46
|
+
- pi 0.85 或更新(`pi --version`)
|
|
47
|
+
- 无运行期依赖。`@earendil-works/pi-coding-agent` 与 `@earendil-works/pi-tui` 是 peer
|
|
48
|
+
dependency,由 pi 自身提供。
|
|
49
|
+
|
|
50
|
+
## 目录结构
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
extensions/ # 每个子目录是一个扩展;pi 发现 <name>/index.ts
|
|
54
|
+
statusline/
|
|
55
|
+
index.ts # pi 入口:事件与 footer 接线
|
|
56
|
+
render.ts # 纯函数:数字格式化、仪表、行布局
|
|
57
|
+
render.test.ts # render.ts 的单元测试
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`package.json` 声明 `"pi": { "extensions": ["./extensions"] }`,并带 `pi-package` 关键字以便被发现。
|
|
61
|
+
pi 读取该清单,找到每个 `extensions/*/index.ts` 并加载——无构建、无打包。
|
|
62
|
+
|
|
63
|
+
## 开发
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
make install # bun install
|
|
67
|
+
make check # 全部门禁:格式、lint、类型、文档配对、单测
|
|
68
|
+
make hooks # 安装 git hooks(只需一次,需要已 git init)
|
|
69
|
+
make help # 列出所有目标
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`make check` 与 CI 跑同一套门禁:
|
|
73
|
+
|
|
74
|
+
| 门禁 | 命令 | 强制内容 |
|
|
75
|
+
| ---- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
76
|
+
| 格式 | `bun run fmt:check` | oxfmt:100 列、单引号、无分号 |
|
|
77
|
+
| Lint | `bun run lint` | oxlint 类型感知模式 + `--deny-warnings`,即 **warning 也判失败** |
|
|
78
|
+
| 类型 | `bun run typecheck` | `--strict` 以及 `noUncheckedIndexedAccess`、`exactOptionalPropertyTypes`、`verbatimModuleSyntax`,这正是保证每个文件无需转译就能被 pi 直接加载的原因 |
|
|
79
|
+
| 文档 | `bun run docs:check` | 每个 `README.md` 都有 `README_CN.md`,标题层级序列一致且语言切换链接可用 |
|
|
80
|
+
| 单测 | `bun test` | `extensions/*/` 下的单元测试 |
|
|
81
|
+
|
|
82
|
+
提交遵循 [Conventional Commits](https://www.conventionalcommits.org/):`commit-msg` 钩子会拒绝不符合
|
|
83
|
+
`type(scope): summary` 的标题,pre-commit 钩子只对暂存文件做格式化与 lint。工具链在 `package.json`
|
|
84
|
+
中锁定精确版本(`bunfig.toml` 设了 `install.exact = true`)。`@earendil-works/*` 刻意保留为 `*`:
|
|
85
|
+
它们是 pi 提供的宿主 API,跟随它才能尽早发现上游变更。
|
|
86
|
+
|
|
87
|
+
## 许可
|
|
88
|
+
|
|
89
|
+
MIT,见 [LICENSE](LICENSE)。
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# statusline
|
|
2
|
+
|
|
3
|
+
English | [中文](README_CN.md)
|
|
4
|
+
|
|
5
|
+
Replaces pi's footer with a labelled two-row one. Every value carries a word, so nothing has to be
|
|
6
|
+
decoded from a symbol or remembered from a legend.
|
|
7
|
+
|
|
8
|
+
```
|
|
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
|
+
LSP Active: typescript
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## What each part is
|
|
15
|
+
|
|
16
|
+
| Part | Meaning |
|
|
17
|
+
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
18
|
+
| `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 |
|
|
21
|
+
| Effort | The active thinking level |
|
|
22
|
+
| `TTFT` | Time from request dispatch to the first streamed token |
|
|
23
|
+
| `tok/s` | Decode throughput, i.e. output tokens per second of decode time |
|
|
24
|
+
| Last line | Other extensions' `ctx.ui.setStatus()` entries, so they do not silently disappear |
|
|
25
|
+
|
|
26
|
+
## The meter
|
|
27
|
+
|
|
28
|
+
The meter is a **fixed 20 cells** and never stretches to the terminal. That is the one structural rule
|
|
29
|
+
taken from [cli-progress](https://www.npmjs.com/package/cli-progress)'s `shades_classic` preset
|
|
30
|
+
(≈10M downloads/week): its bar has a fixed `barsize` and is never widened to fill the row. An earlier
|
|
31
|
+
revision here did stretch it, which made the meter read as chrome rather than as an instrument.
|
|
32
|
+
|
|
33
|
+
The glyphs are that preset's block fill and shaded block track, with a 1/8-cell leading edge
|
|
34
|
+
(`▏▎▍▌▋▊▉`) so the fill grows smoothly instead of jumping a whole cell at a time.
|
|
35
|
+
|
|
36
|
+
Configuration lives at the top of [`render.ts`](render.ts):
|
|
37
|
+
|
|
38
|
+
| Constant | Default | Notes |
|
|
39
|
+
| -------------- | ------- | --------------------------------------------------------------------------------- |
|
|
40
|
+
| `BAR_CELLS` | `20` | Meter width. Narrower for a quieter footer, wider for more resolution |
|
|
41
|
+
| `BAR_FILL` | `█` | Fill glyph |
|
|
42
|
+
| `BAR_TRACK` | `░` | Set to `""` for a trackless meter, or `"─"` for a hairline one |
|
|
43
|
+
| `QUIET_STATUS` | pi-lens | `[statusKey, pattern]` pairs whose matching text is hidden as "nothing to report" |
|
|
44
|
+
|
|
45
|
+
## Metrics
|
|
46
|
+
|
|
47
|
+
Throughput and latency follow the
|
|
48
|
+
[deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) turn-metrics contract
|
|
49
|
+
(`packages/client/ui-chat/src/client/contract/turn-metrics.ts`), including its number formatting:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
ttftMs = firstTokenTime - stepStartTime
|
|
53
|
+
decodeMs = completedTime - firstTokenTime
|
|
54
|
+
tok/s = usage.output / (decodeMs / 1000)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
So the rate excludes prefill and time-to-first-token, which is the industry-standard
|
|
58
|
+
"output tokens per second" figure.
|
|
59
|
+
|
|
60
|
+
- **Final values are exact.** They use the provider's reported `usage.output` over the measured
|
|
61
|
+
decode window.
|
|
62
|
+
- **Live values are estimates**, marked with `~`. Providers report usage only on the final stream
|
|
63
|
+
chunk, so the streaming token count is inferred from streamed characters scaled by a
|
|
64
|
+
tokens-per-character ratio learned from this session's own reported usage.
|
|
65
|
+
- **TTFT is exact as soon as the first token arrives** and never changes for that turn.
|
|
66
|
+
- The reading appears after the first turn completes in the session, because it can only come from
|
|
67
|
+
streamed events.
|
|
68
|
+
|
|
69
|
+
## Requirements and limits
|
|
70
|
+
|
|
71
|
+
- One extension owns the footer. `ctx.ui.setFooter()` is exclusive, so installing another footer
|
|
72
|
+
extension alongside this one is a race, not a merge. This extension stands down if
|
|
73
|
+
[pi-fancy-footer](https://github.com/mavam/pi-fancy-footer) announces itself, so the two coexist
|
|
74
|
+
without fighting.
|
|
75
|
+
- The footer renders on `ctx.sessionManager.getEntries()` and `ctx.getContextUsage()`, both public
|
|
76
|
+
API; nothing reaches into pi's internals.
|
|
77
|
+
- Provider status such as `MCP: 2 servers enabled` is informational and can be turned off at its
|
|
78
|
+
source: `settings.mcpFooterStatus` in `~/.pi/agent/mcp.json`. `LSP Inactive` from pi-lens has no
|
|
79
|
+
such setting, so it is filtered here as a "quiet" status.
|
|
80
|
+
|
|
81
|
+
## Install
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
pi install git:github.com/reedchan7/useful-pi-extensions
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Then `/reload`.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# statusline
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
替换 pi 的 footer,改成带文字标签的两行。每个值都带一个词,不需要靠符号猜、也不需要记图例。
|
|
6
|
+
|
|
7
|
+
```
|
|
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
|
+
LSP Active: typescript
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 每一项是什么
|
|
14
|
+
|
|
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()` 设的状态,否则它们会静默消失 |
|
|
24
|
+
|
|
25
|
+
## 关于这个仪表
|
|
26
|
+
|
|
27
|
+
仪表**固定 20 格,永不拉伸到终端宽度**。这是从 [cli-progress](https://www.npmjs.com/package/cli-progress)
|
|
28
|
+
的 `shades_classic` 预设(约 1000 万次/周下载)里抄来的唯一一条结构性规则:它的进度条有固定 `barsize`,
|
|
29
|
+
永远不会被撑满整行。之前的版本正是撑满了整行,于是它读起来像"装饰线"而不是"仪表"。
|
|
30
|
+
|
|
31
|
+
字形也来自那个预设(实心块填充 + 阴影块轨道),额外加了 1/8 格收尾(`▏▎▍▌▋▊▉`),
|
|
32
|
+
让填充平滑增长,而不是一次跳一整格。
|
|
33
|
+
|
|
34
|
+
可调项在 [`render.ts`](render.ts) 顶部:
|
|
35
|
+
|
|
36
|
+
| 常量 | 默认值 | 说明 |
|
|
37
|
+
| -------------- | ------- | ------------------------------------------------------- |
|
|
38
|
+
| `BAR_CELLS` | `20` | 仪表宽度。想更安静就调窄,想要更高分辨率就调宽 |
|
|
39
|
+
| `BAR_FILL` | `█` | 填充字形 |
|
|
40
|
+
| `BAR_TRACK` | `░` | 设为 `""` 得到无轨道仪表,设为 `"─"` 得到细线轨道 |
|
|
41
|
+
| `QUIET_STATUS` | pi-lens | `[状态键, 正则]` 列表,匹配到的文本视为"无事发生"而隐藏 |
|
|
42
|
+
|
|
43
|
+
## 指标口径
|
|
44
|
+
|
|
45
|
+
吞吐与延迟遵循 [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) 的 turn-metrics 契约
|
|
46
|
+
(`packages/client/ui-chat/src/client/contract/turn-metrics.ts`),数字格式也照抄:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
ttftMs = firstTokenTime - stepStartTime
|
|
50
|
+
decodeMs = completedTime - firstTokenTime
|
|
51
|
+
tok/s = usage.output / (decodeMs / 1000)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
所以速率**不含 prefill 和首 token 延迟**,这是业界标准的"输出 token/秒"。
|
|
55
|
+
|
|
56
|
+
- **回合结束后是精确值**:用 provider 上报的 `usage.output` 除以实测解码窗口。
|
|
57
|
+
- **流式期间是估算值**,前面带 `~`。provider 只在最后一个流式分片才回 usage,所以流式期间的 token 数
|
|
58
|
+
由已流出字符数乘以"每字符 token 比"推断,该比值从本会话自己上报的 usage 学到。
|
|
59
|
+
- **TTFT 在首个 token 到达时就精确**,那一轮内不再变化。
|
|
60
|
+
- 读数在会话中**第一轮结束后**才出现,因为它只能来自流式事件。
|
|
61
|
+
|
|
62
|
+
## 要求与限制
|
|
63
|
+
|
|
64
|
+
- footer 只能有一个扩展占用。`ctx.ui.setFooter()` 是排他的,所以再装一个 footer 扩展是**竞争**而不是叠加。
|
|
65
|
+
本扩展在检测到 [pi-fancy-footer](https://github.com/mavam/pi-fancy-footer) 时会主动让位,两者不会互相打架。
|
|
66
|
+
- footer 只读取 `ctx.sessionManager.getEntries()` 和 `ctx.getContextUsage()`,都是公开 API,
|
|
67
|
+
没有触碰 pi 内部实现。
|
|
68
|
+
- `MCP: 2 servers enabled` 这类信息性状态可以在源头关掉:`~/.pi/agent/mcp.json` 的
|
|
69
|
+
`settings.mcpFooterStatus`。pi-lens 的 `LSP Inactive` 没有类似开关,所以在本地按"安静状态"过滤。
|
|
70
|
+
|
|
71
|
+
## 安装
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
pi install git:github.com/reedchan7/useful-pi-extensions
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
然后 `/reload`。
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status line extension — replaces pi's footer with a labelled two-row one.
|
|
3
|
+
*
|
|
4
|
+
* See README.md for the rendered layout and what each part means.
|
|
5
|
+
*
|
|
6
|
+
* Design notes:
|
|
7
|
+
*
|
|
8
|
+
* - Every value carries a word ("Context", "Cache", "Cost", "Effort"), not a symbol: icon-only labels
|
|
9
|
+
* are unreadable at a glance.
|
|
10
|
+
* - Keys render dim and values render brighter, so a key/value pair reads as one unit instead of as a
|
|
11
|
+
* run of equal-weight tokens.
|
|
12
|
+
* - The meter is a FIXED 20 cells and never stretches to the terminal, so it reads as an instrument
|
|
13
|
+
* instead of as chrome. render.ts records where the idiom is from.
|
|
14
|
+
* - Other extensions' ctx.ui.setStatus() entries are still rendered, or they would silently disappear
|
|
15
|
+
* from the footer.
|
|
16
|
+
*
|
|
17
|
+
* Throughput follows the deepseek-harness turn-metrics contract: ttftMs = firstTokenTime -
|
|
18
|
+
* stepStartTime; decodeMs = completedTime - firstTokenTime; tok/s = usage.output / (decodeMs / 1000)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
ExtensionAPI,
|
|
23
|
+
ExtensionContext,
|
|
24
|
+
ReadonlyFooterDataProvider,
|
|
25
|
+
} from '@earendil-works/pi-coding-agent'
|
|
26
|
+
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
bar,
|
|
30
|
+
BAR_CELLS,
|
|
31
|
+
formatCost,
|
|
32
|
+
formatCwd,
|
|
33
|
+
formatLatency,
|
|
34
|
+
formatTokens,
|
|
35
|
+
formatTps,
|
|
36
|
+
isQuietStatus,
|
|
37
|
+
pair,
|
|
38
|
+
percentColor,
|
|
39
|
+
row,
|
|
40
|
+
shortenPath,
|
|
41
|
+
} from './render.ts'
|
|
42
|
+
|
|
43
|
+
const LIVE_RENDER_MS = 200
|
|
44
|
+
const WINDOW_MS = 2000
|
|
45
|
+
const MIN_SAMPLE_MS = 200
|
|
46
|
+
/** Pi's own estimateTokens() heuristic, used only until a real ratio is known. */
|
|
47
|
+
const FALLBACK_TOKENS_PER_CHAR = 0.25
|
|
48
|
+
|
|
49
|
+
/** A content block as providers stream it; every field is read defensively. */
|
|
50
|
+
type ContentBlock = {
|
|
51
|
+
type?: unknown
|
|
52
|
+
text?: unknown
|
|
53
|
+
thinking?: unknown
|
|
54
|
+
name?: unknown
|
|
55
|
+
arguments?: unknown
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isContentBlock(value: unknown): value is ContentBlock {
|
|
59
|
+
return typeof value === 'object' && value !== null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function blockChars(content: unknown): number {
|
|
63
|
+
if (typeof content === 'string') return content.length
|
|
64
|
+
if (!Array.isArray(content)) return 0
|
|
65
|
+
|
|
66
|
+
let chars = 0
|
|
67
|
+
for (const raw of content as unknown[]) {
|
|
68
|
+
if (!isContentBlock(raw)) continue
|
|
69
|
+
const { type, text, thinking, name, arguments: args } = raw
|
|
70
|
+
if (type === 'text' && typeof text === 'string') chars += text.length
|
|
71
|
+
else if (type === 'thinking' && typeof thinking === 'string') chars += thinking.length
|
|
72
|
+
else if (type === 'toolCall') {
|
|
73
|
+
const nameLength = typeof name === 'string' ? name.length : 0
|
|
74
|
+
chars += nameLength + JSON.stringify(args ?? {}).length
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return chars
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface Totals {
|
|
81
|
+
input: number
|
|
82
|
+
output: number
|
|
83
|
+
cacheRead: number
|
|
84
|
+
cacheWrite: number
|
|
85
|
+
cost: number
|
|
86
|
+
cacheHitRate: number | null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The usage fields this footer totals, read structurally so no cast is needed. */
|
|
90
|
+
type UsageTotals = {
|
|
91
|
+
input: number
|
|
92
|
+
output: number
|
|
93
|
+
cacheRead: number
|
|
94
|
+
cacheWrite: number
|
|
95
|
+
cost: { total: number }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function collectTotals(ctx: ExtensionContext): Totals {
|
|
99
|
+
const totals: Totals = {
|
|
100
|
+
input: 0,
|
|
101
|
+
output: 0,
|
|
102
|
+
cacheRead: 0,
|
|
103
|
+
cacheWrite: 0,
|
|
104
|
+
cost: 0,
|
|
105
|
+
cacheHitRate: null,
|
|
106
|
+
}
|
|
107
|
+
// `usage` stays optional here even though an assistant message always carries it:
|
|
108
|
+
// a truncated or hand-edited session file is the case this guard is for.
|
|
109
|
+
const add = (usage: UsageTotals | undefined, assistant: boolean): void => {
|
|
110
|
+
if (!usage) return
|
|
111
|
+
totals.input += usage.input
|
|
112
|
+
totals.output += usage.output
|
|
113
|
+
totals.cacheRead += usage.cacheRead
|
|
114
|
+
totals.cacheWrite += usage.cacheWrite
|
|
115
|
+
totals.cost += usage.cost.total
|
|
116
|
+
if (!assistant) return
|
|
117
|
+
const prompt = usage.input + usage.cacheRead + usage.cacheWrite
|
|
118
|
+
if (prompt > 0) totals.cacheHitRate = (usage.cacheRead / prompt) * 100
|
|
119
|
+
}
|
|
120
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
121
|
+
if (entry.type === 'message') {
|
|
122
|
+
const { message } = entry
|
|
123
|
+
if (message.role === 'assistant') add(message.usage, true)
|
|
124
|
+
else if (message.role === 'toolResult') add(message.usage, false)
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
// Compaction and branch summaries bill a model call of their own.
|
|
128
|
+
if (entry.type === 'compaction' || entry.type === 'branch_summary') add(entry.usage, false)
|
|
129
|
+
}
|
|
130
|
+
return totals
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Tokens per character, averaged over recent assistant messages with provider usage. */
|
|
134
|
+
function seedRatio(ctx: ExtensionContext): number | null {
|
|
135
|
+
const samples: number[] = []
|
|
136
|
+
for (const entry of ctx.sessionManager.getBranch().toReversed()) {
|
|
137
|
+
if (entry.type !== 'message' || entry.message.role !== 'assistant') continue
|
|
138
|
+
const output = entry.message.usage.output
|
|
139
|
+
const chars = blockChars(entry.message.content)
|
|
140
|
+
if (output <= 0 || chars <= 0) continue
|
|
141
|
+
samples.push(output / chars)
|
|
142
|
+
if (samples.length >= 5) break
|
|
143
|
+
}
|
|
144
|
+
if (samples.length === 0) return null
|
|
145
|
+
return samples.reduce((sum, value) => sum + value, 0) / samples.length
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export default function (pi: ExtensionAPI) {
|
|
149
|
+
let ratio: number | null = null
|
|
150
|
+
let requestRender: (() => void) | null = null
|
|
151
|
+
|
|
152
|
+
// Latest turn reading: { rate, exact, ttftMs }
|
|
153
|
+
let reading: { rate: number; exact: boolean; ttftMs: number | null } | null = null
|
|
154
|
+
|
|
155
|
+
let chars = 0
|
|
156
|
+
let stepStartedAt = 0
|
|
157
|
+
let firstTokenAt: number | null = null
|
|
158
|
+
let windowAt = 0
|
|
159
|
+
let windowTokens = 0
|
|
160
|
+
let renderedAt = 0
|
|
161
|
+
|
|
162
|
+
function resetStream(): void {
|
|
163
|
+
chars = 0
|
|
164
|
+
firstTokenAt = null
|
|
165
|
+
windowAt = 0
|
|
166
|
+
windowTokens = 0
|
|
167
|
+
renderedAt = 0
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function publish(rate: number, exact: boolean, ttftMs: number | null): void {
|
|
171
|
+
reading = { rate, exact, ttftMs }
|
|
172
|
+
requestRender?.()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function installFooter(ctx: ExtensionContext): void {
|
|
176
|
+
ctx.ui.setFooter((tui, theme, footerData: ReadonlyFooterDataProvider) => {
|
|
177
|
+
requestRender = () => tui.requestRender()
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
invalidate() {},
|
|
181
|
+
dispose() {
|
|
182
|
+
requestRender = null
|
|
183
|
+
},
|
|
184
|
+
render(width: number): string[] {
|
|
185
|
+
const usage = ctx.getContextUsage()
|
|
186
|
+
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
|
|
212
|
+
|
|
213
|
+
// Row 2: model and the latest turn's timing on the right, path on the left.
|
|
214
|
+
const model = ctx.model?.id ?? 'no model'
|
|
215
|
+
const row2Parts = [theme.fg('accent', model)]
|
|
216
|
+
if (ctx.thinkingLevel) row2Parts.push(pair(theme, 'Effort', ctx.thinkingLevel, 'muted'))
|
|
217
|
+
if (reading) {
|
|
218
|
+
if (reading.ttftMs !== null)
|
|
219
|
+
row2Parts.push(pair(theme, 'TTFT', formatLatency(reading.ttftMs), 'muted'))
|
|
220
|
+
row2Parts.push(
|
|
221
|
+
theme.fg(
|
|
222
|
+
reading.exact ? 'success' : 'dim',
|
|
223
|
+
`${reading.exact ? '' : '~'}${formatTps(reading.rate)} tok/s`,
|
|
224
|
+
),
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
const row2Right = row2Parts.join(theme.fg('dim', ' · '))
|
|
228
|
+
|
|
229
|
+
const branch = footerData.getGitBranch()
|
|
230
|
+
const path = formatCwd(ctx.cwd)
|
|
231
|
+
const branchSuffix = branch ? ` (${branch})` : ''
|
|
232
|
+
// The model and the live reading matter more than the full path.
|
|
233
|
+
const rightW2 = visibleWidth(row2Right)
|
|
234
|
+
const room = width - rightW2 - 2
|
|
235
|
+
let pwdPlain = ''
|
|
236
|
+
if (room >= 10) {
|
|
237
|
+
if (path.length + branchSuffix.length <= room) {
|
|
238
|
+
pwdPlain = path + branchSuffix
|
|
239
|
+
} else {
|
|
240
|
+
// Shorten the path first; drop the branch only if it still will not fit.
|
|
241
|
+
const withBranch =
|
|
242
|
+
shortenPath(path, Math.max(1, room - branchSuffix.length)) + branchSuffix
|
|
243
|
+
pwdPlain = withBranch.length <= room ? withBranch : shortenPath(path, room)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const row2Left = pwdPlain ? theme.fg('muted', pwdPlain) : ''
|
|
247
|
+
|
|
248
|
+
const lines = [
|
|
249
|
+
row(theme, width, row1Left, row1Right),
|
|
250
|
+
row(theme, width, row2Left, row2Right),
|
|
251
|
+
]
|
|
252
|
+
|
|
253
|
+
// Other extensions' status entries still belong on screen, minus the
|
|
254
|
+
// ones that only report that nothing is happening.
|
|
255
|
+
const visibleStatuses = [...footerData.getExtensionStatuses().entries()]
|
|
256
|
+
.filter(([key, value]) => !isQuietStatus(key, value))
|
|
257
|
+
.toSorted(([a], [b]) => a.localeCompare(b))
|
|
258
|
+
.map(([, value]) => value.replace(/[\r\n\t]+/g, ' ').trim())
|
|
259
|
+
.join(' ')
|
|
260
|
+
if (visibleStatuses)
|
|
261
|
+
lines.push(truncateToWidth(visibleStatuses, width, theme.fg('dim', '...')))
|
|
262
|
+
return lines
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
269
|
+
ratio = seedRatio(ctx)
|
|
270
|
+
reading = null
|
|
271
|
+
resetStream()
|
|
272
|
+
installFooter(ctx)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
pi.on('message_start', async (event) => {
|
|
276
|
+
if (event.message.role !== 'assistant') return
|
|
277
|
+
resetStream()
|
|
278
|
+
stepStartedAt = Date.now()
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
pi.on('message_update', async (event) => {
|
|
282
|
+
const delta = event.assistantMessageEvent
|
|
283
|
+
// Narrowing on the discriminant is what keeps the payload typed; membership in
|
|
284
|
+
// a Set of strings tells the compiler nothing.
|
|
285
|
+
if (
|
|
286
|
+
delta.type !== 'text_delta' &&
|
|
287
|
+
delta.type !== 'thinking_delta' &&
|
|
288
|
+
delta.type !== 'toolcall_delta'
|
|
289
|
+
) {
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const now = Date.now()
|
|
294
|
+
if (firstTokenAt === null) firstTokenAt = now
|
|
295
|
+
chars += delta.delta.length
|
|
296
|
+
|
|
297
|
+
const tokens = chars * (ratio ?? FALLBACK_TOKENS_PER_CHAR)
|
|
298
|
+
if (windowAt === 0 || now - windowAt > WINDOW_MS) {
|
|
299
|
+
windowAt = now
|
|
300
|
+
windowTokens = tokens
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const decodeMs = now - firstTokenAt
|
|
304
|
+
if (decodeMs < MIN_SAMPLE_MS || now - renderedAt < LIVE_RENDER_MS) return
|
|
305
|
+
|
|
306
|
+
const windowMs = now - windowAt
|
|
307
|
+
const rate =
|
|
308
|
+
windowMs > 0 ? ((tokens - windowTokens) / windowMs) * 1000 : (tokens / decodeMs) * 1000
|
|
309
|
+
renderedAt = now
|
|
310
|
+
publish(rate, false, firstTokenAt - stepStartedAt)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
pi.on('message_end', async (event) => {
|
|
314
|
+
if (event.message.role !== 'assistant') return
|
|
315
|
+
|
|
316
|
+
const message = event.message as { content: unknown; usage?: { output?: number } }
|
|
317
|
+
const output = message.usage?.output ?? 0
|
|
318
|
+
const totalChars = blockChars(message.content)
|
|
319
|
+
if (output > 0 && totalChars > 0) {
|
|
320
|
+
const sample = output / totalChars
|
|
321
|
+
ratio = ratio === null ? sample : (ratio + sample) / 2
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const decodeMs = firstTokenAt !== null ? Date.now() - firstTokenAt : 0
|
|
325
|
+
const ttftMs = firstTokenAt !== null ? firstTokenAt - stepStartedAt : null
|
|
326
|
+
const tokens = output > 0 ? output : totalChars * (ratio ?? FALLBACK_TOKENS_PER_CHAR)
|
|
327
|
+
if (ttftMs !== null && decodeMs >= MIN_SAMPLE_MS)
|
|
328
|
+
publish((tokens / decodeMs) * 1000, output > 0, ttftMs)
|
|
329
|
+
resetStream()
|
|
330
|
+
})
|
|
331
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure rendering helpers for the status line: number formatting, the context meter, the two-group
|
|
3
|
+
* row layout, and the status-noise filter.
|
|
4
|
+
*
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
11
|
+
|
|
12
|
+
import type { Theme, ThemeColor } from '@earendil-works/pi-coding-agent'
|
|
13
|
+
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Meter geometry, copied from cli-progress's `shades_classic` preset (10M downloads/week): block
|
|
17
|
+
* fill, shaded block track. Its one structural rule is that the bar is a FIXED width (`barsize`,
|
|
18
|
+
* default 40) and never stretches to the row. An earlier revision here stretched it to the
|
|
19
|
+
* terminal, which is what made it read as chrome rather than as an instrument. 20 cells is the
|
|
20
|
+
* footer-sized equivalent.
|
|
21
|
+
*/
|
|
22
|
+
export const BAR_CELLS = 20
|
|
23
|
+
export const BAR_FILL = '█'
|
|
24
|
+
/**
|
|
25
|
+
* Set to '' for a trackless bar, or '─' for a hairline track. Annotated `string` rather than left
|
|
26
|
+
* as its literal type, so the empty case below is a real branch.
|
|
27
|
+
*/
|
|
28
|
+
export const BAR_TRACK: string = '░'
|
|
29
|
+
|
|
30
|
+
/** 1/8-cell fill ladder (U+258F..U+2588) for a smooth leading edge. */
|
|
31
|
+
const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
|
|
32
|
+
|
|
33
|
+
/** Strip ANSI so status text can be pattern-matched on its visible form. */
|
|
34
|
+
// eslint-disable-next-line no-control-regex -- matching an ANSI escape requires the ESC character
|
|
35
|
+
export const ANSI = /\u001b\[[0-9;]*m/g
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Status entries that only report that nothing is happening. Other extensions set these
|
|
39
|
+
* persistently; they cost a row without carrying information. Matching is keyed by status key, so
|
|
40
|
+
* an unrelated extension that happens to use the same words is left alone.
|
|
41
|
+
*/
|
|
42
|
+
export const QUIET_STATUS: ReadonlyArray<readonly [string, RegExp]> = [
|
|
43
|
+
['pi-lens-lsp', /^LSP Inactive$/i],
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
export function formatTokens(count: number): string {
|
|
47
|
+
if (count < 1000) return String(count)
|
|
48
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`
|
|
49
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`
|
|
50
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`
|
|
51
|
+
return `${Math.round(count / 1000000)}M`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Whole tokens from ten up, one decimal below (matches the harness formatter). */
|
|
55
|
+
export function formatTps(tps: number): string {
|
|
56
|
+
const clamped = Math.max(0, tps)
|
|
57
|
+
return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Milliseconds below a second, seconds with one decimal under ten, whole seconds from there. */
|
|
61
|
+
export function formatLatency(ms: number): string {
|
|
62
|
+
const clamped = Math.max(0, ms)
|
|
63
|
+
if (clamped < 1000) return `${Math.round(clamped)}ms`
|
|
64
|
+
const seconds = clamped / 1000
|
|
65
|
+
return `${seconds < 10 ? Math.round(seconds * 10) / 10 : Math.round(seconds)}s`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Home-relative path, or the absolute path when it is outside the home directory. */
|
|
69
|
+
export function formatCwd(cwd: string): string {
|
|
70
|
+
const home = homedir()
|
|
71
|
+
if (!home) return cwd
|
|
72
|
+
try {
|
|
73
|
+
const rel = relative(resolve(home), resolve(cwd))
|
|
74
|
+
const inside = rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel))
|
|
75
|
+
if (!inside) return cwd
|
|
76
|
+
return rel === '' ? '~' : `~${sep}${rel}`
|
|
77
|
+
} catch {
|
|
78
|
+
return cwd
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 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$/, '')}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Shorten a path from the left, keeping the tail that identifies the project. Never returns a
|
|
89
|
+
* mid-word fragment: it degrades to `…/last` and then to `last…`.
|
|
90
|
+
*/
|
|
91
|
+
export function shortenPath(path: string, maxWidth: number): string {
|
|
92
|
+
if (path.length <= maxWidth) return path
|
|
93
|
+
const parts = path.split(sep).filter(Boolean)
|
|
94
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
95
|
+
const candidate = `…${sep}${parts.slice(i).join(sep)}`
|
|
96
|
+
if (candidate.length <= maxWidth) return candidate
|
|
97
|
+
}
|
|
98
|
+
const tail = parts.at(-1) ?? path
|
|
99
|
+
return tail.length <= maxWidth ? tail : `${tail.slice(0, Math.max(1, maxWidth - 1))}…`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Context pressure: accent is healthy, warning starts above 70%, error above 90%. */
|
|
103
|
+
export function barColor(fraction: number): ThemeColor {
|
|
104
|
+
if (fraction > 0.9) return 'error'
|
|
105
|
+
if (fraction > 0.7) return 'warning'
|
|
106
|
+
return 'accent'
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The same thresholds applied to the printed percentage. */
|
|
110
|
+
export function percentColor(percent: number | null): ThemeColor {
|
|
111
|
+
if (percent === null) return 'text'
|
|
112
|
+
if (percent > 90) return 'error'
|
|
113
|
+
if (percent > 70) return 'warning'
|
|
114
|
+
return 'text'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Cli-progress's bar algorithm (`shades_classic`), plus a 1/8-cell leading edge so the fill grows
|
|
119
|
+
* smoothly instead of jumping a whole cell at a time.
|
|
120
|
+
*/
|
|
121
|
+
export function bar(theme: Theme, cells: number, fraction: number): string {
|
|
122
|
+
const clamped = Math.max(0, Math.min(1, fraction))
|
|
123
|
+
const scaled = clamped * cells
|
|
124
|
+
const whole = Math.min(cells, Math.floor(scaled))
|
|
125
|
+
const edge = whole < cells ? (EIGHTHS[Math.floor((scaled - whole) * 8)] ?? '') : ''
|
|
126
|
+
const fill = theme.fg(barColor(clamped), BAR_FILL.repeat(whole) + edge)
|
|
127
|
+
if (!BAR_TRACK) return fill
|
|
128
|
+
return fill + theme.fg('dim', BAR_TRACK.repeat(Math.max(0, cells - whole - edge.length)))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Left group and right group on one line, padded to `width`. When the two do not fit, the right
|
|
133
|
+
* group is dropped rather than half rendered, so no number ever appears cut; the wider side keeps
|
|
134
|
+
* the line when only one of them fits.
|
|
135
|
+
*/
|
|
136
|
+
export function row(theme: Theme, width: number, left: string, right: string): string {
|
|
137
|
+
const leftWidth = visibleWidth(left)
|
|
138
|
+
const rightWidth = right ? visibleWidth(right) : 0
|
|
139
|
+
const gap = leftWidth > 0 && rightWidth > 0 ? 2 : 0
|
|
140
|
+
if (rightWidth > 0 && leftWidth + gap + rightWidth <= width) {
|
|
141
|
+
return left + ' '.repeat(Math.max(0, width - leftWidth - rightWidth)) + right
|
|
142
|
+
}
|
|
143
|
+
const keep = leftWidth > 0 ? left : right
|
|
144
|
+
return truncateToWidth(keep, width, theme.fg('dim', '…'))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `label value` where the key is quiet and the value is not. */
|
|
148
|
+
export function pair(theme: Theme, key: string, value: string, color: ThemeColor = 'text'): string {
|
|
149
|
+
return `${theme.fg('dim', key)} ${theme.fg(color, value)}`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** True when a status entry only reports that nothing is happening. */
|
|
153
|
+
export function isQuietStatus(key: string, value: unknown): boolean {
|
|
154
|
+
const rule = QUIET_STATUS.find(([candidate]) => candidate === key)
|
|
155
|
+
if (!rule) return false
|
|
156
|
+
return rule[1].test(String(value).replace(ANSI, '').trim())
|
|
157
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "useful-pi-extensions",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"cli",
|
|
8
|
+
"footer",
|
|
9
|
+
"pi",
|
|
10
|
+
"pi-extension",
|
|
11
|
+
"pi-package",
|
|
12
|
+
"statusline",
|
|
13
|
+
"terminal",
|
|
14
|
+
"tui",
|
|
15
|
+
"typescript"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/reedchan7/useful-pi-extensions#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/reedchan7/useful-pi-extensions/issues"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/reedchan7/useful-pi-extensions.git"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"extensions",
|
|
28
|
+
"!extensions/**/*.test.ts",
|
|
29
|
+
"README.md",
|
|
30
|
+
"README_CN.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"type": "module",
|
|
34
|
+
"scripts": {
|
|
35
|
+
"check": "bun run fmt:check && bun run lint && bun run typecheck && bun run docs:check && bun run test",
|
|
36
|
+
"docs:check": "bun tools/check-docs-pairing/src/index.ts",
|
|
37
|
+
"fmt": "oxfmt --write .",
|
|
38
|
+
"fmt:check": "oxfmt --check .",
|
|
39
|
+
"lint": "oxlint --deny-warnings .",
|
|
40
|
+
"lint:fix": "oxlint --fix .",
|
|
41
|
+
"prepublishOnly": "bun run check",
|
|
42
|
+
"test": "bun test",
|
|
43
|
+
"test:changed": "git rev-parse --verify --quiet origin/main >/dev/null && bun test --changed=origin/main || bun test",
|
|
44
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
48
|
+
"@earendil-works/pi-tui": "*",
|
|
49
|
+
"@types/bun": "1.4.2",
|
|
50
|
+
"lefthook": "2.1.14",
|
|
51
|
+
"oxfmt": "0.68.0",
|
|
52
|
+
"oxlint": "1.83.0",
|
|
53
|
+
"oxlint-tsgolint": "7.0.2002",
|
|
54
|
+
"typescript": "7.0.2"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
58
|
+
"@earendil-works/pi-tui": "*"
|
|
59
|
+
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"bun": ">=1.4.0"
|
|
62
|
+
},
|
|
63
|
+
"pi": {
|
|
64
|
+
"extensions": [
|
|
65
|
+
"./extensions"
|
|
66
|
+
],
|
|
67
|
+
"image": "https://github.com/user-attachments/assets/7ae64213-cb05-4440-835e-a633796a87f9"
|
|
68
|
+
}
|
|
69
|
+
}
|