@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,305 @@
1
+ /**
2
+ * Tests for startup-logo/index.ts — 「启动 header 的 logo」与「剪掉三段启动清单」这条链路。
3
+ *
4
+ * Run with: node --test clients/pi/extensions/startup-logo/index.test.ts
5
+ *
6
+ * pi 自己的扩展加载器真的加载 `index.ts`(import 面、事件注册面都在覆盖范围内),假的是扩展
7
+ * 外面的一切:`ctx.ui.setHeader` 按 pi 的行为同步建组件并挂进假 header 容器,`ctx` 是一个
8
+ * 最小对象。于是「装 header → 认容器 → 接管已加载资源清单」这条链能被完整摆出来。
9
+ *
10
+ * 这里**不**覆盖 pi 内部真实行为(渲染节流、换会话冻结窗口、诊断段),那些靠
11
+ * `header-guard.test.ts` / `loaded-sections.test.ts` 的纯逻辑用例与本机实测。
12
+ */
13
+
14
+ import assert from "node:assert/strict";
15
+ import fs from "node:fs";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import test from "node:test";
19
+ import { fileURLToPath, pathToFileURL } from "node:url";
20
+
21
+ import { MARK_WIDTH } from "./logo.ts";
22
+
23
+ const EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
24
+ const SKIP = "找不到本机 pi 的库入口(装过 pi 才有)";
25
+
26
+ /**
27
+ * pi 的库入口(非 CLI):bundle 是 `pi` 实际跑的形态,dist 是 node 构建形态。
28
+ *
29
+ * 先从 `pi` 可执行文件反查**真正的安装位置**(pnpm/npm 的 shim 脚本里留有
30
+ * `# cmd-shim-target=<绝对路径>`;npm 在 Unix 上则是符号链接,两种都试),再退回
31
+ * `~/.pi/agent/npm` 那份副本 —— 后者是**扩展包的安装根**,`pi update --extensions` 会用
32
+ * `--config.auto-install-peers=false` 把自动装进去的 `@earendil-works/pi-*` 同伴包剪掉,
33
+ * 于是那份副本只剩空壳(文件都在、import 报 `ERR_MODULE_NOT_FOUND`;2026-09 升
34
+ * pi-subagents 0.68.0 时实测踩到)。
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(): { emit(channel: string, data: unknown): void; on(channel: string, handler: (data: unknown) => void): () => void } {
82
+ const handlers = new Map<string, Set<(data: unknown) => void>>();
83
+ return {
84
+ on(channel, handler) {
85
+ const set = handlers.get(channel) ?? new Set<(data: unknown) => void>();
86
+ handlers.set(channel, set);
87
+ set.add(handler);
88
+ return () => {
89
+ set.delete(handler);
90
+ };
91
+ },
92
+ emit(channel, data) {
93
+ for (const handler of [...(handlers.get(channel) ?? [])]) handler(data);
94
+ },
95
+ };
96
+ }
97
+
98
+ /** 形状照 pi-tui:`children` 是公开数组,`addChild` 住数组尾部推。 */
99
+ class FakeContainer {
100
+ children: unknown[] = [];
101
+ addChild(child: unknown): void {
102
+ this.children.push(child);
103
+ }
104
+ render(width: number): string[] {
105
+ return this.children.flatMap((child) => (child as { render(w: number): string[] }).render(width));
106
+ }
107
+ }
108
+
109
+ class FakeSection {
110
+ name: string;
111
+ constructor(name: string) {
112
+ this.name = name;
113
+ }
114
+ render(_width: number): string[] {
115
+ return [`\u001b[38;2;121;214;193m[${this.name}]\u001b[39m`, ` ${this.name} 的内容`, ""];
116
+ }
117
+ }
118
+
119
+ class FakeSpacer {
120
+ render(_width: number): string[] {
121
+ return [""];
122
+ }
123
+ }
124
+
125
+ interface FakeTui {
126
+ children: unknown[];
127
+ headerContainer: FakeContainer;
128
+ loadedResourcesContainer: FakeContainer;
129
+ chatContainer: FakeContainer;
130
+ documentContainer: FakeContainer;
131
+ /** 当前挂着的 header 组件(`setHeader(undefined)` 之后是 undefined)。 */
132
+ headerComponent: unknown;
133
+ }
134
+
135
+ function createFakeTui(): FakeTui {
136
+ const headerContainer = new FakeContainer();
137
+ const loadedResourcesContainer = new FakeContainer();
138
+ const chatContainer = new FakeContainer();
139
+ const documentContainer = new FakeContainer();
140
+ documentContainer.addChild(headerContainer);
141
+ documentContainer.addChild(loadedResourcesContainer);
142
+ documentContainer.addChild(chatContainer);
143
+ return {
144
+ children: [documentContainer],
145
+ headerContainer,
146
+ loadedResourcesContainer,
147
+ chatContainer,
148
+ documentContainer,
149
+ headerComponent: undefined,
150
+ };
151
+ }
152
+
153
+ /** 主题:恒等上色(剥 ANSI 后就是纯文本,宽度口径跟真实主题一致)。 */
154
+ const fakeTheme = {
155
+ fg: (_color: string, text: string) => text,
156
+ bold: (text: string) => text,
157
+ dim: (text: string) => text,
158
+ };
159
+
160
+ function createContext(tui: FakeTui): { ctx: unknown; setHeaderCalls: number } {
161
+ const state = { setHeaderCalls: 0 };
162
+ const ui = {
163
+ theme: fakeTheme,
164
+ // pi 的 setExtensionHeader 是同步建组件 + 挂进容器,这里照做(顺序对扩展可见)。
165
+ setHeader(factory?: (tui: unknown, theme: unknown) => { render(width: number): string[]; invalidate(): void }) {
166
+ state.setHeaderCalls += 1;
167
+ const children = tui.headerContainer.children;
168
+ const index = children.indexOf(tui.headerComponent);
169
+ if (index >= 0) children.splice(index, 1);
170
+ tui.headerComponent = undefined;
171
+ if (!factory) return;
172
+ const component = factory(tui, fakeTheme);
173
+ tui.headerComponent = component;
174
+ children.push(component);
175
+ },
176
+ notify() {},
177
+ };
178
+ return { ctx: { mode: "tui", hasUI: true, cwd: "/Users/someone/proj", ui }, setHeaderCalls: state.setHeaderCalls };
179
+ }
180
+
181
+ interface LoadedExtension {
182
+ handlers: Map<string, Handler[]>;
183
+ commands: Map<string, unknown>;
184
+ errors: Array<{ path: string; error: string }>;
185
+ }
186
+
187
+ /** 用 pi 自己的加载器加载本扩展(`./logo.ts` / `./loaded-sections.ts` 的 import 也一起验证)。 */
188
+ async function loadExtension(agentDir: string, projectDir: string): Promise<LoadedExtension> {
189
+ const pi = (await import(pathToFileURL(piEntry as string).href)) as {
190
+ discoverAndLoadExtensions: (
191
+ configuredPaths: string[],
192
+ cwd: string,
193
+ agentDir?: string,
194
+ eventBus?: unknown,
195
+ ) => Promise<{ extensions: LoadedExtension[]; errors: Array<{ path: string; error: string }> }>;
196
+ };
197
+ const loaded = await pi.discoverAndLoadExtensions([EXTENSION_PATH], projectDir, agentDir, createTestBus());
198
+ assert.deepEqual(loaded.errors, [], "pi 的扩展加载器不应该报错");
199
+ assert.equal(loaded.extensions.length, 1);
200
+ const extension = loaded.extensions[0];
201
+ assert.ok(extension);
202
+ return extension;
203
+ }
204
+
205
+ function makeWorkspace(): { agentDir: string; projectDir: string; cleanup: () => void } {
206
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-startup-logo-"));
207
+ const agentDir = path.join(root, "agent");
208
+ const projectDir = path.join(root, "project");
209
+ fs.mkdirSync(agentDir);
210
+ fs.mkdirSync(projectDir);
211
+ return {
212
+ agentDir,
213
+ projectDir,
214
+ cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
215
+ };
216
+ }
217
+
218
+ const stripAnsi = (line: string): string => line.replace(/\u001b\[[0-9;]*m/g, "");
219
+
220
+ const sectionTitlesIn = (container: FakeContainer): string[] =>
221
+ container.children
222
+ .flatMap((child) => {
223
+ const lines = (child as { render(w: number): string[] }).render(80);
224
+ const first = stripAnsi(lines[0] ?? "").trim();
225
+ const match = /^\[([^[\]]+)\]$/.exec(first);
226
+ return match ? [match[1]!] : [];
227
+ })
228
+ .filter((title) => title.length > 0);
229
+
230
+ test("扩展注册面:只有会话事件,不再有 `/logo` 命令", { skip, timeout: 30_000 }, async () => {
231
+ const workspace = makeWorkspace();
232
+ try {
233
+ const extension = await loadExtension(workspace.agentDir, workspace.projectDir);
234
+ assert.equal(extension.commands.size, 0, "`/logo` 命令已按用户要求去掉");
235
+ assert.equal(extension.commands.has("logo"), false);
236
+ for (const event of ["session_start", "session_tree", "session_shutdown"]) {
237
+ assert.ok(extension.handlers.get(event)?.length, `应该注册了 ${event}`);
238
+ }
239
+ } finally {
240
+ workspace.cleanup();
241
+ }
242
+ });
243
+
244
+ test("session_start:header 是 logo(每行缩进一格、没有 /logo 提示),清单被剪掉三段", { skip, timeout: 30_000 }, async () => {
245
+ const workspace = makeWorkspace();
246
+ try {
247
+ const extension = await loadExtension(workspace.agentDir, workspace.projectDir);
248
+ const sessionStart = extension.handlers.get("session_start")?.[0];
249
+ assert.ok(sessionStart);
250
+ const tui = createFakeTui();
251
+ const { ctx } = createContext(tui);
252
+
253
+ await sessionStart({}, ctx);
254
+ assert.ok(tui.headerComponent, "session_start 应该装上 logo header");
255
+
256
+ const lines = (tui.headerComponent as { render(w: number): string[] }).render(80);
257
+ const plain = lines.map(stripAnsi);
258
+ // 前四行是印记:每行都以留白开头,第 0/3 行(没挂侧栏)等宽
259
+ for (const line of plain.slice(0, 4)) assert.ok(line.startsWith(" "), `印记行不能顶格:${JSON.stringify(line)}`);
260
+ assert.equal(plain[0]!.length, MARK_WIDTH, "没挂侧栏的印记行就是整行宽度");
261
+ assert.equal(plain[3]!.length, MARK_WIDTH, "没挂侧栏的印记行就是整行宽度");
262
+ // 挂侧栏的第 1/2 行:侧栏贴在同一列(MARK_WIDTH + 2),所以列对齐
263
+ assert.ok(plain[1]?.includes("pi v"), "侧栏应该带版本号");
264
+ assert.equal(plain[1]!.indexOf("pi v"), MARK_WIDTH + 2, "版本号应该贴在第 MARK_WIDTH + 2 列");
265
+ assert.ok(plain.some((line) => line.includes("/Users/someone/proj")), "侧栏应该带 cwd");
266
+ assert.ok(plain.some((line) => line.includes("Pi can explain its own features")), "说明行应该保留");
267
+ assert.equal(plain.some((line) => line.includes("/logo")), false, "`/logo toggles this header.` 提示已删掉");
268
+
269
+ // 已加载资源清单:按 pi 的顺序填,被剪的三段连同各自的空行都不该进去
270
+ const { loadedResourcesContainer: loaded } = tui;
271
+ loaded.addChild(new FakeSpacer());
272
+ for (const name of ["Context", "Skills", "Prompts", "Extensions", "Themes"]) {
273
+ loaded.addChild(new FakeSection(name));
274
+ loaded.addChild(new FakeSpacer());
275
+ }
276
+ assert.deepEqual(sectionTitlesIn(loaded), ["Skills", "Extensions"]);
277
+ } finally {
278
+ workspace.cleanup();
279
+ }
280
+ });
281
+
282
+ test("session_tree 重装 header:接管幂等,清单里依旧只有 Skills / Extensions", { skip, timeout: 30_000 }, async () => {
283
+ const workspace = makeWorkspace();
284
+ try {
285
+ const extension = await loadExtension(workspace.agentDir, workspace.projectDir);
286
+ const sessionStart = extension.handlers.get("session_start")?.[0];
287
+ const sessionTree = extension.handlers.get("session_tree")?.[0];
288
+ assert.ok(sessionStart && sessionTree);
289
+ const tui = createFakeTui();
290
+ const { ctx } = createContext(tui);
291
+
292
+ await sessionStart({}, ctx);
293
+ await sessionTree({}, ctx);
294
+ assert.equal(tui.headerContainer.children.filter((child) => child === tui.headerComponent).length, 1, "只应挂一个 header");
295
+
296
+ // /reload 形态:清空后重新填一份
297
+ tui.loadedResourcesContainer.children = [];
298
+ tui.loadedResourcesContainer.addChild(new FakeSection("Themes"));
299
+ tui.loadedResourcesContainer.addChild(new FakeSpacer());
300
+ tui.loadedResourcesContainer.addChild(new FakeSection("Skills"));
301
+ assert.deepEqual(sectionTitlesIn(tui.loadedResourcesContainer), ["Skills"]);
302
+ } finally {
303
+ workspace.cleanup();
304
+ }
305
+ });
@@ -0,0 +1,194 @@
1
+ /**
2
+ * startup-logo — 启动时顶部显示一个静态的 pi logo,并剪掉启动清单里没用的三段
3
+ *
4
+ * pi 的内置 header(`interactive-mode.js`)只有 `pi vX.Y.Z` 一行加几条快捷键提示,没有图形。
5
+ * 本扩展用 `ctx.ui.setHeader()` 换掉它:顶部一只静态的 pi.dev 印记(形 `npm:pi-claude-code-tui`
6
+ * 的字形数据,但**不装那个包**,理由见 `logo.ts` 与 README),右侧挂版本号与当前目录
7
+ * (家目录内显示成 `~/...`),下面仍是内置那套紧凑快捷键提示,所以换掉 header 不丢信息。
8
+ *
9
+ * logo **常开**:没有 `/logo` 开关命令(也不再在说明行里提它),`PI_LOGO=off` 是唯一的口子。
10
+ *
11
+ * **静态**:上游那只印记是 22 帧动画(三笔推进来 + 底座闪两下再落定),本仓库只要一只静态
12
+ * logo,所以帧表、定时器、`requestRender` 全都没有 —— 每次渲染都是同一张格子表,颜色现取
13
+ * `theme.fg("accent")`,因此 `/theme` 换肤下一帧即跟随。也因此没有「活过会话的定时器把宿主
14
+ * 进程带崩」那一类坑(本仓库在 `simple-task/`、`working-indicator/` 上踩过)。
15
+ *
16
+ * header 只在**启动时**出现在聊天区上方,随滚动离开视野;它不是常驻控件,也不占编辑器区域。
17
+ *
18
+ * header 下面那份「已加载资源」清单同样是 pi 在会话绑定之后才填的,其中
19
+ * `[Context]` / `[Prompts]` / `[Themes]` 三段没有信息量,本扩展顺手剪掉(`loaded-sections.ts`):
20
+ * 剪枝看的是**已挂载的 header 组件**所在容器,所以只在 logo 装上的时候生效,`[Skills]` /
21
+ * `[Extensions]` 与各类诊断段一律保留。
22
+ *
23
+ * 其余三点约束:
24
+ * 1. 只在 TUI 模式装(`ctx.mode === "tui"`)—— print / RPC / JSON 模式没有 header。
25
+ * 2. 换会话(`/new`、`/clear`、`/resume`、fork、rewind)时 pi 会先 `resetExtensionUI()`
26
+ * 还原内置 header,等新会话 `session_start` 才重装;中间那几十毫秒会真的出帧,
27
+ * 于是顶部「闪」回内置 header 并跳一次版式。这段窗口由 `header-guard.ts` 在渲染路径上
28
+ * 冻住(重放上一帧的行),机制与 statusline 的 footer 冻结同源,各持一份、互不依赖。
29
+ * 3. 终端太窄(印记右侧放不下侧栏)时退化成单行 wordmark,不折行。
30
+ *
31
+ * 开关:`PI_LOGO=off` 启动时不装(无配置文件、无侧效;此时启动清单保持 pi 原样,因为剪枝要靠
32
+ * 已挂载的 header 组件去认容器)。
33
+ */
34
+
35
+ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
36
+ import { keyHint, keyText, rawKeyHint, VERSION } from "@earendil-works/pi-coding-agent";
37
+ import { truncateToWidth } from "@earendil-works/pi-tui";
38
+ import { homedir } from "node:os";
39
+ import {
40
+ MARK_CELL,
41
+ MARK_INDENT,
42
+ MARK_WIDTH,
43
+ attachSideText,
44
+ composeHeaderLines,
45
+ markLines,
46
+ shortenPath,
47
+ } from "./logo.ts";
48
+ import { findRenderContainer, freezeHeaderContainer, releaseHeaderGuard } from "./header-guard.ts";
49
+ import { hideLoadedSections } from "./loaded-sections.ts";
50
+
51
+ const LOGO_DISABLED = (process.env.PI_LOGO ?? "").trim().toLowerCase() === "off";
52
+ /** logo 常开(没有会话内开关,`/logo` 已按用户要求去掉)。 */
53
+ const ENABLED = !LOGO_DISABLED;
54
+ /** 窄终端阈值:印记右侧至少还要留出这些列才画印记。 */
55
+ const MIN_SIDE_COLUMNS = 20;
56
+ const SIDE_GAP = 2;
57
+ const ELLIPSIS = "…";
58
+
59
+ /** header 容器要的是这个组件对象;只要 render/invalidate 形状,便于在守卫里按身份比对。 */
60
+ interface HeaderComponent {
61
+ render(width: number): string[];
62
+ invalidate(): void;
63
+ }
64
+
65
+ export default function startupLogo(pi: ExtensionAPI) {
66
+ // 冻结窗口要用的四样:TUI 根(找容器)、当前 header 组件、它的容器、上一帧渲染出的行。
67
+ let tuiHandle: unknown;
68
+ let headerComponent: HeaderComponent | undefined;
69
+ let headerContainer: unknown;
70
+ let lastLines: string[] | undefined;
71
+ let guardRelease: (() => void) | undefined;
72
+
73
+ let home: string | undefined;
74
+ const homeDir = (): string | undefined => {
75
+ if (home === undefined) {
76
+ try {
77
+ home = homedir();
78
+ } catch {
79
+ home = "";
80
+ }
81
+ }
82
+ return home || undefined;
83
+ };
84
+
85
+ /**
86
+ * 与内置 header 同一批键位的紧凑提示行。
87
+ *
88
+ * 故意包在 try/catch 里:本仓库在 `bash-command-collapse.ts` 里踩过「扩展 import 到的
89
+ * `@earendil-works/pi-coding-agent` 副本与 pi 运行时不是同一份单例,`keyHint` 直接抛
90
+ * `Theme not initialized`、`keyText` 返回空串」(那里只能自己读 keybindings.json 重建)。
91
+ * 实测在 0.85.1 + pnpm 布局下这里拿到的是同一份、键名与颜色都正常(见 README 该节的
92
+ * 实测记录),所以用 pi 的 helper;万一将来变成另一份,最多是提示行缺失,不能把整个
93
+ * header 弄挂(render 里抛错会被 pi 报 extension error)。
94
+ */
95
+ const buildHints = (theme: Theme): string | undefined => {
96
+ try {
97
+ return [
98
+ keyHint("app.interrupt", "interrupt"),
99
+ rawKeyHint(`${keyText("app.clear")}/${keyText("app.exit")}`, "clear/exit"),
100
+ rawKeyHint("/", "commands"),
101
+ rawKeyHint("!", "bash"),
102
+ ].join(theme.fg("muted", " · "));
103
+ } catch {
104
+ return undefined;
105
+ }
106
+ };
107
+
108
+ /** 一帧的完整行:印记 → 提示行 → 说明行。 */
109
+ const buildLines = (theme: Theme, cwd: string, width: number): string[] => {
110
+ const wordmark = theme.bold(theme.fg("accent", "pi")) + theme.fg("dim", ` v${VERSION}`);
111
+ const where = theme.fg("muted", shortenPath(cwd, homeDir()));
112
+ const hints = buildHints(theme);
113
+ const onboarding = theme.fg("dim", "Pi can explain its own features and look up its docs.");
114
+
115
+ // 窄终端:不画印记,退化成单行 wordmark(同样缩进一格,跟印记左边对齐)。
116
+ if (width < MARK_WIDTH + MIN_SIDE_COLUMNS) {
117
+ const room = Math.max(1, width - MARK_INDENT.length);
118
+ return composeHeaderLines({
119
+ logo: [`${MARK_INDENT}${truncateToWidth(wordmark, room, ELLIPSIS)}`],
120
+ hints,
121
+ onboarding,
122
+ });
123
+ }
124
+ const sideRoom = Math.max(0, width - MARK_WIDTH - SIDE_GAP);
125
+ // 逐格上色(整行包一层会让行尾的 trimEnd 失效,见 logo.ts 的 markLines)
126
+ const logo = attachSideText(
127
+ markLines((filled) => (filled ? theme.fg("accent", MARK_CELL) : " ".repeat(MARK_CELL.length))),
128
+ [wordmark, truncateToWidth(where, sideRoom, ELLIPSIS)],
129
+ SIDE_GAP,
130
+ );
131
+ return composeHeaderLines({ logo, hints, onboarding });
132
+ };
133
+
134
+ const installHeader = (ctx: ExtensionContext) => {
135
+ // 先交还上一段换会话窗口的冻结再装新 header:两步在同一个同步块里,中间出不了帧。
136
+ guardRelease?.();
137
+ guardRelease = undefined;
138
+ if (ctx.mode !== "tui" || !ENABLED) return;
139
+
140
+ let component: HeaderComponent | undefined;
141
+ ctx.ui.setHeader((tui, theme) => {
142
+ tuiHandle = tui;
143
+ const own: HeaderComponent = {
144
+ invalidate() {},
145
+ render(width: number): string[] {
146
+ const lines = buildLines(theme, ctx.cwd, Math.max(1, width));
147
+ // 换会话时按身份比对我们是否还在容器里,不在就拿这份行重放(见 header-guard.ts)。
148
+ lastLines = lines;
149
+ return lines;
150
+ },
151
+ };
152
+ component = own;
153
+ return own;
154
+ });
155
+
156
+ // 组件此刻已被 pi 挂进 header 容器(`setHeader` 是同步的),按对象身份认出那个容器;
157
+ // 换会话时冻结要用。顺手解除别的扩展实例遗留的接管(`/reload` 之后可能有一次)。
158
+ if (!component) return;
159
+ headerComponent = component;
160
+ headerContainer = findRenderContainer(tuiHandle, component);
161
+ if (!headerContainer) return;
162
+ releaseHeaderGuard(headerContainer);
163
+ // 已加载资源清单就在 header 容器后面那个兄弟容器里,pi 还没来得及填(本回调跑在
164
+ // `showLoadedResources` 之前),所以这里接管它的 addChild 就能把那三段整段丢掉。
165
+ hideLoadedSections({ root: tuiHandle, headerContainer });
166
+ };
167
+
168
+ /** 换会话窗口内把 header 钉在上一帧:内置 header 一帧都不会出现。 */
169
+ const freezeHeader = () => {
170
+ if (!headerContainer || !headerComponent) return;
171
+ const component = headerComponent;
172
+ const lines = lastLines;
173
+ guardRelease = freezeHeaderContainer({
174
+ container: headerContainer,
175
+ ownComponent: component,
176
+ frozenRender: (width) =>
177
+ lines ? lines.map((line) => truncateToWidth(line, width, ELLIPSIS)) : component.render(width),
178
+ });
179
+ };
180
+
181
+ pi.on("session_start", (_event, ctx) => {
182
+ installHeader(ctx);
183
+ });
184
+
185
+ // resume / fork / rewind 会换掉会话对象,header 得按新 ctx 重装。
186
+ pi.on("session_tree", (_event, ctx) => {
187
+ installHeader(ctx);
188
+ });
189
+
190
+ // resetExtensionUI 发生在本回调之后:先接管容器渲染,还原内置 header 时就不会出帧。
191
+ pi.on("session_shutdown", () => {
192
+ freezeHeader();
193
+ });
194
+ }