@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,142 @@
1
+ /**
2
+ * fenceless-code-block — 纯逻辑:Markdown 代码块去掉围栏
3
+ *
4
+ * 去围栏这个观感来自 `itc-steve/pi-theme`(npm `@itc-steve/pi-theme`,MIT),但**只取这一半**:
5
+ * 上游还给每行铺一块 `toolPendingBg` 底色,本仓库不要底色 —— 代码正文保持 pi 原本的
6
+ * `mdCodeBlock` 前景色,缩进与补白也照 pi 的老规矩来。按本仓库的习惯重写:pi-tui 的量度 /
7
+ * 折行函数由调用方注入(本模块不 import pi,`node --test` 能直跑)。
8
+ *
9
+ * 效果:` ``` ` 开合围栏(连同 `lang` 标签)不再占行;代码正文按 pi 的缩进铺开,语法着色保留。
10
+ * 行尾**不补白**:补白本来是给底色铺满整宽用的,而 `Markdown.render()` 无论如何都会把每行补到
11
+ * 整宽(没有底色时走 `lineWithMargins + " ".repeat(paddingNeeded)` 那条分支),自己再补一遍多余。
12
+ *
13
+ * 为什么必须打原型补丁:pi 的扩展 API 里跟 Markdown 有关的只有 `registerMarkdownTransformer`,
14
+ * 它是**字符串级**的(改完仍交给内置渲染器)。而围栏是内置渲染器自己拼的字面量
15
+ * (`markdown.js` 的 `case "code"`:`theme.codeBlockBorder("```" + lang)` 与收尾的 `"```"`),
16
+ * 源文里没有这两个字符,字符串级改不掉。所以只能接管 `Markdown.prototype.renderToken`。
17
+ *
18
+ * 补丁为什么打得中(跨模块实例,本机实测过,别凭直觉推断):
19
+ * `pi` 命令跑的是 `dist/bundle/cli.js`(bun 打的 bundle),pi-tui 被**内联**进 chunk,不是
20
+ * node_modules 里那份;扩展若拿到 node_modules 那份,补丁就打在另一个类上、毫无效果
21
+ * (实测:改外部包的 `Markdown.prototype`,pi 自己的 `AssistantMessageComponent` 渲染同一个
22
+ * assistant 消息,围栏照样在)。真正让补丁生效的是 pi 扩展加载器的 virtualModules 分支
23
+ * (`core/extensions/loader.js:421`,bundle / 编译二进制走 `{ virtualModules: VIRTUAL_MODULES }`):
24
+ * 扩展 import 的 `@earendil-works/pi-tui` 被指向加载器那份 bundle 自己的命名空间,也就是 pi
25
+ * 渲染时用的同一个类。`index.test.ts` 断言的就是这条端到端链路。
26
+ *
27
+ * 每行都必须 ≤ width(含缩进):`Markdown.render()` 会把 renderToken 返回的**每一行**再交给
28
+ * `wrapTextWithAnsi(line, contentWidth)`,超宽行会被二次折行 —— 而续行不带缩进,代码块看上去
29
+ * 会错位。所以这里自己先折行;缩进放不下(比 width 还宽)时丢缩进,保证可用宽度至少 1 列。
30
+ */
31
+
32
+ /** 补丁标记:`Symbol.for` 走全局注册表,`/reload`(模块重新求值)后仍认得出这个原型包过了。 */
33
+ const PATCH_KEY = Symbol.for("pi-fenceless-code-block.patched");
34
+
35
+ /** 终端列宽量度(注入 pi-tui 的 `visibleWidth`)。 */
36
+ export type Measure = (text: string) => number;
37
+ /** 折行(注入 pi-tui 的 `wrapTextWithAnsi`)。 */
38
+ export type Wrap = (text: string, width: number) => string[];
39
+
40
+ export interface CodeBlockRenderOptions {
41
+ /** 语言标签(` ```js ` 里的 `js`),只用来交给 `highlight`。 */
42
+ lang?: string;
43
+ /** 缩进前缀:pi 的 `MarkdownTheme.codeBlockIndent`(设置项 `markdown.codeBlockIndent`,默认两格)。 */
44
+ indent?: string;
45
+ /** 着色:pi 的 `MarkdownTheme.highlightCode`,返回行数与输入一一对应。 */
46
+ highlight?: (code: string, lang?: string) => string[];
47
+ /** 没有 `highlightCode` 时的兜底着色,等价于官方渲染器的 else 分支(`theme.codeBlock`)。 */
48
+ fallbackStyle?: (text: string) => string;
49
+ measure: Measure;
50
+ wrap: Wrap;
51
+ }
52
+
53
+ /** 代码块正文 → 可直接交给 pi 渲染的行:无围栏、按缩进铺开、每行不超过 width。 */
54
+ export function renderCodeBlockLines(code: string, width: number, options: CodeBlockRenderOptions): string[] {
55
+ const { measure, wrap } = options;
56
+ const total = Math.max(1, width);
57
+ const wanted = options.indent ?? DEFAULT_INDENT;
58
+ // 缩进可能把可用宽度挤成 0(极窄终端):宁可这一段丢缩进,也不能让行超过 width
59
+ // (超了会被 Markdown.render() 二次折行,续行丢缩进、看上去错位)。
60
+ const indent = measure(wanted) < total ? wanted : "";
61
+ const inner = Math.max(1, total - measure(indent));
62
+
63
+ const lines: string[] = [];
64
+ for (const source of styleCode(code, options)) {
65
+ // 折行函数对空串可能返回空数组:空行也要占一行,否则代码块中间会塌掉一行。
66
+ const pieces = wrap(source, inner);
67
+ for (const piece of pieces.length > 0 ? pieces : [""]) lines.push(indent + piece);
68
+ }
69
+ return lines;
70
+ }
71
+
72
+ const DEFAULT_INDENT = " ";
73
+
74
+ /**
75
+ * 着色优先级:`highlight` 有产出就用它;产出为空(空代码块、高亮器不认这门语言)就退回纯文本
76
+ * —— 官方渲染器在这种情况下会一行都不画,那样整个代码块会凭空消失。
77
+ */
78
+ function styleCode(code: string, options: CodeBlockRenderOptions): string[] {
79
+ const highlighted = options.highlight?.(code, options.lang);
80
+ if (highlighted && highlighted.length > 0) return highlighted;
81
+ const plain = code.split("\n");
82
+ return options.fallbackStyle ? plain.map(options.fallbackStyle) : plain;
83
+ }
84
+
85
+ /** Markdown 实例上的主题切片(类的"私有"字段在运行时就是普通属性)。 */
86
+ interface MarkdownLike {
87
+ theme?: {
88
+ codeBlockIndent?: string;
89
+ highlightCode?: (code: string, lang?: string) => string[];
90
+ codeBlock?: (text: string) => string;
91
+ };
92
+ }
93
+
94
+ type RenderToken = (
95
+ this: MarkdownLike,
96
+ token: unknown,
97
+ width: number,
98
+ nextType?: string,
99
+ styleContext?: unknown,
100
+ ) => string[];
101
+
102
+ export interface FencelessPatchOptions {
103
+ /** pi-tui 的 `Markdown` 类。扩展侧 import 到的就是 pi 渲染用的那个类,见文件头。 */
104
+ Markdown: { prototype: Record<string | symbol, unknown> };
105
+ measure: Measure;
106
+ wrap: Wrap;
107
+ }
108
+
109
+ /**
110
+ * 装上「代码块去围栏」补丁。返回**本次是否真的装了**:`/reload` 之后是 `false`(补丁已在),
111
+ * 原型的形状不认识(没有 `renderToken`)时也是 `false` 且什么都不做 —— 不猜、不抛,
112
+ * 宁可这个特性不生效。
113
+ */
114
+ export function installFencelessCodeBlocks(options: FencelessPatchOptions): boolean {
115
+ const prototype = options.Markdown.prototype;
116
+ if (prototype[PATCH_KEY]) return false;
117
+
118
+ const original = prototype.renderToken;
119
+ if (typeof original !== "function") return false;
120
+ prototype[PATCH_KEY] = true;
121
+
122
+ const patched: RenderToken = function (this: MarkdownLike, token, width, nextType, styleContext) {
123
+ const block = token as { type?: string; text?: string; lang?: string } | null | undefined;
124
+ // 只接管代码块,其它 token 原样交给内置渲染器(含 blockquote / list 里的嵌套调用)。
125
+ if (block?.type !== "code") return original.call(this, token, width, nextType, styleContext);
126
+
127
+ const theme = this?.theme ?? {};
128
+ const lines = renderCodeBlockLines(block.text ?? "", width, {
129
+ lang: block.lang,
130
+ indent: theme.codeBlockIndent,
131
+ highlight: theme.highlightCode?.bind(theme),
132
+ fallbackStyle: theme.codeBlock?.bind(theme),
133
+ measure: options.measure,
134
+ wrap: options.wrap,
135
+ });
136
+ // 与官方渲染器一致:后面紧跟的不是空行 token 时补一个空行,免得代码块贴着下一段。
137
+ if (nextType && nextType !== "space") lines.push("");
138
+ return lines;
139
+ };
140
+ prototype.renderToken = patched;
141
+ return true;
142
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Folder-based Command History(本地改版,源自 npm:pi-command-history@0.2.0)
3
+ *
4
+ * 按工作目录持久化命令历史,跨会话复用。历史文件:~/.pi/folder-history/<path-with-dashes>.jsonl
5
+ *
6
+ * 与上游最大的差别:**不再注册任何快捷键**。
7
+ * 上游用 ctrl+up / ctrl+down,在 macOS 上被系统 Mission Control 抢走(终端收不到),
8
+ * 而且和 pi 内置的 tui.altScreen.previousPrompt / nextPrompt 撞键(启动时刷 conflict 警告)。
9
+ *
10
+ * 现在的做法是把跨 session 的历史注入编辑器自身的 history 列表,于是**原生 ↑ / ↓ 直接跨 session 翻历史**:
11
+ * - pi-tui Editor.history 里 history[0] = 最新,索引越大越旧;↑ 在第一视觉行时往旧翻,↓ 往新翻;
12
+ * - pi 启动时只把「当前会话」的用户消息灌进 history(renderInitialMessages → populateHistory),
13
+ * 所以原生 ↑/↓ 原本只能翻当前会话;
14
+ * - 我们把旧 session 的记录**追加到 history 数组尾部**(尾部 = 更旧),顺序变成
15
+ * [当前会话最新…最旧, 上个会话最新…最旧, 更早…],↑ 一路往旧翻,↓ 一路往新翻。
16
+ *
17
+ * 为什么用「注入 history」而不是注册 up/down 快捷键:扩展快捷键在 CustomEditor.handleInput 的
18
+ * 最前面被检查(onExtensionShortcut first),注册 "up" 会吃掉多行输入框的光标上移,
19
+ * 以及所有选择器(模型选择、/resume 列表…)的 ↑/↓ 导航。注入 history 则完全保留原生语义:
20
+ * 多行 prompt 里 ↑/↓ 照常移动光标,只有光标在第一视觉行(或正在翻历史)时才翻历史。
21
+ *
22
+ * 实现要点:ctx.ui.getEditorComponent() 返回的是**工厂函数**(EditorFactory),不是编辑器实例,
23
+ * 所以拿不到 history 数组 —— 只能包一层工厂,在实例化时注入。
24
+ * 扩展按文件名顺序加载(folder-history.ts 排在 prompt-editor.ts 之前),prompt-editor 会在
25
+ * session_start 里 setEditorComponent 覆盖掉我们的工厂,因此这里同步包一次 + setTimeout(0) 再包一次
26
+ * (此时所有 session_start handler 都已跑完),并用 symbol 标记避免重复嵌套包装。
27
+ *
28
+ * 可调环境变量:
29
+ * PI_FOLDER_HISTORY_INJECT 注入编辑器 history 的最大条数,默认 100(= pi-tui 内置 history 上限)
30
+ */
31
+
32
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
33
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
34
+ import { existsSync, mkdirSync, readFileSync, appendFileSync } from "node:fs";
35
+ import { join } from "node:path";
36
+ import { homedir } from "node:os";
37
+
38
+ const HISTORY_DIR = join(homedir(), ".pi", "folder-history");
39
+ const MAX_HISTORY = 500; // 历史文件里保留的条数上限
40
+ // 注入上限默认 100:pi-tui Editor.addToHistory 在 history 超过 100 条时会 pop 掉最旧的一条,
41
+ // 注入更多只会在后续提交时被逐条裁掉,所以和内置上限保持一致。
42
+ const MAX_INJECT = Number(process.env.PI_FOLDER_HISTORY_INJECT || 100);
43
+ const WRAPPED = Symbol.for("pi.folder-history.wrapped");
44
+
45
+ function getHistoryFile(cwd: string): string {
46
+ const name = cwd.replace(/\//g, "-");
47
+ return join(HISTORY_DIR, `${name}.jsonl`);
48
+ }
49
+
50
+ /** 返回 oldest-first 的去重历史(按文件里的写入顺序)。 */
51
+ function loadHistory(cwd: string): string[] {
52
+ const file = getHistoryFile(cwd);
53
+ if (!existsSync(file)) return [];
54
+
55
+ try {
56
+ const lines = readFileSync(file, "utf-8")
57
+ .split("\n")
58
+ .filter((l) => l.trim());
59
+
60
+ const entries: string[] = [];
61
+ for (const line of lines) {
62
+ try {
63
+ const entry = JSON.parse(line);
64
+ if (entry.text && entry.cwd === cwd) {
65
+ entries.push(entry.text);
66
+ }
67
+ } catch {
68
+ // skip malformed lines
69
+ }
70
+ }
71
+
72
+ // Deduplicate keeping last occurrence, then trim to max
73
+ const seen = new Map<string, number>();
74
+ entries.forEach((text, i) => seen.set(text, i));
75
+ const unique = [...seen.entries()]
76
+ .sort((a, b) => a[1] - b[1])
77
+ .map(([text]) => text);
78
+
79
+ return unique.slice(-MAX_HISTORY);
80
+ } catch {
81
+ return [];
82
+ }
83
+ }
84
+
85
+ function appendHistory(cwd: string, text: string): void {
86
+ mkdirSync(HISTORY_DIR, { recursive: true });
87
+ const file = getHistoryFile(cwd);
88
+ const entry = JSON.stringify({ cwd, text, ts: Date.now() });
89
+ appendFileSync(file, entry + "\n", "utf-8");
90
+ }
91
+
92
+ /** 当前会话已有的用户输入(避免 resume 时把同一条记录重复注入成两份)。 */
93
+ function collectSessionTexts(sessionManager: unknown): Set<string> {
94
+ const texts = new Set<string>();
95
+ try {
96
+ const entries = (sessionManager as { getEntries?: () => unknown[] })?.getEntries?.() ?? [];
97
+ for (const entry of entries as Array<Record<string, any>>) {
98
+ if (entry?.type !== "message") continue;
99
+ const message = entry.message;
100
+ if (message?.role !== "user") continue;
101
+ const content = message.content;
102
+ if (typeof content === "string") {
103
+ const trimmed = content.trim();
104
+ if (trimmed) texts.add(trimmed);
105
+ } else if (Array.isArray(content)) {
106
+ const text = content
107
+ .filter((block: any) => block?.type === "text" && typeof block.text === "string")
108
+ .map((block: any) => block.text)
109
+ .join("\n")
110
+ .trim();
111
+ if (text) texts.add(text);
112
+ }
113
+ }
114
+ } catch {
115
+ // sessionManager shape changed — just skip dedupe
116
+ }
117
+ return texts;
118
+ }
119
+
120
+ /**
121
+ * 注入到编辑器 history:history[0] = 最新,索引越大越旧,
122
+ * 所以旧记录追加到**尾部**,且追加顺序必须是 newest-first。
123
+ */
124
+ function injectIntoEditor(
125
+ editor: unknown,
126
+ entriesOldestFirst: string[],
127
+ exclude: Set<string>,
128
+ ): number {
129
+ if (!editor || typeof editor !== "object") return 0;
130
+
131
+ const existing = (editor as { history?: unknown }).history;
132
+ const injectOldestFirst = entriesOldestFirst.filter(
133
+ (text) => !exclude.has(text) && !(Array.isArray(existing) && existing.includes(text)),
134
+ ).slice(-MAX_INJECT);
135
+ if (injectOldestFirst.length === 0) return 0;
136
+
137
+ if (!Array.isArray(existing)) {
138
+ // 兜底:编辑器没有 history 数组时用公开 API 逐条加(oldest-first → 最新的落在 history[0])
139
+ const addToHistory = (editor as { addToHistory?: (t: string) => void }).addToHistory;
140
+ if (typeof addToHistory !== "function") return 0;
141
+ for (const text of injectOldestFirst) addToHistory.call(editor, text);
142
+ return injectOldestFirst.length;
143
+ }
144
+
145
+ existing.push(...injectOldestFirst.slice().reverse());
146
+ return injectOldestFirst.length;
147
+ }
148
+
149
+ export default function (pi: ExtensionAPI) {
150
+ let currentCwd = "";
151
+ let sessionTexts = new Set<string>();
152
+
153
+ /** 包一层编辑器工厂,在实例化时注入跨 session 历史。 */
154
+ function installEditorWrapper(ctx: any): boolean {
155
+ if (!ctx?.hasUI) return false;
156
+
157
+ const previous = ctx.ui.getEditorComponent();
158
+ if (previous && (previous as any)[WRAPPED]) return false; // 已经是我们的包装,别嵌套
159
+
160
+ ctx.ui.setEditorComponent((tui: any, theme: any, keybindings: any) => {
161
+ const editor = previous
162
+ ? previous(tui, theme, keybindings)
163
+ : new CustomEditor(tui, theme, keybindings);
164
+ injectIntoEditor(editor, loadHistory(currentCwd), sessionTexts);
165
+ return editor;
166
+ });
167
+
168
+ const installed = ctx.ui.getEditorComponent();
169
+ if (installed) (installed as any)[WRAPPED] = true;
170
+ return true;
171
+ }
172
+
173
+ pi.on("session_start", (_event, ctx) => {
174
+ currentCwd = ctx.cwd;
175
+ sessionTexts = collectSessionTexts((ctx as any).sessionManager);
176
+
177
+ // 同步包一次;如果别的扩展(如 prompt-editor)之后又覆盖了工厂,
178
+ // 下面的 macrotask 会在所有 session_start handler 跑完后再包一次。
179
+ installEditorWrapper(ctx);
180
+ setTimeout(() => {
181
+ try {
182
+ installEditorWrapper(ctx);
183
+ } catch {
184
+ // ctx 可能已经因为 reload / 换 session 失效
185
+ }
186
+ }, 0);
187
+ });
188
+
189
+ // 只负责持久化:编辑器 history 由 pi 自己在提交时 addToHistory,不要重复加
190
+ pi.on("input", (event, _ctx) => {
191
+ const text = event.text?.trim();
192
+ if (!text || !currentCwd) return;
193
+
194
+ appendHistory(currentCwd, text);
195
+ return { action: "continue" as const };
196
+ });
197
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * /init:仿 Claude Code —— 让模型分析当前仓库,生成或更新「记忆文件」。
3
+ *
4
+ * 目标文件的选取(没显式指定时,只看 `ctx.cwd` 这一层):
5
+ * ① 有 `CLAUDE.md` → 更新 `CLAUDE.md`
6
+ * ② 否则有 `AGENTS.md` → 更新 `AGENTS.md`
7
+ * ③ 两个都没有 → 新建 `AGENTS.md`
8
+ * 显式指定:`/init <文件.md>`(第一个词以 `.md` 结尾就当路径,其余的当附加要求),
9
+ * 例如 `/init docs/NOTES.md`、`/init /tmp/foo.md`、`/init 重点写构建和测试`。
10
+ *
11
+ * 为什么 CLAUDE.md 优先:这个仓库(以及作者的大部分项目)把 `CLAUDE.md` 当正文、
12
+ * `AGENTS.md` 只是指向它的符号链接 —— 写 `CLAUDE.md` 才不会在复制/打包时丢内容。
13
+ * pi 自己两个名字都认(`docs/usage.md` 的 Context Files:从 cwd 逐层向上加载
14
+ * `AGENTS.md` 或 `CLAUDE.md`,同目录有 `AGENTS.override.md` 时以它为准),所以这个顺序
15
+ * 只决定「往哪个文件写」,不影响它会不会被加载。
16
+ *
17
+ * 扩展自己**不写文件**:只做目标判定 + 把一段提示词当**用户消息**发给模型
18
+ *(`pi.sendUserMessage`),由模型的 read / write / edit 去落盘。这正是 Claude Code
19
+ * `/init` 的形态(一条命令 + 一段提示词),好处是用户能在会话里看到模型的产出过程、
20
+ * 也能中途纠正;扩展不碰文件,就没有「扩展在后台偷偷改仓库」这类意外。
21
+ *
22
+ * 三个刻意的决定:
23
+ * ① **先 `await ctx.waitForIdle()`** 再判定:agent 正在跑的时候发用户消息要么抛错
24
+ * (未指定 `deliverAs`),要么被排成 followUp;而目标文件的判定会因此变旧 ——
25
+ * 当前回合可能刚创建/删掉 `CLAUDE.md`,那样就会把内容写进错的记忆文件。
26
+ * 等这轮结束再判定 + 发送,语义最稳(同 `/clear` 先 `waitForIdle` 的理由)。
27
+ * ② **只认 cwd 这一层,不向上找父目录**:pi 是逐层加载的,用户在子目录里 `/init`
28
+ * 想写的就是这个子目录自己的记忆文件;要写别处直接传路径。向上找会让人在子目录里
29
+ * 一不留神改到仓库根的记忆文件。
30
+ * ③ **不注册参数补全**:`/init` 的候选只有两个固定名字,补全的收益抵不上一个额外的
31
+ * pi-tui 依赖面;要指定路径就手敲,反正有 `read`/`write` 的相对路径习惯。
32
+ *
33
+ * `ctx.hasUI` 为 false(print / json 模式)时不 notify —— 那里没有 UI,提示无处可去;
34
+ * 命令本身照常把提示词发出去。
35
+ */
36
+
37
+ import { statSync } from "node:fs";
38
+ import { isAbsolute, relative, resolve } from "node:path";
39
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
40
+
41
+ /** 自动判定时的候选顺序:有 `CLAUDE.md` 就写它,否则 `AGENTS.md`。 */
42
+ const CANDIDATES = ["CLAUDE.md", "AGENTS.md"];
43
+
44
+ /** 两个候选都没有时新建哪个。 */
45
+ const NEW_FILE = "AGENTS.md";
46
+
47
+ /** `statSync` 跟随符号链接 —— 本仓库就是 `AGENTS.md -> CLAUDE.md`,两个名字都该算“存在”。 */
48
+ function isFile(path: string): boolean {
49
+ try {
50
+ return statSync(path).isFile();
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ interface InitTarget {
57
+ /** 交给模型落盘的**绝对路径**。 */
58
+ path: string;
59
+ /** 提示词里展示用的路径(在项目内用相对路径,否则回落绝对路径)。 */
60
+ display: string;
61
+ exists: boolean;
62
+ /** 用户显式指定的路径(提示词里换一种说法,并提醒别动兄弟文件)。 */
63
+ explicit: boolean;
64
+ }
65
+
66
+ /**
67
+ * 解析 `/init` 的参数:第一个词以 `.md` 结尾就算目标文件,其余文字当附加要求;
68
+ * 没有这样的词就整串当附加要求(`/init 重点写构建和测试`)。
69
+ */
70
+ function parseArgs(args: string): { file?: string; extra: string } {
71
+ const trimmed = args.trim();
72
+ if (!trimmed) return { extra: "" };
73
+ const match = /^(\S+\.md)(?:\s+([\s\S]*))?$/i.exec(trimmed);
74
+ if (!match) return { extra: trimmed };
75
+ return { file: match[1], extra: (match[2] ?? "").trim() };
76
+ }
77
+
78
+ /** 项目内显示相对路径(`../x` 这种跳出去的回落绝对路径,免得看不出真写到哪了)。 */
79
+ function displayPath(cwd: string, path: string): string {
80
+ const rel = relative(cwd, path);
81
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
82
+ }
83
+
84
+ function resolveTarget(cwd: string, file: string | undefined): InitTarget {
85
+ if (file) {
86
+ const path = isAbsolute(file) ? file : resolve(cwd, file);
87
+ return { path, display: displayPath(cwd, path), exists: isFile(path), explicit: true };
88
+ }
89
+ for (const name of CANDIDATES) {
90
+ const path = resolve(cwd, name);
91
+ if (isFile(path)) return { path, display: name, exists: true, explicit: false };
92
+ }
93
+ return { path: resolve(cwd, NEW_FILE), display: NEW_FILE, exists: false, explicit: false };
94
+ }
95
+
96
+ /**
97
+ * 提示词。要点:
98
+ * - 把「文件会被之后的每次会话自动加载」说清楚,模型才知道该写什么(写给下一个 agent)。
99
+ * - 明确**只写验证过的事实**(这个仓库的一贯要求:不臆造命令、不猜路径)。
100
+ * - 已存在时强调「先 read、保留正确内容、别为重写而重写」,否则模型很容易整篇重写,
101
+ * 把仓库里那些踩坑记录一把抹掉。
102
+ * - 列出「不要写」的清单(通用建议 / 目录树 / README 复述 / 临时信息)—— 这是
103
+ * Claude Code 的 /init 提示词里最起作用的那几条,照搬。
104
+ */
105
+ function buildPrompt(target: InitTarget, cwd: string, extra: string): string {
106
+ const action = target.exists ? "更新" : "创建";
107
+ const lines = [
108
+ `请分析这个代码库,然后${action}记忆文件 \`${target.display}\`(绝对路径:\`${target.path}\`)。`,
109
+ "",
110
+ "这个文件会被 pi 在**之后的每次会话**自动加载(从工作目录逐层向上读 `AGENTS.md` / `CLAUDE.md`,同目录下有 `AGENTS.override.md` 时以它为准),读者是「下一次在这个仓库里干活的 agent」。",
111
+ `项目根目录:\`${cwd}\``,
112
+ "",
113
+ target.exists
114
+ ? "该文件**已存在**:先 read 它,再决定改什么 —— 保留仍然正确的内容、删掉与代码不符的、补上缺的。不要为了重写而重写,也不要丢掉已有的踩坑记录。"
115
+ : "该文件**还不存在**:从零写一份。",
116
+ "",
117
+ "先自己探查仓库(README、依赖与构建清单如 package.json / pyproject.toml / Makefile / Cargo.toml、CI 配置、测试目录、主要源码),**只写你验证过的事实**:命令要真的存在、路径要真的对,没把握就不写,不要臆造。",
118
+ "",
119
+ "要写进去的(按价值排序):",
120
+ "1. **常用命令**:装依赖 / 构建 / 跑测试(含只跑单个测试的写法)/ lint / 格式化 / 本地起服务;注明在哪个目录执行、有哪些前置条件。命令放代码块,能直接复制。",
121
+ "2. **架构与代码地图**:主要模块各自负责什么、一条典型请求或一次典型构建是怎么流过去的、关键约定与分层、外部依赖、哪些文件是生成物(不要手改)。",
122
+ "3. **这个仓库特有的坑**:非显然的行为、必须遵守的流程(提交信息格式、需要同时改的几处配置、必须保持同步的镜像文件)、容易踩错的地方。",
123
+ "",
124
+ "不要写:",
125
+ "- 通用开发建议(「写单元测试」「不要提交密钥」「给出清晰的报错」这类谁都知道的话)。",
126
+ "- 目录树罗列 / 逐文件清单,或把 README 复述一遍。",
127
+ "- 只跟当前任务有关的临时信息。",
128
+ "",
129
+ "写法:",
130
+ "- 语言与风格跟随该文件现有内容;新文件用中文(除非这仓库的文档清一色是英文)。紧凑,宁缺毋滥。",
131
+ ];
132
+ if (!target.explicit) {
133
+ lines.push(
134
+ "- 如果 `AGENTS.md` 与 `CLAUDE.md` 同时存在、或其中一个是另一个的符号链接,**只改上面指定的这一个**,不要把内容抄进另一个。",
135
+ );
136
+ }
137
+ lines.push(
138
+ "- 仓库里若有别的 agent 记忆文件(`.cursorrules` / `.cursor/rules/` / `.github/copilot-instructions.md` / `.claude/`),可以读来补漏,但不要改它们。",
139
+ "- 用 write / edit 工具写到上面那个绝对路径(不要用 bash 的 cat / heredoc 落盘)。",
140
+ "- 完成后用一两句话说明改了什么。",
141
+ );
142
+ if (extra) lines.push("", `这次 /init 的附加要求:${extra}`);
143
+ return lines.join("\n");
144
+ }
145
+
146
+ export default function (pi: ExtensionAPI) {
147
+ pi.registerCommand("init", {
148
+ description: "分析仓库并生成/更新记忆文件(有 CLAUDE.md 就更新它,否则 AGENTS.md)",
149
+ handler: async (args, ctx) => {
150
+ const { file, extra } = parseArgs(args);
151
+ // 判定必须发生在「发消息」那一刻:agent 正在跑时先等它结束(见文件头 ①)。
152
+ if (!ctx.isIdle()) {
153
+ if (ctx.hasUI) ctx.ui.notify("等当前回合结束再开始 /init …", "info");
154
+ await ctx.waitForIdle();
155
+ }
156
+ const target = resolveTarget(ctx.cwd, file);
157
+ if (ctx.hasUI) {
158
+ ctx.ui.notify(`${target.exists ? "更新" : "创建"} ${target.display}(${target.path})…`, "info");
159
+ }
160
+ pi.sendUserMessage(buildPrompt(target, ctx.cwd, extra));
161
+ },
162
+ });
163
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Tests for bash-prompt.ts — `!` bash 模式的模式判定与「把行首 `!` 从渲染行里摘掉」。
3
+ *
4
+ * Run with: node --test clients/pi/extensions/prompt-editor/bash-prompt.test.ts
5
+ *
6
+ * 被测模块不 import pi / pi-tui,用例直接喂 pi-tui Editor 真正会写出来的渲染行字符串
7
+ * (光标反显 `\x1b[7m!\x1b[0m`、CURSOR_MARKER `\x1b_pi:c\x07`)。
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import { describe, it } from "node:test";
12
+
13
+ import { BASH_MARKER, dropLeadingBang, resolveBashPrompt } from "./bash-prompt.ts";
14
+
15
+ const CURSOR_MARKER = "\x1b_pi:c\x07";
16
+
17
+ describe("resolveBashPrompt", () => {
18
+ it("普通正文不算 bash 模式", () => {
19
+ assert.deepEqual(resolveBashPrompt(""), { active: false, hidden: false });
20
+ assert.deepEqual(resolveBashPrompt("ls -la"), { active: false, hidden: false });
21
+ assert.deepEqual(resolveBashPrompt("ls!"), { active: false, hidden: false });
22
+ assert.deepEqual(resolveBashPrompt("/recap"), { active: false, hidden: false });
23
+ });
24
+
25
+ it("字面以 `!` 开头:算 bash 模式,且那个 `!` 要藏起来", () => {
26
+ assert.deepEqual(resolveBashPrompt("!"), { active: true, hidden: true });
27
+ assert.deepEqual(resolveBashPrompt("!ls"), { active: true, hidden: true });
28
+ assert.deepEqual(resolveBashPrompt("! ls -la"), { active: true, hidden: true });
29
+ assert.deepEqual(resolveBashPrompt("!ls\n-l"), { active: true, hidden: true });
30
+ });
31
+
32
+ it("`!!`(不进上下文的那种)同样只藏第一个 `!`,第二个留在正文里", () => {
33
+ assert.deepEqual(resolveBashPrompt("!!ls"), { active: true, hidden: true });
34
+ assert.deepEqual(resolveBashPrompt("!!"), { active: true, hidden: true });
35
+ });
36
+
37
+ it("前导空白 / 换行后跟 `!`:pi 也算 bash 模式,但位置对不上第一列,不藏", () => {
38
+ assert.deepEqual(resolveBashPrompt(" !ls"), { active: true, hidden: false });
39
+ assert.deepEqual(resolveBashPrompt("\n!ls"), { active: true, hidden: false });
40
+ assert.deepEqual(resolveBashPrompt("\t!ls"), { active: true, hidden: false });
41
+ });
42
+ });
43
+
44
+ describe("dropLeadingBang", () => {
45
+ it("摘掉行首的 `!`,其余逐字保留", () => {
46
+ assert.deepEqual(dropLeadingBang("!ls -la"), { text: "ls -la", dropped: true });
47
+ assert.deepEqual(dropLeadingBang("!"), { text: "", dropped: true });
48
+ assert.deepEqual(dropLeadingBang("! ls"), { text: " ls", dropped: true });
49
+ });
50
+
51
+ it("`!!` 只摘一个(第二个 `!` 是 pi 的「不进上下文」标记,要留在正文里看得见)", () => {
52
+ assert.deepEqual(dropLeadingBang("!!ls"), { text: "!ls", dropped: true });
53
+ });
54
+
55
+ it("没有可摘的 `!` 时原样返回,并且不报 dropped", () => {
56
+ assert.deepEqual(dropLeadingBang("ls -la"), { text: "ls -la", dropped: false });
57
+ assert.deepEqual(dropLeadingBang(""), { text: "", dropped: false });
58
+ assert.deepEqual(dropLeadingBang("ls!"), { text: "ls!", dropped: false });
59
+ });
60
+
61
+ it("光标反显包在 `!` 上时只摘 `!`,转义序列原样留下", () => {
62
+ // 行首那串零宽序列(`\x1b[7m`)属于 head,整段保留;紧跟其后的 `\x1b[0m` 仍然收尾,
63
+ // 所以不会把反显漏给行内其余字符。
64
+ assert.deepEqual(dropLeadingBang("\x1b[7m!\x1b[0mls"), { text: "\x1b[7m\x1b[0mls", dropped: true });
65
+ assert.deepEqual(dropLeadingBang("\x1b[7m!\x1b[0m"), { text: "\x1b[7m\x1b[0m", dropped: true });
66
+ });
67
+
68
+ it("CURSOR_MARKER 在行首时保留(它不占列)", () => {
69
+ assert.deepEqual(dropLeadingBang(`${CURSOR_MARKER}!ls`), {
70
+ text: `${CURSOR_MARKER}ls`,
71
+ dropped: true,
72
+ });
73
+ assert.deepEqual(dropLeadingBang(`${CURSOR_MARKER}\x1b[7m!\x1b[0mls`), {
74
+ text: `${CURSOR_MARKER}\x1b[7m\x1b[0mls`,
75
+ dropped: true,
76
+ });
77
+ assert.deepEqual(dropLeadingBang(`${CURSOR_MARKER}!`), {
78
+ text: `${CURSOR_MARKER}`,
79
+ dropped: true,
80
+ });
81
+ });
82
+
83
+ it("零宽序列后面不是 `!` 就一个字都不动(防御性)", () => {
84
+ assert.deepEqual(dropLeadingBang("\x1b[7mls"), { text: "\x1b[7mls", dropped: false });
85
+ assert.deepEqual(dropLeadingBang(`${CURSOR_MARKER}ls`), {
86
+ text: `${CURSOR_MARKER}ls`,
87
+ dropped: false,
88
+ });
89
+ });
90
+
91
+ it("导出的标记字符就是 Claude Code 的那个 `!`", () => {
92
+ assert.equal(BASH_MARKER, "!");
93
+ });
94
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `!` bash 模式(对齐 Claude Code 的 `!` 命令模式)的纯逻辑:模式判定 + 把渲染行行首那个
3
+ * `!` 摘掉。正文文本**一个字符都不改** —— pi 的 bash 判定(`interactive-mode.js` 的
4
+ * `onChange`)、Enter 提交(`text.startsWith("!")`)、↑ 历史、Esc 清空都还走它自己那条链路,
5
+ * 这里只回答「怎么画」。
6
+ *
7
+ * 刻意不 import pi / pi-tui(与 `thinking-collapse/window.ts` 同一条约定),所以
8
+ * `node --test clients/pi/extensions/prompt-editor/bash-prompt.test.ts` 能直接跑。
9
+ */
10
+
11
+ /** Claude Code 的 bash 模式提示符(也是 pi 触发 bash 的那个字符)。 */
12
+ export const BASH_MARKER = "!";
13
+
14
+ export interface BashPromptState {
15
+ /**
16
+ * gutter 要不要换成 `!`。判定与 pi 自己一字不差:`interactive-mode.js` 的 `onChange` 是
17
+ * `text.trimStart().startsWith("!")`,而边框颜色(`updateEditorBorderColor`)用的就是同一个
18
+ * 标志 —— 跟着它走,gutter 才不会和输入框颜色打架。
19
+ */
20
+ active: boolean;
21
+ /**
22
+ * 正文第一个字符就是 `!`,渲染时要把这一列从正文里摘掉(提示符已经占了它的位置)。
23
+ *
24
+ * 前导空白后跟 `!`(`" !ls"`)时 pi 也算 bash 模式,但那个 `!` 不在正文第一列、位置对不上
25
+ * gutter,所以照常显示。这只影响这一个边角输入:边框颜色/提示符仍按 pi 的判定走,正文不动。
26
+ */
27
+ hidden: boolean;
28
+ }
29
+
30
+ export function resolveBashPrompt(text: string): BashPromptState {
31
+ const active = text.trimStart().startsWith(BASH_MARKER);
32
+ return { active, hidden: active && text.startsWith(BASH_MARKER) };
33
+ }
34
+
35
+ export interface LeadingBangDrop {
36
+ /** 摘掉行首 `!` 之后的行;没有可摘的 `!` 时与输入逐字相同。 */
37
+ text: string;
38
+ /** 真的摘掉了一个可见字符(调用方据此把行尾补回一列,保持整行满宽)。 */
39
+ dropped: boolean;
40
+ }
41
+
42
+ /**
43
+ * 行首可能出现的零宽序列,pi-tui 的 Editor 只会给出这两种:
44
+ * - CSI(`\x1b[7m` / `\x1b[0m`…):光标反显那一段;
45
+ * - APC 的 CURSOR_MARKER(`\x1b_pi:c\x07`):硬件光标 / IME 定位用。
46
+ * 它们都占 0 列,摘 `!` 时要原样留在原位。
47
+ */
48
+ const LEADING_ZERO_WIDTH = /^(?:\x1b\[[0-9;:?]*[ -/]*[@-~]|\x1b_[^\x07\x1b]*(?:\x07|\x1b\\))*/;
49
+
50
+ /**
51
+ * 摘掉渲染行行首那个隐藏的 `!`(只摘第一个可见字符,其前的零宽转义原样保留)。
52
+ * 只在 `resolveBashPrompt().hidden` 为真、且这一行就是正文第一行(未滚动)时调用。
53
+ */
54
+ export function dropLeadingBang(line: string): LeadingBangDrop {
55
+ const head = LEADING_ZERO_WIDTH.exec(line)?.[0] ?? "";
56
+ const rest = line.slice(head.length);
57
+ if (!rest.startsWith(BASH_MARKER)) return { text: line, dropped: false };
58
+ return { text: head + rest.slice(BASH_MARKER.length), dropped: true };
59
+ }