@bachi/pi-coder 1.0.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -0
  4. package/config/AGENTS.md +100 -0
  5. package/config/pi-statusline.json +140 -0
  6. package/config/settings.json +38 -0
  7. package/config/web-search.json +5 -0
  8. package/docs/README.md +14 -0
  9. package/docs/configuration.md +123 -0
  10. package/docs/development.md +177 -0
  11. package/docs/extensions.md +292 -0
  12. package/docs/handbook.zh.md +432 -0
  13. package/docs/installation.md +124 -0
  14. package/docs/themes.md +107 -0
  15. package/extensions/ask-user-question/answers.test.ts +104 -0
  16. package/extensions/ask-user-question/answers.ts +72 -0
  17. package/extensions/ask-user-question/dialog.test.ts +180 -0
  18. package/extensions/ask-user-question/dialog.ts +102 -0
  19. package/extensions/ask-user-question/index.ts +253 -0
  20. package/extensions/ask-user-question/model.test.ts +275 -0
  21. package/extensions/ask-user-question/model.ts +259 -0
  22. package/extensions/ask-user-question/schema.ts +49 -0
  23. package/extensions/ask-user-question/types.ts +86 -0
  24. package/extensions/ask-user-question/validate.test.ts +183 -0
  25. package/extensions/ask-user-question/validate.ts +110 -0
  26. package/extensions/ask-user-question/view.ts +262 -0
  27. package/extensions/auto-default-model/default-model.test.ts +268 -0
  28. package/extensions/auto-default-model/index.ts +87 -0
  29. package/extensions/bash-command-collapse.ts +1476 -0
  30. package/extensions/below-editor-after-statusline.ts +118 -0
  31. package/extensions/clear-command.ts +29 -0
  32. package/extensions/cwd-statusline.ts +39 -0
  33. package/extensions/exit-command.ts +59 -0
  34. package/extensions/fenceless-code-block/index.test.ts +208 -0
  35. package/extensions/fenceless-code-block/index.ts +28 -0
  36. package/extensions/fenceless-code-block/render.test.ts +177 -0
  37. package/extensions/fenceless-code-block/render.ts +142 -0
  38. package/extensions/folder-history.ts +197 -0
  39. package/extensions/init-command.ts +163 -0
  40. package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
  41. package/extensions/prompt-editor/bash-prompt.ts +59 -0
  42. package/extensions/prompt-editor/render.test.ts +283 -0
  43. package/extensions/prompt-editor.ts +212 -0
  44. package/extensions/read-path-collapse.ts +474 -0
  45. package/extensions/recap/index.test.ts +348 -0
  46. package/extensions/recap/index.ts +462 -0
  47. package/extensions/recap/subagents.test.ts +144 -0
  48. package/extensions/recap/subagents.ts +128 -0
  49. package/extensions/rewind/README.md +229 -0
  50. package/extensions/rewind/checkpoints.test.ts +560 -0
  51. package/extensions/rewind/checkpoints.ts +820 -0
  52. package/extensions/rewind/flow.test.ts +756 -0
  53. package/extensions/rewind/flow.ts +362 -0
  54. package/extensions/rewind/index.ts +400 -0
  55. package/extensions/rewind/picker.ts +135 -0
  56. package/extensions/rewind/viewport.test.ts +76 -0
  57. package/extensions/rewind/viewport.ts +48 -0
  58. package/extensions/simple-task/gap.test.ts +147 -0
  59. package/extensions/simple-task/gap.ts +122 -0
  60. package/extensions/simple-task/index.ts +439 -0
  61. package/extensions/simple-task/types.ts +53 -0
  62. package/extensions/simple-task/widget.ts +86 -0
  63. package/extensions/startup-logo/header-guard.test.ts +274 -0
  64. package/extensions/startup-logo/header-guard.ts +166 -0
  65. package/extensions/startup-logo/index.test.ts +305 -0
  66. package/extensions/startup-logo/index.ts +194 -0
  67. package/extensions/startup-logo/loaded-sections.test.ts +257 -0
  68. package/extensions/startup-logo/loaded-sections.ts +267 -0
  69. package/extensions/startup-logo/logo.test.ts +124 -0
  70. package/extensions/startup-logo/logo.ts +124 -0
  71. package/extensions/statusline/footer-guard.test.ts +273 -0
  72. package/extensions/statusline/footer-guard.ts +171 -0
  73. package/extensions/statusline/git.test.ts +174 -0
  74. package/extensions/statusline/git.ts +142 -0
  75. package/extensions/statusline/index.ts +294 -0
  76. package/extensions/statusline/line.test.ts +316 -0
  77. package/extensions/statusline/line.ts +201 -0
  78. package/extensions/subagent-log-guard/filter.test.ts +85 -0
  79. package/extensions/subagent-log-guard/filter.ts +32 -0
  80. package/extensions/subagent-log-guard/index.ts +112 -0
  81. package/extensions/theme-command.ts +263 -0
  82. package/extensions/thinking-collapse/window.test.ts +321 -0
  83. package/extensions/thinking-collapse/window.ts +354 -0
  84. package/extensions/thinking-collapse.ts +60 -0
  85. package/extensions/tool-diff/title-row.test.ts +254 -0
  86. package/extensions/tool-diff/title-row.ts +191 -0
  87. package/extensions/tool-diff.ts +1276 -0
  88. package/extensions/working-indicator/bash-spinner.test.ts +135 -0
  89. package/extensions/working-indicator/bash-spinner.ts +114 -0
  90. package/extensions/working-indicator/index.test.ts +579 -0
  91. package/extensions/working-indicator/index.ts +940 -0
  92. package/extensions/working-indicator/spinner-frames.test.ts +219 -0
  93. package/extensions/working-indicator/spinner-frames.ts +156 -0
  94. package/extensions/working-indicator/summary-request.test.ts +195 -0
  95. package/extensions/working-indicator/summary-request.ts +207 -0
  96. package/extensions/working-indicator/working-summary.test.ts +499 -0
  97. package/extensions/working-indicator/working-summary.ts +375 -0
  98. package/package.json +71 -0
  99. package/themes/ayu.json +97 -0
  100. package/themes/catppuccin.json +103 -0
  101. package/themes/summer-night.json +87 -0
@@ -0,0 +1,474 @@
1
+ /**
2
+ * read-path-collapse — read 工具调用标题行的路径压缩:**永远一行**,超宽时用 `…` 前缀
3
+ * 表示被省略的前缀,把**文件名尾部**显示完整。
4
+ *
5
+ * 要解决的问题:pi 内置 read 的标题行是 `read <路径>:<行号>`,路径由 pi-tui 的
6
+ * `Text`(`wrapTextWithAnsi`)折行 —— 那是**贪心词折行**,装不进当前行剩余空间的词会被
7
+ * **整块挑到下一行**再去断。长路径是一整个「词」(中间没有空格),于是实测 79 列终端上
8
+ *
9
+ * read
10
+ * ~/.pi/agent/npm/node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/loader.js:62-116
11
+ *
12
+ * 会渲成 `read` 单独一行(首行只有 4 列、行尾白白空着七十多列)+ 路径从中间断成两截。
13
+ * 和 `bash-command-collapse.ts` 里 `$ cp <78 列路径>` 那个「提前折行」是同一个根因,
14
+ * 只是 bash 那边选择 break-all 硬折行(命令正文要尽量完整可见),这里选择**压缩到一行**:
15
+ * 路径的信息量集中在**尾部**(文件名 + 最后几级目录),前面的 `~/.pi/agent/npm/...`
16
+ * 那串前缀谁都知道,宁可省掉前缀也不要占两行。
17
+ *
18
+ * ## 结果形态
19
+ *
20
+ * Read …@earendil-works/pi-coding-agent/dist/core/extensions/loader.js:62-116
21
+ *
22
+ * - `Read ` 前缀(`toolTitle` 色 + 粗体)与 `:62-116` 行号区间(`warning` 色)原样保留;
23
+ * - 路径用 `text` 色(与 `tool-diff.ts` 的 `Edit <path>` 同一约定),**不是 pi 内置的
24
+ * `accent`**:pi 的 `renderToolPath()` 给路径上的是 `accent`,而标题行是「动词 + 路径」
25
+ * 并排 —— 只要皮肤的 `accent` 与 `toolTitle` 取同一个调色板色(catppuccin 的 mauve),
26
+ * 两段就完全同色、看不出层次。所以**装得下的短路径也换色**(见 `recolorToolPath`);
27
+ * - 路径从**尾部**往前装:一级一级目录往左加,装到恰好放不下为止,前面补一个 `…`;
28
+ * - 装得下时(短路径)**结构完全不动 pi 的原始渲染**(只把路径颜色从 `accent` 换成 `text`),包括
29
+ * OSC 8 可点击超链接;
30
+ * - 极窄终端下连最后一级目录都装不下时,按 grapheme 从右往左硬截(emoji / 组合字符
31
+ * 不会被切成两半,CJK 按 2 列计)。
32
+ *
33
+ * ## 工具名首字母大写(`Read`)
34
+ *
35
+ * pi 内置 read 渲染器写的是小写 `read`,本扩展显示成 `Read` —— 与 `tool-diff.ts` 的
36
+ * `Edit` / `Write` 同一约定(仿 Claude Code 的工具标题观感),**只改显示形态**:注册名、
37
+ * 路由、session 记录里的原文都不动。三种动词形态(`read <路径>:<行号>` /
38
+ * `read docs <标签>` / `read resource <标签>`)与压缩后自绘的标题都覆盖;`[skill] <目录名>`
39
+ * 形态里没有这个词,原样保留。替换只发生在**动词段**内、且只动**第一行**
40
+ * (`capitalizeReadVerb` / `capitalizeReadTitle`),读一个名字里带 `read` 的文件、或折到行首的
41
+ * `read ` 片段都不会被误改。`/read-collapse off` 只关长路径压缩,工具名照样大写。
42
+ *
43
+ * ## 只在需要压缩时才接管渲染(不重写整个 renderCall)
44
+ *
45
+ * 做法是**后处理** pi 渲好的组件:先委托内置 `readRenderers.renderCall` 拿到那个 `Text`,
46
+ * 在 `render(width)` 里渲染它;**≤ 1 行就原样返回**(pi 的配色、`~` 缩写、OSC 8 超链接、
47
+ * `[skill]` / `read docs` / `read resource` 紧凑形态、`(ctrl+o to expand)` 提示全部保留),
48
+ * **> 1 行才自己重建一行压缩标题**。这样短路径(绝大多数 read)走的还是 pi 的原生渲染,
49
+ * 本扩展不引入任何行为差异;只有真正超宽的那部分才换成压缩形态。
50
+ *
51
+ * 两个必须注意的坑:
52
+ * 1. **`lastComponent` 必须传内层 `Text`,不能传我们自己的 wrapper** —— 内置 read.js 是
53
+ * `context.lastComponent ?? new Text("", 0, 0)` 然后 `setText()`;pi 会把上一次
54
+ * renderCall 的返回值(我们的 wrapper)当 lastComponent 传回来,wrapper 没有 setText,
55
+ * 于是 renderCall 抛异常,而 `updateDisplay()` 的 try/catch 会**静默退回**
56
+ * `createCallFallback()` —— 标题只剩一个光秃秃的 `read`,路径全丢。所以内层组件存在
57
+ * `context.state.innerText` 里跨次复用(与 `bash-command-collapse.ts` 的
58
+ * `state.innerComponent` 同一套做法)。
59
+ * 2. **OSC 8 超链接的开关不能读我们自己那份 pi-tui 的 `getCapabilities()`** —— 扩展
60
+ * import 到的 pi-tui 是 loader alias 指向的 npm/dist 副本,pi 运行时(bundle)调的
61
+ * `setCapabilityOverrides(settings)` 只改 bundle 那份的缓存,副本按环境变量自己检测,
62
+ * 两边可能不一致(终端不支持 OSC 8 时副本却判 true → 链接文本被吞)。所以改成
63
+ * **从 pi 渲出来的标题里探测**:pi 的标题串里出现 `\x1b]8;;` 就说明它启用了超链接,
64
+ * 我们再用 pi-tui 的 `hyperlink()`(纯字符串拼接函数,不查能力)复刻同一个 URL。
65
+ *
66
+ * ## 紧凑形态(`[skill]` / `read docs` / `read resource`)
67
+ *
68
+ * pi 对 SKILL.md、pi 自己的 README/docs/examples、AGENTS.md/CLAUDE.md 这几类文件不显示
69
+ * 完整路径,而是显示 `[skill] <目录名>` / `read docs <相对标签>` / `read resource <相对路径>`
70
+ * (`getCompactReadClassification`,模块私有)。这些标签通常很短,但 `read resource` 的
71
+ * 标签是**相对 cwd 的路径**,一样可能超宽。所以压缩分支把这三种形态一并复刻
72
+ * (`getReadmePath()` 是包根导出的,docs 判定与 pi 同源),压缩的只是标签本身,前缀与
73
+ * `(ctrl+o to expand)` 提示保留。
74
+ *
75
+ * 提示行里的键名**绝不能 import pi 的 `keyHint` / `keyText`**:实测 `keyHint` 抛
76
+ * `Theme not initialized`(读的是 npm/dist 副本自己的 theme 单例,pi 只初始化了 bundle 那份)、
77
+ * `keyText` 返回空串(副本的 keybindings 表是空的),而 renderCall 抛异常会被 pi 静默 catch
78
+ * 退回 fallback。所以从 `~/.pi/agent/keybindings.json` 读 `app.tools.expand`,读不到用默认
79
+ * `ctrl+o`(与 `bash-command-collapse.ts` 的 `expandKeyText()` 同一套做法)。
80
+ * 终端窄到给标签留不出 `MIN_LABEL_WIDTH` 列时,先丢掉提示行再算一遍预算 —— 提示是说明性的,
81
+ * 文件名更重要。
82
+ *
83
+ * ## 展开态(ctrl+o)
84
+ *
85
+ * `context.expanded` 为 true 时 pi 不做紧凑形态判定(一律走完整路径),本扩展同样只在
86
+ * 超宽时压缩,规则完全一致 —— 折叠态与展开态的折行规则必须一致,否则展开态又会提前折行。
87
+ * 注意展开态**不影响文件内容**的显示(那是 renderResult 的事,本扩展不碰 renderResult)。
88
+ *
89
+ * 纯显示层:发给模型的 tool call 参数、session 记录里的原文、read 的执行逻辑完全不变
90
+ * (`execute` / `parameters` / `description` / `promptSnippet` / `promptGuidelines` /
91
+ * `constrainedSampling` 全部由 `{ ...base }` 从 `createReadToolDefinition()` 原样继承)。
92
+ *
93
+ * 用法:
94
+ * /read-collapse 查看当前状态
95
+ * /read-collapse off 关闭压缩(回到 pi 的贪心折行,长路径占两行)
96
+ * /read-collapse on 打开压缩(默认)
97
+ * PI_READ_COLLAPSE=off 启动时就关闭
98
+ */
99
+
100
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
101
+ import { createReadToolDefinition, getReadmePath } from "@earendil-works/pi-coding-agent";
102
+ import { hyperlink, Text, visibleWidth } from "@earendil-works/pi-tui";
103
+ import { readFileSync } from "node:fs";
104
+ import { homedir } from "node:os";
105
+ import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath, sep } from "node:path";
106
+ import { pathToFileURL } from "node:url";
107
+
108
+ /** 省略前缀的标记。1 列宽,`visibleWidth` 量出来就是 1。 */
109
+ const ELLIPSIS = "…";
110
+ /** 紧凑形态下给标签留的最少列数,低于它就丢掉 `(ctrl+o to expand)` 提示再算一遍预算。 */
111
+ const MIN_LABEL_WIDTH = 12;
112
+ /** pi 判定为「紧凑形态」的资源文件名(照抄 read.js 的 COMPACT_RESOURCE_FILE_NAMES)。 */
113
+ const COMPACT_RESOURCE_FILE_NAMES = new Set(["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]);
114
+
115
+ /** grapheme 分段器(pi-tui 没导出它自己的实例,本地建一个;Node 内置 Intl.Segmenter)。 */
116
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
117
+
118
+ /** pi 的 agent 目录(`PI_CODING_AGENT_DIR` 可覆盖,否则 `~/.pi/agent`)。 */
119
+ function resolveAgentDir(): string {
120
+ const envDir = process.env.PI_CODING_AGENT_DIR;
121
+ return envDir ? (envDir.startsWith("~") ? join(homedir(), envDir.slice(1)) : envDir) : join(homedir(), ".pi", "agent");
122
+ }
123
+
124
+ /**
125
+ * `app.tools.expand` 的键名文本(默认 `ctrl+o`)。
126
+ *
127
+ * 不能用 pi 导出的 `keyText` / `keyHint`,理由见文件头「紧凑形态」一节。
128
+ */
129
+ function expandKeyText(): string {
130
+ try {
131
+ const parsed = JSON.parse(readFileSync(join(resolveAgentDir(), "keybindings.json"), "utf8"));
132
+ const bound = parsed?.["app.tools.expand"];
133
+ const keys = Array.isArray(bound) ? bound : [bound];
134
+ const text = keys.filter((k: unknown): k is string => typeof k === "string" && k.trim() !== "").join("/");
135
+ if (text) return text;
136
+ } catch {
137
+ // 没配置文件 / 解析失败 / 没绑这个键,都用默认值
138
+ }
139
+ return "ctrl+o";
140
+ }
141
+
142
+ /** pi 的 `str()`:string 原样,null/undefined → "",其它类型 → null(= invalid arg)。 */
143
+ function strArg(value: unknown): string | null {
144
+ if (typeof value === "string") return value;
145
+ if (value == null) return "";
146
+ return null;
147
+ }
148
+
149
+ /** pi 的 `shortenPath()`:`$HOME` 前缀换成 `~`(注意 pi 用的是裸 startsWith,不要求分隔符)。 */
150
+ function shortenPath(path: string): string {
151
+ const home = homedir();
152
+ if (home && path.startsWith(home)) return `~${path.slice(home.length)}`;
153
+ return path;
154
+ }
155
+
156
+ /**
157
+ * 复刻 `resolveToCwd()` 里我们用得上的那部分:剥 `@` 前缀、展开 `~`、相对 cwd 解析。
158
+ * pi 还会归一 Unicode 空格(macOS 截图文件名那些窄不换行空格),这里跳过 ——
159
+ * 它只影响文件名里带特殊空格的路径,且我们只用它来算 OSC 8 的 URL 与紧凑形态判定。
160
+ */
161
+ function resolveAgainstCwd(rawPath: string, cwd: string): string {
162
+ let normalized = rawPath;
163
+ if (normalized.startsWith("@")) normalized = normalized.slice(1);
164
+ if (normalized === "~") return homedir();
165
+ if (normalized.startsWith("~/")) return join(homedir(), normalized.slice(2));
166
+ return isAbsolute(normalized) ? resolvePath(normalized) : resolvePath(cwd, normalized);
167
+ }
168
+
169
+ /** pi 的 `getCwdRelativePath()`:cwd 内返回相对路径,否则 undefined。 */
170
+ function cwdRelativePath(filePath: string, cwd: string): string | undefined {
171
+ const resolvedCwd = resolvePath(cwd);
172
+ const resolved = resolvePath(filePath, resolvedCwd);
173
+ const rel = relative(resolvedCwd, resolved);
174
+ const inside = rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
175
+ return inside ? rel || "." : undefined;
176
+ }
177
+
178
+ /** pi 的 `formatPathRelativeToCwdOrAbsolute()`:cwd 内给相对路径,否则绝对路径,一律 posix 分隔符。 */
179
+ function pathRelativeToCwdOrAbsolute(filePath: string, cwd: string): string {
180
+ const absolutePath = resolvePath(filePath, cwd);
181
+ return (cwdRelativePath(absolutePath, cwd) ?? absolutePath).split(sep).join("/");
182
+ }
183
+
184
+ /** pi 的 `getPiDocsClassification()`:pi 包内的 README.md / docs/** / examples/**。 */
185
+ function piDocsLabel(absolutePath: string): string | undefined {
186
+ try {
187
+ const packageRoot = dirname(getReadmePath());
188
+ const rel = relative(resolvePath(packageRoot), resolvePath(absolutePath));
189
+ if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined;
190
+ const label = rel.split(sep).join("/");
191
+ if (label === "README.md" || label.startsWith("docs/") || label.startsWith("examples/")) return label;
192
+ } catch {
193
+ // getReadmePath() 抛了就放弃 docs 判定(回落到完整路径形态)
194
+ }
195
+ return undefined;
196
+ }
197
+
198
+ interface CompactClassification {
199
+ kind: "skill" | "docs" | "resource";
200
+ label: string;
201
+ }
202
+
203
+ /** 复刻 pi 的 `getCompactReadClassification()`(模块私有,只能照抄)。 */
204
+ function getCompactClassification(args: any, cwd: string): CompactClassification | undefined {
205
+ const rawPath = strArg(args?.path);
206
+ if (!rawPath) return undefined;
207
+ const absolutePath = resolveAgainstCwd(rawPath, cwd);
208
+ const fileName = basename(absolutePath);
209
+ if (fileName === "SKILL.md") return { kind: "skill", label: basename(dirname(absolutePath)) || fileName };
210
+ const docsLabel = piDocsLabel(absolutePath);
211
+ if (docsLabel) return { kind: "docs", label: docsLabel };
212
+ if (COMPACT_RESOURCE_FILE_NAMES.has(fileName)) return { kind: "resource", label: pathRelativeToCwdOrAbsolute(absolutePath, cwd) };
213
+ return undefined;
214
+ }
215
+
216
+ /** pi 的 `formatReadLineRange()`:`:62-116`(warning 色),offset/limit 都没给就返回 ""。 */
217
+ function formatReadLineRange(args: any, theme: any): string {
218
+ if (args?.offset === undefined && args?.limit === undefined) return "";
219
+ const startLine = args.offset ?? 1;
220
+ const endLine = args.limit !== undefined ? startLine + args.limit - 1 : "";
221
+ return theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
222
+ }
223
+
224
+ /**
225
+ * 按 grapheme 从**右**往左取,取到装满预算为止。
226
+ * 用于极窄终端下连最后一级目录都装不下的兜底 —— 按列硬切会把 emoji / 组合字符切成两半,
227
+ * 宽字符(CJK 等,2 列)装不进剩下的 1 列时就在它前面停(留 1 列空白),一个 grapheme 不可分。
228
+ */
229
+ function tailGraphemes(text: string, budget: number): string {
230
+ if (budget <= 0) return "";
231
+ let out = "";
232
+ let used = 0;
233
+ for (const { segment } of [...graphemeSegmenter.segment(text)].reverse()) {
234
+ const w = visibleWidth(segment);
235
+ if (used + w > budget) break;
236
+ out = segment + out;
237
+ used += w;
238
+ }
239
+ return out;
240
+ }
241
+
242
+ /**
243
+ * 路径压缩:装得下就原样返回;装不下就从**尾部**往前一级一级加目录,前面补 `…`。
244
+ *
245
+ * 刻意保留尽量多的尾部层级而不是只留文件名:同样的列预算,
246
+ * `…core/extensions/loader.js` 比 `…loader.js` 更能说明读的是哪个文件(同名文件很常见)。
247
+ */
248
+ function compressTail(text: string, budget: number): string {
249
+ const width = Math.max(1, Math.floor(budget));
250
+ if (visibleWidth(text) <= width) return text;
251
+ const ellipsisWidth = visibleWidth(ELLIPSIS);
252
+ // macOS / Linux 是 `/`;Windows 的 `~C:\...` 也照办(pi 的 shortenPath 不改分隔符)
253
+ const separator = text.includes("/") ? "/" : "\\";
254
+ const segments = text.split(separator);
255
+ let tail = "";
256
+ for (let i = segments.length - 1; i >= 0; i--) {
257
+ const candidate = tail ? `${segments[i]}${separator}${tail}` : segments[i];
258
+ if (visibleWidth(candidate) + ellipsisWidth > width) break;
259
+ tail = candidate;
260
+ }
261
+ if (!tail) {
262
+ // 连最后一级目录都装不下:按 grapheme 从右往左硬截
263
+ return ELLIPSIS + tailGraphemes(segments[segments.length - 1] ?? "", width - ellipsisWidth);
264
+ }
265
+ return ELLIPSIS + tail;
266
+ }
267
+
268
+ /**
269
+ * 探测 pi 有没有启用 OSC 8 超链接:看它渲出来的标题串里有没有 `\x1b]8;;`。
270
+ * 理由见文件头 —— 不能读副本自己的 `getCapabilities()`。
271
+ * `inner.text` 是 `Text` 的私有字段(TS 层面 private,运行时可读);拿不到就退回渲染行。
272
+ */
273
+ function piTitleUsesHyperlink(inner: any, lines: string[]): boolean {
274
+ const raw = typeof inner?.text === "string" ? inner.text : lines.join("\n");
275
+ return raw.includes("\x1b]8;;");
276
+ }
277
+
278
+ /**
279
+ * 前景色重置序列 —— `theme.fg()` 的每个色段都以它收尾,所以行内第一个它标着**动词段的结束**。
280
+ */
281
+ const FG_RESET = "\x1b[39m";
282
+
283
+ /** SGR 序列(`theme.fg()` / `theme.bold()` 发出的那些),只用于量可见文本。 */
284
+ const SGR_SEQUENCE = /\x1b\[[0-9;]*m/g;
285
+
286
+ /**
287
+ * 把标题行里的动词 `read` 换成大写的 `Read`(详见文件头「工具名首字母大写」一节)。
288
+ *
289
+ * 只在**动词段**(行首到第一个 `\x1b[39m`,与 `recolorToolPath` 同一套定位)里动手,并在
290
+ * 剥掉 SGR 后要求这段可见文本恰好是 `read` 或以 `read ` 开头:
291
+ * - `read <路径>` / `read docs <标签>` / `read resource <标签>` 命中;
292
+ * - `[skill] <目录名>` 的动词段是 `[skill]`,不含这个词,原样返回;
293
+ * - 颜色被关掉(没有 ANSI)时动词段退化成整行,同一条件仍能把 `readme.md` 这类
294
+ * 以 `read` 开头却没有空格的续行排除在外;命中时行内第一个 `read` 必然是动词本身。
295
+ */
296
+ function capitalizeReadVerb(line: string): string {
297
+ const verbEnd = line.indexOf(FG_RESET);
298
+ const head = verbEnd === -1 ? line : line.slice(0, verbEnd);
299
+ const visible = head.replace(SGR_SEQUENCE, "");
300
+ if (visible !== "read" && !visible.startsWith("read ")) return line;
301
+ const at = head.indexOf("read");
302
+ return head.slice(0, at) + "Read" + line.slice(at + "read".length);
303
+ }
304
+
305
+ /**
306
+ * 标题行数组的入口:**只动第一行**。动词只可能出现在第一行;续行全是路径 / 标签,
307
+ * 那里以 `read ` 开头的片段(带空格的目录名 / 文件名被折到行首)不能被误改成大写。
308
+ */
309
+ function capitalizeReadTitle(lines: string[]): string[] {
310
+ if (lines.length === 0) return lines;
311
+ const [first, ...rest] = lines;
312
+ return [capitalizeReadVerb(first), ...rest];
313
+ }
314
+
315
+ /**
316
+ * 把 pi 渲染出来的标题行里的**路径**颜色从 `accent` 换成 `text`。
317
+ *
318
+ * 为什么是**替换 SGR** 而不是自己重建整行:这一支(装得下的短路径)刻意保留 pi 的原生
319
+ * 渲染 —— 折行、OSC 8 超链接、`[skill]` / `read docs` / `read resource` 紧凑形态、
320
+ * `(ctrl+o to expand)` 提示全都由 pi 自己决定,本扩展只改一个颜色。pi 的 read 标题里
321
+ * `accent` 只用在路径上(`renderToolPath()`)或紧凑形态的标签上(`formatCompactReadCall`
322
+ * 的 docs / resource 分支),`skill` 形态的标签走 `customMessageText`,不受影响。
323
+ *
324
+ * **只换动词之后那一段**:动词(`read`)是整行的第一个色段,第一个 `\x1b[39m` 就是它的
325
+ * 结尾;后面的 `accent` 才是路径。这一步不是耍小聪明 —— 皮肤把 `accent` 与 `toolTitle` 设成
326
+ * 同一个调色板色时(上游 catppuccin 的 mauve 就是)整行直接替换会把动词一起换成 `text`,
327
+ * 两段仍旧同色,正好就是本扩展要消掉的那个症状。
328
+ *
329
+ * 两个色相同时(皮肤把 `accent` 和 `text` 设成同一个色)直接原样返回,不做无谓改写 ——
330
+ * `getFgAnsi` 拿到的就是 pi 自己会发的那个序列(truecolor / 256 色两种模式都对得上)。
331
+ */
332
+ function recolorToolPath(line: string, theme: any): string {
333
+ const accent = theme.getFgAnsi("accent");
334
+ const text = theme.getFgAnsi("text");
335
+ if (!accent || accent === text) return line;
336
+ const verbEnd = line.indexOf(FG_RESET);
337
+ if (verbEnd === -1) return line.split(accent).join(text);
338
+ const cut = verbEnd + FG_RESET.length;
339
+ return line.slice(0, cut) + line.slice(cut).split(accent).join(text);
340
+ }
341
+
342
+ /** 压缩后的完整路径形态:`read …<尾部路径>:<行号>`。 */
343
+ function compressedPathTitle(args: any, theme: any, cwd: string, budget: number, hyperlinksEnabled: boolean): string {
344
+ const prefix = `${theme.fg("toolTitle", theme.bold("Read"))} `;
345
+ const range = formatReadLineRange(args, theme);
346
+ const rawPath = strArg(args?.path);
347
+ // 与 pi 的 renderToolPath 对齐:null → `[invalid arg]`,空串 → `...`(toolOutput 色)
348
+ if (rawPath === null) return `${prefix}${theme.fg("error", "[invalid arg]")}${range}`;
349
+ if (!rawPath) return `${prefix}${theme.fg("toolOutput", "...")}${range}`;
350
+ const styled = theme.fg("text", compressTail(shortenPath(rawPath), budget - visibleWidth(prefix) - visibleWidth(range)));
351
+ const display = hyperlinksEnabled ? hyperlink(styled, pathToFileURL(resolveAgainstCwd(rawPath, cwd)).href) : styled;
352
+ return prefix + display + range;
353
+ }
354
+
355
+ /** 压缩后的紧凑形态:`[skill] …<标签>` / `read docs …<标签>` / `read resource …<标签>`。 */
356
+ function compressedCompactTitle(
357
+ classification: CompactClassification,
358
+ args: any,
359
+ theme: any,
360
+ budget: number,
361
+ hyperlinksEnabled: boolean,
362
+ cwd: string,
363
+ ): string {
364
+ const range = formatReadLineRange(args, theme);
365
+ const hint = theme.fg("dim", ` (${expandKeyText()} to expand)`);
366
+ const isSkill = classification.kind === "skill";
367
+ const prefix = isSkill
368
+ ? theme.fg("customMessageLabel", "\x1b[1m[skill]\x1b[22m ")
369
+ : `${theme.fg("toolTitle", theme.bold(`Read ${classification.kind}`))} `;
370
+ // 先带提示算预算;窄到给标签留不出 MIN_LABEL_WIDTH 列就丢掉提示再算一遍
371
+ let suffix = hint;
372
+ let labelBudget = budget - visibleWidth(prefix) - visibleWidth(range) - visibleWidth(hint);
373
+ if (labelBudget < MIN_LABEL_WIDTH) {
374
+ suffix = "";
375
+ labelBudget = budget - visibleWidth(prefix) - visibleWidth(range);
376
+ }
377
+ const styled = theme.fg(isSkill ? "customMessageText" : "text", compressTail(classification.label, labelBudget));
378
+ // docs / resource 的标签是包内路径,同样给超链接(skill 的标签是目录名,不是路径,不给)
379
+ const display =
380
+ hyperlinksEnabled && !isSkill
381
+ ? hyperlink(styled, pathToFileURL(resolveAgainstCwd(classification.label, cwd)).href)
382
+ : styled;
383
+ return prefix + display + range + suffix;
384
+ }
385
+
386
+ /**
387
+ * 包一层组件:pi 渲出来 ≤ 1 行就原样返回,> 1 行才换成压缩标题。
388
+ * 压缩结果按宽度缓存 —— `render(width)` 每帧都调,终端 resize 时自动重算。
389
+ */
390
+ function createCollapsedCallComponent(
391
+ inner: any,
392
+ options: { args: any; theme: any; cwd: string; expanded: boolean; isEnabled: () => boolean },
393
+ ) {
394
+ const cache = new Map<number, string[]>();
395
+ return {
396
+ render(width: number): string[] {
397
+ const lines: string[] = inner.render(width);
398
+ // 关闭开关 → 回到 pi 的原生渲染(含路径颜色,见 `/read-collapse off`);工具名照旧大写
399
+ if (!options.isEnabled()) return capitalizeReadTitle(lines);
400
+ // 没超宽(短路径,绝大多数 read)→ 只把路径的颜色换成 `text`,其余原样交给 pi
401
+ if (lines.length <= 1) return capitalizeReadTitle(lines.map((line) => recolorToolPath(line, options.theme)));
402
+ const fixedWidth = visibleWidth(`${options.theme.fg("toolTitle", options.theme.bold("Read"))} `) + visibleWidth(ELLIPSIS);
403
+ if (width - fixedWidth < 1) return capitalizeReadTitle(lines);
404
+ const hit = cache.get(width);
405
+ if (hit) return hit;
406
+ const hyperlinksEnabled = piTitleUsesHyperlink(inner, lines);
407
+ const classification = options.expanded ? undefined : getCompactClassification(options.args, options.cwd);
408
+ const line = classification
409
+ ? compressedCompactTitle(classification, options.args, options.theme, width, hyperlinksEnabled, options.cwd)
410
+ : compressedPathTitle(options.args, options.theme, options.cwd, width, hyperlinksEnabled);
411
+ // 极窄终端兜底:固定部分(前缀 + 行号区间,或紧凑形态的提示行)本身就比整行还宽时,
412
+ // 压缩后的标题仍然会超宽 —— 那时 pi 自己的折行渲染同样难看,没有更好的选择,直接交回去。
413
+ if (visibleWidth(line) > width) return capitalizeReadTitle(lines);
414
+ const out = [line];
415
+ cache.set(width, out);
416
+ return out;
417
+ },
418
+ invalidate() {
419
+ cache.clear();
420
+ inner.invalidate?.();
421
+ },
422
+ };
423
+ }
424
+
425
+ export default function (pi: ExtensionAPI) {
426
+ let enabled = process.env.PI_READ_COLLAPSE?.trim().toLowerCase() !== "off";
427
+
428
+ // cwd 只是兜底:内置 execute 用的是 ctx.cwd(每次调用的当前 session cwd),
429
+ // renderCall 也用 context.cwd,不读这里这个值。
430
+ const base = createReadToolDefinition(process.cwd());
431
+
432
+ const statusText = () => (enabled ? "长路径压缩到一行(`Read …<尾部>:<行号>`),装得下时原样用 pi 的渲染" : "已关闭(回到 pi 的贪心折行,长路径会占两行)");
433
+
434
+ pi.registerTool({
435
+ // 用展开而不是逐字段抄:`description` / `parameters` / `promptSnippet` /
436
+ // `promptGuidelines` / `constrainedSampling` / `execute` 全部原样继承
437
+ // (prompt 元数据不会自动继承,必须显式带上)。
438
+ ...base,
439
+ renderCall(args, theme, context) {
440
+ const state = context.state;
441
+ // 关键:传给内置实现的 lastComponent 必须是**内层** Text 而不是我们的 wrapper,
442
+ // 否则内置 `setText()` 抛异常、pi 静默退回只剩 `read` 的 fallback(见文件头)。
443
+ const inner = base.renderCall?.(args, theme, { ...context, lastComponent: state.innerText }) ?? new Text("", 0, 0);
444
+ state.innerText = inner;
445
+ return createCollapsedCallComponent(inner, {
446
+ args,
447
+ theme,
448
+ cwd: context.cwd,
449
+ expanded: context.expanded,
450
+ isEnabled: () => enabled,
451
+ });
452
+ },
453
+ });
454
+
455
+ pi.registerCommand("read-collapse", {
456
+ description: "read 标题行的长路径压缩:off | on",
457
+ handler: async (args, ctx) => {
458
+ const arg = args.trim().toLowerCase();
459
+
460
+ if (arg === "") {
461
+ ctx.ui.notify(`read 路径压缩:${statusText()}`, "info");
462
+ return;
463
+ }
464
+
465
+ if (arg !== "off" && arg !== "on") {
466
+ ctx.ui.notify("用法:/read-collapse off | on", "warning");
467
+ return;
468
+ }
469
+
470
+ enabled = arg === "on";
471
+ ctx.ui.notify(`read 路径压缩${enabled ? "已开启" : "已关闭"},${statusText()}`, "info");
472
+ },
473
+ });
474
+ }