@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,1476 @@
1
+ /**
2
+ * Bash Command Collapse Extension
3
+ *
4
+ * 把 bash 工具调用里显示的 shell 命令折叠成前 N 个**视觉行** + 一行「被折叠内容有多大」
5
+ * 提示,避免超长命令(heredoc、多行管道、内联脚本)刷屏。纯显示层:发给模型的 tool call
6
+ * 参数、session 记录里的原文完全不变。
7
+ *
8
+ * 提示格式 `… (123 tokens hidden)`,token 为估算值。注意 thinking-collapse.ts 已经
9
+ * 换成滚动窗口、不再输出这个提示(底部 spinner 在数 token),所以这套提示格式现在
10
+ * 只剩 bash 命令折叠在用。
11
+ * ctrl+o(app.tools.expand)展开工具输出时,命令也会完整显示。
12
+ *
13
+ * ## 折叠视图:break-all 硬折行(不提前折行、不用 `…` 截断)
14
+ *
15
+ * 折行规则是 CSS `word-break: break-all` 那一套:按列预算逐个 grapheme 填充,**装满到
16
+ * 恰好放不下为止再断**,断点就在行末,不管它落在单词/路径中间。实现见 `hardWrapToWidth`。
17
+ *
18
+ * 为什么不用 pi-tui 的 `wrapTextWithAnsi`:它是**贪心词折行 + 长词按列断开**,装不进当前行
19
+ * 剩余空间的词会被**整块挑到下一行**再去断 —— 实测 79 列终端上 `$ cp <78 列路径>` 渲染成
20
+ * `$ cp` 单独一行 + 路径从中间断成两截:行尾白白空着几十列(就是“提前折行”那个难看的
21
+ * 样子),命令和它的参数还被拆开。硬折行则把每行填满,`$ ` 后面直接跟命令正文。
22
+ *
23
+ * 中间试过第三条路(commit 9024c26):不折行、装不下就把行尾截成 `…`。它确实不会提前
24
+ * 折行,但一条长命令只剩一行可见内容、后面全丢,信息量太低 —— 现在改成硬折行后,
25
+ * 同样的预算能装满 3 整行正文,超出部分才走隐藏提示。
26
+ *
27
+ * 行数预算(`limit`,默认 3)按**视觉行**算而不是源行:一条超长单行命令占满整个预算
28
+ * 而不是刷十几行,而短行的多行命令(heredoc 等)仍然显示前 3 条源行 —— 两种情况都不
29
+ * 会失控。超出预算的内容(当前源行的尾巴 + 后面所有源行)计入 `… (N tokens hidden)`。
30
+ * 展开态(ctrl+o)/ `/bash-collapse off` 用同一套硬折行规则,只是不限行数 —— 折行规则
31
+ * 必须一致,否则展开态又会出现“提前折行”。
32
+ *
33
+ * ## 输出预览行数(pi 写死 5 行,这里改成 3 行)
34
+ *
35
+ * pi 把 bash 输出的预览行数写死在 `core/tools/renderers/bash.js` 的
36
+ * `const BASH_PREVIEW_LINES = 5`:模块私有常量,既没从包里导出,也不在
37
+ * `BashToolOptions` 里,`docs/settings.md` 里也没有对应设置项 —— **没有全局配置可改**,
38
+ * 只能在扩展里后处理(`withPreviewLimit` / `trimPreviewLines`)。
39
+ *
40
+ * 做法是拿 pi 渲好的组件做后处理而不是重写整个 resultRenderer,这样 pi 的语义
41
+ *(截断页脚剔除、warnings、`Took Xs`、展开态、图片)全部保留,我们只动预览那一段。
42
+ * 两个关键点:① **逐 child 渲染**而不是拿平铺行数组(平铺数组里分不清哪几行是预览);
43
+ * ② 裁掉的行数必须补进提示行的计数,否则「隐藏了多少行」就谎了 —— pi 给了提示行就
44
+ * 在它那行**原地改数字**(保住 pi 的配色与真实键名),pi 没给(输出刚好 ≤ 5 行)
45
+ * 就自建一行,否则那 1~2 行会静默消失。
46
+ *
47
+ * 展开态(ctrl+o)自动不受影响:那时 pi 用的是 `new Text(...)` 而不是预览组件,
48
+ * 鸭子判定直接跳过,完整输出一行不裁。
49
+ *
50
+ * ## 输出树形 gutter(`│` / `└`)
51
+ *
52
+ * 给输出预览的每一行挂一个字符的缩进:除末行外是 `│ `,末行是 `└ `,对齐 codex
53
+ * 的 bash 输出样式 —— 命令在上、输出挂在一棵树下,层次一眼可辨:
54
+ *
55
+ * ```
56
+ * $ cat text.txt
57
+ * │ ... (7 earlier lines, ctrl+o to expand)
58
+ * │ hello world
59
+ * └ hello world
60
+ *
61
+ * Took 2.4s
62
+ * ```
63
+ *
64
+ * (`Took Xs` 那行只在执行够久时才画 —— 上面这个例子里命令跑了 2.4s,
65
+ * 见下面「耗时页脚门槛」一节。)
66
+ *
67
+ * 实现挂在 `withPreviewLimit` 里(它本来就要逐 child 找出「哪个子组件是输出」,
68
+ * gutter 用的是同一条判定),细节与三个不能想当然的点见 `prefixTreeLines`。
69
+ * 两个刻意的设计决定:
70
+ * ① **展开态(ctrl+o)不加 gutter** —— 展开态要的就是原样完整输出。pi 那时用的是
71
+ * `new Text(...)`(有 `setText`),鸭子判定天然跳过它,所以这条不需要额外分支,
72
+ * 与「展开态不裁预览行」共用同一个机制。
73
+ * ② **warnings / `Took Xs` 不进树** —— 它们是元信息页脚而不是命令输出,和命令
74
+ * 上方的 `… (N tokens hidden)` 提示一样留在树外(那条提示属于 renderCall 的
75
+ * 块,本来就不在结果组件里)。
76
+ * gutter 占 2 列,所以输出子组件必须按 `width - 2` 渲染(详见 `withPreviewLimit`)。
77
+ * `/bash-tree off` 可关;关掉后渲染路径与加 gutter 之前逐行一致。
78
+ *
79
+ * ## 耗时页脚门槛(短命令不画 `Took`)
80
+ *
81
+ * pi 内置的结果渲染器**无条件**在末尾画一行 `Took X.Xs`(`state.startedAt` 有值就画),
82
+ * 而它是个**独占一行**的页脚:对绝大多数几十毫秒的命令来说只是白占一行高度,还额外带进
83
+ * 一行分隔空行(页脚是 `new Text("\n" + …)`,那个前导 `\n` 就是正文与页脚之间的空白行):
84
+ *
85
+ * ```
86
+ * $ echo hello
87
+ * └ hello
88
+ *
89
+ * Took 0.1s
90
+ * ```
91
+ *
92
+ * 所以**短命令整条页脚都不画**(连那行分隔空行一起)—— 区块变成「命令行 + 输出 +
93
+ * 下边界空行」,一行都不浪费。门槛默认 2000ms(常量 `DEFAULT_MIN_TIME_FOOTER_MS`),
94
+ * `PI_BASH_MIN_TIME_MS` 启动时可改,`0` = 永远显示(等于关掉这个优化)。
95
+ *
96
+ * 判定用**真实耗时** `endedAt - startedAt`(与 pi 画那个数字用的是同一个量),不去解析
97
+ * 页脚上的文本 —— 文本是 `(ms / 1000).toFixed(1)` 四舍五入过的,拿它判会出现「显示 2.0s
98
+ * 其实只跑了 1.96s」这类边界偏差。门槛 >= 耗时即隐藏,所以能看见的数字必然 >= 2.0s,
99
+ * 不会出现自相矛盾的 `Took 1.9s`。
100
+ *
101
+ * 流式模式(`/bash-stream on`)下执行期中那个 `Elapsed X.Xs` 走同一条判定(同一个页脚,
102
+ * 只是文案跟着 `isPartial` 变):短命令执行期间不会闪出那一行,跑过 2s 才出现 ——
103
+ * 正好是「值得看一眼」的时刻。非流式(默认)下只在执行结束时判一次,正是用户要的语义。
104
+ *
105
+ * 实现挂在 `withPreviewLimit` 里(它本来就要逐 child 渲染、也本来就把 warnings / `Took`
106
+ * 当「非输出 child」透传),判定见 `isTimeFooterChild`:末位 + 文本形态两条缺一不可。
107
+ *
108
+ * ## bash 命令语法高亮(轻量版)
109
+ *
110
+ 命令行按 shell 词法上色:命令名 `syntaxFunction`、选项 `-x/--xxx` `syntaxKeyword`、
111
+ 引号串与路径 `syntaxString`、`$VAR`/`NAME=` 赋值 `syntaxVariable`、`|`/`&&`/重定向
112
+ `syntaxOperator`、`#` 注释 `syntaxComment`,`$ ` 前缀用 `toolTitle`(正常色,不用 dim)。配色走主题的 `syntax*`
113
+ 槽(和 markdown 代码块同一套),所以换主题自动跟着变。默认开,`PI_BASH_HIGHLIGHT=off`
114
+ 回到改动前的「整行 toolTitle 粗体」—— 刻意**没有** `/bash-highlight` 指令:纯观感开关,
115
+ env 一个入口就够,没必要再占一条斜杠指令。
116
+
117
+ 参照 `@sting8k/pi-droid-styling` 的 `tool-tags/bash.ts`:它同样是**手写 shell 分词器**
118
+ (`tokenizeShellLinePreservingText` + `colorShellWord`),只在分词失败(引号没闭合)时
119
+ 才退回 pi 导出的 `highlightCode(line, "bash")`。这里不采它的退回路径 —— `highlightCode`
120
+ 返回的是**带 ANSI 的整行**,而本扩展的折行是 break-all 硬折行、必须「先折纯文本、
121
+ 后上色」(反过来会把 SGR 序列从中间切断,见 renderCall 里的注释),带 ANSI 的行没法
122
+ 再喂给 `hardWrapRows`。所以分词失败就退回单色粗体,而不是换一个高亮器。
123
+
124
+ 对齐办法:token 偏移是**源行**坐标,碎片是**折行后**坐标,`hardWrapRows` 记下每条
125
+ 碎片对应原文的 `[start, end)` 字符区间,上色时按区间切 token。三条要点:
126
+ ① 一个 token 被折行切成两半时,两半是同一种颜色 —— 视觉上无碍,这就是用户说的
127
+ 「有折行所以高亮可能不准,轻一些」的那部分。
128
+ ② 引号状态**跨源行**保留(`openQuote`),所以多行字符串(`git commit -m "…\n…"`)
129
+ 的第二行不会被当成命令重新分词。heredoc 正文没有这个待遇(`<<EOF` 不是引号),
130
+ 会按命令行上色 —— 无害,只是不准。
131
+ ③ 折叠预算用完就停止分词,被隐藏的尾巴不参与上色(也不参与 token 计数以外的任何
132
+ 计算),所以折叠提示里的 token 估算仍然基于纯文本。
133
+
134
+ ## 输出正文的独立颜色(扩展 token `bashOutput`)
135
+
136
+ pi 的内置 bash 结果渲染器把输出正文写死成 `theme.fg("toolOutput", line)` —— 那是**所有
137
+ 工具输出共用的槽**(read / grep / ls 的正文、`…` 占位符都吃它),想只调 bash 输出的颜色
138
+ 就得绕开它。做法是在**委托给内置渲染器的那个同步窗口**里把主题的 `toolOutput` 临时指向
139
+ `bashOutput`(`withBashOutputColor()`),于是只有输出正文换色 —— 命令行(`toolTitle` +
140
+ 自绘语法高亮)、折叠提示(`muted` / `dim`)、树形 gutter(`muted`)、`Took Xs` 页脚
141
+ (`muted`)一律不受影响,其他工具的输出也完全不受影响(它们的渲染器不在这个窗口里跑)。
142
+
143
+ 注意**传给 `renderResult` 的那个 theme 参数是没用的**:pi 的 bash 渲染器签名把第二个
144
+ theme 参数写成 `_theme` 后根本不用它,输出行是用**模块级 theme 单例**上的色。
145
+ 那个单例(`Proxy` → `globalThis[Symbol.for("@earendil-works/pi-coding-agent:theme")]`)
146
+ 与扩展拿到的渲染器参数是同一个对象,所以改它的 `fgColors` 表就是改渲染器看到的色值。
147
+
148
+ `bashOutput` 是本仓库自造的 token(pi 官方 schema 里没有,与 `toolDiffAddedBg` 那两个
149
+ 同一条路:TypeBox 校验对未知 key 放行、`createTheme()` 把它们收进前景表)。**主题没定义它
150
+ 就什么都不做**(探测方式是真调一次 `getFgAnsi()`,pi 对未知 token 抛
151
+ `Unknown theme color: …`),所以内置主题与 summer-night / catppuccin 照旧走 `toolOutput`,
152
+ 目前只有 `ayu.json` 定义了这个 token。展开态(ctrl+o)同样是输出正文,一并生效。
153
+
154
+ ## 染色块的上下边界空行
155
+ *
156
+ * self 模式下 pi 不再套 `Box(1, 1, bgFn)`,所以底色块的上下内边距得自己画回来:
157
+ * 命令行**上面**一行、最后一行(通常是 `Took Xs`)**下面**一行,两行都是染了底色的
158
+ * 空行(Box.applyBg 会把每行补满到 width 再上色,空行也不例外),这样文字不会顶着
159
+ * 染色区的上/下边缘。对齐 pi 默认 shell 的观感(`Box(1, 1)` 就是这个效果),
160
+ * 但**中间**(命令与输出之间)仍然紧贴 —— 那是刻意去掉的,见 renderResult 里的注释。
161
+ *
162
+ * 为什么不能直接给两个 Box 各设 `paddingY: 1`:命令与结果是两个独立的 Box,各自的
163
+ * 垂直 padding 会叠加成「命令与输出之间三行空白」(实测过),所以只在最外侧补:
164
+ * 上边界放在 call 组件的首行,下边界放在 result 组件的末行。命令已出、结果还没到的
165
+ * 中间态(pending)由 call 组件自己补一行下边界,否则那几十毫秒~几十秒里块是
166
+ * 「上留白、下触底」的歪样子。
167
+ *
168
+ * 用法:
169
+ * /bash-collapse 查看当前状态
170
+ * /bash-collapse off 关闭折叠(完整显示)
171
+ * /bash-collapse on 打开折叠(默认 3 行 + token 提示)
172
+ * /bash-collapse 5 打开折叠并保留前 5 个视觉行(1-50)
173
+ * /bash-preview 查看输出预览行数
174
+ * /bash-preview 5 输出预览改成 5 行(1-50)
175
+ * /bash-preview off 恢复 pi 内置的 5 行预览
176
+ * /bash-tree 查看输出树形缩进状态
177
+ * /bash-tree off 输出顶格显示(不挂 │ / └)
178
+ * /bash-tree on 输出挂树形缩进(默认)
179
+ * /bash-stream 查看当前输出方式
180
+ * /bash-stream off 非流式(默认:命令与输出都一次性显示)
181
+ * /bash-stream on 流式(pi 原生行为:命令逐字刷、输出边跑边刷)
182
+ * /bash-timeout 查看 bash 执行期限(默认 / 上限 / env 覆盖)
183
+ *
184
+ * 附带第五个职责:**短命令不画耗时页脚** —— 默认执行时长 < 2s 就把 `Took 0.1s` 那一行
185
+ *(连它的前导分隔空行)整个去掉,详见上面「耗时页脚门槛」一节。`PI_BASH_MIN_TIME_MS`
186
+ * 可改门槛,`0` = 永远显示。
187
+ *
188
+ * 附带第四个职责:把 bash **输出预览**从 pi 内置的 5 行改成 3 行
189
+ *(`BASH_PREVIEW_LINES` 是 pi 的模块私有常量,没导出也没设置项,只能后处理,
190
+ * 详见下面「输出预览行数」一节)。`/bash-preview` 可改。
191
+ *
192
+ * 附带第三个职责:给每条 bash 命令**强制一个执行期限**(默认 120s、上限 600s,
193
+ * 照抄 Claude Code 的 BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS 策略,详见下面
194
+ * CLAUDE_CODE_DEFAULT_TIMEOUT_MS 那块的注释)。pi 内置 bash 的 timeout 无默认值,
195
+ * 不注入就会无限期等下去。
196
+ *
197
+ * 附带第二个职责:把 bash 工具调用的屏幕显示改成**非流式**(默认,对齐 opencode / codex)。
198
+ * pi **没有**任何设置项能做到这件事(settings.md 里 Shell 一节只有 shellPath /
199
+ * shellCommandPrefix / npmCommand)。而且这里有**两条独立的流式机制**,必须分别处理:
200
+ *
201
+ * (1)**输出流式**:内置 bash 的 execute 每收到 stdout/stderr 数据就 onUpdate() 一份快照,
202
+ * 节流 100ms(renderers/bash.js 的 BASH_UPDATE_THROTTLE_MS),经 tool_execution_update
203
+ * 事件到 TUI(interactive-mode.js)当 partial 结果重画一遍。
204
+ * 关掉的办法:把 onUpdate 传成 undefined —— execute 里每个更新点都有
205
+ * `if (!onUpdate) return` 守卫,于是全程零更新。渲染器不受影响:组件在
206
+ * `tool_execution_start` 就已创建(与更新无关),最终显示走 `tool_execution_end` →
207
+ * `updateResult(result, isPartial=false)`,所以压掉 partial 只是少了中间帧,
208
+ * 结束那一次照常出(5 行预览 + 展开提示 + 截断提示 + "Took Xs")。
209
+ *
210
+ * (2)**命令文本流式**:模型生成工具调用时参数是流式的(json 事件里能看到 toolcall_delta
211
+ * 一片一片到:`{"command": "echo a` / `; sleep 0` / `.3; echo` …),pi 每收一片就
212
+ * updateArgs() → updateDisplay() → 重画一次 renderCall,于是命令一个字一个字冒出来。
213
+ * 这条与(1)**完全无关**,光压 onUpdate 管不到它。
214
+ *
215
+ * 关掉的办法是把两个时间点**分开**处理(对齐 codex / opencode):
216
+ * 时间点一:命令字符全收完(`context.argsComplete`)→ renderCall 一次性出完整命令;
217
+ * 时间点二:命令执行完 → renderResult 把结果补刷到命令下面。
218
+ * 具体做法:renderCall 在「args 可能还在流」(`!argsComplete && isPartial === true`)时
219
+ * 返回**零行组件**(连占位行都不画)。
220
+ * 注意不能**只**用 isPartial 当阈值:isPartial 要等 final 结果才置 false,单用它会把
221
+ * 命令也压到结果之后,退化成「全等到结果才一次性出」。
222
+ * (setArgsComplete() 在 assistant message_end 时调,比 tool_execution_start 早 ~60ms,
223
+ * 正好是“命令收完”这个语义点。)
224
+ * 也不能**只**用 argsComplete:它只在实时流里置位,`/resume` 等历史重建路径从不调
225
+ * setArgsComplete(见 renderCall 里的详细说明),单用它会让恢复出来的 bash 块
226
+ * 只剩输出、命令行整行消失(这就是曾经的 resume bug)。
227
+ * 另外零行组件不会画出空盒子:Box.render 开头有 `childLines.length === 0 → []` 守卫
228
+ * (paddingY 是在这之后才加的)。
229
+ *
230
+ * 实测证据:
231
+ * - `pi -p --mode json` 跑真实 pi 数事件,同一条
232
+ * `echo a; sleep 0.35; echo b; sleep 0.35; echo c`:扩展加载时 `tool_execution_update` = **0**,
233
+ * `--no-extensions` 跑内置 bash 时 = **4**;两种情况的 tool result 都是 `"a\nb\nc\n"`。
234
+ * - 直接调包根导出的 createBashToolDefinition 数 onUpdate 次数:传 onUpdate = 4 次
235
+ * (1 次初始空更新 + 3 份输出快照),传 undefined = 0 次,final result 逐字节相同。
236
+ * - **执行耗时不受影响**:同一条 `echo a; sleep 0.5; echo b`,流式平均 545ms / 非流式
237
+ * 平均 566ms(各跑 3 次,差 21ms 在噪声内)。所谓“变慢”是非流式的固有代价:
238
+ * 以前第一块输出 ~100ms 就冒出来了,现在整个命令跑完才显示,感知延迟 = 命令全时长。
239
+ * - `echo hello` 的真实时间线:toolcall_start → toolcall_end 197ms(args 流式,模型侧)、
240
+ * toolcall_end → tool_execution_start 63ms、tool_execution_start → end 只 31ms。
241
+ * 即“等了一下”的主体是模型在生成 tool call,不是命令执行。
242
+ *
243
+ * 为什么必须放在本扩展里而不是新开一个 bash-stream.ts:跨扩展的同名工具注册是
244
+ * **first registration per name wins**(runner.js getAllRegisteredTools 的注释原文,
245
+ * 按扩展加载顺序即文件名顺序遍历)。本文件排在前面,新开的那个会被**静默忽略**。
246
+ *
247
+ * 非流式的代价(刻意的,别顺手"优化"):
248
+ * - 命令收完到执行完之间**没有进度反馈**(只有那行命令,没有输出、没有计时)。
249
+ * `renderResult` 里的每秒计时器只在 `options.isPartial` 时才起
250
+ * (`if (state.startedAt !== undefined && options.isPartial && !state.interval)`),
251
+ * 没有 partial 就永远不起,也没有 "Elapsed" 跳动,直到结束才补上
252
+ * 「输出 + Took 12.3s」。命令通常很短所以可接受;真要盯长任务就 `/bash-stream on`。
253
+ * - 发给模型的内容**完全不变**:onUpdate 只喂显示层(tool_execution_update →
254
+ * tool-execution.js 的 isPartial),既不进 session 落盘也不进 tool result,
255
+ * execute 的返回值一字不差;renderCall 也只改显示,tool call 参数与 session 原文不动。
256
+ */
257
+
258
+ import type { BashToolOptions, ExtensionAPI, ThemeColor, ToolDefinition } from "@earendil-works/pi-coding-agent";
259
+ import { createBashToolDefinition } from "@earendil-works/pi-coding-agent";
260
+ import { Box, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
261
+ import { readFileSync } from "node:fs";
262
+ import { homedir } from "node:os";
263
+ import { join } from "node:path";
264
+
265
+ /** 缩略保留的**视觉行**数(硬折行后一条超长单行命令也最多占这么多行)。 */
266
+ const DEFAULT_LINES = 3;
267
+
268
+ /**
269
+ * 输出预览保留的行数(pi 内置是 5,这里改成 3)。
270
+ *
271
+ * pi 把行数写死在 `core/tools/renderers/bash.js` 的 `const BASH_PREVIEW_LINES = 5`:
272
+ * 模块私有常量,既没从包里导出(`index.d.ts` 里没有),也不在 `BashToolOptions`
273
+ * 里,`docs/settings.md` 里也没有任何对应的设置项 —— 所以没有全局配置可改,
274
+ * 只能在扩展里后处理。详见文件头「输出预览行数」一节。
275
+ */
276
+ const DEFAULT_OUTPUT_PREVIEW_LINES = 3;
277
+
278
+ /**
279
+ * 耗时页脚的展示门槛(毫秒):执行时长**短于**它就不画 `Took 0.1s` 那一行。
280
+ *
281
+ * pi 内置 `renderResult` 无条件在结果末尾画一行 `Took X.Xs`(`state.startedAt` 有值就画),
282
+ * 而它是个独占一行的页脚(外加自己那行前导空行)—— 对几十毫秒的命令来说纯属白占两行。
283
+ * 详见文件头「耗时页脚门槛」一节。
284
+ *
285
+ * `PI_BASH_MIN_TIME_MS` 启动时可改;`0` 是合法值 = 永远显示(等于关掉这个优化)。
286
+ */
287
+ const DEFAULT_MIN_TIME_FOOTER_MS = 2000;
288
+
289
+ /**
290
+ * 第三个职责:给每条 bash 命令**强制一个执行期限**(照抄 Claude Code 的策略)。
291
+ *
292
+ * 为什么必须有:pi 的内置 bash `timeout` 是可选参数且**无默认值**(schema 描述原文
293
+ * "Timeout in seconds (optional, no default timeout)",settings.md 里也没有任何全局
294
+ * 工具超时项),所以模型不传 timeout 时,一条不退出的命令会让 pi **无限期等下去**。
295
+ * 而本扩展默认非流式(onUpdate 被摘掉),内置渲染器的 `Elapsed` 计时器只在
296
+ * `isPartial` 时才起(renderers/bash.js),于是屏幕上「进程卡死」与「纯粹耗时」
297
+ * 长得一模一样 —— 既不会自己脱困,也看不出该不该等。
298
+ *
299
+ * Claude Code 的做法(从 v2.1.268 二进制里扒出的原文,JS 是内嵌的):
300
+ * var xRo=120000, ARo=600000; // 默认 2min / 上限 10min
301
+ * wCe(env) → BASH_DEFAULT_TIMEOUT_MS 否则 xRo // 默认
302
+ * i7e(env) → Math.max(BASH_MAX_TIMEOUT_MS 否则 ARo, wCe(env)) // 上限=max(配置,默认)
303
+ * Math.min(z || Xe(), Be()) // 模型传的 timeout **静默 clamp**
304
+ * 工具描述里还把数字告诉模型:"You may specify an optional timeout in milliseconds
305
+ * (up to ${max}ms…). By default, your command will timeout after ${default}ms…"。
306
+ * 注意它的单位是 ms,pi 的 bash 参数是**秒**,所以下面统一除 1000。
307
+ *
308
+ * env 变量名沿用 Claude Code 的(BASH_DEFAULT_TIMEOUT_MS / BASH_MAX_TIMEOUT_MS),
309
+ * 这样两边行为一致、迁移过来的配置直接可用;跟 Claude Code 一样在**调用时**读
310
+ * process.env(不是模块加载时),所以运行时改 env 也生效。
311
+ *
312
+ * 超时后 pi 自己会 `killProcessTree(pid)`(整棵进程树,孙进程一起清)并把
313
+ * "Command timed out after N seconds" 连同已有输出一起返回给模型 —— 这一步不用我们管。
314
+ * 代价(与 Claude Code 同):合法的长命令(大 build、长跑测试)会被默认期限杀掉,
315
+ * 模型必须自己传更大的 timeout(上限 10min),或者用 env 抬高默认值。
316
+ *
317
+ * **对标审计(对 v2.1.268 二进制逐项核过,别凭文档印象怀疑下面的数值)**:
318
+ * Claude Code 的 env 白名单里只有三个 `BASH_*` 是它自己的 —— `BASH_DEFAULT_TIMEOUT_MS` /
319
+ * `BASH_MAX_TIMEOUT_MS` / `BASH_MAX_OUTPUT_LENGTH`(其余 `BASH_ARGC` / `BASH_SOURCE` 等
320
+ * 都是 bash 自身的内部变量)。前两个就是上面那两个,已对齐;第三个**不采**:它只
321
+ * 控制输出回读窗口(官方原文 "on its own only sizes the read-back window"),而 pi 的
322
+ * 截断是“保留最后 2000 行 / 50KB + 全文写临时文件”,两者机制不同且 pi 的
323
+ * `DEFAULT_MAX_LINES` / `DEFAULT_MAX_BYTES` 是模块常量、扩展改不了。
324
+ * 已知差异(刻意的,不要“顺手对齐”):
325
+ * ① **单位**:Claude Code 的 timeout 参数是 ms,pi 是秒 —— 所以 env 读 ms、内部除 1000,
326
+ * 工具描述也用秒(不改 pi 的参数单位,否则会跟它自己的 schema 矛盾)。
327
+ * ② **非法值**:Claude Code 的 `z || Xe()` 会把**负数**原样透传(负数是 truthy),
328
+ * 我们则把 `<=0` / 非有限值一律归到默认 —— 因为 pi 的 `resolveTimeoutMs` 对 `<=0`
329
+ * 会直接 throw,不归就会把工具调用变成硬报错。NaN 两边行为一致(都落默认)。
330
+ * ③ **超时语义**:pi 是 throw(tool result 带 isError=true)+ 已积累输出,
331
+ * Claude Code 是普通结果 + 超时注释 —— 改不了(throw 发生在 `base.execute` 内部)。
332
+ * ④ Claude Code 的第 2/3 层(`run_in_background` + `BashOutput`/`KillShell`(新名
333
+ * `TaskOutput`/`TaskStop`)+ `/bashes` + Ctrl+B,以及 `CLAUDE_CODE_AUTO_BACKGROUND_TIMEOUT_MS`
334
+ * 到点不杀、自动转后台)**未实现**:pi 没有对应物,要做得新增三个工具 + 进程登记表。
335
+ * ⑤ pi 自己的硬上限 `MAX_TIMEOUT_MS = 2_147_483_647`(≈24.8 天)远高于我们 clamp 的
336
+ * 600s,所以不会撞上 pi 的报错。
337
+ */
338
+ const CLAUDE_CODE_DEFAULT_TIMEOUT_MS = 120_000;
339
+ const CLAUDE_CODE_MAX_TIMEOUT_MS = 600_000;
340
+
341
+ /** 读 env 里的毫秒值;非正数/非数字一律当未配置(与 Claude Code 的 `!isNaN(r)&&r>0` 一致)。 */
342
+ function readTimeoutEnvMs(name: string): number | undefined {
343
+ const raw = process.env[name]?.trim();
344
+ if (!raw) return undefined;
345
+ const value = Number(raw);
346
+ return Number.isFinite(value) && value > 0 ? value : undefined;
347
+ }
348
+
349
+ /** 默认期限(秒)。 */
350
+ function defaultTimeoutSeconds(): number {
351
+ return (readTimeoutEnvMs("BASH_DEFAULT_TIMEOUT_MS") ?? CLAUDE_CODE_DEFAULT_TIMEOUT_MS) / 1000;
352
+ }
353
+
354
+ /** 期限上限(秒):Claude Code 是 `Math.max(配置上限, 默认)`,抬高默认时上限跟着抬。 */
355
+ function maxTimeoutSeconds(): number {
356
+ return Math.max(readTimeoutEnvMs("BASH_MAX_TIMEOUT_MS") ?? CLAUDE_CODE_MAX_TIMEOUT_MS, readTimeoutEnvMs("BASH_DEFAULT_TIMEOUT_MS") ?? CLAUDE_CODE_DEFAULT_TIMEOUT_MS) / 1000;
357
+ }
358
+
359
+ /**
360
+ * 算出这次执行的**有效期限(秒)**,即 Claude Code 那句 `Math.min(z || default, max)`:
361
+ * - 模型没传 / 传了非法值(0、负数、NaN、字符串)→ 落到默认期限;
362
+ * - 传了超过上限的值 → **静默 clamp 到上限**(不报错;Claude Code 同款行为,
363
+ * 它这个静默 clamp 是已知 issue #83824,这里照抄以保持两边一致)。
364
+ * 顺带避开 pi 的硬报错:内置 resolveTimeoutMs 对 <=0 / 非有限值会直接 throw。
365
+ */
366
+ function effectiveTimeoutSeconds(requested: unknown): number {
367
+ const max = maxTimeoutSeconds();
368
+ const requestedSeconds = typeof requested === "number" ? requested : Number(requested);
369
+ if (!Number.isFinite(requestedSeconds) || requestedSeconds <= 0) return Math.min(defaultTimeoutSeconds(), max);
370
+ return Math.min(requestedSeconds, max);
371
+ }
372
+
373
+ /** CJK 等全角字符按 2 列宽度计,保证截出来的行数贴近终端实际行数。 */
374
+ function charWidth(code: number): number {
375
+ if (
376
+ (code >= 0x1100 && code <= 0x115f) ||
377
+ (code >= 0x2e80 && code <= 0xa4cf) ||
378
+ (code >= 0xac00 && code <= 0xd7a3) ||
379
+ (code >= 0xf900 && code <= 0xfaff) ||
380
+ (code >= 0xfe30 && code <= 0xfe6f) ||
381
+ (code >= 0xff00 && code <= 0xff60) ||
382
+ (code >= 0xffe0 && code <= 0xffe6) ||
383
+ code >= 0x20000
384
+ ) {
385
+ return 2;
386
+ }
387
+ return 1;
388
+ }
389
+
390
+ /** token 估算:宽字符(中日韩等)≈ 1 token/字,其余 ≈ 1 token/4 字符。 */
391
+ function estimateTokens(text: string): number {
392
+ let wide = 0;
393
+ let narrow = 0;
394
+ for (const ch of text) {
395
+ if (charWidth(ch.codePointAt(0) ?? 0) === 2) wide++;
396
+ else narrow++;
397
+ }
398
+ return Math.max(1, Math.ceil(wide + narrow / 4));
399
+ }
400
+
401
+ /** 1234 → "1.2k",避免提示行里出现五位数。 */
402
+ function formatCount(n: number): string {
403
+ if (n < 1000) return String(n);
404
+ const k = n / 1000;
405
+ const text = k >= 10 ? k.toFixed(0) : k.toFixed(1);
406
+ return `${text.replace(/\.0$/, "")}k`;
407
+ }
408
+
409
+ /** grapheme 分段器(pi-tui 没导出它自己的实例,所以本地建一个;Node 内置 Intl.Segmenter)。 */
410
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
411
+
412
+ /** 一条折行碎片:`text` 是碎片本身,`start`/`end` 是它在**折行前原文**里的字符偏移。 */
413
+ interface WrappedRow {
414
+ text: string;
415
+ start: number;
416
+ end: number;
417
+ }
418
+
419
+ /**
420
+ * **break-all 硬折行**(带偏移):按列预算逐个 grapheme 填充,装满就断 —— 类似 CSS 的
421
+ * `word-break: break-all`,不管断点是不是单词/路径中间。除了碎片文本还记下它在原文里
422
+ * 的 `[start, end)` 字符区间,命令高亮靠它把「先折行、后上色」接起来 —— token 偏移是
423
+ * **源行**坐标、碎片是**折行后**坐标,没有这个区间就对不上号。
424
+ *
425
+ * 为什么不用 pi-tui 的 `wrapTextWithAnsi`:它是**贪心词折行 + 长词按列断开**,
426
+ * 一个装不进当前行剩余空间的词会被**整块挑到下一行**再去断 —— 实测 79 列终端上
427
+ * `$ cp <78 列路径>` 渲染成 `$ cp` 单独一行 + 路径从中间断成两截,行尾白白空着
428
+ * 几十列,命令和它的参数还被拆开。硬折行则把每行填到恰好装不下为止,断点就在
429
+ * 行末,不会提前折行。
430
+ *
431
+ * 按 grapheme 而不是按列硬切:emoji / 组合字符的 segment 长度 ≠ 1 个字符,
432
+ * 按字符下标切会把它们切成两半。宽字符(CJK 等,2 列)装不进剩下的 1 列时
433
+ * 在它**前面**断行(行尾留 1 列空白)—— 一个 grapheme 不可分。
434
+ *
435
+ * @param firstRowBudget 首行预算(给 timeout 后缀留位置)
436
+ * @param restRowBudget 其余行预算
437
+ */
438
+ function hardWrapRows(text: string, firstRowBudget: number, restRowBudget: number): WrappedRow[] {
439
+ const rows: WrappedRow[] = [];
440
+ let row = "";
441
+ let rowWidth = 0;
442
+ let rowStart = 0;
443
+ let budget = Math.max(1, firstRowBudget);
444
+ for (const { segment, index } of graphemeSegmenter.segment(text)) {
445
+ const w = visibleWidth(segment);
446
+ // rowWidth > 0 守卫:单个 grapheme 比整行预算还宽时(极窄终端)也得放下,
447
+ // 否则会产生空行死循环
448
+ if (rowWidth > 0 && rowWidth + w > budget) {
449
+ rows.push({ text: row, start: rowStart, end: index });
450
+ budget = Math.max(1, restRowBudget);
451
+ row = segment;
452
+ rowWidth = w;
453
+ rowStart = index;
454
+ } else {
455
+ // row 为空说明这是本行第一个 grapheme,记下它的原文偏移
456
+ if (row === "") rowStart = index;
457
+ row += segment;
458
+ rowWidth += w;
459
+ }
460
+ }
461
+ rows.push({ text: row, start: rowStart, end: rowStart + row.length });
462
+ return rows;
463
+ }
464
+
465
+ /* -------------------------------------------------------------------------- *
466
+ * bash 命令语法高亮(见文件头「bash 命令语法高亮(轻量版)」一节)
467
+ * -------------------------------------------------------------------------- */
468
+
469
+ type ShellTokenKind = "space" | "comment" | "operator" | "command" | "flag" | "string" | "path" | "variable" | "word";
470
+
471
+ /** 一个词法单元;`start`/`end` 是**源行内**的字符偏移(不含 `$ ` 前缀)。 */
472
+ interface ShellToken {
473
+ kind: ShellTokenKind;
474
+ start: number;
475
+ end: number;
476
+ }
477
+
478
+ /** token → 主题色槽。`null` = 不上色(空白原样输出)。 */
479
+ const SHELL_TOKEN_COLORS: Record<ShellTokenKind, ThemeColor | null> = {
480
+ space: null,
481
+ comment: "syntaxComment",
482
+ operator: "syntaxOperator",
483
+ command: "syntaxFunction",
484
+ flag: "syntaxKeyword",
485
+ string: "syntaxString",
486
+ path: "syntaxString",
487
+ variable: "syntaxVariable",
488
+ word: "syntaxString",
489
+ };
490
+
491
+ /** `$VAR` / `${VAR}`。 */
492
+ const SHELL_VAR_PATTERN = /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/;
493
+ /** 赋值:`NAME=`。 */
494
+ const SHELL_ASSIGN_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*=/;
495
+ /**
496
+ * 操作符,**先长后短**按顺序取第一个匹配。重定向那条带可选的数字 fd,所以
497
+ * `2>&1` 会被切成 `2>&`(操作符)+ `1`(词)而不是把 `2` 当成参数。
498
+ */
499
+ const SHELL_OPERATOR_PATTERNS = [/^&&/, /^\|\|/, /^\|&/, /^;;/, /^<<-?/, /^\d*(?:>>|>&|<&|<|>)/, /^[|&;()]/];
500
+ /** 能起头的操作符字符(粗筛用,避免对每个字符都跑一遍正则 —— 长命令行下是 O(n²))。 */
501
+ const SHELL_OPERATOR_CHARS = new Set(["|", "&", ";", "(", ")", "<", ">"]);
502
+ /** 这些操作符后面接的是新命令(`cmd1 | cmd2`),其余(重定向等)后面接的是参数。 */
503
+ const SHELL_COMMAND_NEXT_OPS = new Set(["|", "||", "&&", ";", "&", "|&", "("]);
504
+
505
+ function stripOuterQuotes(word: string): string {
506
+ const match = /^(['"])([\s\S]*)\1$/.exec(word);
507
+ return match ? match[2]! : word;
508
+ }
509
+
510
+ /** 纯数字后面紧跟 `<`/`>` 才是 fd 重定向(`2>`);否则 `foo2` 里的 `2` 属于词。 */
511
+ function isFdRedirectAt(line: string, pos: number): boolean {
512
+ let j = pos;
513
+ while (j < line.length && line[j]! >= "0" && line[j]! <= "9") j++;
514
+ const next = line[j];
515
+ return next === "<" || next === ">";
516
+ }
517
+
518
+ /** 在 `pos` 处匹配一个操作符;不是操作符返回 null。 */
519
+ function matchShellOperatorAt(line: string, pos: number): string | null {
520
+ const char = line[pos]!;
521
+ if (!SHELL_OPERATOR_CHARS.has(char) && !(char >= "0" && char <= "9")) return null;
522
+ const rest = line.slice(pos);
523
+ for (const pattern of SHELL_OPERATOR_PATTERNS) {
524
+ const match = pattern.exec(rest);
525
+ if (match) return match[0];
526
+ }
527
+ return null;
528
+ }
529
+
530
+ /**
531
+ * 词的归类。顺序有意义:赋值 > 引号串 > 选项 > `$VAR` > 路径 > 命令/参数。
532
+ * `"--foo"` 算引号串而不是选项;`$HOME/x` 算变量而不是路径(`$` 优先)。
533
+ */
534
+ function classifyShellWord(word: string, commandExpected: boolean): ShellTokenKind {
535
+ const normalized = stripOuterQuotes(word);
536
+ if (SHELL_ASSIGN_PATTERN.test(normalized)) return "variable";
537
+ if (word.startsWith("'") || word.startsWith('"')) return "string";
538
+ if (word.startsWith("-") && word.length > 1) return "flag";
539
+ if (SHELL_VAR_PATTERN.test(normalized)) return "variable";
540
+ // 含 `/` 就是路径;额外的分支覆盖裸的 `.` / `..` / `~`
541
+ if (normalized.includes("/") || /^\.{1,2}(?:\/|$)/.test(normalized) || normalized.startsWith("~/")) return "path";
542
+ return commandExpected ? "command" : "word";
543
+ }
544
+
545
+ /**
546
+ * 给一条源行分词。token **连续覆盖整行**(含空白 token),所以折行碎片可以直接按
547
+ * 偏移切片上色,不用再去猜碎片和 token 的对应关系。
548
+ *
549
+ * `openQuote` 是上一条源行留下的未闭合引号:多行字符串的第二行整段算 string,
550
+ * 不会被当成一条新命令重新分词。分词**不会失败**(不像参照插件那样返回 undefined
551
+ * 退回 highlightCode)—— 引号没闭合就一路吃到行尾并把状态传给下一条源行。
552
+ */
553
+ function tokenizeShellLine(line: string, openQuote: string | null): { tokens: ShellToken[]; openQuote: string | null } {
554
+ const tokens: ShellToken[] = [];
555
+ let quote = openQuote;
556
+ let commandExpected = true;
557
+ let i = 0;
558
+
559
+ while (i < line.length) {
560
+ const char = line[i]!;
561
+
562
+ // 跨行未闭合的引号:吃到本行的闭合引号为止,没有闭合就吃掉整行尾巴
563
+ if (quote) {
564
+ let j = i;
565
+ while (j < line.length) {
566
+ const c = line[j]!;
567
+ if (c === "\\" && quote === '"' && j + 1 < line.length) {
568
+ j += 2;
569
+ continue;
570
+ }
571
+ if (c === quote) break;
572
+ j++;
573
+ }
574
+ if (j >= line.length) {
575
+ tokens.push({ kind: "string", start: i, end: line.length });
576
+ i = line.length;
577
+ continue;
578
+ }
579
+ tokens.push({ kind: "string", start: i, end: j + 1 });
580
+ i = j + 1;
581
+ quote = null;
582
+ continue;
583
+ }
584
+
585
+ if (/\s/.test(char)) {
586
+ let j = i;
587
+ while (j < line.length && /\s/.test(line[j]!)) j++;
588
+ tokens.push({ kind: "space", start: i, end: j });
589
+ i = j;
590
+ continue;
591
+ }
592
+
593
+ // `#` 只在**词首**才是注释(`foo#bar` 里的 `#` 是普通字符)
594
+ if (char === "#") {
595
+ tokens.push({ kind: "comment", start: i, end: line.length });
596
+ i = line.length;
597
+ continue;
598
+ }
599
+
600
+ const operator = matchShellOperatorAt(line, i);
601
+ if (operator !== null) {
602
+ tokens.push({ kind: "operator", start: i, end: i + operator.length });
603
+ commandExpected = SHELL_COMMAND_NEXT_OPS.has(operator);
604
+ i += operator.length;
605
+ continue;
606
+ }
607
+
608
+ // 词:吃到空白 / 操作符 / 行尾为止,中途遇到引号就连引号一起吞
609
+ let j = i;
610
+ let wordQuote: string | null = null;
611
+ while (j < line.length) {
612
+ const c = line[j]!;
613
+ if (wordQuote) {
614
+ if (c === "\\" && wordQuote === '"' && j + 1 < line.length) {
615
+ j += 2;
616
+ continue;
617
+ }
618
+ if (c === wordQuote) wordQuote = null;
619
+ j++;
620
+ continue;
621
+ }
622
+ if (/\s/.test(c)) break;
623
+ if (c === "'" || c === '"') {
624
+ wordQuote = c;
625
+ j++;
626
+ continue;
627
+ }
628
+ if (SHELL_OPERATOR_CHARS.has(c)) break;
629
+ if (c >= "0" && c <= "9" && isFdRedirectAt(line, j)) break;
630
+ j++;
631
+ }
632
+ const word = line.slice(i, j);
633
+ tokens.push({ kind: classifyShellWord(word, commandExpected), start: i, end: j });
634
+ // 赋值前缀(`FOO=bar cmd`)后面仍然跟的是命令,其余词都把「该出命令了」清掉
635
+ if (!SHELL_ASSIGN_PATTERN.test(stripOuterQuotes(word))) commandExpected = false;
636
+ i = j;
637
+ if (wordQuote) quote = wordQuote; // 引号没闭合 → 传给下一条源行
638
+ }
639
+
640
+ return { tokens, openQuote: quote };
641
+ }
642
+
643
+ /**
644
+ * 给一条折行碎片上色。`prefix` 是这条源行在折行文本里的前缀(第一条源行是 `$ `,
645
+ * 其余为空),`line` 是**不含前缀**的源行原文 —— token 偏移就是按它算的。
646
+ * `tokens` 为 null(高亮关掉)时退回改动前的整行单色粗体 —— 连 `$ ` 也一起粗体,
647
+ * 逐字符和改动前一致。
648
+ */
649
+ function styleWrappedRow(row: WrappedRow, prefix: string, line: string, tokens: ShellToken[] | null, theme: any): string {
650
+ if (!tokens) return theme.fg("toolTitle", theme.bold(row.text));
651
+ // `$ ` 前缀不参与分词(否则裸 `$` 会被当成命令名上色),单独用正常色 `toolTitle`
652
+ const prefixEnd = Math.min(row.end, prefix.length);
653
+ let out = row.start < prefixEnd ? theme.fg("toolTitle", row.text.slice(0, prefixEnd - row.start)) : "";
654
+ const from = Math.max(0, row.start - prefix.length);
655
+ const to = Math.max(from, row.end - prefix.length);
656
+ if (to <= from) return out;
657
+
658
+ let cursor = from;
659
+ for (const token of tokens) {
660
+ if (token.end <= from) continue;
661
+ if (token.start >= to) break;
662
+ const start = Math.max(token.start, cursor);
663
+ const end = Math.min(token.end, to);
664
+ if (start >= end) continue;
665
+ const text = line.slice(start, end);
666
+ const color = SHELL_TOKEN_COLORS[token.kind];
667
+ // 命令名保留粗体,当作整块的视觉锚点(改动前整行都是粗体)
668
+ out += color === null ? text : theme.fg(color, token.kind === "command" ? theme.bold(text) : text);
669
+ cursor = end;
670
+ }
671
+ // token 连续覆盖整行,所以正常走不到这里;真走到就按老样子兜底,不丢字符
672
+ if (cursor < to) out += theme.fg("toolTitle", theme.bold(line.slice(cursor, to)));
673
+ return out;
674
+ }
675
+
676
+ /**
677
+ * 一条源行 → 折行碎片(带偏移)+ 该行的 token。高亮关掉时不分词,`nextQuote` 原样
678
+ * 透传(此时引号状态没有意义)。
679
+ */
680
+ function wrapAndTokenizeLine(
681
+ prefix: string,
682
+ line: string,
683
+ firstRowBudget: number,
684
+ restRowBudget: number,
685
+ openQuote: string | null,
686
+ highlight: boolean,
687
+ ): { rows: WrappedRow[]; tokens: ShellToken[] | null; nextQuote: string | null } {
688
+ const rows = hardWrapRows(prefix + line, firstRowBudget, restRowBudget);
689
+ if (!highlight) return { rows, tokens: null, nextQuote: openQuote };
690
+ const result = tokenizeShellLine(line, openQuote);
691
+ return { rows, tokens: result.tokens, nextQuote: result.openQuote };
692
+ }
693
+
694
+ /** pi 的 agent 目录(`PI_CODING_AGENT_DIR` 可覆盖,否则 `~/.pi/agent`)。 */
695
+ function resolveAgentDir(): string {
696
+ const envDir = process.env.PI_CODING_AGENT_DIR;
697
+ return envDir ? (envDir.startsWith("~") ? join(homedir(), envDir.slice(1)) : envDir) : join(homedir(), ".pi", "agent");
698
+ }
699
+
700
+ /**
701
+ * `app.tools.expand` 的键名文本,给自建的提示行用。
702
+ *
703
+ * 为什么不直接用 pi 导出的 `keyText` / `keyHint`:扩展里 `import` 到的
704
+ * `@earendil-works/pi-coding-agent` / `pi-tui` 是 loader alias 指向的 **npm/dist 副本**,
705
+ * 与 pi 运行时(bundle)用的是两个不同的模块实例。实测(在 `~/.pi/agent/npm` 下直接
706
+ * `node` 跑):`keyHint("app.tools.expand", "to expand")` **直接抛**
707
+ * `Theme not initialized. Call initTheme() first.`(它读的是副本自己的 theme 单例,
708
+ * 而 pi 只初始化了 bundle 那份),`keyText(...)` 则返回 `""`(副本的 keybindings
709
+ * 表是空的)。而 renderResult 抛异常会被 pi 静默 catch 并退回 `createResultFallback()`,
710
+ * 整个自定义渲染就没了 —— 所以这两个函数绝不能 import,只能读配置文件。
711
+ *
712
+ * 默认值 `ctrl+o` 就是 pi 的默认绑定(`docs/keybindings.md` 的 `app.tools.expand` 行)。
713
+ */
714
+ function expandKeyText(): string {
715
+ try {
716
+ const parsed = JSON.parse(readFileSync(join(resolveAgentDir(), "keybindings.json"), "utf8"));
717
+ const bound = parsed?.["app.tools.expand"];
718
+ const keys = Array.isArray(bound) ? bound : [bound];
719
+ const text = keys.filter((k: unknown): k is string => typeof k === "string" && k.trim() !== "").join("/");
720
+ if (text) return text;
721
+ } catch {
722
+ // 没配置文件 / 解析失败 / 没绑这个键,都用默认值
723
+ }
724
+ return "ctrl+o";
725
+ }
726
+
727
+ /**
728
+ * 归一耗时页脚门槛(毫秒):未设置 / 空串 / 非法值(NaN、负数)一律落到默认 2000;
729
+ * **`0` 是合法值**(永远显示,等于关掉这个优化)—— 所以不能用 `value || DEFAULT` 那种写法。
730
+ * 与 `clampPreviewLines` 一样在扩展注册时读一次。
731
+ */
732
+ function resolveMinTimeFooterMs(raw: string | undefined): number {
733
+ if (raw === undefined || raw.trim() === "") return DEFAULT_MIN_TIME_FOOTER_MS;
734
+ const value = Number(raw.trim());
735
+ return Number.isFinite(value) && value >= 0 ? value : DEFAULT_MIN_TIME_FOOTER_MS;
736
+ }
737
+
738
+ /**
739
+ * 把输出预览行数归一到 1-50 的整数;非法值(NaN / <=0 / 小数)一律落到默认 3。
740
+ * 刻意用 Number 而不是 parseInt:parseInt("2.5") 会静默变成 2,而这里是归一到默认。
741
+ */
742
+ function clampPreviewLines(value: number): number {
743
+ if (!Number.isFinite(value) || value < 1 || value > 50 || !Number.isInteger(value)) return DEFAULT_OUTPUT_PREVIEW_LINES;
744
+ return value;
745
+ }
746
+
747
+ /**
748
+ * 读 shellPath / shellCommandPrefix 设置,让覆盖后的 bash 工具和内置工具行为一致
749
+ * (内置工具由 AgentSession 用 settings 里的这两个值构造)。
750
+ */
751
+ function readShellOptions(): BashToolOptions {
752
+ const paths = [join(resolveAgentDir(), "settings.json"), join(process.cwd(), ".pi", "settings.json")];
753
+
754
+ const merged: Record<string, unknown> = {};
755
+ for (const path of paths) {
756
+ try {
757
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
758
+ if (parsed && typeof parsed === "object") Object.assign(merged, parsed);
759
+ } catch {
760
+ // 缺文件 / 解析失败都按默认值处理
761
+ }
762
+ }
763
+
764
+ const options: BashToolOptions = {};
765
+ if (typeof merged.shellPath === "string" && merged.shellPath) {
766
+ options.shellPath = merged.shellPath.startsWith("~") ? join(homedir(), merged.shellPath.slice(1)) : merged.shellPath;
767
+ }
768
+ if (typeof merged.shellCommandPrefix === "string") {
769
+ options.commandPrefix = merged.shellCommandPrefix;
770
+ }
771
+ return options;
772
+ }
773
+
774
+ /**
775
+ * 剥掉组件渲染结果的前导空行。
776
+ *
777
+ * 内置 bash 的 renderResult 会在输出前插一个前导空行(`new Text("\n" + styledOutput)`),
778
+ * 在默认 shell 下那是“命令与输出之间的一行间距”;但本扩展走 self 模式,命令与输出是
779
+ * 两个独立的块,这一行会叠上两个 Box 各自的 paddingY,变成三行空白(实测)。
780
+ * 先判 ANSI 再 trim:行里可能带前景色转义序列,直接 trim() 不会为空。
781
+ * 只剥**前导**空行:输出与 "Took" 之间那个空行(也是 `\n` 前缀)刻意保留,
782
+ * 用来分隔正文与耗时页脚。
783
+ */
784
+ function stripLeadingBlanks(inner: any) {
785
+ return {
786
+ render(width: number): string[] {
787
+ const lines: string[] = inner.render(width);
788
+ let i = 0;
789
+ while (i < lines.length && lines[i].replace(/\x1b\[[0-9;]*m/g, "").trim() === "") i++;
790
+ return i > 0 ? lines.slice(i) : lines;
791
+ },
792
+ invalidate() {
793
+ inner.invalidate?.();
794
+ },
795
+ };
796
+ }
797
+
798
+ /**
799
+ * 给染色块补一行**下边界**空行(染底色的空行,见文件头「染色块的上下边界空行」)。
800
+ * 空行是作为子组件的行加进去的,所以会走 Box.applyBg —— 补满到 width 再上色,
801
+ * 与 paddingY 画出来的边界行完全同色同宽。
802
+ */
803
+ function withBottomBlank(inner: any) {
804
+ return {
805
+ render(width: number): string[] {
806
+ return [...inner.render(width), ""];
807
+ },
808
+ invalidate() {
809
+ inner.invalidate?.();
810
+ },
811
+ };
812
+ }
813
+
814
+ /**
815
+ * 树形 gutter 占的列数(`│ ` / `└ ` = 1 个制表符(box-drawing 竖线 / 拐角,不是 TAB)+ 1 个空格)。
816
+ * 输出子组件必须按 `width - GUTTER_WIDTH` 渲染,否则加上前缀就超宽。
817
+ */
818
+ const GUTTER_WIDTH = 2;
819
+
820
+ /**
821
+ * 给输出行画**树形 gutter**:除末行外每行前面挂 `│ `,末行挂 `└ `(对齐 codex
822
+ * 的 bash 输出样式),让输出与命令行之间的层次关系一眼可辨。
823
+ *
824
+ * 三个不能想当然的点:
825
+ * ① **前导空行不上前缀**:那是命令与输出之间的分隔行,外层
826
+ * `stripLeadingBlanks` 还要靠「这一行是空的」把它剥掉 —— 一旦挂上前缀
827
+ * 就变成非空行,剥不掉了(行里还可能带前景色转义序列,所以判空必须先剔
828
+ * ANSI 再 trim,跟 `stripLeadingBlanks` 的判法一致)。
829
+ * ② `└` 挂在**最后一个非空行**上而不是数组末行:pi 的输出是 `.trim()` 过的,
830
+ * 正常不会有尾部空行,但万一有,`└ ` 挂在空行上会变成一行只有拐角符。
831
+ * 尾部空行照旧挂 `│ `,竖线保持连续。
832
+ * ③ 输出**内部的空行也要挂 `│`**(只跳前导那一段),否则竖线断在半路,
833
+ * 看上去不像一棵树。
834
+ * 全是空行时直接原样返回(没内容可挂)。
835
+ */
836
+ function prefixTreeLines(lines: string[], theme: any, gutter: boolean): string[] {
837
+ if (!gutter || lines.length === 0) return lines;
838
+ const isBlank = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "").trim() === "";
839
+ let start = 0;
840
+ while (start < lines.length && isBlank(lines[start])) start++;
841
+ if (start >= lines.length) return lines;
842
+ let last = lines.length - 1;
843
+ while (last > start && isBlank(lines[last])) last--;
844
+ return lines.map((line, i) => (i < start ? line : theme.fg("muted", i === last ? "└ " : "│ ") + line));
845
+ }
846
+
847
+ /**
848
+ * 耗时页脚的**纯文本形态**:`Took 0.1s`(执行完)/ `Elapsed 1.2s`(流式执行中,每秒跳)。
849
+ * 小数位数由 pi 的 `formatDuration` 决定(`(ms / 1000).toFixed(1)`,恒为一位)。
850
+ */
851
+ const TIME_FOOTER_PATTERN = /^(?:Took|Elapsed) \d+\.\d+s$/;
852
+
853
+ /** 去掉 SGR 转义序列(页脚那行是 `theme.fg("muted", …)` 包着的,判形态 / 判空都得先剥)。 */
854
+ function stripAnsiCodes(text: string): string {
855
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
856
+ }
857
+
858
+ /**
859
+ * 这个 child 是不是 pi 画的**耗时页脚**(`new Text("\n" + theme.fg("muted", "Took 0.1s"), 0, 0)`)。
860
+ *
861
+ * 两条缺一不可:
862
+ * ① `setText` 存在 = 是 pi-tui 的 `Text` —— 不能用 `instanceof`,理由与预览组件那条鸭子
863
+ * 判定完全相同(扩展 import 到的 pi-tui 是 loader alias 指向的 npm/dist 副本,与 pi
864
+ * 运行时 bundle 里的 `Text` 不是同一个类对象,`instanceof` 跨实例必然 false);
865
+ * ② 剥 ANSI、trim 之后正好是 `Took 0.1s` / `Elapsed 1.2s` 这个形态。
866
+ * 位置条件(**必须是末位 child**)由 `shouldHideTimeFooter` 带着判:pi 的
867
+ * `rebuildBashResultRenderComponent` 按「输出 → warnings → 页脚」的顺序 addChild,
868
+ * 所以页脚恒为末位。两条一起才敢下结论说末位那个就是页脚 —— 只看末位会把「输出正文恰好
869
+ * 是一行 `Took 1.2s`」删掉(展开态下正文就是一个 `Text`),只看形态则会把碰巧长得像的
870
+ * 末位子组件删掉。
871
+ */
872
+ function isTimeFooterChild(child: any): boolean {
873
+ if (typeof child?.setText !== "function" || typeof child.text !== "string") return false;
874
+ return TIME_FOOTER_PATTERN.test(stripAnsiCodes(child.text).trim());
875
+ }
876
+
877
+ /**
878
+ * 该不该把耗时页脚整个丢掉:**短于门槛的执行不画那一行**(默认 2000ms,见
879
+ * `DEFAULT_MIN_TIME_FOOTER_MS`)。
880
+ *
881
+ * `elapsedMs === undefined` 表示压根不知道耗时(`state.startedAt` 为空 —— `/resume` 恢复的
882
+ * 历史块不调 `markExecutionStarted`)—— 那种情况下 pi 本来就没有页脚可画,所以一律返回
883
+ * false、不动手:即展开态里「输出正文恰好是一行 `Took 1.2s`」不会被误删。
884
+ * 门槛与耗时比的是**同一个量**(`endedAt - startedAt`,见 renderResult 里的 `elapsedMs`),
885
+ * 不是页脚上那个四舍五入后的数字。
886
+ */
887
+ function shouldHideTimeFooter(lastChild: any, elapsedMs: number | undefined, minTimeFooterMs: number): boolean {
888
+ if (elapsedMs === undefined || elapsedMs >= minTimeFooterMs) return false;
889
+ return isTimeFooterChild(lastChild);
890
+ }
891
+
892
+ /**
893
+ * 把 pi 内置 bash 渲染器的**输出预览**从 5 行裁到 `previewLines` 行,给输出
894
+ * 挂上树形 gutter(`gutter` 为 true 时),并在短命令上丢掉耗时页脚。
895
+ *
896
+ * pi 的行数写死在它的 `BASH_PREVIEW_LINES = 5`(模块私有常量,改不了,也没有
897
+ * 设置项),所以只能在拿到它的组件之后做后处理。详见文件头「输出预览行数」一节。
898
+ *
899
+ * 做法:**逐个 child 渲染**而不是拿整个 Container 的平铺行数组 —— 平铺数组里分不清
900
+ * 哪几行属于预览(预览行、warnings 行、Took 行都是纯文本 + 不同前景色,按颜色猜很
901
+ * 脆弱)。child 结构实测过:输出非空时 `children[0]` 就是预览组件,行形状固定是
902
+ * `["", (提示行?), ...预览行]`;后面的 warnings / `Took Xs` 都是 `Text` 实例
903
+ *(各自的 `\n` 前缀渲染成一行空行 + 正文)。
904
+ *
905
+ * 预览组件的判定用**鸭子类型**(`typeof child.setText !== "function"`)而不是
906
+ * `instanceof Text`:扩展 import 到的 pi-tui 是 loader alias 指向的 npm/dist 副本,
907
+ * 与 pi 运行时(bundle)用的 Text 不是同一个类对象,`instanceof` 跨模块实例必然 false。
908
+ *
909
+ * **展开态(ctrl+o)自动不受影响**:那时 pi 用的是 `new Text("\n" + styledOutput)`
910
+ * 而不是预览组件,所以鸭子判定直接跳过它,完整输出一行不裁、**也不挂 gutter**
911
+ * (展开态要的就是原样输出)—— 正是想要的。
912
+ *
913
+ * 耗时页脚的过滤(`shouldHideTimeFooter`)也在这里:末位 child 是 `Took X.Xs` 页脚、
914
+ * 且实际耗时短于门槛时,把它整个跳过(连它那行前导空行一起 —— 那是正文与页脚之间的
915
+ * 分隔行,页脚不画时不该留)。详见文件头「耗时页脚门槛」一节。
916
+ */
917
+ function withPreviewLimit(inner: any, previewLines: number, theme: any, gutter: boolean, elapsedMs: () => number | undefined, minTimeFooterMs: number) {
918
+ return {
919
+ render(width: number): string[] {
920
+ const out: string[] = [];
921
+ let trimmed = false;
922
+ // gutter 占 2 列,所以**输出子组件必须按 width - 2 渲染**:pi 的预览行是按
923
+ // 传进去的宽度折行 / 截断的(`truncateToVisualLines` 内部用 `Text.render(width)`),
924
+ // 按整宽渲染再加前缀就会超出终端宽度。其余子组件(warnings / Took)不上
925
+ // 前缀,照旧按整宽渲染。
926
+ const contentWidth = gutter ? Math.max(1, width - GUTTER_WIDTH) : width;
927
+ // 耗时页脚的判定放在 render 里而不是拿组件时就算死:流式模式下 pi 每秒
928
+ // `invalidate()` 一次(内置 renderResult 里那个 setInterval),跨过门槛的
929
+ // 那一刻页脚就能出现,不用等下一次 partial 结果。
930
+ let children: any[] = inner.children ?? [];
931
+ if (shouldHideTimeFooter(children[children.length - 1], elapsedMs(), minTimeFooterMs)) {
932
+ children = children.slice(0, -1);
933
+ }
934
+ for (const child of children) {
935
+ const isOutput = !trimmed && typeof child.setText !== "function";
936
+ const lines: string[] = child.render(isOutput ? contentWidth : width);
937
+ if (!isOutput) {
938
+ out.push(...lines);
939
+ continue;
940
+ }
941
+ trimmed = true;
942
+ out.push(...prefixTreeLines(trimPreviewLines(lines, previewLines, contentWidth, theme), theme, gutter));
943
+ }
944
+ return out;
945
+ },
946
+ invalidate() {
947
+ inner.invalidate?.();
948
+ },
949
+ };
950
+ }
951
+
952
+ /**
953
+ * 裁掉预览行里超出预算的部分(保留**尾部** —— pi 的预览本来就是输出的最后几行),
954
+ * 并把被裁掉的行数补进提示行的计数。
955
+ *
956
+ * 提示行的两种情况:
957
+ * ① pi 已经给了提示行(输出 > 5 行)—— 就在它那一行**原地改数字**,
958
+ * 这样 pi 的配色与真实键名(`keyHint` 渲出来的 dim 键名 + muted 描述)一字不动,
959
+ * 不用重建样式。正则匹配的是 SGR 包裹里的纯 ASCII 数字与文本,所以直接在
960
+ * 带样式的字符串上替换是安全的。
961
+ * ② pi 没给提示行(输出刚好 ≤ 5 行,但我们裁到了 3 行)—— 必须自己造一行,
962
+ * 否则那 1~2 行就**静默消失**了。用传进来的 `theme`(pi 运行时真正初始化过的那份)
963
+ * 复刻 pi 的格式与配色,键名读 `keybindings.json`(见 `expandKeyText`)。
964
+ */
965
+ function trimPreviewLines(lines: string[], previewLines: number, width: number, theme: any): string[] {
966
+ if (lines.length <= 1) return lines;
967
+ const head = lines[0]; // 前导空行(去留由外层 stripLeadingBlanks 决定)
968
+ let rest = lines.slice(1);
969
+ let hintLine: string | undefined;
970
+ const firstPlain = (rest[0] ?? "").replace(/\x1b\[[0-9;]*m/g, "");
971
+ if (firstPlain.startsWith("... (") && firstPlain.includes("earlier lines")) {
972
+ hintLine = rest[0];
973
+ rest = rest.slice(1);
974
+ }
975
+ if (rest.length <= previewLines) return lines;
976
+ const dropped = rest.length - previewLines;
977
+ const kept = rest.slice(dropped);
978
+ const hint = hintLine
979
+ ? hintLine.replace(/(\(\s*)(\d+)(\s*earlier lines)/, (_m, open, count, tail) => `${open}${Number(count) + dropped}${tail}`)
980
+ : truncateToWidth(
981
+ theme.fg("muted", `... (${dropped} earlier lines,`) + " " + theme.fg("dim", expandKeyText()) + theme.fg("muted", " to expand") + theme.fg("muted", ")"),
982
+ width,
983
+ "...",
984
+ );
985
+ return [head, hint, ...kept];
986
+ }
987
+
988
+ /**
989
+ * 按执行状态选整块底色,与 tool-execution.js `updateDisplay()` 里的 bgFn 一致
990
+ * (pending / error / success)。默认 shell 下这个 bgFn 由 pi 套在整个 contentBox 上;
991
+ * 切到 `renderShell: "self"` 后 pi 不再套(selfRenderContainer 是个纯 Container),
992
+ * 所以得自己用 Box + theme.bg 把底色块画回来,否则 bash 输出会失去现在的背景块。
993
+ */
994
+ function stateBgFn(theme: any, isPartial: boolean, isError: boolean) {
995
+ if (isPartial) return (text: string) => theme.bg("toolPendingBg", text);
996
+ if (isError) return (text: string) => theme.bg("toolErrorBg", text);
997
+ return (text: string) => theme.bg("toolSuccessBg", text);
998
+ }
999
+
1000
+ /**
1001
+ * 在**同步窗口**内把主题的 `toolOutput` 临时换成 `bashOutput`,让 pi 内置 bash 结果渲染器
1002
+ * 画出来的输出正文用上自己的颜色槽(详见文件头「输出正文的独立颜色」一节)。
1003
+ *
1004
+ * 为什么必须是「换主题表」而不是「把主题对象换给渲染器」:pi 的 bash 渲染器**根本不看传进去
1005
+ * 的那个 theme 参数**(`renderResult(result, options, _theme, context)`),它用的是模块级的
1006
+ * `theme` 单例(`Proxy` → `globalThis[Symbol.for("@earendil-works/pi-coding-agent:theme")]`)。
1007
+ * 那个单例与传给扩展的渲染器参数**是同一个对象**(单例就是为跨 loader 共用而设计的),
1008
+ * 所以直接改它的 `fgColors` 表,渲染器下一次 `theme.fg("toolOutput", line)` 就会命中新色值。
1009
+ *
1010
+ * 四个要点:
1011
+ * ① **只在同步窗口内换**:`base.renderResult()` 是同步的,输出行(含展开态那份)全部在
1012
+ * 这次调用里 `theme.fg("toolOutput", …)` 烘焙成字符串,所以 try/finally 里换进换出
1013
+ * 不会有第二个渲染插进来;换颜色也**不会**泄漏给其他工具(read / grep 的输出是它们
1014
+ * 自己的渲染器画的,不在这个窗口里)。
1015
+ * ② **主题里没有 `bashOutput` 就什么都不做**(内置主题与 summer-night / catppuccin 都没
1016
+ * 这个 token)—— 探测方式是真调一次 `theme.getFgAnsi()`,pi 对未知 token 抛
1017
+ * `Unknown theme color: …`。
1018
+ * ③ `fgColors` 是 pi `Theme` 类的公开字段(`Map<string, string>`,存的是**已解析的
1019
+ * SGR 前缀**);哪天 pi 把它藏起来/改名,这里就自动退化成原样调用(返回前那个
1020
+ * `typeof … .set` 判定),不会抛异常、只是颜色不生效。
1021
+ * ④ 只动 `toolOutput`:命令标题 / `... (N earlier lines …)` 提示 / `Took Xs` 页脚走的是
1022
+ * `toolTitle` / `muted`,命令行与折叠提示是扩展自绘的,一律不受影响。
1023
+ */
1024
+ function withBashOutputColor<T>(theme: any, render: () => T): T {
1025
+ const fgColors = theme?.fgColors;
1026
+ if (typeof fgColors?.set !== "function" || typeof fgColors?.get !== "function") return render();
1027
+ let override: string;
1028
+ try {
1029
+ override = theme.getFgAnsi("bashOutput");
1030
+ } catch {
1031
+ return render();
1032
+ }
1033
+ const previous = fgColors.get("toolOutput");
1034
+ fgColors.set("toolOutput", override);
1035
+ try {
1036
+ return render();
1037
+ } finally {
1038
+ if (previous === undefined) fgColors.delete("toolOutput");
1039
+ else fgColors.set("toolOutput", previous);
1040
+ }
1041
+ }
1042
+
1043
+ export default function (pi: ExtensionAPI) {
1044
+ let enabled = true;
1045
+ let maxLines = DEFAULT_LINES;
1046
+ // 输出预览行数:pi 内置写死 5(`BASH_PREVIEW_LINES`,模块私有常量 + 无设置项),
1047
+ // 所以只能在扩展里后处理。/bash-preview 可改;PI_BASH_PREVIEW 启动时覆盖。
1048
+ let previewLines = clampPreviewLines(Number(process.env.PI_BASH_PREVIEW));
1049
+ // 输出树形 gutter(`│ ` / `└ `):默认开(对齐 codex)。PI_BASH_TREE=off 启动时关闭。
1050
+ // 与 previewLines 相互独立:`/bash-preview off` 恢复 pi 的 5 行预览后 gutter 照旧生效。
1051
+ let treeEnabled = process.env.PI_BASH_TREE?.trim().toLowerCase() !== "off";
1052
+ // 流式输出开关:默认关(非流式,对齐 opencode / codex)。PI_BASH_STREAM=on 恢复 pi 原生流式。
1053
+ let streaming = process.env.PI_BASH_STREAM?.trim().toLowerCase() === "on";
1054
+ // 耗时页脚门槛(毫秒):短于它的执行不画 `Took X.Xs` 那一行(详见文件头「耗时页脚门槛」)。
1055
+ // PI_BASH_MIN_TIME_MS=0 永远显示。与 previewLines / treeEnabled 一样在注册时读一次。
1056
+ const minTimeFooterMs = resolveMinTimeFooterMs(process.env.PI_BASH_MIN_TIME_MS);
1057
+ // 命令语法高亮开关:默认开。PI_BASH_HIGHLIGHT=off 启动时关闭(回到整行 toolTitle 粗体)。
1058
+ // 刻意**不注册 /bash-highlight 指令** —— 这是个纯观感开关,env 一个入口就够,
1059
+ // 没必要再占一条斜杠指令(与 /bash-collapse / /bash-tree 那种需要随时切换的不同)。
1060
+ // 展开态(ctrl+o)与折叠态走同一套分词,所以开关对两边同时生效。
1061
+ const highlightEnabled = process.env.PI_BASH_HIGHLIGHT?.trim().toLowerCase() !== "off";
1062
+
1063
+ // cwd 只是兜底:内置 execute 用的是 ctx.cwd(每次调用的当前 session cwd)。
1064
+ const base: ToolDefinition<any, any, any> = createBashToolDefinition(process.cwd(), readShellOptions());
1065
+
1066
+ const statusText = () => `保留前 ${maxLines} 个视觉行(break-all 硬折行),超出部分显示 token 估算`;
1067
+ const streamStatusText = () =>
1068
+ streaming
1069
+ ? "流式(命令逐字刷、输出边跑边刷,含 Elapsed 计时)"
1070
+ : "非流式(命令收完一次性出,结果执行完补刷到下面)";
1071
+
1072
+ pi.registerTool({
1073
+ name: base.name,
1074
+ label: base.label,
1075
+ // 照抄 Claude Code:把默认期限和上限**写进工具描述**告诉模型,否则模型不知道
1076
+ // 可以传 timeout,长命令就会被默认期限杀掉却不知道该调大。
1077
+ // (内置描述结尾本来只是 "Optionally provide a timeout in seconds.",没给数字。)
1078
+ description: `${base.description} By default, your command will time out after ${defaultTimeoutSeconds()} seconds. You may specify an optional timeout in seconds (up to ${maxTimeoutSeconds()} seconds); larger values are clamped to that maximum.`,
1079
+ parameters: base.parameters,
1080
+ // prompt 元数据不会从内置工具继承,必须显式带上
1081
+ promptSnippet: base.promptSnippet,
1082
+ promptGuidelines: base.promptGuidelines,
1083
+ constrainedSampling: base.constrainedSampling,
1084
+ executionMode: base.executionMode,
1085
+ prepareArguments: base.prepareArguments,
1086
+ // `renderShell: "self"` 是为了让“流式接命令字符时屏幕上一行都不出”成为可能。
1087
+ // 默认 shell 下 ToolExecutionComponent 构造里常驻一个 `Spacer(1)`,而 render() 走
1088
+ // `super.render(width)`(Container)会把所有子组件都画出来 —— 所以即使 renderCall
1089
+ // 返回零行组件,仍会渲染出那一行空行(实测 `[""]`)。而 `hideComponent` 那条路
1090
+ // 走不到:updateDisplay() 里只要 callRenderer 成功返回组件就把 `hasContent` 置 true,
1091
+ // 三个分支全都置 true,所以末尾 `if (… && !hasContent …) hideComponent = true` 永远不成立。
1092
+ // self 模式下 render() 绕过 super.render(),只画 selfRenderContainer,于是那个 Spacer
1093
+ // 根本不会被渲染;且开头有 `contentLines.length === 0 → return []` 守卫,
1094
+ // 真正做到“空就什么都不出”。代价是 pi 不再给整块套 bgFn,所以 renderCall /
1095
+ // renderResult 两边都自己包一层 Box 把底色块画回来(见 stateBgFn)。
1096
+ renderShell: "self",
1097
+ // renderResult 委托内置 bash 的实现(输出预览 / 截断提示 / "Took Xs" 都是它画的),
1098
+ // 只在外层包一个 Box 把底色块补回来,页脚那行则在 withPreviewLimit 里按门槛滤掉。
1099
+ // 注意传给内置的 `lastComponent` 必须是
1100
+ // **内层**组件而不是我们的 Box —— 内置实现会 `context.lastComponent ?? new
1101
+ // BashResultRenderComponent()` 然后对它 clear() / addChild(),喂个 Box 进去会嵌套错乱。
1102
+ // 所以内层组件存在 context.state 里跨次复用(state 本来就用来存 startedAt/endedAt/interval)。
1103
+ renderResult(result, options, theme, context) {
1104
+ const state = context.state;
1105
+ // renderCall 靠这个标记决定要不要自己补下边界空行(结果还没到时才补)。
1106
+ // 放在委托内置实现**之前**置位:万一内置实现抛异常,pi 会退回自己的
1107
+ // fallback 结果组件,那时下边界已经由它那边负责了。
1108
+ state.resultSeen = true;
1109
+ // 输出正文换用主题的 `bashOutput` 槽(主题没定义就原样;详见 withBashOutputColor)
1110
+ const inner = withBashOutputColor(theme, () =>
1111
+ base.renderResult(result, options, theme, { ...context, lastComponent: state.innerComponent }),
1112
+ );
1113
+ state.innerComponent = inner;
1114
+ // isError 必须从 **context** 读,不能从 result 读:pi 调 resultRenderer 时传的是
1115
+ // `{ content: this.result.content, details: this.result.details }`,**没有 isError 字段**
1116
+ // (tool-execution.js 的 updateDisplay),isError 只在 getRenderContext() 里
1117
+ // (`isError: this.result?.isError ?? false`)。读 result.isError 会永远拿到 undefined,
1118
+ // 于是失败的命令也会染成 success 底色。
1119
+ //
1120
+ // paddingY 必须用 0,且要剥掉 inner 的前导空行 —— 否则命令与输出之间会有
1121
+ // **三个**空行(实测过):① callBox 的下 padding、② resultBox 的上 padding、
1122
+ // ③ 内置 renderResult 自己的 `new Text("\n" + styledOutput)` 前导空行。
1123
+ // 第③行在默认 shell 下是“命令与输出之间的一行间距”(那时两者在同一个
1124
+ // contentBox 里,只有这一行),但 self 模式下两者是两个独立的 Box,
1125
+ // 各自的 paddingY 会叠加上去,所以这里把三者全部去掉,让输出紧贴命令。
1126
+ // 整块的**下边界**空行则由 withBottomBlank 补回来(只补最外侧那一行,
1127
+ // 不会落到命令与输出之间)。
1128
+ // 耗时页脚门槛判据(见文件头「耗时页脚门槛」):与 pi 画那行字用的是**同一个量**
1129
+ // —— `state.endedAt ?? Date.now()` 减 `state.startedAt`(内置 renderResult 刚在上面
1130
+ // 那次调用里补上了 endedAt)。做成**函数**、在 render 时才求值:流式模式下每秒
1131
+ // invalidate 一次,跨过门槛的那一刻页脚自然出现,不用等下一次 partial 结果。
1132
+ // startedAt 为空 = pi 根本没画页脚(`/resume` 恢复的历史块不调 markExecutionStarted),
1133
+ // 这时返回 undefined,过滤逻辑一律不动手。
1134
+ const elapsedMs = () => (state.startedAt === undefined ? undefined : (state.endedAt ?? Date.now()) - state.startedAt);
1135
+ const box = new Box(1, 0, stateBgFn(theme, options.isPartial, context.isError === true));
1136
+ box.addChild(
1137
+ withBottomBlank(
1138
+ stripLeadingBlanks(withPreviewLimit(inner, Math.max(1, Math.round(previewLines)), theme, treeEnabled, elapsedMs, minTimeFooterMs)),
1139
+ ),
1140
+ );
1141
+ return box;
1142
+ },
1143
+
1144
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
1145
+ // 关流式就是把 onUpdate 摘掉:内置 execute 的每个更新点都有 !onUpdate 守卫,
1146
+ // 于是渲染器只会收到最后那次 final 结果。返回值与 details 不受影响。
1147
+ //
1148
+ // 同时把**有效期限**注进 params:pi 内置 bash 无默认 timeout,不注入的话
1149
+ // 一条不退出的命令会无限期挂着。注入只影响这次执行 —— session 里落盘的
1150
+ // toolCall.arguments 是模型原样发来的那份,不会被改写。
1151
+ const nextParams = { ...params, timeout: effectiveTimeoutSeconds(params?.timeout) };
1152
+ return base.execute(toolCallId, nextParams, signal, streaming ? onUpdate : undefined, ctx);
1153
+ },
1154
+
1155
+ renderCall(args, theme, context) {
1156
+ // 内置 renderCall 靠这里记时("Took 1.2s"),覆盖后需要自己维护
1157
+ const state = context.state;
1158
+ if (context.executionStarted && state.startedAt === undefined) {
1159
+ state.startedAt = Date.now();
1160
+ state.endedAt = undefined;
1161
+ }
1162
+
1163
+ // 非流式模式下管住**命令文本的逐字刷新**,但要分两个时间点(对齐 codex / opencode):
1164
+ // 时间点一:命令字符全收完(argsComplete)→ 一次性把完整命令打到屏幕上;
1165
+ // 时间点二:命令执行完 → 结果由 renderResult 补刷到命令下面。
1166
+ // 两个点必须分开:用 isPartial 做阈值会把命令也压到结果之后才出,变成
1167
+ // 「全等到结果才一次性出」,那不是想要的效果。
1168
+ //
1169
+ // 为什么命令会逐字刷:模型生成 tool call 时参数是流式的(toolcall_delta 一片
1170
+ // 一片到),pi 每收一片就 updateArgs() → updateDisplay() → 重画一次 renderCall。
1171
+ // 所以收完前返回**零行组件**(连占位行都不画),argsComplete 后才出完整命令。
1172
+ // 实测这段时间占大头:`echo hello` 从 toolcall_start 到 tool_execution_end 约 290ms,
1173
+ // 其中 args 流式 197ms、toolcall_end→execution_start 63ms、命令执行只 31ms。
1174
+ //
1175
+ // argsComplete 在 assistant message_end 时置位(setArgsComplete),比
1176
+ // tool_execution_start 早 ~60ms,正好是“命令收完”这个语义点。
1177
+ // 返回零行组件是安全的:Box.render 开头有 `childLines.length === 0 → []` 守卫
1178
+ // (paddingY 是在这之后才加的),所以不会画出空的带底色块。
1179
+ // (updateDisplay 每次都传全新的 context,getRenderContext 现拼对象,
1180
+ // 所以 argsComplete 读得到实时值。)
1181
+ //
1182
+ // **但 argsComplete 只在实时流里置位**,所以判定条件是「args 可能还在流」
1183
+ // 而不是「args 还没收完」:pi 的 interactive-mode.js 只在 `message_end` 分支调
1184
+ // `component.setArgsComplete()`,而 `/resume`(以及 renderInitialMessages /
1185
+ // compaction_end / rebuildChatFromMessages)走的 `renderSessionItems` 重建历史时
1186
+ // 只 `new ToolExecutionComponent(...)` + `component.updateResult(message)`,
1187
+ // **从不调 setArgsComplete / markExecutionStarted** —— 历史块的 argsComplete
1188
+ // 永远是 false。若只按 argsComplete 判定,恢复出来的 bash 块就只剩输出、
1189
+ // 命令行整行消失(实测过:RESTORE 渲染出 ["", " hello"],LIVE 渲染出
1190
+ // ["", " $ echo hello", " hello", "", " Took 0.0s"])。
1191
+ // isPartial 正好补上这个缺口:构造时默认 true,只有 updateResult(result, false)
1192
+ // 会置 false —— 实时流里那必然发生在 argsComplete 之后(tool_execution_end),
1193
+ // 历史重建里则发生在第一次渲染之后。两种路径都能出命令,而流式阶段
1194
+ // (isPartial 仍为 true)照旧一行都不画。
1195
+ // 副作用(可接受):实时流里按 Esc 中断一个还没收完参数的 tool call 时,
1196
+ // message_end(aborted) 会 updateResult(isPartial=false) 而不置 argsComplete,
1197
+ // 于是命令行会显示出来(args 不全时是 `$ ...` 占位)—— 能看到被中断的是
1198
+ // 什么命令,比只显示一行报错更有用。
1199
+ if (!streaming && !context.argsComplete && context.isPartial === true) {
1200
+ return {
1201
+ render(): string[] {
1202
+ return [];
1203
+ },
1204
+ invalidate() {},
1205
+ };
1206
+ }
1207
+
1208
+ const rawCommand = args?.command;
1209
+ const invalid = rawCommand !== undefined && rawCommand !== null && typeof rawCommand !== "string";
1210
+ const command = typeof rawCommand === "string" ? rawCommand : "";
1211
+ const timeout = args?.timeout;
1212
+
1213
+ const commandDisplay = invalid ? theme.fg("error", "[invalid arg]") : command ? command : theme.fg("toolOutput", "...");
1214
+ const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
1215
+ const styledFull = theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix;
1216
+
1217
+ // 缓存放在组件闭包里而不是 state:流式阶段 args 会不断变长,
1218
+ // 而 state 是整行共享的,按 width 缓存会返回旧命令的行。
1219
+ let cachedWidth: number | undefined;
1220
+ let cachedExpanded: boolean | undefined;
1221
+ let cachedEnabled: boolean | undefined;
1222
+ let cachedLimit: number | undefined;
1223
+ let cachedResultSeen: boolean | undefined;
1224
+ let cachedLines: string[] | undefined;
1225
+
1226
+ const component = {
1227
+ render(width: number): string[] {
1228
+ const wrapWidth = Math.max(20, width || 80);
1229
+ const limit = Math.max(1, Math.round(maxLines));
1230
+ // 缓存键要带上 enabled / limit,否则 /bash-collapse 切换后已渲染的行不会刷新
1231
+ const resultSeen = state.resultSeen === true;
1232
+ if (
1233
+ cachedLines &&
1234
+ cachedWidth === wrapWidth &&
1235
+ cachedExpanded === context.expanded &&
1236
+ cachedEnabled === enabled &&
1237
+ cachedLimit === limit &&
1238
+ cachedResultSeen === resultSeen
1239
+ )
1240
+ return cachedLines;
1241
+
1242
+ let result: string[];
1243
+ // invalid(args.command 不是字符串)/ 空命令走 pi-tui 折行分支:这两种文本固定是
1244
+ // `$ [invalid arg]` / `$ ...`,短到根本不会折行,而那个分支能原样保留
1245
+ // 嵌套样式(error 色 / toolOutput 色),不用在硬折分支里重建
1246
+ if (enabled && !context.expanded && !invalid) {
1247
+ // 折叠态:**break-all 硬折行** + 视觉行数预算(`limit`,默认 3 行)。
1248
+ // 详见文件头「折叠视图:break-all 硬折行」一节。
1249
+ // 先折**纯文本**再逐行上样式:反过来会把 SGR 序列从中间切断。
1250
+ const commandLines = (command || "...").split("\n");
1251
+ const suffixWidth = visibleWidth(timeoutSuffix);
1252
+ // 首行(且仅首行)要给 timeout 后缀留位置,否则长命令会把后缀挤掉
1253
+ const firstRowBudget = suffixWidth > 0 ? Math.max(4, wrapWidth - suffixWidth) : wrapWidth;
1254
+ const shown: string[] = [];
1255
+ const hiddenParts: string[] = [];
1256
+ let budgetExhausted = false;
1257
+ // 引号状态跨源行保留(多行字符串的第二行不会被当成新命令分词)
1258
+ let openQuote: string | null = null;
1259
+ for (let i = 0; i < commandLines.length && !budgetExhausted; i++) {
1260
+ const line = commandLines[i]!;
1261
+ const prefix = i === 0 ? "$ " : "";
1262
+ const { rows, tokens, nextQuote } = wrapAndTokenizeLine(prefix, line, i === 0 ? firstRowBudget : wrapWidth, wrapWidth, openQuote, highlightEnabled);
1263
+ openQuote = nextQuote;
1264
+ for (let r = 0; r < rows.length; r++) {
1265
+ if (shown.length >= limit) {
1266
+ // 预算用完:本源行剩下的碎片(拼回去就是它的尾巴)
1267
+ // + 后续源行全部隐藏。硬折行不丢字符,所以碎片直接
1268
+ // join("") 就是原文尾巴(不用像旧代码那样用
1269
+ // startsWith 反推截断点)。用的是**纯文本**碎片,所以
1270
+ // token 估算不会把 SGR 序列算进去。
1271
+ hiddenParts.push(rows.slice(r).map((piece) => piece.text).join(""));
1272
+ for (let j = i + 1; j < commandLines.length; j++) hiddenParts.push(commandLines[j]!);
1273
+ budgetExhausted = true;
1274
+ break;
1275
+ }
1276
+ shown.push(styleWrappedRow(rows[r]!, prefix, line, tokens, theme) + (i === 0 && r === 0 ? timeoutSuffix : ""));
1277
+ }
1278
+ }
1279
+ result = shown;
1280
+ // 只要有任何内容被折掉或被折叠就出提示:一行长命令硬折后可能
1281
+ // 刚好装满预算(行数 == limit),靠「行数 > limit」判定会漏
1282
+ const hidden = hiddenParts.join("\n");
1283
+ if (hidden.trim() !== "") {
1284
+ const hint = theme.fg("muted", `… (${formatCount(estimateTokens(hidden))} tokens hidden)`);
1285
+ result = [...shown, truncateToWidth(hint, wrapWidth, "…")];
1286
+ }
1287
+ } else if (invalid || !command) {
1288
+ // invalid(args.command 不是字符串)/ 空命令:文本固定是 `$ [invalid arg]`
1289
+ // / `$ ...`,短到根本不会折行,而折行分支能原样保留嵌套样式
1290
+ //(error 色 / toolOutput 色),不用在硬折分支里重建
1291
+ result = wrapTextWithAnsi(styledFull, wrapWidth);
1292
+ } else {
1293
+ // 展开态(ctrl+o)/ 关闭折叠:要的就是完整命令,**同样用 break-all
1294
+ // 硬折行** —— 贪心词折行在这里一样会把长路径整块挪到下一行
1295
+ // 再从中间断开(就是用户看到的 `$ ` 后面直接折行),展开态只是
1296
+ // 不限行数,折行规则必须一致。
1297
+ const commandLines = command.split("\n");
1298
+ const suffixWidth = visibleWidth(timeoutSuffix);
1299
+ const firstRowBudget = suffixWidth > 0 ? Math.max(4, wrapWidth - suffixWidth) : wrapWidth;
1300
+ const rows: string[] = [];
1301
+ let openQuote: string | null = null;
1302
+ for (let i = 0; i < commandLines.length; i++) {
1303
+ const line = commandLines[i]!;
1304
+ const prefix = i === 0 ? "$ " : "";
1305
+ const { rows: pieces, tokens, nextQuote } = wrapAndTokenizeLine(prefix, line, i === 0 ? firstRowBudget : wrapWidth, wrapWidth, openQuote, highlightEnabled);
1306
+ openQuote = nextQuote;
1307
+ for (const piece of pieces) rows.push(styleWrappedRow(piece, prefix, line, tokens, theme));
1308
+ }
1309
+ result = rows.map((row, idx) => row + (idx === 0 ? timeoutSuffix : ""));
1310
+ }
1311
+
1312
+ // 染色块的上下边界空行(见文件头「染色块的上下边界空行」):
1313
+ // 上边界永远补;下边界只在结果还没到时补 —— 结果到了之后紧接着就是
1314
+ // resultBox,那时补会在命令与输出之间多出一行空白。
1315
+ // 判定用 state.resultSeen(renderResult 置位),不用 isPartial:
1316
+ // 流式模式下 partial 结果也会调 renderResult,isPartial 仍是 true,
1317
+ // 用它会在命令与输出之间留下空白。
1318
+ result = ["", ...result, ...(resultSeen ? [] : [""])];
1319
+
1320
+ cachedWidth = wrapWidth;
1321
+ cachedExpanded = context.expanded;
1322
+ cachedEnabled = enabled;
1323
+ cachedLimit = limit;
1324
+ cachedResultSeen = resultSeen;
1325
+ cachedLines = result;
1326
+ return result;
1327
+ },
1328
+ invalidate() {
1329
+ cachedWidth = undefined;
1330
+ cachedExpanded = undefined;
1331
+ cachedEnabled = undefined;
1332
+ cachedLimit = undefined;
1333
+ cachedResultSeen = undefined;
1334
+ cachedLines = undefined;
1335
+ },
1336
+ };
1337
+
1338
+ // self 模式下 pi 不给整块套底色,自己包一层 Box 保持原有的背景块观感。
1339
+ // paddingY 用 0:命令与结果是两个独立的 Box,各自的垂直 padding 会叠加成
1340
+ // 命令与输出之间的多余空行(详见 renderResult 里那条注释)。
1341
+ const box = new Box(1, 0, stateBgFn(theme, context.isPartial === true, context.isError === true));
1342
+ box.addChild(component);
1343
+ return box;
1344
+ },
1345
+ });
1346
+
1347
+ pi.registerCommand("bash-collapse", {
1348
+ description: "折叠 bash 命令显示:off | on | <视觉行数 1-50>",
1349
+ handler: async (args, ctx) => {
1350
+ const arg = args.trim();
1351
+
1352
+ if (arg === "off") {
1353
+ enabled = false;
1354
+ ctx.ui.notify("bash 命令折叠已关闭(完整显示)", "info");
1355
+ return;
1356
+ }
1357
+
1358
+ if (arg === "" || arg === "on") {
1359
+ enabled = true;
1360
+ ctx.ui.notify(`bash 命令折叠已开启,${statusText()}`, "info");
1361
+ return;
1362
+ }
1363
+
1364
+ const parsed = Number.parseInt(arg, 10);
1365
+ if (!Number.isFinite(parsed) || parsed < 1 || parsed > 50) {
1366
+ ctx.ui.notify("用法:/bash-collapse off | on | <视觉行数 1-50>", "warning");
1367
+ return;
1368
+ }
1369
+
1370
+ enabled = true;
1371
+ maxLines = parsed;
1372
+ ctx.ui.notify(`bash 命令折叠已开启,${statusText()}`, "info");
1373
+ },
1374
+ });
1375
+
1376
+ pi.registerCommand("bash-preview", {
1377
+ description: "bash 输出预览行数:off(pi 内置 5 行)| <行数 1-50>",
1378
+ handler: async (args, ctx) => {
1379
+ const arg = args.trim().toLowerCase();
1380
+
1381
+ if (arg === "off") {
1382
+ previewLines = 5; // pi 的 BASH_PREVIEW_LINES,等于不裁
1383
+ ctx.ui.notify("bash 输出预览已恢复 pi 内置的 5 行", "info");
1384
+ return;
1385
+ }
1386
+
1387
+ if (arg === "") {
1388
+ ctx.ui.notify(`当前输出预览:前 ${previewLines} 行(pi 内置是 5 行,超出部分带 earlier lines 提示)`, "info");
1389
+ return;
1390
+ }
1391
+
1392
+ // 用 Number 而不是 parseInt:parseInt("2.5") 会静默变成 2(与
1393
+ // clampPreviewLines 的归一策略一致,小数应当被拒而不是静默截断)
1394
+ const parsed = Number(arg);
1395
+ const next = clampPreviewLines(parsed);
1396
+ if (next !== parsed) {
1397
+ ctx.ui.notify("用法:/bash-preview off | <行数 1-50 的整数>", "warning");
1398
+ return;
1399
+ }
1400
+
1401
+ previewLines = next;
1402
+ ctx.ui.notify(`bash 输出预览已改为前 ${previewLines} 行`, "info");
1403
+ },
1404
+ });
1405
+
1406
+ // 输出树形 gutter 开关。默认开;展开态(ctrl+o)不受这个开关影响 —— 鸭子判定
1407
+ // 已经跳过了 `Text` 分支(见文件头「输出树形 gutter」一节)。
1408
+ // 空参数只报状态、不改状态,与 /bash-collapse / /bash-preview / /bash-stream 的约定一致。
1409
+ pi.registerCommand("bash-tree", {
1410
+ description: "bash 输出的树形缩进(│ / └):off | on",
1411
+ handler: async (args, ctx) => {
1412
+ const arg = args.trim().toLowerCase();
1413
+
1414
+ if (arg === "") {
1415
+ ctx.ui.notify(`当前输出树形缩进:${treeEnabled ? "开(每行 │、末行 └)" : "关(输出顶格显示)"}`, "info");
1416
+ return;
1417
+ }
1418
+
1419
+ if (arg === "off") {
1420
+ treeEnabled = false;
1421
+ ctx.ui.notify("bash 输出的树形缩进已关闭(输出顶格显示)", "info");
1422
+ return;
1423
+ }
1424
+
1425
+ if (arg === "on") {
1426
+ treeEnabled = true;
1427
+ ctx.ui.notify("bash 输出的树形缩进已开启(展开态不受影响)", "info");
1428
+ return;
1429
+ }
1430
+
1431
+ ctx.ui.notify("用法:/bash-tree off | on", "warning");
1432
+ },
1433
+ });
1434
+
1435
+ pi.registerCommand("bash-stream", {
1436
+ description: "bash 屏幕显示:off(默认,命令收完一次性出、结果补刷)| on(pi 原生流式)",
1437
+ handler: async (args, ctx) => {
1438
+ const arg = args.trim().toLowerCase();
1439
+
1440
+ if (arg === "off") {
1441
+ streaming = false;
1442
+ ctx.ui.notify(streamStatusText(), "info");
1443
+ return;
1444
+ }
1445
+
1446
+ if (arg === "on") {
1447
+ streaming = true;
1448
+ ctx.ui.notify(streamStatusText(), "info");
1449
+ return;
1450
+ }
1451
+
1452
+ if (arg === "") {
1453
+ ctx.ui.notify(`当前:${streamStatusText()}`, "info");
1454
+ return;
1455
+ }
1456
+
1457
+ ctx.ui.notify("用法:/bash-stream off | on", "warning");
1458
+ },
1459
+ });
1460
+
1461
+ // 只读查看当前生效的期限:默认值、上限、以及 env 有没有覆盖。
1462
+ // 值在 execute 里每次调用时重算,所以这里显示的永远是下一次执行的真实期限。
1463
+ pi.registerCommand("bash-timeout", {
1464
+ description: "查看 bash 执行期限(默认 / 上限 / env 覆盖)",
1465
+ handler: async (_args, ctx) => {
1466
+ const override = (name: string) => {
1467
+ const value = readTimeoutEnvMs(name);
1468
+ return value === undefined ? "未设置" : `${name}=${value}ms`;
1469
+ };
1470
+ ctx.ui.notify(
1471
+ `默认 ${defaultTimeoutSeconds()}s,上限 ${maxTimeoutSeconds()}s;env:${override("BASH_DEFAULT_TIMEOUT_MS")} / ${override("BASH_MAX_TIMEOUT_MS")}(改完需重启 pi)`,
1472
+ "info",
1473
+ );
1474
+ },
1475
+ });
1476
+ }