useful-pi-extensions 1.10.2 → 1.11.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 +3 -3
- package/README_CN.md +3 -3
- package/package.json +2 -2
- package/packages/statusline/README.md +83 -21
- package/packages/statusline/README_CN.md +73 -19
- package/packages/statusline/extensions/statusline/index.ts +148 -1
- package/packages/statusline/extensions/statusline/render.ts +387 -2
package/README.md
CHANGED
|
@@ -14,9 +14,9 @@ pi install npm:useful-pi-extensions
|
|
|
14
14
|
|
|
15
15
|
## What is in here
|
|
16
16
|
|
|
17
|
-
| Extension | What it does
|
|
18
|
-
| ------------------------------------------------------- |
|
|
19
|
-
| [`@reedchan/statusline`](packages/statusline/README.md) | Replaces pi's footer with a labelled two-row one: context pressure as a fixed-size meter, the session's input/output tokens, cache hit rate and cost (in whatever currency you configure), the model and effort level, and the latest turn's TTFT and decode throughput in tokens/second |
|
|
17
|
+
| 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. `ctrl+e` opens an opt-in context-breakdown panel — what occupies the window, bucket by bucket, with the arithmetic closed |
|
|
20
20
|
|
|
21
21
|
Only one extension owns the footer, so `statusline` is a complete replacement rather than an
|
|
22
22
|
addition. If you want pi's stock footer back, remove this package and `/reload`.
|
package/README_CN.md
CHANGED
|
@@ -14,9 +14,9 @@ pi install npm:useful-pi-extensions
|
|
|
14
14
|
|
|
15
15
|
## 包含什么
|
|
16
16
|
|
|
17
|
-
| 扩展 | 作用
|
|
18
|
-
| ---------------------------------------------------------- |
|
|
19
|
-
| [`@reedchan/statusline`](packages/statusline/README_CN.md) | 替换 pi 的 footer,改成带文字标签的两行:上下文压力用固定宽度仪表显示,外加本次会话的输入/输出 token 量、缓存命中率与花费(币种可配置)、模型与思考等级,以及最近一轮的 TTFT 和解码速度(tok/s
|
|
17
|
+
| 扩展 | 作用 |
|
|
18
|
+
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
19
|
+
| [`@reedchan/statusline`](packages/statusline/README_CN.md) | 替换 pi 的 footer,改成带文字标签的两行:上下文压力用固定宽度仪表显示,外加本次会话的输入/输出 token 量、缓存命中率与花费(币种可配置)、模型与思考等级,以及最近一轮的 TTFT 和解码速度(tok/s)。`ctrl+e` 可打开一个可选的上下文明细面板——窗口里到底装了什么,逐桶列出,账目闭合 |
|
|
20
20
|
|
|
21
21
|
footer 只能有一个扩展占用,所以 `statusline` 是**替换**而不是叠加。想回到 pi 原生 footer,移除本包再 `/reload`。
|
|
22
22
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "useful-pi-extensions",
|
|
3
|
-
"version": "1.
|
|
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.",
|
|
3
|
+
"version": "1.11.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, plus an opt-in context breakdown panel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
7
7
|
"cli",
|
|
@@ -4,33 +4,35 @@ English | [中文](README_CN.md)
|
|
|
4
4
|
|
|
5
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
6
|
|
|
7
|
-
Replaces pi's footer with a labelled two-row one. Every value carries a word, so nothing has to
|
|
8
|
-
decoded from a symbol or remembered from a legend.
|
|
7
|
+
Replaces pi's footer with a labelled two-row one. Every value carries a word, so nothing has to
|
|
8
|
+
be decoded from a symbol or remembered from a legend. `ctrl+e` opens an optional context-breakdown
|
|
9
|
+
panel (next section) when you want to see what is occupying the window.
|
|
9
10
|
|
|
10
11
|
```text
|
|
11
|
-
Context █████████▍░░░░░░░░░░ 47% 471k / 1.0M Input 194k · Output 89k | Cache hit 99.9% | Cost $0.23 · Today $1.63
|
|
12
|
+
Context window █████████▍░░░░░░░░░░ 47% 471k / 1.0M Input 194k · Output 89k | Cache hit 99.9% | Cost $0.23 · Today $1.63
|
|
12
13
|
~/.pi (master) deepseek-flash · Effort high | TTFT 482ms · Avg TTFT 612ms | Last 729 tok/s · Avg 512 tok/s
|
|
13
14
|
LSP Active: typescript
|
|
14
15
|
```
|
|
15
16
|
|
|
16
|
-
A narrower terminal gives the row up in a fixed order rather than all at once: the `471k / 1.0M`
|
|
17
|
+
A narrower terminal gives the top row up in a fixed order rather than all at once: the `471k / 1.0M`
|
|
17
18
|
detail goes first, then the input/output volumes, leaving the hit rate and the bill. Below about 60
|
|
18
19
|
columns only the meter is left. Every step is a whole value — a number is never shown cut in half.
|
|
19
20
|
|
|
20
21
|
## What each part is
|
|
21
22
|
|
|
22
|
-
| Part
|
|
23
|
-
|
|
|
24
|
-
| `Context` + meter + `47%` | Share of the model's context window in use. The fill turns `warning` above 70% and `error` above 90%, the same thresholds pi's shipped footer uses, and the percentage changes color with it |
|
|
25
|
-
| `471k / 1.0M`
|
|
26
|
-
|
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
| `
|
|
30
|
-
|
|
|
31
|
-
|
|
|
32
|
-
| `
|
|
33
|
-
|
|
|
23
|
+
| Part | Meaning |
|
|
24
|
+
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
25
|
+
| `Context window` + meter + `47%` | Share of the model's context window in use. The fill turns `warning` above 70% and `error` above 90%, the same thresholds pi's shipped footer uses, and the percentage changes color with it |
|
|
26
|
+
| `471k / 1.0M` | Absolute context tokens over the window size. The first thing dropped when the terminal is narrow |
|
|
27
|
+
| Breakdown panel | What occupies the window — opt-in via `ctrl+e` or `/breakdown` — one bucket per line under a total. See [Context breakdown](#context-breakdown) |
|
|
28
|
+
| `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 |
|
|
29
|
+
| `Cache hit` | The latest turn's cache hit rate, `cacheRead / (input + cacheRead + cacheWrite)` |
|
|
30
|
+
| `Cost` | Session cost, in USD unless a config file names another currency (see below) |
|
|
31
|
+
| `Today` | Today's running cost across every project, session and model on this machine |
|
|
32
|
+
| Effort | The active thinking level |
|
|
33
|
+
| `TTFT` | Time from request dispatch to the first streamed token |
|
|
34
|
+
| `tok/s` | Decode throughput, i.e. output tokens per second of decode time |
|
|
35
|
+
| Last line | Other extensions' `ctx.ui.setStatus()` entries, so they do not silently disappear |
|
|
34
36
|
|
|
35
37
|
## The meter
|
|
36
38
|
|
|
@@ -42,6 +44,67 @@ revision here did stretch it, which made the meter read as chrome rather than as
|
|
|
42
44
|
The glyphs are that preset's block fill and shaded block track, with a 1/8-cell leading edge
|
|
43
45
|
(`▏▎▍▌▋▊▉`) so the fill grows smoothly instead of jumping a whole cell at a time.
|
|
44
46
|
|
|
47
|
+
## Context breakdown
|
|
48
|
+
|
|
49
|
+
`ctrl+e` or `/breakdown` opens an opt-in panel that answers the question the meter raises: what
|
|
50
|
+
is actually occupying the window. The concise footer stays the default; the choice is remembered
|
|
51
|
+
in `~/.pi/agent/statusline/config.json` (`"detail": true`).
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
Used 360k 36.0% ███░░░░░░░
|
|
55
|
+
─────────────────────────────────────────────
|
|
56
|
+
Messages 289k 28.9% ██░░░░░░░░
|
|
57
|
+
Memory files ×2 1.5k 0.1% ░░░░░░░░░░
|
|
58
|
+
System tools ×22 686 0.1% ░░░░░░░░░░
|
|
59
|
+
Ext tools ×23 13k 1.3% ░░░░░░░░░░
|
|
60
|
+
Skills ×164 20k 2.0% ░░░░░░░░░░
|
|
61
|
+
System prompt 1.7k 0.2% ░░░░░░░░░░
|
|
62
|
+
Unaccounted 34k 3.4% ░░░░░░░░░░
|
|
63
|
+
─────────────────────────────────────────────
|
|
64
|
+
Autocompact buffer 16k 1.6% ░░░░░░░░░░
|
|
65
|
+
Free space 624k 62.4% ██████░░░░
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The title is row 1's and appears nowhere else. Three kinds of number, each behind a dim rule:
|
|
69
|
+
`Used`, the provider-reported total; the estimated buckets, ending with `Unaccounted` so the books
|
|
70
|
+
close (buckets are chars/4 estimates and Used is real — the gap is shown, not hidden, so the
|
|
71
|
+
column adds up both ways: buckets + Unaccounted = Used, Used + Autocompact buffer + Free space =
|
|
72
|
+
the window); and what is kept back or still open — which is also what says Free space is the
|
|
73
|
+
remainder, not a consumer. `×N` on a label is that bucket's count of files, tools or skills;
|
|
74
|
+
every value is tokens; every percentage is a share of the window; and every row ends in the same
|
|
75
|
+
share bar — the row-1 meter's own `█`/`░` glyphs at half size, one fill color, no new visual
|
|
76
|
+
device. Names and order are Claude Code's panel, with one exception:
|
|
77
|
+
|
|
78
|
+
| Row | What it counts |
|
|
79
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
80
|
+
| `Used` | The provider-reported context total — the real number the estimates below are measured against |
|
|
81
|
+
| `Messages` | The conversation on the active branch, including tool results, custom messages and compaction summaries |
|
|
82
|
+
| `Memory files` | Context files (AGENTS.md, CLAUDE.md and friends) rendered into the prompt, with their count |
|
|
83
|
+
| `System tools` | JSON schemas of the built-in tools actually sent to the provider |
|
|
84
|
+
| `Ext tools` | JSON schemas of the tools registered by extensions, the SDK and MCP, with their count — the one bucket CC has no name for, since pi draws tools from extensions as well as MCP |
|
|
85
|
+
| `Skills` | The prompt's skills section, with the number of skills listed |
|
|
86
|
+
| `System prompt` | The rest of the system prompt: identity, tool list, rules, docs, cwd, extension sections |
|
|
87
|
+
| `Unaccounted` | Used minus the estimate sum — the honest remainder of estimating, in dim; the reason the percentage column adds up |
|
|
88
|
+
| `Autocompact buffer` | Tokens compaction holds back for the model's reply — `compaction.reserveTokens` from settings; hidden when compaction is off |
|
|
89
|
+
| `Free space` | Window minus usage minus reserve. Carries the meter's 70/90 pressure color, so it warns as the window fills |
|
|
90
|
+
|
|
91
|
+
Every bucket except `Used` is an **estimate**, by pi's own chars/4 heuristic (`estimateTokens` in
|
|
92
|
+
`core/compaction`), which is exactly why `Unaccounted` exists: the estimates miss the real total,
|
|
93
|
+
and the panel shows the miss instead of letting the column not add up. The prompt shares are cut
|
|
94
|
+
from the exact prompt text the turn sends (captured at `before_agent_start`), and tool schemas
|
|
95
|
+
are measured from what `pi.getAllTools()` reports for the active set, so those two are close to
|
|
96
|
+
exact.
|
|
97
|
+
|
|
98
|
+
What Claude Code's panel shows that this panel deliberately does not: **custom agents** (pi has no
|
|
99
|
+
subagent concept) and **usage limits** (provider-specific subscription accounting; pi is
|
|
100
|
+
multi-provider). **Deferred tools** have no pi equivalent either — every active tool's schema is
|
|
101
|
+
always on the wire.
|
|
102
|
+
|
|
103
|
+
The panel is ~50 columns wide and fits any terminal by truncating rather than by dropping
|
|
104
|
+
buckets. The toggle lives on `ctrl+e` because `ctrl+j` is a bare LF in legacy terminals and reads
|
|
105
|
+
as Enter; if another extension owns `ctrl+e` on your machine, the key is `BREAKDOWN_SHORTCUT` at
|
|
106
|
+
the top of `index.ts`.
|
|
107
|
+
|
|
45
108
|
Configuration lives at the top of [`render.ts`](render.ts):
|
|
46
109
|
|
|
47
110
|
| Constant | Default | Notes |
|
|
@@ -84,13 +147,11 @@ rather than silently showing dollars with nothing to explain why.
|
|
|
84
147
|
|
|
85
148
|
## Metrics
|
|
86
149
|
|
|
87
|
-
## Metrics
|
|
88
|
-
|
|
89
150
|
Throughput and latency follow the
|
|
90
151
|
[deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) turn-metrics contract
|
|
91
152
|
(`packages/client/ui-chat/src/client/contract/turn-metrics.ts`), including its number formatting:
|
|
92
153
|
|
|
93
|
-
```
|
|
154
|
+
```text
|
|
94
155
|
ttftMs = firstTokenTime - stepStartTime
|
|
95
156
|
decodeMs = completedTime - firstTokenTime
|
|
96
157
|
tok/s = usage.output / (decodeMs / 1000)
|
|
@@ -114,8 +175,9 @@ So the rate excludes prefill and time-to-first-token, which is the industry-stan
|
|
|
114
175
|
extension alongside this one is a race, not a merge. This extension stands down if
|
|
115
176
|
[pi-fancy-footer](https://github.com/mavam/pi-fancy-footer) announces itself, so the two coexist
|
|
116
177
|
without fighting.
|
|
117
|
-
- The footer renders on `ctx.
|
|
118
|
-
API; nothing reaches into pi's
|
|
178
|
+
- The footer renders on `ctx.getContextUsage()`, `ctx.getSystemPrompt()`, `ctx.sessionManager`,
|
|
179
|
+
`pi.getAllTools()` and `pi.getActiveTools()`, all public API; nothing reaches into pi's
|
|
180
|
+
internals.
|
|
119
181
|
- Provider status such as `MCP: 2 servers enabled` is informational and can be turned off at its
|
|
120
182
|
source: `settings.mcpFooterStatus` in `~/.pi/agent/mcp.json`. `LSP Inactive` from pi-lens has no
|
|
121
183
|
such setting, so it is filtered here as a "quiet" status.
|
|
@@ -5,31 +5,33 @@
|
|
|
5
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
6
|
|
|
7
7
|
替换 pi 的 footer,改成带文字标签的两行。每个值都带一个词,不需要靠符号猜、也不需要记图例。
|
|
8
|
+
需要看窗口里到底装了什么时,`ctrl+e` 可打开一个可选的上下文明细面板(见下文)。
|
|
8
9
|
|
|
9
10
|
```text
|
|
10
|
-
Context █████████▍░░░░░░░░░░ 47% 471k / 1.0M Input 194k · Output 89k | Cache hit 99.9% | Cost $0.23 · Today $1.63
|
|
11
|
+
Context window █████████▍░░░░░░░░░░ 47% 471k / 1.0M Input 194k · Output 89k | Cache hit 99.9% | Cost $0.23 · Today $1.63
|
|
11
12
|
~/.pi (master) deepseek-flash · Effort high | TTFT 482ms · Avg TTFT 612ms | Last 729 tok/s · Avg 512 tok/s
|
|
12
13
|
LSP Active: typescript
|
|
13
14
|
```
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
终端变窄时,第一行是**按固定顺序**逐项舍弃的,而不是整体消失:先丢 `471k / 1.0M`,
|
|
16
17
|
再丢输入/输出量,最后保留命中率与花费。窄于约 60 列时就只剩仪表了。
|
|
17
18
|
每一步丢的都是完整的一项,绝不出现被截掉一半的数字。
|
|
18
19
|
|
|
19
20
|
## 每一项是什么
|
|
20
21
|
|
|
21
|
-
| 部分
|
|
22
|
-
|
|
|
23
|
-
| `Context` + 仪表 + `47.1%` | 已占模型上下文窗口的比例。填充超过 70% 转 `warning`、超过 90% 转 `error`(与 pi 原生 footer 同一套阈值),百分比同步变色 |
|
|
24
|
-
| `471k / 1.0M`
|
|
25
|
-
|
|
|
26
|
-
| `
|
|
27
|
-
| `
|
|
28
|
-
| `
|
|
29
|
-
|
|
|
30
|
-
|
|
|
31
|
-
| `
|
|
32
|
-
|
|
|
22
|
+
| 部分 | 含义 |
|
|
23
|
+
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
24
|
+
| `Context window` + 仪表 + `47.1%` | 已占模型上下文窗口的比例。填充超过 70% 转 `warning`、超过 90% 转 `error`(与 pi 原生 footer 同一套阈值),百分比同步变色 |
|
|
25
|
+
| `471k / 1.0M` | 上下文已用 token / 窗口大小。终端变窄时**第一个被舍弃** |
|
|
26
|
+
| Breakdown 面板 | 窗口里到底装了什么,总数领队、每桶一行——可选,`ctrl+e` 或 `/breakdown` 打开。见[上下文明细](#上下文明细) |
|
|
27
|
+
| `Input` / `Output` | 会话的输入与输出 token 量。`Input` 指既没命中缓存、也没写入缓存的那部分 prompt token,因为 pi 把这两种情况在同一个 `usage` 对象里分开报 |
|
|
28
|
+
| `Cache hit` | 最近一轮的缓存命中率,`cacheRead / (input + cacheRead + cacheWrite)` |
|
|
29
|
+
| `Cost` | 会话花费,默认美元;配置了其他币种则换算显示(见下) |
|
|
30
|
+
| `Today` | 当天跨项目、跨会话、跨模型的累计花费 |
|
|
31
|
+
| Effort | 当前思考等级 |
|
|
32
|
+
| `TTFT` | 从发出请求到第一个流式 token 的耗时 |
|
|
33
|
+
| `tok/s` | 解码吞吐,即每秒解码时间产出的输出 token 数 |
|
|
34
|
+
| 最后一行 | 其他扩展通过 `ctx.ui.setStatus()` 设的状态,否则它们会静默消失 |
|
|
33
35
|
|
|
34
36
|
## 关于这个仪表
|
|
35
37
|
|
|
@@ -40,6 +42,60 @@ LSP Active: typescript
|
|
|
40
42
|
字形也来自那个预设(实心块填充 + 阴影块轨道),额外加了 1/8 格收尾(`▏▎▍▌▋▊▉`),
|
|
41
43
|
让填充平滑增长,而不是一次跳一整格。
|
|
42
44
|
|
|
45
|
+
## 上下文明细
|
|
46
|
+
|
|
47
|
+
`ctrl+e` 或 `/breakdown` 打开一个可选面板,回答仪表提出的问题:窗口里到底装了什么。
|
|
48
|
+
简洁两行是默认形态;开关状态记在 `~/.pi/agent/statusline/config.json`(`"detail": true`)。
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
Used 360k 36.0% ███░░░░░░░
|
|
52
|
+
─────────────────────────────────────────────
|
|
53
|
+
Messages 289k 28.9% ██░░░░░░░░
|
|
54
|
+
Memory files ×2 1.5k 0.1% ░░░░░░░░░░
|
|
55
|
+
System tools ×22 686 0.1% ░░░░░░░░░░
|
|
56
|
+
Ext tools ×23 13k 1.3% ░░░░░░░░░░
|
|
57
|
+
Skills ×164 20k 2.0% ░░░░░░░░░░
|
|
58
|
+
System prompt 1.7k 0.2% ░░░░░░░░░░
|
|
59
|
+
Unaccounted 34k 3.4% ░░░░░░░░░░
|
|
60
|
+
─────────────────────────────────────────────
|
|
61
|
+
Autocompact buffer 16k 1.6% ░░░░░░░░░░
|
|
62
|
+
Free space 624k 62.4% ██████░░░░
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
标题是第一行的,面板里不再重复。三类数字,每类后面一条 dim 分割线:`Used`——provider 上报的
|
|
66
|
+
真实总量;估算桶——以 `Unaccounted` 收尾让账目闭合(桶是 chars/4 估算、Used 是真实值,差值亮出
|
|
67
|
+
来而不是藏掉,于是两个方向都加得平:桶 + Unaccounted = Used,Used + Autocompact buffer +
|
|
68
|
+
Free space = 窗口);以及预留与余量——这也同时说清了 Free space 是余量而非占用者。标签上的
|
|
69
|
+
`×N` 是该桶的文件/工具/技能数;所有数值都是 token;百分比都是占窗口的比例;每行末尾都是同一种
|
|
70
|
+
占比条——第一行仪表自己的 `█`/`░` 字形、减半尺寸、单一填充色,不引入任何新视觉元素。
|
|
71
|
+
桶名和顺序与 Claude Code 面板逐字一致,唯一的例外:
|
|
72
|
+
|
|
73
|
+
| 行 | 统计对象 |
|
|
74
|
+
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
75
|
+
| `Used` | provider 上报的上下文总量——下面的估算所对齐的真实数字 |
|
|
76
|
+
| `Messages` | 当前分支上的会话内容,包括工具结果、自定义消息和 compaction 摘要 |
|
|
77
|
+
| `Memory files` | 渲染进系统提示的 context 文件(AGENTS.md、CLAUDE.md 等),带文件数 |
|
|
78
|
+
| `System tools` | 实际发给 provider 的内置工具 JSON schema |
|
|
79
|
+
| `Ext tools` | 扩展、SDK 和 MCP 注册的工具 JSON schema,带工具数——这是唯一没有沿用 CC 名字的桶:pi 的非内置工具同时来自扩展和 MCP,叫 "MCP tools" 会标错大半 |
|
|
80
|
+
| `Skills` | 系统提示里的 skills 段,带技能数 |
|
|
81
|
+
| `System prompt` | 其余系统提示:身份、工具清单、规则、docs、cwd、扩展自定义段 |
|
|
82
|
+
| `Unaccounted` | Used 减估算和——估算的诚实残差,dim 显示;百分比列因此能加得起来 |
|
|
83
|
+
| `Autocompact buffer` | compaction 为模型回复预留的 token——来自 settings 的 `compaction.reserveTokens`;compaction 关闭时隐藏 |
|
|
84
|
+
| `Free space` | 窗口减已用减预留。带仪表同源的 70/90 压力色,窗口将满时它会自己变色 |
|
|
85
|
+
|
|
86
|
+
除 `Used` 外每一行都是**估算值**,用的是 pi 自己的 chars/4 启发式(`core/compaction` 里的
|
|
87
|
+
`estimateTokens`)——这正是 `Unaccounted` 存在的原因:估算会偏离真实总量,面板把偏差亮出来,
|
|
88
|
+
而不是让百分比列加不平。Prompt 侧的份额是从本轮实际发送的提示原文里切出来的(在
|
|
89
|
+
`before_agent_start` 捕获),工具 schema 按 `pi.getAllTools()` 报告的激活工具集计算,这两处接近精确。
|
|
90
|
+
|
|
91
|
+
Claude Code 面板里有、而这里有意不做的:**Custom agents**(pi 没有子代理概念)和 **usage limits**
|
|
92
|
+
(特定厂商的订阅额度,pi 是多 provider 的)。**Deferred tools** 在 pi 里也没有对应物——
|
|
93
|
+
激活工具的 schema 永远在线上。
|
|
94
|
+
|
|
95
|
+
面板约 50 列宽,任何终端都放得下;放不下时逐行截断,而不是丢桶。开关键默认 `ctrl+e`——
|
|
96
|
+
原本选的 `ctrl+j` 在传统终端里就是一个裸 LF,和回车无法区分;如果你的机器上 `ctrl+e`
|
|
97
|
+
已被其他扩展占用,改 `index.ts` 顶部的 `BREAKDOWN_SHORTCUT` 即可。
|
|
98
|
+
|
|
43
99
|
可调项在 [`render.ts`](render.ts) 顶部:
|
|
44
100
|
|
|
45
101
|
| 常量 | 默认值 | 说明 |
|
|
@@ -78,12 +134,10 @@ pi 里所有模型价格都是美元,而且它的 `cost` 字段**不带任何
|
|
|
78
134
|
|
|
79
135
|
## 指标口径
|
|
80
136
|
|
|
81
|
-
## 指标口径
|
|
82
|
-
|
|
83
137
|
吞吐与延迟遵循 [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) 的 turn-metrics 契约
|
|
84
138
|
(`packages/client/ui-chat/src/client/contract/turn-metrics.ts`),数字格式也照抄:
|
|
85
139
|
|
|
86
|
-
```
|
|
140
|
+
```text
|
|
87
141
|
ttftMs = firstTokenTime - stepStartTime
|
|
88
142
|
decodeMs = completedTime - firstTokenTime
|
|
89
143
|
tok/s = usage.output / (decodeMs / 1000)
|
|
@@ -101,8 +155,8 @@ tok/s = usage.output / (decodeMs / 1000)
|
|
|
101
155
|
|
|
102
156
|
- footer 只能有一个扩展占用。`ctx.ui.setFooter()` 是排他的,所以再装一个 footer 扩展是**竞争**而不是叠加。
|
|
103
157
|
本扩展在检测到 [pi-fancy-footer](https://github.com/mavam/pi-fancy-footer) 时会主动让位,两者不会互相打架。
|
|
104
|
-
- footer
|
|
105
|
-
|
|
158
|
+
- footer 读取 `ctx.getContextUsage()`、`ctx.getSystemPrompt()`、`ctx.sessionManager`、
|
|
159
|
+
`pi.getAllTools()`、`pi.getActiveTools()`,全部是公开 API,没有触碰 pi 内部实现。
|
|
106
160
|
- `MCP: 2 servers enabled` 这类信息性状态可以在源头关掉:`~/.pi/agent/mcp.json` 的
|
|
107
161
|
`settings.mcpFooterStatus`。pi-lens 的 `LSP Inactive` 没有类似开关,所以在本地按"安静状态"过滤。
|
|
108
162
|
|
|
@@ -31,24 +31,32 @@ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'
|
|
|
31
31
|
|
|
32
32
|
import {
|
|
33
33
|
avgTokPerSec,
|
|
34
|
+
breakdownPanel,
|
|
34
35
|
contextRow,
|
|
35
36
|
currencyFromConfig,
|
|
37
|
+
detailFromConfig,
|
|
36
38
|
formatCwd,
|
|
37
39
|
formatLatency,
|
|
38
40
|
formatTps,
|
|
39
41
|
avgMs,
|
|
40
42
|
isQuietStatus,
|
|
43
|
+
messagesTokens,
|
|
41
44
|
pair,
|
|
42
45
|
cachedRates,
|
|
43
46
|
ratesFromPayload,
|
|
44
47
|
cacheIsFresh,
|
|
45
48
|
withCachedRates,
|
|
49
|
+
withDetailFlag,
|
|
50
|
+
promptBreakdown,
|
|
46
51
|
row,
|
|
47
52
|
shortenPath,
|
|
53
|
+
splitToolTokens,
|
|
48
54
|
ttftDisplay,
|
|
49
55
|
ttftMs,
|
|
50
56
|
USD,
|
|
51
57
|
type Currency,
|
|
58
|
+
type PromptBreakdown,
|
|
59
|
+
type ToolSplit,
|
|
52
60
|
} from './render.ts'
|
|
53
61
|
|
|
54
62
|
const LIVE_RENDER_MS = 200
|
|
@@ -75,6 +83,16 @@ const STATE_DIR = join(STATUSLINE_DIR, 'state')
|
|
|
75
83
|
const LEGACY_STATE = join(homedir(), '.pi', 'agent', 'statusline-state.json')
|
|
76
84
|
/** The 1.8 single-file state: one bucket every session shared, also migrated to per-session. */
|
|
77
85
|
const LEGACY_SHARED_STATE = join(STATUSLINE_DIR, 'state.json')
|
|
86
|
+
/** Pi's own default when settings name no reserve (DEFAULT_COMPACTION_SETTINGS). */
|
|
87
|
+
const DEFAULT_RESERVE_TOKENS = 16_384
|
|
88
|
+
/**
|
|
89
|
+
* The key that toggles the breakdown line; change here if another extension owns it.
|
|
90
|
+
*
|
|
91
|
+
* Ctrl+j was tried first and had to go: legacy terminals send it as a bare LF, which is
|
|
92
|
+
* indistinguishable from Enter, so it typed newlines instead of toggling. ctrl+e is unbound across
|
|
93
|
+
* pi's default keymaps and in every extension installed on this machine.
|
|
94
|
+
*/
|
|
95
|
+
const BREAKDOWN_SHORTCUT = 'ctrl+e'
|
|
78
96
|
const FETCH_TIMEOUT_MS = 5000
|
|
79
97
|
|
|
80
98
|
function today(): string {
|
|
@@ -162,6 +180,7 @@ async function pruneState(): Promise<void> {
|
|
|
162
180
|
const filePath = join(STATE_DIR, name)
|
|
163
181
|
const stats = await stat(filePath).catch(() => null)
|
|
164
182
|
if (stats !== null && stats.mtimeMs < cutoff) await unlink(filePath).catch(() => {})
|
|
183
|
+
return null
|
|
165
184
|
}),
|
|
166
185
|
)
|
|
167
186
|
} catch {
|
|
@@ -382,6 +401,71 @@ export default function (pi: ExtensionAPI) {
|
|
|
382
401
|
// Latest turn reading: { rate, exact, ttftMs }
|
|
383
402
|
let reading: { rate: number; exact: boolean; ttftMs: number | null } | null = null
|
|
384
403
|
|
|
404
|
+
// Breakdown-line inputs, refreshed once per turn rather than per frame. The prompt and the
|
|
405
|
+
// tool list change only across turns (before_agent_start re-captures both), and the message
|
|
406
|
+
// estimate only changes when the branch's leaf does, so the footer's per-frame work is
|
|
407
|
+
// string assembly over cached numbers.
|
|
408
|
+
let detailVisible = false
|
|
409
|
+
let promptParts: PromptBreakdown | null = null
|
|
410
|
+
let toolSplit: ToolSplit | null = null
|
|
411
|
+
let reserveTokens: number | null = null
|
|
412
|
+
let messagesCache: { leafId: string | null; tokens: number } | null = null
|
|
413
|
+
|
|
414
|
+
/** Re-splits the rendered system prompt into its files, skills and remaining shares. */
|
|
415
|
+
function refreshPrompt(prompt: string): void {
|
|
416
|
+
promptParts = promptBreakdown(prompt)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Re-costs the tools actually sent to the provider: only the active ones cost anything. */
|
|
420
|
+
function refreshTools(): void {
|
|
421
|
+
toolSplit = splitToolTokens(pi.getAllTools(), pi.getActiveTools())
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Conversation estimate, recomputed only when the branch's leaf entry has changed. */
|
|
425
|
+
function messagesFor(ctx: ExtensionContext): number {
|
|
426
|
+
const leafId = ctx.sessionManager.getLeafId() ?? null
|
|
427
|
+
if (messagesCache === null || messagesCache.leafId !== leafId) {
|
|
428
|
+
messagesCache = { leafId, tokens: messagesTokens(ctx.sessionManager.buildContextEntries()) }
|
|
429
|
+
}
|
|
430
|
+
return messagesCache.tokens
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Flips the breakdown line and remembers the choice in the config file. */
|
|
434
|
+
function setDetailVisible(value: boolean): void {
|
|
435
|
+
detailVisible = value
|
|
436
|
+
void (async () => {
|
|
437
|
+
try {
|
|
438
|
+
const text = await readFile(CONFIG_PATH, 'utf8').catch(() => '{}')
|
|
439
|
+
const updated = withDetailFlag(text, value)
|
|
440
|
+
if (updated !== null) {
|
|
441
|
+
await mkdir(STATUSLINE_DIR, { recursive: true })
|
|
442
|
+
await writeFile(CONFIG_PATH, updated)
|
|
443
|
+
}
|
|
444
|
+
} catch {
|
|
445
|
+
// The toggle still works this session; only its memory across sessions is lost.
|
|
446
|
+
}
|
|
447
|
+
})()
|
|
448
|
+
requestRender?.()
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Reads pi's compaction reserve from settings; null hides the slot (compaction off). */
|
|
452
|
+
async function loadReserve(): Promise<number | null> {
|
|
453
|
+
try {
|
|
454
|
+
const config: unknown = JSON.parse(
|
|
455
|
+
await readFile(join(homedir(), '.pi', 'agent', 'settings.json'), 'utf8'),
|
|
456
|
+
)
|
|
457
|
+
// No compaction block is the normal case and means pi's defaults: enabled, reserving 16,384.
|
|
458
|
+
// Treating it as "compaction off" hid the buffer for everyone who never touched settings.
|
|
459
|
+
const compaction = isRecord(config) && isRecord(config.compaction) ? config.compaction : {}
|
|
460
|
+
if (compaction.enabled === false) return null
|
|
461
|
+
const reserve = compaction.reserveTokens
|
|
462
|
+
if (typeof reserve === 'number' && Number.isFinite(reserve) && reserve > 0) return reserve
|
|
463
|
+
return DEFAULT_RESERVE_TOKENS
|
|
464
|
+
} catch {
|
|
465
|
+
return DEFAULT_RESERVE_TOKENS
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
385
469
|
let chars = 0
|
|
386
470
|
let requestAt: number | null = null
|
|
387
471
|
let firstTokenAt: number | null = null
|
|
@@ -514,6 +598,26 @@ export default function (pi: ExtensionAPI) {
|
|
|
514
598
|
currency,
|
|
515
599
|
)
|
|
516
600
|
|
|
601
|
+
// The breakdown panel sits directly under the meter it explains, before the identity
|
|
602
|
+
// row. The concise footer renders by default; ctrl+e or /breakdown opens the panel.
|
|
603
|
+
let breakdown: string[] | null = null
|
|
604
|
+
if (detailVisible) {
|
|
605
|
+
const window =
|
|
606
|
+
usage?.contextWindow ?? shown?.contextWindow ?? ctx.model?.contextWindow ?? 0
|
|
607
|
+
const used = shown?.tokens ?? 0
|
|
608
|
+
const free = window > 0 ? Math.max(0, window - used - (reserveTokens ?? 0)) : null
|
|
609
|
+
breakdown = breakdownPanel(theme, width, {
|
|
610
|
+
messages: messagesFor(ctx),
|
|
611
|
+
promptParts,
|
|
612
|
+
toolSplit,
|
|
613
|
+
reserve: reserveTokens,
|
|
614
|
+
free,
|
|
615
|
+
usedPercent: shown?.percent ?? null,
|
|
616
|
+
window,
|
|
617
|
+
used,
|
|
618
|
+
})
|
|
619
|
+
}
|
|
620
|
+
|
|
517
621
|
// Row 2: model and the latest turn's timing on the right, path on the left. Three
|
|
518
622
|
// groups — identity, first token, throughput — separated by a wall instead of another
|
|
519
623
|
// dot, because a run of similar-looking pairs is what made the old footer unreadable.
|
|
@@ -567,7 +671,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
567
671
|
}
|
|
568
672
|
const row2Left = pwdPlain ? theme.fg('muted', pwdPlain) : ''
|
|
569
673
|
|
|
570
|
-
const lines =
|
|
674
|
+
const lines =
|
|
675
|
+
breakdown === null
|
|
676
|
+
? [row1, row(theme, width, row2Left, row2Right)]
|
|
677
|
+
: [row1, ...breakdown, row(theme, width, row2Left, row2Right)]
|
|
571
678
|
|
|
572
679
|
// Other extensions' status entries still belong on screen, minus the
|
|
573
680
|
// ones that only report that nothing is happening.
|
|
@@ -601,6 +708,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
601
708
|
stopTicker()
|
|
602
709
|
})
|
|
603
710
|
|
|
711
|
+
// The prompt and the tool list are inputs to the breakdown line, and both are settled by the
|
|
712
|
+
// time this fires: earlier handlers have chained their changes, so what is captured here is
|
|
713
|
+
// what this turn actually sends. A later handler can still mutate it, which would only make
|
|
714
|
+
// the buckets estimates of an estimate — the same honesty the panel already carries.
|
|
715
|
+
pi.on('before_agent_start', async (event) => {
|
|
716
|
+
refreshPrompt(event.systemPrompt)
|
|
717
|
+
refreshTools()
|
|
718
|
+
})
|
|
719
|
+
|
|
604
720
|
pi.on('session_start', async (_event, ctx) => {
|
|
605
721
|
ratio = seedRatio(ctx)
|
|
606
722
|
reading = null
|
|
@@ -618,6 +734,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
618
734
|
const file = ctx.sessionManager.getSessionFile()
|
|
619
735
|
await restore(file ?? null)
|
|
620
736
|
todayBase = await sumOtherTodaysCost(file ?? null, startOfToday())
|
|
737
|
+
|
|
738
|
+
// Breakdown inputs. The prompt is read once so the line exists before the first turn;
|
|
739
|
+
// from there before_agent_start keeps it current.
|
|
740
|
+
refreshPrompt(ctx.getSystemPrompt())
|
|
741
|
+
refreshTools()
|
|
742
|
+
reserveTokens = await loadReserve()
|
|
743
|
+
detailVisible = await (async () => {
|
|
744
|
+
try {
|
|
745
|
+
return detailFromConfig(await readFile(CONFIG_PATH, 'utf8'))
|
|
746
|
+
} catch {
|
|
747
|
+
return false
|
|
748
|
+
}
|
|
749
|
+
})()
|
|
750
|
+
|
|
621
751
|
installFooter(ctx)
|
|
622
752
|
})
|
|
623
753
|
|
|
@@ -716,4 +846,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
716
846
|
requestAt = null
|
|
717
847
|
resetStream()
|
|
718
848
|
})
|
|
849
|
+
|
|
850
|
+
// The breakdown panel is opt-in: the concise footer stays the default. Both toggles remember
|
|
851
|
+
// the choice in the config file. ctrl+e is unbound across pi's default keymaps and in every
|
|
852
|
+
// extension installed on this machine; if another extension owns it here, BREAKDOWN_SHORTCUT
|
|
853
|
+
// is the one place to move it.
|
|
854
|
+
pi.registerShortcut(BREAKDOWN_SHORTCUT, {
|
|
855
|
+
description: 'Toggle the context breakdown panel in the status line',
|
|
856
|
+
handler: () => {
|
|
857
|
+
setDetailVisible(!detailVisible)
|
|
858
|
+
},
|
|
859
|
+
})
|
|
860
|
+
pi.registerCommand('breakdown', {
|
|
861
|
+
description: 'Toggle the context breakdown panel in the status line',
|
|
862
|
+
handler: async () => {
|
|
863
|
+
setDetailVisible(!detailVisible)
|
|
864
|
+
},
|
|
865
|
+
})
|
|
719
866
|
}
|
|
@@ -332,9 +332,14 @@ export function withCachedRates(
|
|
|
332
332
|
*
|
|
333
333
|
* All of them, not just one: a converted amount lands on `¥17.80` often enough that a single
|
|
334
334
|
* trailing zero would show up as `¥17.8` beside `¥2.71` and read as a different precision.
|
|
335
|
+
*
|
|
336
|
+
* A nonzero amount that rounds down to zero renders as a bounded floor, `<$0.01`, because `$0`
|
|
337
|
+
* claims nothing was spent — the one thing a billing figure must never say.
|
|
335
338
|
*/
|
|
336
339
|
export function formatCost(cost: number, currency: Currency = USD): string {
|
|
337
|
-
|
|
340
|
+
const amount = cost * currency.perUsd
|
|
341
|
+
if (cost > 0 && Number(amount.toFixed(2)) === 0) return `<${currency.symbol}0.01`
|
|
342
|
+
return `${currency.symbol}${amount.toFixed(2).replace(/\.?0+$/, '')}`
|
|
338
343
|
}
|
|
339
344
|
|
|
340
345
|
/**
|
|
@@ -435,7 +440,8 @@ export function contextRow(
|
|
|
435
440
|
// Whole percents: the meter carries the precision, and a decimal here is noise.
|
|
436
441
|
parts.percent === null ? '--' : `${Math.round(parts.percent)}%`,
|
|
437
442
|
)
|
|
438
|
-
|
|
443
|
+
// The title is Claude Code's: "Context window", so the two panels read the same at a glance.
|
|
444
|
+
const meter = `${theme.fg('dim', 'Context window')} ${bar(theme, BAR_CELLS, fraction)} ${percent}`
|
|
439
445
|
const detail =
|
|
440
446
|
parts.window > 0 ? `${formatTokens(parts.tokens)} / ${formatTokens(parts.window)}` : ''
|
|
441
447
|
const withDetail = detail === '' ? meter : `${meter} ${theme.fg('dim', detail)}`
|
|
@@ -485,3 +491,382 @@ export function isQuietStatus(key: string, value: unknown): boolean {
|
|
|
485
491
|
if (!rule) return false
|
|
486
492
|
return rule[1].test(String(value).replace(ANSI, '').trim())
|
|
487
493
|
}
|
|
494
|
+
|
|
495
|
+
// ----------------------------------------------------------------------------
|
|
496
|
+
// Context breakdown
|
|
497
|
+
//
|
|
498
|
+
// Claude Code's context panel answers "what is occupying the window": messages, memory files,
|
|
499
|
+
// tools, skills, and what is left. pi exposes every ingredient through public API, and this
|
|
500
|
+
// section turns them into one footer line.
|
|
501
|
+
//
|
|
502
|
+
// The accounting rule is pi's own: every bucket is tokens = ceil(chars / 4), the same heuristic
|
|
503
|
+
// as estimateTokens in core/compaction. So every number here is an estimate, and the buckets
|
|
504
|
+
// differ from the row-1 total (which comes from provider usage) by the same margin pi's own
|
|
505
|
+
// estimate does. Claude Code's panel has the same property — its buckets also sum past its
|
|
506
|
+
// header total.
|
|
507
|
+
// ----------------------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
/** Tokens from characters, by pi's own chars/4 heuristic (estimateTokens in core/compaction). */
|
|
510
|
+
export function tokensFromChars(chars: number): number {
|
|
511
|
+
return Math.ceil(Math.max(0, chars) / 4)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Images carry this many estimated characters each, pi's own ESTIMATED_IMAGE_CHARS. */
|
|
515
|
+
const IMAGE_CHARS = 4800
|
|
516
|
+
|
|
517
|
+
/** Characters of message content: strings, text blocks, tool calls and image placeholders. */
|
|
518
|
+
export function contentChars(content: unknown): number {
|
|
519
|
+
if (typeof content === 'string') return content.length
|
|
520
|
+
if (!Array.isArray(content)) return 0
|
|
521
|
+
let chars = 0
|
|
522
|
+
for (const raw of content as unknown[]) {
|
|
523
|
+
if (!isRecord(raw)) continue
|
|
524
|
+
if (raw.type === 'text' && typeof raw.text === 'string') chars += raw.text.length
|
|
525
|
+
else if (raw.type === 'thinking' && typeof raw.thinking === 'string')
|
|
526
|
+
chars += raw.thinking.length
|
|
527
|
+
else if (raw.type === 'image') chars += IMAGE_CHARS
|
|
528
|
+
else if (raw.type === 'toolCall') {
|
|
529
|
+
const name = typeof raw.name === 'string' ? raw.name.length : 0
|
|
530
|
+
let args = 0
|
|
531
|
+
try {
|
|
532
|
+
args = JSON.stringify(raw.arguments ?? {}).length
|
|
533
|
+
} catch {
|
|
534
|
+
// A circular argument object is not worth a crash; count the rest.
|
|
535
|
+
}
|
|
536
|
+
chars += name + args
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return chars
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Tokens one context entry occupies in the window, mirroring pi's sessionEntryToContextMessages:
|
|
544
|
+
* message entries count as their role renders, custom messages and compaction/branch summaries
|
|
545
|
+
* count as the text replayed into context, and system messages count as zero because the prompt
|
|
546
|
+
* bucket below already owns them (counting both would double the prompt).
|
|
547
|
+
*
|
|
548
|
+
* Reads structurally and returns 0 for anything it does not recognize, so a hand-edited session
|
|
549
|
+
* file degrades to an underestimate instead of a crash.
|
|
550
|
+
*/
|
|
551
|
+
export function entryChars(entry: unknown): number {
|
|
552
|
+
if (!isRecord(entry)) return 0
|
|
553
|
+
if (entry.type === 'message') {
|
|
554
|
+
const message = entry.message
|
|
555
|
+
if (!isRecord(message)) return 0
|
|
556
|
+
if (message.role === 'system') return 0
|
|
557
|
+
return contentChars(message.content)
|
|
558
|
+
}
|
|
559
|
+
if (
|
|
560
|
+
entry.type === 'custom_message' ||
|
|
561
|
+
entry.type === 'compaction' ||
|
|
562
|
+
entry.type === 'branch_summary'
|
|
563
|
+
) {
|
|
564
|
+
return typeof entry.summary === 'string' ? entry.summary.length : contentChars(entry.content)
|
|
565
|
+
}
|
|
566
|
+
return 0
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Tokens for a whole context-entry list, as `sessionManager.buildContextEntries()` returns. */
|
|
570
|
+
export function messagesTokens(entries: readonly unknown[]): number {
|
|
571
|
+
let chars = 0
|
|
572
|
+
for (const entry of entries) chars += entryChars(entry)
|
|
573
|
+
return tokensFromChars(chars)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* The inner text and the full tagged span of one system-prompt section, or null when absent.
|
|
578
|
+
*
|
|
579
|
+
* Pi renders every section as `<tag>\n...\n</tag>` (buildSystemPromptSections), so a plain indexOf
|
|
580
|
+
* pair is exact and cannot trip over regex metacharacters in the prompt.
|
|
581
|
+
*/
|
|
582
|
+
function sectionOf(prompt: string, tag: string): { inner: string; full: string } | null {
|
|
583
|
+
const open = `<${tag}>\n`
|
|
584
|
+
const close = `\n</${tag}>`
|
|
585
|
+
const start = prompt.indexOf(open)
|
|
586
|
+
if (start < 0) return null
|
|
587
|
+
const contentStart = start + open.length
|
|
588
|
+
const end = prompt.indexOf(close, contentStart)
|
|
589
|
+
if (end < 0) return null
|
|
590
|
+
return { inner: prompt.slice(contentStart, end), full: prompt.slice(start, end + close.length) }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** How a system prompt splits into its context-file, skills and remaining shares. */
|
|
594
|
+
export interface PromptBreakdown {
|
|
595
|
+
/** Context files (AGENTS.md and friends) inside the project_context section. */
|
|
596
|
+
files: number
|
|
597
|
+
fileCount: number
|
|
598
|
+
/** The skills section, in tokens. */
|
|
599
|
+
skills: number
|
|
600
|
+
skillCount: number
|
|
601
|
+
/** The rest of the prompt: identity, tool list, rules, docs, cwd, custom sections. */
|
|
602
|
+
prompt: number
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Splits a rendered system prompt into shares, in tokens.
|
|
607
|
+
*
|
|
608
|
+
* The sections are cut out of the prompt text itself rather than rebuilt from systemPromptOptions,
|
|
609
|
+
* so extension chains and section patches are all accounted for: what is measured is the exact text
|
|
610
|
+
* the model receives.
|
|
611
|
+
*/
|
|
612
|
+
export function promptBreakdown(prompt: string): PromptBreakdown {
|
|
613
|
+
const files = sectionOf(prompt, 'project_context')
|
|
614
|
+
const skills = sectionOf(prompt, 'skills')
|
|
615
|
+
let rest = prompt
|
|
616
|
+
if (files !== null) rest = rest.replace(files.full, '')
|
|
617
|
+
if (skills !== null) rest = rest.replace(skills.full, '')
|
|
618
|
+
return {
|
|
619
|
+
files: tokensFromChars(files?.inner.length ?? 0),
|
|
620
|
+
fileCount: files === null ? 0 : files.inner.split('<project_instructions ').length - 1,
|
|
621
|
+
skills: tokensFromChars(skills?.inner.length ?? 0),
|
|
622
|
+
skillCount: skills === null ? 0 : skills.inner.split('<skill>').length - 1,
|
|
623
|
+
prompt: tokensFromChars(rest.length),
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** The shape of one entry from `pi.getAllTools()`, read structurally for tests. */
|
|
628
|
+
export interface ToolLike {
|
|
629
|
+
name: string
|
|
630
|
+
description: string
|
|
631
|
+
parameters?: unknown
|
|
632
|
+
sourceInfo?: { source?: string } | null
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** How active tools split into built-in and everything else. */
|
|
636
|
+
export interface ToolSplit {
|
|
637
|
+
/** Built-in tool schemas, in tokens. */
|
|
638
|
+
builtin: number
|
|
639
|
+
/** How many built-in tools are active. */
|
|
640
|
+
builtinCount: number
|
|
641
|
+
/** Extension-, SDK- and MCP-registered tool schemas together, in tokens. */
|
|
642
|
+
other: number
|
|
643
|
+
otherCount: number
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** JSON envelope keys ("name", "description", "input_schema") around each tool schema, in chars. */
|
|
647
|
+
const TOOL_ENVELOPE_CHARS = 48
|
|
648
|
+
|
|
649
|
+
/** Tokens one tool's schema costs on the wire: name, description and parameter JSON. */
|
|
650
|
+
export function toolTokens(tool: ToolLike): number {
|
|
651
|
+
let chars = tool.name.length + tool.description.length + TOOL_ENVELOPE_CHARS
|
|
652
|
+
try {
|
|
653
|
+
chars += JSON.stringify(tool.parameters ?? {}).length
|
|
654
|
+
} catch {
|
|
655
|
+
// A circular schema still costs its name and description; count those.
|
|
656
|
+
}
|
|
657
|
+
return tokensFromChars(chars)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Splits the tools actually sent to the provider into built-in and the rest.
|
|
662
|
+
*
|
|
663
|
+
* Inactive tools cost nothing — the provider never sees them — so only names in `active` count. MCP
|
|
664
|
+
* tools arrive registered by extensions, so they land in `other` with them; pi has no deferred-tool
|
|
665
|
+
* concept that would keep a tool's schema out of the request.
|
|
666
|
+
*/
|
|
667
|
+
export function splitToolTokens(tools: readonly ToolLike[], active: readonly string[]): ToolSplit {
|
|
668
|
+
const wanted = new Set(active)
|
|
669
|
+
const split: ToolSplit = { builtin: 0, builtinCount: 0, other: 0, otherCount: 0 }
|
|
670
|
+
for (const tool of tools) {
|
|
671
|
+
if (!wanted.has(tool.name)) continue
|
|
672
|
+
const tokens = toolTokens(tool)
|
|
673
|
+
if (tool.sourceInfo?.source === 'builtin') {
|
|
674
|
+
split.builtin += tokens
|
|
675
|
+
split.builtinCount += 1
|
|
676
|
+
} else {
|
|
677
|
+
split.other += tokens
|
|
678
|
+
split.otherCount += 1
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return split
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** The breakdown panel's figures, all in tokens and all estimates. */
|
|
685
|
+
export interface BreakdownParts {
|
|
686
|
+
/** The conversation on the active branch. */
|
|
687
|
+
messages: number
|
|
688
|
+
/** Prompt anatomy from the latest capture; null until one exists. */
|
|
689
|
+
promptParts: PromptBreakdown | null
|
|
690
|
+
/** Active tools from the latest capture; null until one exists. */
|
|
691
|
+
toolSplit: ToolSplit | null
|
|
692
|
+
/** Tokens compaction keeps in reserve for the model's reply; null to hide the slot. */
|
|
693
|
+
reserve: number | null
|
|
694
|
+
/** Window minus usage minus reserve; null when the window is unknown. */
|
|
695
|
+
free: number | null
|
|
696
|
+
/** Used share of the window, 0-100; colors Free space with the meter's 70/90 thresholds. */
|
|
697
|
+
usedPercent: number | null
|
|
698
|
+
/** The context window the percentages are anchored to; percentages hide when unknown. */
|
|
699
|
+
window: number
|
|
700
|
+
/** Total used tokens — the hero figure the panel's rows explain. */
|
|
701
|
+
used: number
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* A quiet meter for one bucket's share of the window, in the row-1 meter's own glyphs — `█` fill on
|
|
706
|
+
* `░` track — at whole cells only: the 1/8-cell edge read as stray vertical strokes at this size.
|
|
707
|
+
* One fill color for every row; a bucket that fills the window is not thereby a warning.
|
|
708
|
+
*/
|
|
709
|
+
export function shareBar(
|
|
710
|
+
theme: Theme,
|
|
711
|
+
cells: number,
|
|
712
|
+
fraction: number,
|
|
713
|
+
fill: ThemeColor,
|
|
714
|
+
track: ThemeColor,
|
|
715
|
+
): string {
|
|
716
|
+
const whole = Math.min(cells, Math.floor(Math.max(0, Math.min(1, fraction)) * cells))
|
|
717
|
+
return theme.fg(fill, BAR_FILL.repeat(whole)) + theme.fg(track, BAR_TRACK.repeat(cells - whole))
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* The breakdown panel: what occupies the context window, and what remains — a plain data grid, one
|
|
722
|
+
* row per figure, every column aligned, kinds separated by dim rules.
|
|
723
|
+
*
|
|
724
|
+
* The title is row 1's and appears nowhere else. Three groups, each a different kind of number,
|
|
725
|
+
* each behind a rule: `Used`, the provider-reported total; the estimated buckets, ending with
|
|
726
|
+
* `Unaccounted` so the books close (buckets are chars/4 estimates, Used is real, and the gap is
|
|
727
|
+
* shown rather than hidden — buckets + Unaccounted = Used, and Used + Autocompact buffer + Free
|
|
728
|
+
* space = the window); and what is kept back or still open. Free space is the remainder, not a
|
|
729
|
+
* consumer, and the closed arithmetic is what says so. Counts ride on labels as `×N`; every value
|
|
730
|
+
* is tokens; every percentage is a share of the window; and every row ends in the same muted share
|
|
731
|
+
* bar — the row-1 meter's glyphs at half size — so magnitudes compare without reading a number and
|
|
732
|
+
* without a single new visual device.
|
|
733
|
+
*
|
|
734
|
+
* Bucket names and their order follow Claude Code's panel. Ext tools is the one bucket CC has no
|
|
735
|
+
* name for: pi's non-builtin tools come from extensions, the SDK and MCP together, so "MCP tools"
|
|
736
|
+
* would mislabel most of them.
|
|
737
|
+
*/
|
|
738
|
+
export function breakdownPanel(theme: Theme, width: number, parts: BreakdownParts): string[] {
|
|
739
|
+
const share = (tokens: number): number =>
|
|
740
|
+
parts.window > 0 ? Math.min(1, tokens / parts.window) : 0
|
|
741
|
+
const pctOf = (tokens: number): number | null =>
|
|
742
|
+
parts.window > 0 ? (tokens / parts.window) * 100 : null
|
|
743
|
+
|
|
744
|
+
type Row = {
|
|
745
|
+
label: string
|
|
746
|
+
tokens: number
|
|
747
|
+
pct: number | null
|
|
748
|
+
labelColor?: ThemeColor
|
|
749
|
+
valueColor?: ThemeColor
|
|
750
|
+
}
|
|
751
|
+
// Destructured to zero, so the pushes below read in display order without nesting.
|
|
752
|
+
const { files, fileCount, skills, skillCount, prompt } = parts.promptParts ?? {
|
|
753
|
+
files: 0,
|
|
754
|
+
fileCount: 0,
|
|
755
|
+
skills: 0,
|
|
756
|
+
skillCount: 0,
|
|
757
|
+
prompt: 0,
|
|
758
|
+
}
|
|
759
|
+
const { builtin, builtinCount, other, otherCount } = parts.toolSplit ?? {
|
|
760
|
+
builtin: 0,
|
|
761
|
+
builtinCount: 0,
|
|
762
|
+
other: 0,
|
|
763
|
+
otherCount: 0,
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const pressure = percentColor(parts.usedPercent)
|
|
767
|
+
// Group 1: the real total — the number the estimates below are measured against.
|
|
768
|
+
const total: Row[] = [
|
|
769
|
+
{
|
|
770
|
+
label: 'Used',
|
|
771
|
+
tokens: parts.used,
|
|
772
|
+
pct: parts.usedPercent,
|
|
773
|
+
labelColor: 'text',
|
|
774
|
+
valueColor: pressure,
|
|
775
|
+
},
|
|
776
|
+
]
|
|
777
|
+
// Group 2: the estimated buckets, in the Claude Code panel's order.
|
|
778
|
+
const buckets: Row[] = [{ label: 'Messages', tokens: parts.messages, pct: pctOf(parts.messages) }]
|
|
779
|
+
if (fileCount > 0)
|
|
780
|
+
buckets.push({ label: `Memory files ×${fileCount}`, tokens: files, pct: pctOf(files) })
|
|
781
|
+
if (builtin > 0) {
|
|
782
|
+
buckets.push({
|
|
783
|
+
label: builtinCount > 0 ? `System tools ×${builtinCount}` : 'System tools',
|
|
784
|
+
tokens: builtin,
|
|
785
|
+
pct: pctOf(builtin),
|
|
786
|
+
})
|
|
787
|
+
}
|
|
788
|
+
if (otherCount > 0)
|
|
789
|
+
buckets.push({ label: `Ext tools ×${otherCount}`, tokens: other, pct: pctOf(other) })
|
|
790
|
+
if (skillCount > 0)
|
|
791
|
+
buckets.push({ label: `Skills ×${skillCount}`, tokens: skills, pct: pctOf(skills) })
|
|
792
|
+
if (prompt > 0) buckets.push({ label: 'System prompt', tokens: prompt, pct: pctOf(prompt) })
|
|
793
|
+
// The estimates miss the real total by design; showing the miss keeps the column honest.
|
|
794
|
+
const unaccounted = parts.used - (parts.messages + files + skills + prompt + builtin + other)
|
|
795
|
+
if (unaccounted > 0) {
|
|
796
|
+
buckets.push({
|
|
797
|
+
label: 'Unaccounted',
|
|
798
|
+
tokens: unaccounted,
|
|
799
|
+
pct: pctOf(unaccounted),
|
|
800
|
+
valueColor: 'dim',
|
|
801
|
+
})
|
|
802
|
+
}
|
|
803
|
+
// Group 3: what is kept back, and what remains.
|
|
804
|
+
const remaining: Row[] = []
|
|
805
|
+
if (parts.reserve !== null && parts.reserve > 0) {
|
|
806
|
+
remaining.push({
|
|
807
|
+
label: 'Autocompact buffer',
|
|
808
|
+
tokens: parts.reserve,
|
|
809
|
+
pct: pctOf(parts.reserve),
|
|
810
|
+
})
|
|
811
|
+
}
|
|
812
|
+
if (parts.free !== null) {
|
|
813
|
+
remaining.push({
|
|
814
|
+
label: 'Free space',
|
|
815
|
+
tokens: parts.free,
|
|
816
|
+
pct: pctOf(parts.free),
|
|
817
|
+
valueColor: pressure,
|
|
818
|
+
})
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const groups = [total, buckets, remaining].filter((group) => group.length > 0)
|
|
822
|
+
const labelWidth = Math.max(...groups.flat().map((bucket) => bucket.label.length))
|
|
823
|
+
const barCells = 10
|
|
824
|
+
// Column plan: inset(2) · label · gap(2) · tokens(7) · gap(2) · share(6) · gap(2) · bar(10).
|
|
825
|
+
const gridWidth = labelWidth + 2 + 7 + 2 + 6 + 2 + barCells
|
|
826
|
+
const clip = (line: string): string => truncateToWidth(line, width, theme.fg('dim', '…'))
|
|
827
|
+
const rule = clip(` ${theme.fg('dim', '─'.repeat(gridWidth))}`)
|
|
828
|
+
|
|
829
|
+
const lines: string[] = []
|
|
830
|
+
for (const group of groups) {
|
|
831
|
+
// A rule between kinds — never around them: the kinds are what the rules are for.
|
|
832
|
+
if (lines.length > 0) lines.push(rule)
|
|
833
|
+
for (const bucket of group) {
|
|
834
|
+
const label = theme.fg(bucket.labelColor ?? 'dim', bucket.label.padEnd(labelWidth + 2))
|
|
835
|
+
const tokens = theme.fg(bucket.valueColor ?? 'text', formatTokens(bucket.tokens).padStart(7))
|
|
836
|
+
const pct = (bucket.pct === null ? '—' : `${bucket.pct.toFixed(1)}%`).padStart(6)
|
|
837
|
+
const meter =
|
|
838
|
+
parts.window > 0
|
|
839
|
+
? ` ${shareBar(theme, barCells, share(bucket.tokens), 'muted', 'dim')}`
|
|
840
|
+
: ''
|
|
841
|
+
lines.push(clip(` ${label}${tokens} ${theme.fg(bucket.valueColor ?? 'text', pct)}${meter}`))
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return lines
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Reads the config's `detail` flag: the concise footer unless the file asks for the panel. */
|
|
848
|
+
export function detailFromConfig(text: string | null): boolean {
|
|
849
|
+
if (text === null) return false
|
|
850
|
+
let config: unknown
|
|
851
|
+
try {
|
|
852
|
+
config = JSON.parse(text)
|
|
853
|
+
} catch {
|
|
854
|
+
return false
|
|
855
|
+
}
|
|
856
|
+
return isRecord(config) && config.detail === true
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* The config file's text with `detail` set, or null when the file cannot be parsed — the extension
|
|
861
|
+
* has no business replacing a config it could not read with one it wrote.
|
|
862
|
+
*/
|
|
863
|
+
export function withDetailFlag(text: string, value: boolean): string | null {
|
|
864
|
+
let config: unknown
|
|
865
|
+
try {
|
|
866
|
+
config = JSON.parse(text)
|
|
867
|
+
} catch {
|
|
868
|
+
return null
|
|
869
|
+
}
|
|
870
|
+
if (!isRecord(config)) return null
|
|
871
|
+
return `${JSON.stringify({ ...config, detail: value }, null, 2)}\n`
|
|
872
|
+
}
|