@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,257 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ DETECT_WIDTH,
5
+ HIDDEN_SECTION_NAMES,
6
+ SECTION_PRUNER_KEY,
7
+ findLoadedResourcesContainer,
8
+ hideLoadedSections,
9
+ installHiddenSectionPruner,
10
+ isBlankLineComponent,
11
+ isHiddenSection,
12
+ pruneHiddenSections,
13
+ releaseHiddenSectionPruner,
14
+ sectionTitleOfLine,
15
+ } from "./loaded-sections.ts";
16
+
17
+ /** 最小可用的假组件 / 假容器:形状照着 pi-tui 的 Container / Text / Spacer 来。 */
18
+ class FakeSection {
19
+ name: string;
20
+ constructor(name: string) {
21
+ this.name = name;
22
+ }
23
+ render(_width: number): string[] {
24
+ return [`\u001b[38;2;121;214;193m[${this.name}]\u001b[39m`, ` ${this.name} 的内容`, ""];
25
+ }
26
+ }
27
+
28
+ class FakeSpacer {
29
+ render(_width: number): string[] {
30
+ return [""];
31
+ }
32
+ }
33
+
34
+ class FakeText {
35
+ text: string;
36
+ constructor(text: string) {
37
+ this.text = text;
38
+ }
39
+ render(_width: number): string[] {
40
+ // pi-tui 的 Text 对空串返回空数组(不是 [""]),这里照抄这个细节
41
+ return this.text ? [this.text] : [];
42
+ }
43
+ }
44
+
45
+ class FakeContainer {
46
+ children: unknown[] = [];
47
+ addChild(child: unknown): void {
48
+ this.children.push(child);
49
+ }
50
+ render(width: number): string[] {
51
+ return this.children.flatMap((child) => (child as { render(w: number): string[] }).render(width));
52
+ }
53
+ }
54
+
55
+ /** 照 `showLoadedResources` 的顺序把一整份清单填进容器。 */
56
+ function fillLoadedResources(container: FakeContainer, names: readonly string[] = ["Context", "Skills", "Prompts", "Extensions", "Themes"]): void {
57
+ container.addChild(new FakeSpacer()); // [Context] 前面那个额外空行
58
+ for (const name of names) {
59
+ container.addChild(new FakeSection(name));
60
+ container.addChild(new FakeSpacer());
61
+ }
62
+ }
63
+
64
+ const titlesIn = (container: FakeContainer): string[] =>
65
+ container.children
66
+ .map((child) => sectionTitleOfLine((child as FakeSection).render(80)[0]))
67
+ .filter((title): title is string => title !== undefined);
68
+
69
+ test("sectionTitleOfLine:只认 `[Name]` 整行,ANSI 不参与", () => {
70
+ assert.equal(sectionTitleOfLine("\u001b[36m[Context]\u001b[39m"), "Context");
71
+ assert.equal(sectionTitleOfLine(" [Prompts] "), "Prompts");
72
+ assert.equal(sectionTitleOfLine("[Skill conflicts]"), "Skill conflicts");
73
+ assert.equal(sectionTitleOfLine("[Context] 三个文件"), undefined);
74
+ assert.equal(sectionTitleOfLine("[[Context]]"), undefined);
75
+ assert.equal(sectionTitleOfLine(""), undefined);
76
+ assert.equal(sectionTitleOfLine(undefined), undefined);
77
+ assert.equal(sectionTitleOfLine(42), undefined);
78
+ });
79
+
80
+ test("isHiddenSection / isBlankLineComponent:只看第一行,渲染抛错只当「不是」", () => {
81
+ assert.equal(isHiddenSection(new FakeSection("Context")), true);
82
+ assert.equal(isHiddenSection(new FakeSection("Prompts")), true);
83
+ assert.equal(isHiddenSection(new FakeSection("Themes")), true);
84
+ assert.equal(isHiddenSection(new FakeSection("Skills")), false);
85
+ assert.equal(isHiddenSection(new FakeSection("Extensions")), false);
86
+ assert.equal(isHiddenSection(new FakeSpacer()), false);
87
+ assert.equal(isHiddenSection({ render: () => [{}, "x"] }), false);
88
+ assert.equal(
89
+ isHiddenSection({
90
+ render: () => {
91
+ throw new Error("boom");
92
+ },
93
+ }),
94
+ false,
95
+ );
96
+ assert.equal(isHiddenSection(undefined), false);
97
+ assert.equal(isBlankLineComponent(new FakeSpacer()), true);
98
+ assert.equal(isBlankLineComponent(new FakeText("")), false, "空 Text 渲染出 [],不是 spacer 形状");
99
+ assert.equal(isBlankLineComponent(new FakeSection("Skills")), false);
100
+ assert.equal(isBlankLineComponent({ render: () => [""] }), true);
101
+ });
102
+
103
+ test("addChild 接管:被隐藏的三段连同各自的分隔空行都进不去", () => {
104
+ const container = new FakeContainer();
105
+ assert.equal(installHiddenSectionPruner({ container }) !== undefined, true);
106
+ fillLoadedResources(container);
107
+
108
+ assert.deepEqual(titlesIn(container), ["Skills", "Extensions"]);
109
+ // 前面那个空行 + [Skills]、[Extensions] 各自的分隔空行
110
+ assert.equal(container.children.length, 5);
111
+ assert.deepEqual(container.children.map((child) => child instanceof FakeSpacer), [true, false, true, false, true]);
112
+ });
113
+
114
+ test("addChild 接管:非 `[x]` 形状、渲染抛错、非对象的 child 一律放行", () => {
115
+ const container = new FakeContainer();
116
+ installHiddenSectionPruner({ container });
117
+ const throwing = {
118
+ render: () => {
119
+ throw new Error("boom");
120
+ },
121
+ };
122
+ const plainText = new FakeText("[Context] 这三个文件");
123
+ container.addChild(throwing);
124
+ container.addChild(plainText);
125
+ container.addChild("裸字符串");
126
+ container.addChild(new FakeText(""));
127
+ assert.deepEqual(container.children, [throwing, plainText, "裸字符串", container.children[3]]);
128
+ });
129
+
130
+ test("addChild 接管:只有被剪掉的段才吃后面的空行,普通段后面照常", () => {
131
+ const container = new FakeContainer();
132
+ installHiddenSectionPruner({ container });
133
+ container.addChild(new FakeSection("Skills"));
134
+ container.addChild(new FakeSpacer());
135
+ container.addChild(new FakeSection("Prompts"));
136
+ container.addChild(new FakeSpacer());
137
+ container.addChild(new FakeSection("Extensions"));
138
+ assert.deepEqual(titlesIn(container), ["Skills", "Extensions"]);
139
+ assert.equal(container.children.length, 3);
140
+ });
141
+
142
+ test("pruneHiddenSections:装晚了(清单已填好)也能剪干净", () => {
143
+ const container = new FakeContainer();
144
+ fillLoadedResources(container);
145
+ assert.equal(container.children.length, 11); // 1 + 5×2
146
+ assert.equal(pruneHiddenSections(container), 3);
147
+ assert.deepEqual(titlesIn(container), ["Skills", "Extensions"]);
148
+ assert.equal(container.children.length, 5);
149
+ });
150
+
151
+ test("pruneHiddenSections:没有任何隐藏段时原样返回 0", () => {
152
+ const container = new FakeContainer();
153
+ fillLoadedResources(container, ["Skills", "Extensions"]);
154
+ const before = [...container.children];
155
+ assert.equal(pruneHiddenSections(container), 0);
156
+ assert.deepEqual(container.children, before);
157
+ assert.equal(pruneHiddenSections(undefined), 0);
158
+ assert.equal(pruneHiddenSections({}), 0);
159
+ });
160
+
161
+ test("installHiddenSectionPruner:装晚了先剪一遍已有内容,之后新加的也进不去", () => {
162
+ const container = new FakeContainer();
163
+ fillLoadedResources(container);
164
+ installHiddenSectionPruner({ container });
165
+ assert.deepEqual(titlesIn(container), ["Skills", "Extensions"]);
166
+ // /reload:clear() 之后重新填一份清单
167
+ container.children = [];
168
+ container.addChild(new FakeSpacer());
169
+ container.addChild(new FakeSection("Context"));
170
+ container.addChild(new FakeSpacer());
171
+ container.addChild(new FakeSection("Skills"));
172
+ assert.deepEqual(titlesIn(container), ["Skills"]);
173
+ });
174
+
175
+ test("接管幂等:重复安装不叠 wrapper,旧 release 不拆新 wrapper,release 后恢复原状", () => {
176
+ const container = new FakeContainer();
177
+ const release1 = installHiddenSectionPruner({ container });
178
+ assert.equal(typeof release1, "function");
179
+ const release2 = installHiddenSectionPruner({ container });
180
+ assert.equal(typeof release2, "function");
181
+
182
+ // 第二次安装已经顶掉了第一次:再调旧 release 也不能把新 wrapper 拆掉
183
+ release1!();
184
+ container.addChild(new FakeSection("Context"));
185
+ assert.deepEqual(titlesIn(container), [], "旧 release 不应动到新接管");
186
+
187
+ assert.equal(releaseHiddenSectionPruner(container), true);
188
+ release2!();
189
+ assert.equal(releaseHiddenSectionPruner(container), false);
190
+
191
+ // 解除之后新加的段不再被剪(等价于 pi-tui Container 的原始 addChild)
192
+ container.addChild(new FakeSection("Context"));
193
+ assert.deepEqual(titlesIn(container), ["Context"]);
194
+ });
195
+
196
+ test("接管记录挂在符号键上:别的实例能解除旧的接管", () => {
197
+ const container = new FakeContainer();
198
+ installHiddenSectionPruner({ container });
199
+ assert.equal(typeof (container as { [SECTION_PRUNER_KEY]?: unknown })[SECTION_PRUNER_KEY], "object");
200
+ // 模拟 /reload:新实例只拿到容器对象也要能清掉旧 wrapper
201
+ assert.equal(releaseHiddenSectionPruner(container), true);
202
+ assert.equal((container as { [SECTION_PRUNER_KEY]?: unknown })[SECTION_PRUNER_KEY], undefined);
203
+ const container2 = new FakeContainer();
204
+ assert.equal(releaseHiddenSectionPruner(container2), false);
205
+ });
206
+
207
+ test("findLoadedResourcesContainer:按 header 容器的下一个兄弟容器认,认不出返回 undefined", () => {
208
+ const headerContainer = new FakeContainer();
209
+ const loadedResources = new FakeContainer();
210
+ const chatContainer = new FakeContainer();
211
+ const document = new FakeContainer();
212
+ document.addChild(headerContainer);
213
+ document.addChild(loadedResources);
214
+ document.addChild(chatContainer);
215
+ const root = { children: [document] };
216
+
217
+ assert.equal(findLoadedResourcesContainer({ root, headerContainer }), loadedResources);
218
+ // header 容器自己不在树里 → 认不出
219
+ assert.equal(findLoadedResourcesContainer({ root, headerContainer: new FakeContainer() }), undefined);
220
+ assert.equal(findLoadedResourcesContainer({ root, headerContainer: undefined }), undefined);
221
+ // 兄弟不是容器形状 → 认不出
222
+ const oddHeader = new FakeContainer();
223
+ const oddDocument = new FakeContainer();
224
+ oddDocument.addChild(oddHeader);
225
+ oddDocument.addChild({ 不是容器: true });
226
+ assert.equal(findLoadedResourcesContainer({ root: { children: [oddDocument] }, headerContainer: oddHeader }), undefined);
227
+ });
228
+
229
+ test("hideLoadedSections:整条链路(找容器 → 接管 → 填清单)", () => {
230
+ const headerContainer = new FakeContainer();
231
+ const loadedResources = new FakeContainer();
232
+ const document = new FakeContainer();
233
+ document.addChild(headerContainer);
234
+ document.addChild(loadedResources);
235
+ const root = { children: [document] };
236
+
237
+ assert.equal(hideLoadedSections({ root, headerContainer }), true);
238
+ fillLoadedResources(loadedResources);
239
+ assert.deepEqual(titlesIn(loadedResources), ["Skills", "Extensions"]);
240
+
241
+ // 找不到容器时返回 false,什么都不改
242
+ assert.equal(hideLoadedSections({ root, headerContainer: new FakeContainer() }), false);
243
+ // 自定义隐藏名单
244
+ const other = new FakeContainer();
245
+ const otherHeader = new FakeContainer();
246
+ const otherDocument = new FakeContainer();
247
+ otherDocument.addChild(otherHeader);
248
+ otherDocument.addChild(other);
249
+ assert.equal(hideLoadedSections({ root: { children: [otherDocument] }, headerContainer: otherHeader, names: ["Skills"] }), true);
250
+ fillLoadedResources(other);
251
+ assert.deepEqual(titlesIn(other), ["Context", "Prompts", "Extensions", "Themes"]);
252
+ });
253
+
254
+ test("默认隐藏名单就是 Context / Prompts / Themes,判定宽度是个常规值", () => {
255
+ assert.deepEqual(HIDDEN_SECTION_NAMES, ["Context", "Prompts", "Themes"]);
256
+ assert.ok(DETECT_WIDTH >= 80, "标题行 `[Context]` 只有 9 列,判定宽度够宽就行");
257
+ });
@@ -0,0 +1,267 @@
1
+ /**
2
+ * loaded-sections.ts — 启动时把 `[Context]` / `[Prompts]` / `[Themes]` 三段从「已加载资源」清单里剪掉
3
+ *
4
+ * pi 启动后会在 header 下面打一份「已加载资源」清单(`interactive-mode.js` 的 `showLoadedResources`,
5
+ * 每段 = 一个 `ExpandableText` 标题行 + 一行内容 + 一个 `Spacer(1)`):
6
+ * `[Context]`(AGENTS.md 等上下文文件)、`[Skills]`、`[Prompts]`(斜杠模板)、`[Extensions]`、`[Themes]`。
7
+ * 其中 `[Context]` / `[Prompts]` / `[Themes]` 对使用者没有信息量(前两个每次启动都一样、主题文件也没人会去数),
8
+ * 留着只是白占屏幕,所以这里在**它们进容器之前**就丢掉;`[Skills]` / `[Extensions]` 以及
9
+ * `[Skill conflicts]` 之类的诊断段全部原样保留。
10
+ *
11
+ * 为什么不用 pi 自己的 `quietStartup`:那个开关把**整份清单**(含 Skills / Extensions / 诊断)一起关掉,
12
+ * 这里要的只是剪掉三段。清单是 pi 在 `bindCurrentSessionExtensions()` 里 `session_start` **之后**才填的
13
+ * (`showLoadedResources` 紧跟 `await session.bindExtensions(...)`),所以扩展在 `session_start` 里
14
+ * 接管容器的 `addChild` 就赶得上:被隐藏的段一次都不会进容器,也就不存在「先画出来再抹掉」的闪帧。
15
+ *
16
+ * 怎么认出「已加载资源」那个容器:pi 的 `documentContainer` 固定按
17
+ * `[headerContainer, loadedResourcesContainer, chatContainer]` 顺序挂子节点(`interactive-mode.js`
18
+ * 构造函数),所以从**已挂载的 header 组件**反查出 header 容器(`findRenderContainer`),再往上找它的
19
+ * 父容器、取紧随其后的那个兄弟容器即可。认不出就**什么都不做**(清单照旧显示),不猜下标、不抛错。
20
+ *
21
+ * 判定与剪枝都是内容驱动的,跟容器认的是谁无关:只有「渲染出来的第一行恰好是 `[Context]` / `[Prompts]` /
22
+ * `[Themes]`」的组件才会被丢,所以万一 pi 挪了容器位置、我们摸到了别的容器(例如 chatContainer),
23
+ * 最多是白接管一次,不会误删别的东西。任何一步抛错都当作「不是要剪的段」,一律放行。
24
+ *
25
+ * 全部是纯逻辑(容器只要求 `children` 数组 + `addChild`),不 import pi / pi-tui,`node --test` 直接跑。
26
+ */
27
+
28
+ /** 要剪掉的段名(与 pi 的 `addLoadedSection("Context"…)` 逐字一致,大小写敏感)。 */
29
+ export const HIDDEN_SECTION_NAMES: readonly string[] = ["Context", "Prompts", "Themes"];
30
+
31
+ /** 接管记录挂在容器上的符号键(全局符号注册表:`/reload` 后的新实例能看到并解除旧实例的接管)。 */
32
+ export const SECTION_PRUNER_KEY: symbol = Symbol.for("litellm-any.pi-startup-logo.sectionPruner");
33
+
34
+ /**
35
+ * 判定用的渲染宽度。标题行只有 `[Context]` 这么长(9 列),远小于任何终端宽度,不会因折行而认不出;
36
+ * 取一个固定值是因为 `addChild` 那一刻还不知道真实宽度(`render` 才带宽度)。
37
+ */
38
+ export const DETECT_WIDTH = 200;
39
+
40
+ /** pi-tui `Container` 里本模块用到的那部分结构(`children` / `addChild` 都是公开的)。 */
41
+ export interface PrunableContainer {
42
+ children: unknown[];
43
+ addChild?(child: unknown): void;
44
+ render?(width: number): string[];
45
+ }
46
+
47
+ interface PrunerRecord {
48
+ release: () => void;
49
+ }
50
+
51
+ const ANSI_PATTERN = /\u001b\[[0-9;]*m/g;
52
+
53
+ /** 组件渲染出来的第一行(原样返回,可能是 undefined / 非字符串)。 */
54
+ function firstRenderedLine(component: unknown, width: number): unknown {
55
+ try {
56
+ if (component === null || typeof component !== "object") return undefined;
57
+ const render = (component as { render?: unknown }).render;
58
+ if (typeof render !== "function") return undefined;
59
+ const lines = (render as (w: number) => unknown).call(component, width);
60
+ if (!Array.isArray(lines) || lines.length === 0) return undefined;
61
+ return lines[0];
62
+ } catch {
63
+ return undefined;
64
+ }
65
+ }
66
+
67
+ /** 剥掉 ANSI、去掉首尾空白后,这一行是不是 `[Name]` 形状;是就返回名字。 */
68
+ export function sectionTitleOfLine(line: unknown): string | undefined {
69
+ if (typeof line !== "string") return undefined;
70
+ const plain = line.replace(ANSI_PATTERN, "").trim();
71
+ // 名字里可以带空格(`[Skill conflicts]` 这种诊断段),但不允许嵌套方括号。
72
+ const match = /^\[([^[\]]+)\]$/.exec(plain);
73
+ const title = match?.[1].trim();
74
+ return title ? title : undefined;
75
+ }
76
+
77
+ /** 这个组件的第一行是不是某个被隐藏的段标题。 */
78
+ export function isHiddenSection(
79
+ component: unknown,
80
+ names: readonly string[] = HIDDEN_SECTION_NAMES,
81
+ width: number = DETECT_WIDTH,
82
+ ): boolean {
83
+ const title = sectionTitleOfLine(firstRenderedLine(component, width));
84
+ return title !== undefined && names.includes(title);
85
+ }
86
+
87
+ /** 这个组件是不是「一行空行」——`addLoadedSection` 每段后面那个 `Spacer(1)` 就是这个形状。 */
88
+ export function isBlankLineComponent(component: unknown, width: number = DETECT_WIDTH): boolean {
89
+ try {
90
+ if (component === null || typeof component !== "object") return false;
91
+ const render = (component as { render?: unknown }).render;
92
+ if (typeof render !== "function") return false;
93
+ const lines = (render as (w: number) => unknown).call(component, width);
94
+ return Array.isArray(lines) && lines.length === 1 && lines[0] === "";
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * 把 `container` 里现有的隐藏段(连同它后面那个分隔空行)直接删掉,返回删掉的段数。
102
+ *
103
+ * 正常路径用不到它(`addChild` 接管在前面就拦住了),留着是为了「接管装晚了」的情况 ——
104
+ * 例如 `/reload` 之后清单已经填好才轮到新实例。
105
+ */
106
+ export function pruneHiddenSections(
107
+ container: unknown,
108
+ names: readonly string[] = HIDDEN_SECTION_NAMES,
109
+ width: number = DETECT_WIDTH,
110
+ ): number {
111
+ const target = container as PrunableContainer | undefined;
112
+ if (!target || !Array.isArray(target.children)) return 0;
113
+ const children = target.children;
114
+ let removed = 0;
115
+ // 倒着走:删掉下标 i 之后,原 i+1(紧跟的空行)正好落到 i 上。
116
+ for (let i = children.length - 1; i >= 0; i--) {
117
+ if (!isHiddenSection(children[i], names, width)) continue;
118
+ try {
119
+ children.splice(i, 1);
120
+ removed++;
121
+ if (i < children.length && isBlankLineComponent(children[i], width)) children.splice(i, 1);
122
+ } catch {
123
+ // 数组只读之类:能删多少算多少,不再往下试。
124
+ break;
125
+ }
126
+ }
127
+ return removed;
128
+ }
129
+
130
+ /** 解除 `container` 上现有的接管(可能是别的扩展实例遗留的)。返回是否解掉了什么。 */
131
+ export function releaseHiddenSectionPruner(container: unknown): boolean {
132
+ try {
133
+ const record = (container as { [SECTION_PRUNER_KEY]?: PrunerRecord } | undefined)?.[SECTION_PRUNER_KEY];
134
+ if (!record || typeof record.release !== "function") return false;
135
+ record.release();
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ function isPrunableContainer(value: unknown): value is PrunableContainer {
143
+ return (
144
+ value !== null &&
145
+ typeof value === "object" &&
146
+ Array.isArray((value as PrunableContainer).children) &&
147
+ typeof (value as PrunableContainer).addChild === "function"
148
+ );
149
+ }
150
+
151
+ /**
152
+ * 接管 `container.addChild`:隐藏段(以及它后面那个分隔空行)直接不放进去,其余一律透传。
153
+ * 返回解除函数(幂等);容器不可用时返回 undefined。
154
+ *
155
+ * 幂等:重复调用先解除上一次接管再装新的,不会叠 wrapper;`/reload` 后新实例能解除旧实例的接管。
156
+ */
157
+ export function installHiddenSectionPruner(options: {
158
+ container: unknown;
159
+ names?: readonly string[];
160
+ width?: number;
161
+ }): (() => void) | undefined {
162
+ if (!isPrunableContainer(options.container)) return undefined;
163
+ const container = options.container;
164
+ releaseHiddenSectionPruner(container);
165
+
166
+ const names = options.names ?? HIDDEN_SECTION_NAMES;
167
+ const width = options.width ?? DETECT_WIDTH;
168
+ // 装晚了(清单已经填好)时先把现有的剪掉。
169
+ pruneHiddenSections(container, names, width);
170
+
171
+ const originalAddChild = container.addChild!;
172
+ const state: { released: boolean; record?: PrunerRecord } = { released: false };
173
+ let dropNextBlank = false;
174
+
175
+ const release = (): void => {
176
+ if (state.released) return;
177
+ state.released = true;
178
+ try {
179
+ const holder = container as { [SECTION_PRUNER_KEY]?: PrunerRecord };
180
+ // 已经被别的实例的新接管顶掉:只管让自己的 wrapper 失效,别去拆新的那个。
181
+ if (state.record && holder[SECTION_PRUNER_KEY] !== state.record) return;
182
+ delete holder[SECTION_PRUNER_KEY];
183
+ container.addChild = originalAddChild;
184
+ } catch {
185
+ // 容器只读 / 已被替换:wrapper 自己会因 released 标志透传。
186
+ }
187
+ };
188
+
189
+ const wrapper = (child: unknown): void => {
190
+ if (state.released) {
191
+ originalAddChild.call(container, child);
192
+ return;
193
+ }
194
+ try {
195
+ if (isHiddenSection(child, names, width)) {
196
+ // 段后面紧跟的就是它自己的 Spacer(1),一起收掉,免得留下多余空行。
197
+ dropNextBlank = true;
198
+ return;
199
+ }
200
+ if (dropNextBlank) {
201
+ dropNextBlank = false;
202
+ if (isBlankLineComponent(child, width)) return;
203
+ }
204
+ } catch {
205
+ // 判定失败:按普通 child 放行。
206
+ }
207
+ originalAddChild.call(container, child);
208
+ };
209
+
210
+ try {
211
+ container.addChild = wrapper;
212
+ state.record = { release };
213
+ (container as { [SECTION_PRUNER_KEY]?: PrunerRecord })[SECTION_PRUNER_KEY] = state.record;
214
+ } catch {
215
+ return undefined;
216
+ }
217
+ return release;
218
+ }
219
+
220
+ /**
221
+ * 在 `root` 的子树里找「直接装着 `headerContainer` 的那个容器」,再取它**后面**那个兄弟容器 ——
222
+ * 就是 pi 的 `loadedResourcesContainer`。认不出返回 undefined。
223
+ */
224
+ export function findLoadedResourcesContainer(options: {
225
+ root: unknown;
226
+ headerContainer: unknown;
227
+ maxDepth?: number;
228
+ }): PrunableContainer | undefined {
229
+ const { root, headerContainer } = options;
230
+ if (!headerContainer) return undefined;
231
+ const maxDepth = options.maxDepth ?? 6;
232
+ const seen = new Set<unknown>();
233
+
234
+ const walk = (node: unknown, depth: number): unknown => {
235
+ if (depth > maxDepth || node === null || typeof node !== "object" || seen.has(node)) return undefined;
236
+ seen.add(node);
237
+ const children = (node as { children?: unknown }).children;
238
+ if (!Array.isArray(children)) return undefined;
239
+ const index = children.indexOf(headerContainer);
240
+ if (index >= 0) return children[index + 1];
241
+ for (const child of children) {
242
+ const found = walk(child, depth + 1);
243
+ if (found !== undefined) return found;
244
+ }
245
+ return undefined;
246
+ };
247
+
248
+ try {
249
+ const sibling = walk(root, 0);
250
+ return isPrunableContainer(sibling) ? sibling : undefined;
251
+ } catch {
252
+ return undefined;
253
+ }
254
+ }
255
+
256
+ /**
257
+ * 找到「已加载资源」容器并装上剪枝接管。返回是否装上了(找不到容器 / 容器形状不对 → false,界面照旧)。
258
+ */
259
+ export function hideLoadedSections(options: {
260
+ root: unknown;
261
+ headerContainer: unknown;
262
+ names?: readonly string[];
263
+ }): boolean {
264
+ const container = findLoadedResourcesContainer({ root: options.root, headerContainer: options.headerContainer });
265
+ if (!container) return false;
266
+ return installHiddenSectionPruner({ container, names: options.names }) !== undefined;
267
+ }
@@ -0,0 +1,124 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ MARK_BLANK,
5
+ MARK_CELL,
6
+ MARK_COLS,
7
+ MARK_GLYPH_WIDTH,
8
+ MARK_INDENT,
9
+ MARK_ROWS,
10
+ MARK_WIDTH,
11
+ attachSideText,
12
+ composeHeaderLines,
13
+ markCellFilled,
14
+ markLines,
15
+ shortenPath,
16
+ } from "./logo.ts";
17
+
18
+ /** 去掉 ANSI,只看几何。 */
19
+ const plain = (line: string): string => line.replace(/\u001b\[[0-9;]*m/g, "");
20
+
21
+ test("印记是 4×4 格子,本体宽 12、左侧留白一格所以整行 13", () => {
22
+ assert.equal(MARK_CELL, "███");
23
+ assert.equal(MARK_BLANK, " ");
24
+ assert.equal(MARK_INDENT, " ");
25
+ assert.equal(MARK_ROWS, 4);
26
+ assert.equal(MARK_COLS, 4);
27
+ assert.equal(MARK_GLYPH_WIDTH, 12);
28
+ assert.equal(MARK_WIDTH, MARK_INDENT.length + MARK_GLYPH_WIDTH);
29
+ assert.equal(MARK_WIDTH, 13);
30
+ });
31
+
32
+ test("markLines 的四行形状(含补回来的那一格)与左侧留白", () => {
33
+ assert.deepEqual(markLines(), [
34
+ " █████████ ", // 上横:三格实心
35
+ " ███ ███ ", // 左竖 + 右腿上身
36
+ " ██████ ███", // 左竖 + 内衬 + 右腿(上游落定帧漏掉的 (5,5))
37
+ " ███ ███", // 左竖 + 右腿脚
38
+ ]);
39
+ for (const line of markLines()) {
40
+ assert.ok(line.startsWith(MARK_INDENT), "每行都要顶一个空格,不能贴左边缘");
41
+ assert.equal(line.length, MARK_WIDTH, "每行可见宽度必须一致,否则侧栏歪");
42
+ }
43
+ });
44
+
45
+ test("markCellFilled 与格子表一致,越界当空格", () => {
46
+ markLines().forEach((line, row) => {
47
+ for (let col = 0; col < MARK_COLS; col++) {
48
+ assert.equal(
49
+ markCellFilled(row, col),
50
+ line.slice(MARK_INDENT.length + col * 3, MARK_INDENT.length + col * 3 + 3) === MARK_CELL,
51
+ `(${row},${col}) 取色与整行渲染不一致`,
52
+ );
53
+ }
54
+ });
55
+ assert.equal(markCellFilled(9, 9), false);
56
+ assert.equal(markCellFilled(-1, 0), false);
57
+ });
58
+
59
+ test("右腿不是悬空的一只脚(回归:上游 phase 6 少了 (2,3) 那格)", () => {
60
+ // 最后一行最右格实心 → 它上一行最右格也必须实心,否则腿断了
61
+ assert.equal(markCellFilled(MARK_ROWS - 1, MARK_COLS - 1), true);
62
+ assert.equal(markCellFilled(MARK_ROWS - 2, MARK_COLS - 1), true);
63
+ });
64
+
65
+ test("attachSideText:两条侧栏落在第 1、2 行,其余行原样等宽", () => {
66
+ const out = attachSideText(markLines().map((l) => `[${l}]`), ["V", "C"]);
67
+ assert.equal(out.length, MARK_ROWS);
68
+ assert.equal(out[1], `[${markLines()[1]}] V`);
69
+ assert.equal(out[2], `[${markLines()[2]}] C`);
70
+ assert.equal(out[0], `[${markLines()[0]}]`);
71
+ assert.equal(out[3], `[${markLines()[3]}]`);
72
+ });
73
+
74
+ test("attachSideText:ANSI 不参与列计算", () => {
75
+ const colored = markLines().map((l) => `\x1b[38;2;121;214;193m${l}\x1b[39m`);
76
+ const out = attachSideText(colored, ["A", "B"]);
77
+ // 挂侧栏的行:剥掉 ANSI 后,侧栏前正好是整行宽度 + 2 空格
78
+ for (const [i, text] of [
79
+ [1, "A"],
80
+ [2, "B"],
81
+ ] as const) {
82
+ const stripped = plain(out[i]!);
83
+ assert.equal(stripped, `${markLines()[i]} ${text}`);
84
+ assert.equal(stripped.indexOf(text), MARK_WIDTH + 2);
85
+ }
86
+ // 没挂侧栏的行仍是完整等宽
87
+ assert.equal(plain(out[0]!), " █████████ ");
88
+ assert.equal(plain(out[0]!).length, MARK_WIDTH);
89
+ });
90
+
91
+ test("attachSideText:侧栏为空 / 比行数多 / 行数为 0 都不炸", () => {
92
+ const lines = markLines();
93
+ assert.deepEqual(attachSideText(lines, []), [...lines]);
94
+ assert.equal(attachSideText(lines, ["a", "b", "c", "d", "e"]).length, MARK_ROWS);
95
+ assert.deepEqual(attachSideText([], ["a"]), []);
96
+ // 空字符串那条不占位:只有一行被贴字,其余保持等宽
97
+ const blanked = attachSideText(lines, ["", "only"]);
98
+ assert.equal(blanked.filter((l) => l.endsWith("only")).length, 1);
99
+ assert.equal(blanked.filter((l) => l.endsWith("only") && l !== blanked[blanked.findIndex((x) => x.endsWith("only"))]).length, 0);
100
+ });
101
+
102
+ test("每行可见宽度恒等于 MARK_WIDTH(挂不挂侧栏的行都等宽,侧栏列才不会歪)", () => {
103
+ for (const line of markLines()) assert.equal(plain(line).length, MARK_WIDTH);
104
+ for (const line of markLines((f) => (f ? `\x1b[31m${MARK_CELL}\x1b[39m` : MARK_BLANK))) {
105
+ assert.equal(plain(line).length, MARK_WIDTH);
106
+ }
107
+ });
108
+
109
+ test("composeHeaderLines:段之间恰好一个空行,缺段不留空行", () => {
110
+ assert.deepEqual(composeHeaderLines({ logo: ["a", "b"], hints: "h", onboarding: "o" }), ["a", "b", "", "h", "", "o"]);
111
+ assert.deepEqual(composeHeaderLines({ logo: ["a"], hints: "h" }), ["a", "", "h"]);
112
+ assert.deepEqual(composeHeaderLines({ logo: [], hints: "h", onboarding: "o" }), ["h", "", "o"]);
113
+ assert.deepEqual(composeHeaderLines({ logo: [] }), []);
114
+ assert.deepEqual(composeHeaderLines({ logo: [], onboarding: "o" }), ["o"]);
115
+ });
116
+
117
+ test("shortenPath:家目录内缩写,外面原样", () => {
118
+ assert.equal(shortenPath("/Users/bachi", "/Users/bachi"), "~");
119
+ assert.equal(shortenPath("/Users/bachi/jaylli/litellm-any", "/Users/bachi"), "~/jaylli/litellm-any");
120
+ assert.equal(shortenPath("/tmp/x", "/Users/bachi"), "/tmp/x");
121
+ // 同前缀但不同目录(/Users/bachix)不能被当成子路径缩写
122
+ assert.equal(shortenPath("/Users/bachix", "/Users/bachi"), "/Users/bachix");
123
+ assert.equal(shortenPath("/srv/app", undefined), "/srv/app");
124
+ });