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 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
+ }