@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,283 @@
1
+ /**
2
+ * Tests for prompt-editor.ts — `!` bash 模式在**真编辑器**上的渲染契约。
3
+ *
4
+ * Run with: node --test clients/pi/extensions/prompt-editor/render.test.ts
5
+ *
6
+ * pi 自己的扩展加载器真的加载 `../prompt-editor.ts`(语法、import 面、session_start 注册都在
7
+ * 覆盖范围内),假的只有编辑器外面的一切:tui 只要 `terminal.rows` 与 `requestRender()`,
8
+ * 主题的 `borderColor` 是恒等函数,keybindings 的 `matches` 一律 false。于是
9
+ * 「gutter 换成 `!`、正文里输入的 `!` 不再显示、光标进不到被摘掉的那一列、点选跨过那一列、
10
+ * 退格删掉 `!` 就退出模式」这条链能被逐行 / 逐光标列断言。纯逻辑另在 `bash-prompt.test.ts`。
11
+ */
12
+
13
+ import assert from "node:assert/strict";
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import test from "node:test";
18
+ import { fileURLToPath, pathToFileURL } from "node:url";
19
+
20
+ const EXTENSION_PATH = path.join(
21
+ path.dirname(fileURLToPath(import.meta.url)),
22
+ "..",
23
+ "prompt-editor.ts",
24
+ );
25
+ const SKIP = "找不到本机 pi 的库入口(装过 pi 才有)";
26
+
27
+ /**
28
+ * pi 的库入口(非 CLI):bundle 是 `pi` 实际跑的形态,dist 是 node 构建形态。
29
+ *
30
+ * 先从 `pi` 可执行文件反查**真正的安装位置**(pnpm/npm 的 shim 脚本里留有
31
+ * `# cmd-shim-target=<绝对路径>`;npm 在 Unix 上则是符号链接,两种都试),再退回
32
+ * `~/.pi/agent/npm` 那份副本 —— 后者是**扩展包的安装根**,`pi update --extensions` 会用
33
+ * `--config.auto-install-peers=false` 把自动装进去的 `@earendil-works/pi-*` 同伴包剪掉,
34
+ * 于是那份副本只剩空壳(文件都在、import 报 `ERR_MODULE_NOT_FOUND`)。
35
+ *
36
+ * 判定方式是**能不能真 import**,不是路径存不存在 —— 只有真 import 一次才分得清空壳。
37
+ * 全都不行就返回 undefined,调用方整体 skip(不假装通过)。
38
+ */
39
+ async function findPiLibraryEntry(): Promise<string | undefined> {
40
+ const candidates: string[] = [];
41
+ if (process.env.PI_TEST_PI_ENTRY) candidates.push(process.env.PI_TEST_PI_ENTRY);
42
+
43
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
44
+ if (!dir) continue;
45
+ const shimPath = path.join(dir, "pi");
46
+ try {
47
+ // npm 的 shim 是符号链接
48
+ const real = fs.realpathSync(shimPath);
49
+ if (real !== shimPath) candidates.push(path.join(path.dirname(real), "index.js"));
50
+ } catch {
51
+ // 不是符号链接 / 不存在:看下面的 shim 脚本
52
+ }
53
+ try {
54
+ const match = /^# cmd-shim-target=(.+)$/m.exec(fs.readFileSync(shimPath, "utf8"));
55
+ if (match?.[1]) candidates.push(path.join(path.dirname(match[1].trim()), "index.js"));
56
+ } catch {
57
+ // 读不到这个 shim:跳过
58
+ }
59
+ }
60
+
61
+ const packageDir = path.join(os.homedir(), ".pi/agent/npm/node_modules/@earendil-works/pi-coding-agent");
62
+ candidates.push(path.join(packageDir, "dist/bundle/index.js"), path.join(packageDir, "dist/index.js"));
63
+
64
+ for (const candidate of candidates) {
65
+ if (!fs.existsSync(candidate)) continue;
66
+ try {
67
+ await import(pathToFileURL(candidate).href);
68
+ return candidate;
69
+ } catch {
70
+ // 空壳副本:换下一个候选
71
+ }
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ const piEntry = await findPiLibraryEntry();
77
+ const skip = piEntry === undefined ? SKIP : false;
78
+
79
+ type Handler = (event: unknown, ctx: unknown) => Promise<unknown> | unknown;
80
+
81
+ function createTestBus(): {
82
+ emit(channel: string, data: unknown): void;
83
+ on(channel: string, handler: (data: unknown) => void): () => void;
84
+ } {
85
+ const handlers = new Map<string, Set<(data: unknown) => void>>();
86
+ return {
87
+ on(channel, handler) {
88
+ const set = handlers.get(channel) ?? new Set<(data: unknown) => void>();
89
+ handlers.set(channel, set);
90
+ set.add(handler);
91
+ return () => {
92
+ set.delete(handler);
93
+ };
94
+ },
95
+ emit(channel, data) {
96
+ for (const handler of [...(handlers.get(channel) ?? [])]) handler(data);
97
+ },
98
+ };
99
+ }
100
+
101
+ interface EditorLike {
102
+ setText(text: string): void;
103
+ getText(): string;
104
+ getCursor(): { line: number; col: number };
105
+ handleInput(data: string): void;
106
+ handleMouse(event: Record<string, unknown>): unknown;
107
+ render(width: number): string[];
108
+ }
109
+
110
+ function makeWorkspace(): { agentDir: string; projectDir: string; cleanup: () => void } {
111
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-prompt-editor-"));
112
+ const agentDir = path.join(root, "agent");
113
+ const projectDir = path.join(root, "project");
114
+ fs.mkdirSync(agentDir);
115
+ fs.mkdirSync(projectDir);
116
+ return {
117
+ agentDir,
118
+ projectDir,
119
+ cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
120
+ };
121
+ }
122
+
123
+ /**
124
+ * 用 pi 自己的加载器加载扩展,再调用它注册的 session_start,抓出编辑器工厂并实例化。
125
+ * 编辑器外面全部是假的:`paddingX: 0` 的 Editor 只需要 `tui.terminal.rows`,
126
+ * 主题只需要一个恒等的 `borderColor`,keybindings 只需要一个永远不命中的 `matches`
127
+ * (Editor 自己的键位走 pi-tui 的全局默认表,所以 Ctrl+A / 退格都是真按真绑定的行为)。
128
+ */
129
+ async function mountEditor(): Promise<EditorLike> {
130
+ const workspace = makeWorkspace();
131
+ try {
132
+ const pi = (await import(pathToFileURL(piEntry as string).href)) as {
133
+ discoverAndLoadExtensions: (
134
+ configuredPaths: string[],
135
+ cwd: string,
136
+ agentDir?: string,
137
+ eventBus?: unknown,
138
+ ) => Promise<{
139
+ extensions: Array<{ handlers: Map<string, Handler[]> }>;
140
+ errors: Array<{ path: string; error: string }>;
141
+ }>;
142
+ };
143
+ const loaded = await pi.discoverAndLoadExtensions(
144
+ [EXTENSION_PATH],
145
+ workspace.projectDir,
146
+ workspace.agentDir,
147
+ createTestBus(),
148
+ );
149
+ assert.deepEqual(loaded.errors, [], "pi 的扩展加载器不应该报错");
150
+ const extension = loaded.extensions[0];
151
+ assert.ok(extension, "扩展应该被加载");
152
+ const sessionStart = extension.handlers.get("session_start")?.[0];
153
+ assert.ok(sessionStart, "扩展应该注册 session_start");
154
+
155
+ let factory: ((tui: unknown, theme: unknown, keybindings: unknown) => EditorLike) | undefined;
156
+ const ctx = {
157
+ hasUI: true,
158
+ cwd: workspace.projectDir,
159
+ ui: {
160
+ setEditorComponent(next: typeof factory) {
161
+ factory = next;
162
+ },
163
+ },
164
+ };
165
+ await sessionStart({}, ctx);
166
+ assert.ok(factory, "session_start 应该注册编辑器工厂");
167
+
168
+ return factory(
169
+ { terminal: { rows: 40 }, requestRender() {} },
170
+ { borderColor: (text: string) => text },
171
+ { matches: () => false },
172
+ );
173
+ } finally {
174
+ workspace.cleanup();
175
+ }
176
+ }
177
+
178
+ /** 去掉 CSI 与 APC(CURSOR_MARKER)转义,剩下纯文本。 */
179
+ function plainLines(lines: string[]): string[] {
180
+ return lines.map((line) =>
181
+ line
182
+ .replace(/\x1b\[[0-9;:?]*[ -/]*[@-~]/g, "")
183
+ .replace(/\x1b_[^\x07\x1b]*(?:\x07|\x1b\\)/g, ""),
184
+ );
185
+ }
186
+
187
+ /** Editor.handleMouse 要的完整事件形状;x / y 是组件内坐标。 */
188
+ function mouseClick(x: number, y: number): Record<string, unknown> {
189
+ return {
190
+ type: "click",
191
+ button: "left",
192
+ x,
193
+ y,
194
+ screenX: x,
195
+ screenY: y,
196
+ width: 40,
197
+ height: 40,
198
+ shift: false,
199
+ alt: false,
200
+ ctrl: false,
201
+ clickCount: 1,
202
+ };
203
+ }
204
+
205
+ test("普通输入:gutter 是 ❯,正文原样", { skip, timeout: 30_000 }, async () => {
206
+ const editor = await mountEditor();
207
+ editor.setText("hello");
208
+
209
+ const lines = plainLines(editor.render(40));
210
+ assert.ok(lines[1]?.startsWith("❯ hello"), `第一行正文应是 "❯ hello":${JSON.stringify(lines[1])}`);
211
+ assert.equal(lines[1]?.length, 40, "每行都要满宽(gutter + contentWidth)");
212
+ assert.equal(lines[0]?.length, 40, "上边框也要满宽");
213
+ });
214
+
215
+ test("`!` 开头:gutter 换成 !,正文里输入的 ! 不再显示", { skip, timeout: 30_000 }, async () => {
216
+ const editor = await mountEditor();
217
+ editor.setText("!ls -la");
218
+
219
+ const lines = plainLines(editor.render(40));
220
+ assert.ok(lines[1]?.startsWith("! ls -la"), `gutter 应是 "! " 且正文紧接其后:${JSON.stringify(lines[1])}`);
221
+ assert.equal(lines[1]?.includes("!ls"), false, "正文里不能再出现那个 `!`");
222
+ assert.equal(lines[1]?.length, 40, "摘掉一列后要补回满宽");
223
+ assert.equal(lines[0]?.length, 40, "上边框不受影响");
224
+ });
225
+
226
+ test("`!!`(不进上下文)只藏第一个 !,第二个留在正文里可见", { skip, timeout: 30_000 }, async () => {
227
+ const editor = await mountEditor();
228
+ editor.setText("!!ls");
229
+
230
+ const lines = plainLines(editor.render(40));
231
+ assert.ok(lines[1]?.startsWith("! !ls"), `第二个 "!" 要留在正文里:${JSON.stringify(lines[1])}`);
232
+ assert.equal(lines[1]?.length, 40);
233
+ });
234
+
235
+ test("正文只剩 `!` 时按退格退出模式:gutter 自己变回 ❯", { skip, timeout: 30_000 }, async () => {
236
+ const editor = await mountEditor();
237
+ editor.setText("!");
238
+ assert.ok(plainLines(editor.render(40))[1]?.startsWith("! "), "先进入 bash 模式");
239
+
240
+ editor.handleInput("\x7f"); // backspace
241
+ assert.equal(editor.getText(), "", "退格删掉了那个 `!`");
242
+ assert.deepEqual(editor.getCursor(), { line: 0, col: 0 });
243
+ assert.ok(
244
+ plainLines(editor.render(40))[1]?.startsWith("❯ "),
245
+ "退出模式后 gutter 自然回到 ❯",
246
+ );
247
+ });
248
+
249
+ test("Ctrl+A 把光标挡在隐藏的 `!` 之后:继续打字不会顶掉 bash 模式", { skip, timeout: 30_000 }, async () => {
250
+ const editor = await mountEditor();
251
+ editor.setText("!ls");
252
+
253
+ editor.handleInput("\x01"); // ctrl+a
254
+ assert.deepEqual(editor.getCursor(), { line: 0, col: 1 }, "光标不该落到被摘掉的那一列");
255
+
256
+ editor.handleInput("c");
257
+ assert.equal(editor.getText(), "!cls", "插入点在第 1 列之后,`!` 还在最前面");
258
+ assert.ok(plainLines(editor.render(40))[1]?.startsWith("! cls"), "仍然是 bash 模式的画法");
259
+ });
260
+
261
+ test("普通正文的 Ctrl+A 不受影响:光标可以到第 0 列", { skip, timeout: 30_000 }, async () => {
262
+ const editor = await mountEditor();
263
+ editor.setText("ls");
264
+
265
+ editor.handleInput("\x01"); // ctrl+a
266
+ assert.deepEqual(editor.getCursor(), { line: 0, col: 0 });
267
+ });
268
+
269
+ test("点选:正文左移一列后坐标仍然对得上(点 gutter 也会被挡回第 1 列)", { skip, timeout: 30_000 }, async () => {
270
+ const editor = await mountEditor();
271
+ editor.setText("!ls");
272
+ editor.render(40); // handleMouse 依赖最近一次 render 的宽度与行数
273
+
274
+ // 可见正文:gutter 占 x=0/1,`l` 在 x=2,`s` 在 x=3
275
+ editor.handleMouse(mouseClick(3, 1));
276
+ assert.deepEqual(editor.getCursor(), { line: 0, col: 2 }, "点在 `s` 上 → 光标在 `s` 之前");
277
+
278
+ editor.handleMouse(mouseClick(2, 1));
279
+ assert.deepEqual(editor.getCursor(), { line: 0, col: 1 }, "点在 `l` 上 → 光标在 `l` 之前");
280
+
281
+ editor.handleMouse(mouseClick(0, 1));
282
+ assert.deepEqual(editor.getCursor(), { line: 0, col: 1 }, "点 gutter(隐藏列)被挡回第 1 列");
283
+ });
@@ -0,0 +1,212 @@
1
+ /**
2
+ * 输入框视觉层微调:① 类似 Claude Code 的 "❯" 提示符(`!` bash 模式换成 "!");
3
+ * ② 补全列表与 statusline 之间留一行空行。
4
+ *
5
+ * ① 提示符实现方式:继承 CustomEditor,把真正的编辑区收窄 gutter 列(super.render(width - gutter)),
6
+ * 再把 gutter 补回每一行的左侧:
7
+ * - 上/下边框:由 renderTopBorder / renderBottomBorder 自己补满,保持边框满宽
8
+ * - 第一行文本(未滚动时)→ "❯ ",bash 模式(正文以 `!` 开头)→ "! "
9
+ * - 其余文本行 / 自动补全行 → 等宽空格
10
+ * 光标位置由行内的 CURSOR_MARKER 决定,跟着文本一起右移,所以硬件光标 / IME 定位不受影响;
11
+ * 鼠标点击坐标同样左移 gutter 列后再交给父类(bash 模式再多算一列隐藏的 `!`),点击定位保持准确。
12
+ *
13
+ * ② 补全列表与 statusline 的空行:Editor.render() 把补全列表渲染在**编辑器底边框之后**
14
+ * (editor.js:renderBottomBorder 之后才 push autocompleteList.render()),而 statusline 紧跟
15
+ * 在编辑器下方,所以输入 "/xxx" 时补全列表会紧贴 statusline。这里在补全列表真的渲染了的时候
16
+ * 追加一行空白行(只在有补全列表时补,静态布局不变)。判据用公开的 isShowingAutocomplete()
17
+ * (= autocompleteState !== null),而 Editor 里 state 与 autocompleteList 总是一起设置 / 一起清除
18
+ * (clearAutocompleteUi),所以它与“列表真的渲染了”严格同步,不必碰 private 的
19
+ * renderedAutocompleteHeight。空白行按 pi 自己的惯例补满宽度(Editor 每行都 pad 到
20
+ * contentWidth),避免差分渲染器只写变化前缀时留残影。
21
+ * 鼠标不受影响:空白行在补全列表之下(超出 autocompleteStartRow + renderedAutocompleteHeight),
22
+ * 落到 Editor.handleMouse 的“超出可见行”分支;Container 的鼠标分派用 render().length 算子组件
23
+ * 高度,所以高度 +1 后下方组件的坐标仍然对得上。
24
+ *
25
+ * 为什么不开第三个编辑器包装器扩展:`ctx.ui.setEditorComponent()` 是**替换**而非叠加,
26
+ * prompt-editor 的工厂会直接丢掉之前的包装(README 里 folder-history 就是被这个顺序坑过)。
27
+ * 所以这个改动放在已经拥有编辑器子类的本文件里,不新增任何加载顺序约束。
28
+ *
29
+ * Editor 的 theme / scrollOffset / renderedAutocompleteHeight 都是 private,
30
+ * 所以这里只用 protected 的 renderTopBorder/renderBottomBorder 拿到需要的信息。
31
+ *
32
+ * ③ 补全列表左移:Editor 把补全列表渲染在底边框之后,PromptEditor 又给每一行补了 gutter
33
+ * 列,于是命令词落在第 gutter+2 列("→ " 前缀占 2 列)。这里让底边框之后的补全行少补
34
+ * shift 列空格(默认 shift = 1,即整个列表含选择箭头左移一列)。
35
+ * 渲染器对每条变化行先写 \x1b[2K 整行清除,补全行变短不会留残影;SelectList.handleMouse
36
+ * 只用 event.y 定位,左移不影响鼠标点选。
37
+ *
38
+ * ④ `!` bash 模式(对齐 Claude Code):输入框第一个字符是 `!` 时,gutter 的 `❯` 换成 `!`,
39
+ * 正文里那个 `!` 不再显示 —— 于是正文看起来就是命令本身,而光标/IME/点选都还在它原来那一列。
40
+ * 模式判定、Enter 提交(`text.startsWith("!")`)、↑ 历史、Esc 清空全部沿用 pi 自己的链路
41
+ * (`interactive-mode.js` 的 `onChange` 是 `text.trimStart().startsWith("!")`,边框颜色用的就是它),
42
+ * 这里只把渲染层对齐过去:判定与摘除在 `./prompt-editor/bash-prompt.ts`(纯逻辑,可单测)。
43
+ * 三处细节:
44
+ * - 正文一个字符都不动(隐藏只是画法),所以 pi 那边的颜色/提交/历史不需要任何配合;
45
+ * - 隐藏掉一列后,正文整体左移一列 —— 渲染时把首行摘掉一个 `!` 再补一个空格回满宽,
46
+ * 鼠标点选要多算一列,光标如果停在那一列(Ctrl+A / 方向键 / 点 gutter)会被挡回第 1 列:
47
+ * 放进去的话反显光标会落在被摘掉的空列上,而在那儿打字会把 `x!ls` 写进正文,
48
+ * pi 当场判定「不是 bash 模式了」;
49
+ * - 退出模式不需要额外代码:正文为空时按回退键删掉 `!`(或 Enter 提交 / Esc 清空),
50
+ * pi 那边 `isBashMode` 变回 false、边框颜色照旧逻辑回落,这里下一帧就画回 `❯`。
51
+ *
52
+ * 可用环境变量:
53
+ * PI_EDITOR_PROMPT 提示符字符,默认 "❯"(bash 模式的 "!" 不跟着变)
54
+ * PI_EDITOR_AUTOCOMPLETE_GAP 设为 off 关闭补全列表下方的空白行
55
+ * PI_EDITOR_AUTOCOMPLETE_SHIFT 补全列表左移列数,0..gutter,默认 1;
56
+ * 设 0 恢复旧行为,设 2(= gutter)则命令词对齐光标起始列
57
+ */
58
+
59
+ import {
60
+ CustomEditor,
61
+ type ExtensionAPI,
62
+ type KeybindingsManager,
63
+ } from "@earendil-works/pi-coding-agent";
64
+ import type { EditorTheme, TUI, TuiMouseEvent, TuiMouseEventResult } from "@earendil-works/pi-tui";
65
+ import { visibleWidth } from "@earendil-works/pi-tui";
66
+ import { BASH_MARKER, dropLeadingBang, resolveBashPrompt } from "./prompt-editor/bash-prompt.ts";
67
+
68
+ const PROMPT_CHAR = process.env.PI_EDITOR_PROMPT ?? "❯";
69
+ const AUTOCOMPLETE_GAP = (process.env.PI_EDITOR_AUTOCOMPLETE_GAP ?? "").toLowerCase() !== "off";
70
+
71
+ /** 补全列表左移列数覆盖值;未设置时默认左移 1 列。 */
72
+ const AUTOCOMPLETE_SHIFT_OVERRIDE = (() => {
73
+ const raw = process.env.PI_EDITOR_AUTOCOMPLETE_SHIFT;
74
+ if (raw === undefined || raw.trim() === "") return undefined;
75
+ const parsed = Number.parseInt(raw, 10);
76
+ return Number.isFinite(parsed) ? parsed : undefined;
77
+ })();
78
+
79
+ class PromptEditor extends CustomEditor {
80
+ private readonly gutter: number;
81
+ /** 最近一次 renderBottomBorder 生成的整行,用来在 render() 里认出下边框 */
82
+ private bottomBorderLine = "";
83
+ /** 上边框带的 "↑ N more" 说明当前视图是滚动过的 */
84
+ private scrolled = false;
85
+
86
+ constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) {
87
+ super(tui, theme, keybindings, { paddingX: 0 });
88
+ this.gutter = visibleWidth(PROMPT_CHAR) + 1; // 提示符 + 一个空格
89
+ }
90
+
91
+ private get gutterFill(): string {
92
+ return this.borderColor("─".repeat(this.gutter));
93
+ }
94
+
95
+ /**
96
+ * 提示符字形铺满 gutter 列(`❯` / `!` 都是 1 列,但 `PI_EDITOR_PROMPT` 可能是宽字符)。
97
+ * 只把字形染色、补齐的空格不染 —— 和原来的 `${borderColor("❯")} ` 逐字一致。
98
+ */
99
+ private promptCell(char: string): string {
100
+ const pad = Math.max(0, this.gutter - visibleWidth(char));
101
+ return this.borderColor(char) + " ".repeat(pad);
102
+ }
103
+
104
+ /**
105
+ * 把光标挡在隐藏的 `!` 之外:Claude Code 里光标永远进不到模式标记上。
106
+ * 放它进去有两个后果 —— 反显光标落在被摘掉的那一空列上(看起来光标消失),
107
+ * 以及在那儿打字会把 `x!ls` 写进正文,pi 当场判定「不是 bash 模式了」。
108
+ * 光标列住在 Editor private 的 state 里、没有公开 setter,所以直接调它自己的
109
+ * setCursorCol(1)(顺带清掉 sticky 列);pi 以后改名时这里退化成「光标停在 0 列」,
110
+ * 只影响这个边角行为,不影响渲染。
111
+ */
112
+ private keepCursorOffBang(): void {
113
+ if (!resolveBashPrompt(this.getText()).hidden) return;
114
+ const cursor = this.getCursor();
115
+ if (cursor.line !== 0 || cursor.col !== 0) return;
116
+
117
+ const internals = this as unknown as {
118
+ setCursorCol?: (col: number) => void;
119
+ state?: { cursorCol: number };
120
+ };
121
+ if (typeof internals.setCursorCol === "function") internals.setCursorCol(1);
122
+ else if (internals.state) internals.state.cursorCol = 1;
123
+ }
124
+
125
+ protected renderTopBorder(width: number, hiddenLineCount: number): string {
126
+ this.scrolled = hiddenLineCount > 0;
127
+ return this.gutterFill + super.renderTopBorder(width, hiddenLineCount);
128
+ }
129
+
130
+ protected renderBottomBorder(width: number, hiddenLineCount: number): string {
131
+ this.bottomBorderLine = this.gutterFill + super.renderBottomBorder(width, hiddenLineCount);
132
+ return this.bottomBorderLine;
133
+ }
134
+
135
+ render(width: number): string[] {
136
+ const gutter = this.gutter;
137
+ const lines = super.render(Math.max(1, width - gutter));
138
+ if (lines.length === 0) return lines;
139
+
140
+ // pi 的 bash 模式只由正文文本决定,不额外维护状态:每帧重算,永远不会和边框颜色脱节
141
+ const bash = resolveBashPrompt(this.getText());
142
+
143
+ const blank = " ".repeat(gutter);
144
+ // 滚动后第一可见行已经不是逻辑首行,不再显示提示符
145
+ const showPrompt = !this.scrolled;
146
+ // EditorTheme 只暴露 borderColor,所以提示符沿用边框色(bash 模式下就是 bashMode 色)
147
+ const prompt = this.promptCell(bash.active ? BASH_MARKER : PROMPT_CHAR);
148
+ // 要摘 `!` 的那一行正好就是显示提示符的那一行
149
+ const hideBang = bash.hidden && showPrompt;
150
+
151
+ // 补全列表左移:默认 1 列(含选择箭头一起左移)。
152
+ const shift = Math.max(0, Math.min(gutter, AUTOCOMPLETE_SHIFT_OVERRIDE ?? 1));
153
+ const autocompleteBlank = " ".repeat(gutter - shift);
154
+
155
+ let inAutocomplete = false;
156
+ const mapped = lines.map((line, index) => {
157
+ // 上边框已经在上面补满宽度
158
+ if (index === 0) return line;
159
+ // 下边框已补满宽度;其后的行都是补全列表行
160
+ if (line === this.bottomBorderLine) {
161
+ inAutocomplete = true;
162
+ return line;
163
+ }
164
+ if (inAutocomplete) return autocompleteBlank + line;
165
+ if (index === 1 && showPrompt) {
166
+ if (!hideBang) return prompt + line;
167
+ // 摘掉正文那个 `!` 后整行少 1 列,补一个空格回满宽(Editor 每行都铺满 contentWidth,
168
+ // 少一列会在差分渲染器下留残影)
169
+ const dropped = dropLeadingBang(line);
170
+ return prompt + dropped.text + (dropped.dropped ? " " : "");
171
+ }
172
+ return blank + line;
173
+ });
174
+
175
+ // 补全列表渲染在编辑器底边框之后,紧接着就是 statusline,视觉上拥挤;
176
+ // 只在补全列表真的存在时补一行空白行,静态布局不变。
177
+ // 按 pi 自己的惯例补满宽度(其余行也都是 gutter + contentWidth = width)。
178
+ if (AUTOCOMPLETE_GAP && this.isShowingAutocomplete()) {
179
+ mapped.push(" ".repeat(Math.max(0, width)));
180
+ }
181
+
182
+ return mapped;
183
+ }
184
+
185
+ handleInput(data: string): void {
186
+ super.handleInput(data);
187
+ // 任何一条能改光标/正文的路径(方向键、Home/Ctrl+A、↑ 历史、退格、粘贴、undo…)都从这里过
188
+ this.keepCursorOffBang();
189
+ }
190
+
191
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
192
+ // bash 模式下正文整体左移了 1 列(隐藏的 `!`),点选要往右多算 1 列才对得上原文。
193
+ // 补全列表的 x 不参与命中(SelectList 只用 y),多这 1 列不影响它。
194
+ const bangHitShift = resolveBashPrompt(this.getText()).hidden ? 1 : 0;
195
+ const result = super.handleMouse({
196
+ ...event,
197
+ x: Math.max(0, event.x - this.gutter + bangHitShift),
198
+ width: Math.max(1, event.width - this.gutter),
199
+ });
200
+ this.keepCursorOffBang();
201
+ return result;
202
+ }
203
+ }
204
+
205
+ export default function (pi: ExtensionAPI) {
206
+ pi.on("session_start", (_event, ctx) => {
207
+ if (!ctx.hasUI) return;
208
+ ctx.ui.setEditorComponent(
209
+ (tui, theme, keybindings) => new PromptEditor(tui, theme, keybindings),
210
+ );
211
+ });
212
+ }