useful-pi-extensions 1.10.3 → 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 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.10.3",
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 be
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 | Meaning |
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` | Absolute context tokens over the window size. The first thing dropped when the terminal is narrow |
26
- | `Input` / `Output` | Session prompt and completion tokens. `Input` counts the prompt tokens that were neither read from nor written to cache, because pi reports those two separately in the same `usage` object |
27
- | `Cache hit` | The latest turn's cache hit rate, `cacheRead / (input + cacheRead + cacheWrite)` |
28
- | `Cost` | Session cost, in USD unless a config file names another currency (see below) |
29
- | `Today` | Today's running cost across every project, session and model on this machine |
30
- | Effort | The active thinking level |
31
- | `TTFT` | Time from request dispatch to the first streamed token |
32
- | `tok/s` | Decode throughput, i.e. output tokens per second of decode time |
33
- | Last line | Other extensions' `ctx.ui.setStatus()` entries, so they do not silently disappear |
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.sessionManager.getEntries()` and `ctx.getContextUsage()`, both public
118
- API; nothing reaches into pi's internals.
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
- 终端变窄时,这一行是**按固定顺序**逐项舍弃的,而不是整体消失:先丢 `471k / 1.0M`,
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` | 上下文已用 token / 窗口大小。终端变窄时**第一个被舍弃** |
25
- | `Input` / `Output` | 会话的输入与输出 token 量。`Input` 指既没命中缓存、也没写入缓存的那部分 prompt token,因为 pi 把这两种情况在同一个 `usage` 对象里分开报 |
26
- | `Cache hit` | 最近一轮的缓存命中率,`cacheRead / (input + cacheRead + cacheWrite)` |
27
- | `Cost` | 会话花费,默认美元;配置了其他币种则换算显示(见下) |
28
- | `Today` | 当天跨项目、跨会话、跨模型的累计花费 |
29
- | Effort | 当前思考等级 |
30
- | `TTFT` | 从发出请求到第一个流式 token 的耗时 |
31
- | `tok/s` | 解码吞吐,即每秒解码时间产出的输出 token |
32
- | 最后一行 | 其他扩展通过 `ctx.ui.setStatus()` 设的状态,否则它们会静默消失 |
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 只读取 `ctx.sessionManager.getEntries()` 和 `ctx.getContextUsage()`,都是公开 API,
105
- 没有触碰 pi 内部实现。
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 = [row1, row(theme, width, row2Left, row2Right)]
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
  }
@@ -440,7 +440,8 @@ export function contextRow(
440
440
  // Whole percents: the meter carries the precision, and a decimal here is noise.
441
441
  parts.percent === null ? '--' : `${Math.round(parts.percent)}%`,
442
442
  )
443
- const meter = `${theme.fg('dim', 'Context')} ${bar(theme, BAR_CELLS, fraction)} ${percent}`
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}`
444
445
  const detail =
445
446
  parts.window > 0 ? `${formatTokens(parts.tokens)} / ${formatTokens(parts.window)}` : ''
446
447
  const withDetail = detail === '' ? meter : `${meter} ${theme.fg('dim', detail)}`
@@ -490,3 +491,382 @@ export function isQuietStatus(key: string, value: unknown): boolean {
490
491
  if (!rule) return false
491
492
  return rule[1].test(String(value).replace(ANSI, '').trim())
492
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
+ }