@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,348 @@
1
+ /**
2
+ * Tests for recap/index.ts — the idle gate in front of the auto recap.
3
+ *
4
+ * Run with: node --test clients/pi/extensions/recap/index.test.ts
5
+ *
6
+ * This one takes ~75s on purpose: the 30s idle threshold is hard-coded and the test uses
7
+ * real timers (the whole point is "does it *not* recap while work is still running"), so
8
+ * nothing is shortened or faked as a shortcut. What is faked is everything outside the
9
+ * extension: the ctx (a canned last exchange + a recording modelRegistry/ui) and the
10
+ * other side of the event bus. pi's own extension loader really loads `index.ts`, so the
11
+ * `./subagents.ts` import and the handler/command registrations are covered too.
12
+ *
13
+ * Timeline asserted:
14
+ * agent_settled → 30s idle → probe #1 says "1 subagent running" → no recap
15
+ * (only a 10s poll is armed)
16
+ * flip the fake fleet to idle → probe #2 says "nothing running" → still no recap
17
+ * (the 30s idle timer restarts)
18
+ * → 30s later → recap generated and shown.
19
+ *
20
+ * The second test covers the widget's breathing line: the blank line above the recap is
21
+ * decided at render time by walking the editor's widget container (`../simple-task/gap.ts`)
22
+ * instead of guessing from the task-list state, and the re-entrancy guard keeps
23
+ * simple-task's own walk (which renders this component right back) from recursing forever.
24
+ */
25
+
26
+ import assert from "node:assert/strict";
27
+ import fs from "node:fs";
28
+ import os from "node:os";
29
+ import path from "node:path";
30
+ import test from "node:test";
31
+ import { fileURLToPath, pathToFileURL } from "node:url";
32
+
33
+ import { SUBAGENT_RPC_REPLY_PREFIX, SUBAGENT_RPC_REQUEST } from "./subagents.ts";
34
+
35
+ const EXTENSION_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
36
+
37
+ /**
38
+ * pi 的库入口(非 CLI):bundle 是 `pi` 实际跑的形态,dist 是 node 构建形态,两个都能加载扩展。
39
+ *
40
+ * 先从 `pi` 可执行文件反查**真正的安装位置**(pnpm/npm 的 shim 脚本里留有
41
+ * `# cmd-shim-target=<绝对路径>`;npm 在 Unix 上则是符号链接,两种都试),再退回
42
+ * `~/.pi/agent/npm` 那份副本 —— 后者是**扩展包的安装根**,`pi update --extensions` 会用
43
+ * `--config.auto-install-peers=false` 把自动装进去的 `@earendil-works/pi-*` 同伴包剪掉,
44
+ * 于是那份副本只剩空壳(文件都在、import 报 `ERR_MODULE_NOT_FOUND`;2026-09 升
45
+ * pi-subagents 0.68.0 时实测踩到)。
46
+ *
47
+ * 判定方式是**能不能真 import**,不是路径存不存在 —— 只有真 import 一次才分得清空壳。
48
+ * 全都不行就返回 undefined,调用方整体 skip(不假装通过)。
49
+ */
50
+ async function findPiLibraryEntry(): Promise<string | undefined> {
51
+ const candidates: string[] = [];
52
+ if (process.env.PI_TEST_PI_ENTRY) candidates.push(process.env.PI_TEST_PI_ENTRY);
53
+
54
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
55
+ if (!dir) continue;
56
+ const shimPath = path.join(dir, "pi");
57
+ try {
58
+ // npm 的 shim 是符号链接
59
+ const real = fs.realpathSync(shimPath);
60
+ if (real !== shimPath) candidates.push(path.join(path.dirname(real), "index.js"));
61
+ } catch {
62
+ // 不是符号链接 / 不存在:看下面的 shim 脚本
63
+ }
64
+ try {
65
+ const match = /^# cmd-shim-target=(.+)$/m.exec(fs.readFileSync(shimPath, "utf8"));
66
+ if (match?.[1]) candidates.push(path.join(path.dirname(match[1].trim()), "index.js"));
67
+ } catch {
68
+ // 读不到这个 shim:跳过
69
+ }
70
+ }
71
+
72
+ const packageDir = path.join(os.homedir(), ".pi/agent/npm/node_modules/@earendil-works/pi-coding-agent");
73
+ candidates.push(path.join(packageDir, "dist/bundle/index.js"), path.join(packageDir, "dist/index.js"));
74
+
75
+ for (const candidate of candidates) {
76
+ if (!fs.existsSync(candidate)) continue;
77
+ try {
78
+ await import(pathToFileURL(candidate).href);
79
+ return candidate;
80
+ } catch {
81
+ // 空壳副本:换下一个候选
82
+ }
83
+ }
84
+ return undefined;
85
+ }
86
+
87
+ const piEntry = await findPiLibraryEntry();
88
+ const skip = piEntry === undefined ? "找不到本机 pi 的库入口(装过 pi 才有)" : false;
89
+
90
+ type Handler = (event: unknown, ctx: unknown) => Promise<unknown> | unknown;
91
+
92
+ interface WidgetComponent {
93
+ render(width: number): string[];
94
+ }
95
+ type WidgetFactory = (tui: unknown, theme: unknown) => WidgetComponent;
96
+
97
+ interface Recorder {
98
+ /** `modelRegistry.complete` 被调用的次数(摘要生成 = 一次)。 */
99
+ completeCalls: number;
100
+ /** 每次 `setWidget(key, <factory>)` 记一个;undefined 不入列。 */
101
+ widgets: WidgetFactory[];
102
+ }
103
+
104
+ /** 假 ctx:只提供 recap 这条路径上真正用到的东西。 */
105
+ function createContext(recorder: Recorder): unknown {
106
+ return {
107
+ mode: "tui",
108
+ isIdle: () => true,
109
+ model: { provider: "test", id: "test-model" },
110
+ sessionManager: {
111
+ getBranch: () => [
112
+ { type: "message", message: { role: "user", content: "把 recap 的定时改成等子代理结束" } },
113
+ { type: "message", message: { role: "assistant", content: [{ type: "text", text: "改完了,正在等子代理的结果。" }] } },
114
+ ],
115
+ },
116
+ modelRegistry: {
117
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "test" }),
118
+ complete: async () => {
119
+ recorder.completeCalls += 1;
120
+ return { content: [{ type: "text", text: "已把 recap 定时改成等子代理结束" }] };
121
+ },
122
+ },
123
+ ui: {
124
+ setWidget: (_key: string, widget: unknown) => {
125
+ if (widget) recorder.widgets.push(widget as WidgetFactory);
126
+ },
127
+ notify: () => {},
128
+ },
129
+ };
130
+ }
131
+
132
+ interface TestBus {
133
+ emit(channel: string, data: unknown): void;
134
+ on(channel: string, handler: (data: unknown) => void): () => void;
135
+ }
136
+
137
+ function createTestBus(): TestBus {
138
+ const handlers = new Map<string, Set<(data: unknown) => void>>();
139
+ return {
140
+ on(channel, handler) {
141
+ const set = handlers.get(channel) ?? new Set<(data: unknown) => void>();
142
+ handlers.set(channel, set);
143
+ set.add(handler);
144
+ return () => {
145
+ set.delete(handler);
146
+ };
147
+ },
148
+ emit(channel, data) {
149
+ // 复制一份:真实 EventEmitter 也是同步派发给订阅者。
150
+ for (const handler of [...(handlers.get(channel) ?? [])]) handler(data);
151
+ },
152
+ };
153
+ }
154
+
155
+ /** 等谓词成立;超时就把「在等什么」一并报出来。 */
156
+ async function waitFor(predicate: () => boolean, timeoutMs: number, what: string): Promise<void> {
157
+ const deadline = Date.now() + timeoutMs;
158
+ while (Date.now() < deadline) {
159
+ if (predicate()) return;
160
+ await new Promise((resolve) => setTimeout(resolve, 100));
161
+ }
162
+ throw new Error(`等不到:${what}(超时 ${timeoutMs}ms)`);
163
+ }
164
+
165
+ function makeWorkspace(): { agentDir: string; projectDir: string; cleanup: () => void } {
166
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-recap-idle-gate-"));
167
+ const agentDir = path.join(root, "agent");
168
+ const projectDir = path.join(root, "project");
169
+ fs.mkdirSync(agentDir);
170
+ fs.mkdirSync(projectDir);
171
+ return {
172
+ agentDir,
173
+ projectDir,
174
+ cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
175
+ };
176
+ }
177
+
178
+ test("有子代理在跑时不生成摘要;等它结束后重新起 30s 定时再生成", { skip, timeout: 180_000 }, async () => {
179
+ const workspace = makeWorkspace();
180
+ try {
181
+ const bus = createTestBus();
182
+ /** 假 pi-subagents:`active` 就是它 `status` 回包里的 `fleet.totalActive`。 */
183
+ const bridge = { active: true, requests: [] as Array<Record<string, unknown>> };
184
+ bus.on(SUBAGENT_RPC_REQUEST, (data) => {
185
+ const request = data as Record<string, unknown>;
186
+ bridge.requests.push(request);
187
+ const reply = {
188
+ version: 1,
189
+ requestId: request.requestId,
190
+ method: "status",
191
+ success: true,
192
+ data: { text: "…", fleet: { version: 1, entries: [], totalActive: bridge.active ? 1 : 0, omitted: 0 } },
193
+ };
194
+ // 真实 RPC 的回复是异步的(handler 里 await 过)。
195
+ setTimeout(() => bus.emit(`${SUBAGENT_RPC_REPLY_PREFIX}${String(request.requestId)}`, reply), 0);
196
+ });
197
+
198
+ const pi = (await import(pathToFileURL(piEntry as string).href)) as {
199
+ discoverAndLoadExtensions: (
200
+ configuredPaths: string[],
201
+ cwd: string,
202
+ agentDir?: string,
203
+ eventBus?: unknown,
204
+ ) => Promise<{
205
+ extensions: Array<{ handlers: Map<string, Handler[]>; commands: Map<string, unknown> }>;
206
+ errors: Array<{ path: string; error: string }>;
207
+ }>;
208
+ };
209
+ const loaded = await pi.discoverAndLoadExtensions([EXTENSION_PATH], workspace.projectDir, workspace.agentDir, bus);
210
+ assert.deepEqual(loaded.errors, [], "pi 的扩展加载器不应该报错(`./subagents.ts` 的 import 也在这里被验证)");
211
+ assert.equal(loaded.extensions.length, 1);
212
+ const extension = loaded.extensions[0];
213
+ assert.ok(extension);
214
+
215
+ // 注册面:这里挂了计时器(agent_settled / agent_start)与清理(session_*),命令面有 /recap。
216
+ const settled = extension.handlers.get("agent_settled")?.[0];
217
+ const shutdown = extension.handlers.get("session_shutdown")?.[0];
218
+ assert.ok(settled, "应该注册了 agent_settled");
219
+ assert.ok(extension.handlers.get("agent_start")?.length, "应该注册了 agent_start");
220
+ assert.ok(extension.handlers.get("input")?.length, "应该注册了 input");
221
+ assert.ok(shutdown, "应该注册了 session_shutdown");
222
+ assert.ok(extension.commands.has("recap"), "应该注册了 /recap 命令");
223
+
224
+ const recorder: Recorder = { completeCalls: 0, widgets: [] };
225
+ const ctx = createContext(recorder);
226
+
227
+ // 回合结束:起 30s 空闲表(此刻不会有任何输出)。
228
+ await settled({}, ctx);
229
+ assert.equal(recorder.completeCalls, 0);
230
+ assert.equal(recorder.widgets.length, 0);
231
+
232
+ // ~30s 后第一次探测:假舰队里有 1 个活跃子代理 → 只重查,不生成。
233
+ await waitFor(() => bridge.requests.length >= 1, 45_000, "空闲 30s 后的第一次子代理探测");
234
+ assert.equal(bridge.requests[0]?.method, "status");
235
+ assert.equal(recorder.completeCalls, 0, "有子代理在跑时绝不能生成摘要");
236
+ assert.equal(recorder.widgets.length, 0, "有子代理在跑时不能出现 recap widget");
237
+
238
+ // 子代理结束:等下一次重查(10s 一轮)确认「没活了」——但这时也不生成,
239
+ // 而是重新起一轮 30s 定时(结果刚回来、被唤醒的回合正要跑,现在总结是半截的)。
240
+ bridge.active = false;
241
+ await waitFor(() => bridge.requests.length >= 2, 15_000, "waiting 模式下的 10s 重查");
242
+ assert.equal(recorder.completeCalls, 0, "刚查到没活时应重新起 30s 定时,而不是立刻生成");
243
+
244
+ // 重新起表后 30s:生成并显示摘要。
245
+ await waitFor(() => recorder.completeCalls >= 1, 45_000, "重新起表 30s 后的摘要生成");
246
+ assert.equal(recorder.widgets.length, 1, "摘要应该已经挂上 widget");
247
+
248
+ // widget 渲染的是清洗后的摘要文本(`✦ Recap:` 前缀 + 下方空行)。
249
+ const component = recorder.widgets[0]?.(undefined, { fg: (_color: string, text: string) => text });
250
+ assert.ok(component);
251
+ const rendered = component.render(80).join("\n");
252
+ assert.match(rendered, /✦ Recap:/);
253
+ assert.match(rendered, /已把 recap 定时改成等子代理结束/);
254
+
255
+ await shutdown?.({}, ctx);
256
+ } finally {
257
+ workspace.cleanup();
258
+ }
259
+ });
260
+
261
+ test("间隔由渲染时探测邻居决定:无邻居不加、邻居有内容加一行、邻居已带空行不重复、重入不死循环", { skip, timeout: 30_000 }, async () => {
262
+ const workspace = makeWorkspace();
263
+ try {
264
+ const bus = createTestBus();
265
+ const pi = (await import(pathToFileURL(piEntry as string).href)) as {
266
+ discoverAndLoadExtensions: (
267
+ configuredPaths: string[],
268
+ cwd: string,
269
+ agentDir?: string,
270
+ eventBus?: unknown,
271
+ ) => Promise<{
272
+ extensions: Array<{
273
+ commands: Map<string, { handler: (args: string, ctx: unknown) => Promise<void> }>;
274
+ }>;
275
+ errors: Array<{ path: string; error: string }>;
276
+ }>;
277
+ };
278
+ const loaded = await pi.discoverAndLoadExtensions([EXTENSION_PATH], workspace.projectDir, workspace.agentDir, bus);
279
+ assert.deepEqual(loaded.errors, []);
280
+ const command = loaded.extensions[0]?.commands.get("recap");
281
+ assert.ok(command, "应该注册了 /recap 命令");
282
+
283
+ // /recap 是 force=true 的那条路(不走 30s 闲置闸门),拿到的 widget 工厂就是渲染现场。
284
+ const recorder: Recorder = { completeCalls: 0, widgets: [] };
285
+ await command.handler("", createContext(recorder));
286
+ assert.equal(recorder.widgets.length, 1, "/recap 应该挂上 widget");
287
+
288
+ const theme = { fg: (_color: string, text: string) => text };
289
+ /** 每个场景都要一个新组件:重入标记在闭包里,不能复用。 */
290
+ const newComponent = (tui: unknown): WidgetComponent =>
291
+ (recorder.widgets[0] as WidgetFactory)(tui, theme);
292
+ /** pi 的「编辑器上方」容器长这样:先一个全空的 Spacer,再按注册顺序放各扩展的 widget。 */
293
+ const container = (...neighbours: unknown[]): { children: unknown[] } => {
294
+ const tui = { children: [{ render: (): string[] => [""] }] as unknown[] };
295
+ for (const neighbour of neighbours) tui.children.push(neighbour);
296
+ return tui;
297
+ };
298
+
299
+ // 1) 容器里只有自己(上面的 Spacer 是全空行)→ 不加前导空行。
300
+ const alone = newComponent(undefined);
301
+ const aloneLines = alone.render(80);
302
+ assert.equal(aloneLines.length, 2, "单独显示 = 摘要一行 + 下方空行");
303
+ assert.match(aloneLines[0] ?? "", /✦ Recap:/);
304
+ assert.equal(aloneLines[1], "", "摘要下方固定补一个空行");
305
+
306
+ // 2) 上方邻居有可见内容(任务清单 / pi-subagents 的 `async subagent` 块)→ 补前导空行。
307
+ const withNeighbour = container({ render: (): string[] => [" ● 3 tasks", " ◻ #1 甲"], invalidate() {} });
308
+ const spaced = newComponent(withNeighbour);
309
+ withNeighbour.children.push(spaced);
310
+ const spacedLines = spaced.render(80);
311
+ assert.equal(spacedLines.length, 3, "邻居有内容 → 恰好一个前导空行");
312
+ assert.equal(spacedLines[0], "");
313
+ assert.match(spacedLines[1] ?? "", /✦ Recap:/);
314
+ assert.equal(spacedLines[2], "", "下方空行不受影响");
315
+
316
+ // 3) 上方邻居自己就以空行收尾(间隔已经有了)→ 不再补,否则会多出一行。
317
+ const blankTailed = container({ render: (): string[] => [" ● 3 tasks", ""], invalidate() {} });
318
+ const quiet = newComponent(blankTailed);
319
+ blankTailed.children.push(quiet);
320
+ const quietLines = quiet.render(80);
321
+ assert.equal(quietLines.length, 2, "邻居已带空行 → 不重复补");
322
+ assert.match(quietLines[0] ?? "", /✦ Recap:/);
323
+
324
+ // 4) 邻居的 render 反过来再渲染 recap(simple-task 的 gap.ts 就是这样看邻居的)→
325
+ // 有重入保护:只重入一次,且那次不带前导空行;间隔由外层那次 walk 统一决定。
326
+ const nested: string[][] = [];
327
+ let cyclic: WidgetComponent | undefined;
328
+ const cyclicNeighbour = {
329
+ render: (): string[] => {
330
+ nested.push(cyclic?.render(80) ?? []);
331
+ return [" ● 3 tasks", " ◻ #1 甲"];
332
+ },
333
+ invalidate() {},
334
+ };
335
+ const cyclicContainer = container(cyclicNeighbour);
336
+ cyclic = newComponent(cyclicContainer);
337
+ cyclicContainer.children.push(cyclic);
338
+ const outerLines = cyclic.render(80);
339
+ assert.equal(nested.length, 1, "重入只发生一次(没有重入保护会一直递归到栈溢出)");
340
+ assert.equal(nested[0]?.length, 2, "重入的那次只输出内容行");
341
+ assert.match(nested[0]?.[0] ?? "", /✦ Recap:/);
342
+ assert.equal(outerLines.length, 3, "外层那次 walk 看到邻居有内容 → 补前导空行");
343
+ assert.equal(outerLines[0], "");
344
+ assert.match(outerLines[1] ?? "", /✦ Recap:/);
345
+ } finally {
346
+ workspace.cleanup();
347
+ }
348
+ });