@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,499 @@
1
+ /**
2
+ * Tests for working-summary.ts — spinner 行右对齐提示词摘要的压平 + 纯布局逻辑。
3
+ *
4
+ * Run with: node --test clients/pi/extensions/working-indicator/working-summary.test.ts
5
+ *
6
+ * 被测模块不 import pi / pi-tui(宽度函数是注入的),所以这里喂一个简单的
7
+ * 假宽度函数:ASCII/半角 = 1 列,CJK 全角 = 2 列,与 pi-tui `visibleWidth`
8
+ * 对本用例涉及字符的口径一致(`…`、`✦` 都是 1 列)。
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+
14
+ import {
15
+ DEFAULT_MIN_GAP,
16
+ MAX_PROMPT_SCAN_CHARS,
17
+ MIN_SUMMARY_TEXT_WIDTH,
18
+ SUMMARY_MARKER,
19
+ availableSummaryTextWidth,
20
+ fitToWidth,
21
+ flattenPrompt,
22
+ layoutPromptSummary,
23
+ } from "./working-summary.ts";
24
+
25
+ /** 假宽度函数:CJK 常见全角区间按 2 列,其余按 1 列(含 `…` / `✦`)。 */
26
+ function fakeWidth(text: string): number {
27
+ let width = 0;
28
+ for (const ch of text) {
29
+ const code = ch.codePointAt(0) ?? 0;
30
+ const wide =
31
+ (code >= 0x1100 && code <= 0x115f) ||
32
+ (code >= 0x2e80 && code <= 0xa4cf) ||
33
+ (code >= 0xac00 && code <= 0xd7a3) ||
34
+ (code >= 0xf900 && code <= 0xfaff) ||
35
+ (code >= 0xfe30 && code <= 0xfe6f) ||
36
+ (code >= 0xff00 && code <= 0xff60) ||
37
+ (code >= 0xffe0 && code <= 0xffe6);
38
+ width += wide ? 2 : 1;
39
+ }
40
+ return width;
41
+ }
42
+
43
+ const MARKER_WIDTH = fakeWidth(`${SUMMARY_MARKER} `); // "✦ " = 2
44
+
45
+ describe("flattenPrompt: line joining", () => {
46
+ it("joins multiple lines into one compact line", () => {
47
+ assert.equal(flattenPrompt("hello\nworld"), "hello world");
48
+ assert.equal(flattenPrompt("hello\r\nworld"), "hello world");
49
+ assert.equal(flattenPrompt("hello\rworld"), "hello world");
50
+ });
51
+
52
+ it("treats unicode line/paragraph separators and VT/FF as line breaks", () => {
53
+ assert.equal(flattenPrompt("a\u2028b"), "a b");
54
+ assert.equal(flattenPrompt("a\u2029b"), "a b");
55
+ assert.equal(flattenPrompt("a\u0085b"), "a b");
56
+ assert.equal(flattenPrompt("a\u000bb"), "a b");
57
+ assert.equal(flattenPrompt("a\u000cb"), "a b");
58
+ });
59
+
60
+ it("does not insert a space between two CJK characters at a line break", () => {
61
+ assert.equal(flattenPrompt("我要优化\n这个模块"), "我要优化这个模块");
62
+ assert.equal(flattenPrompt("第一行\n第二行\n第三行"), "第一行第二行第三行");
63
+ });
64
+
65
+ it("inserts one space for CJK/ASCII mixed boundaries", () => {
66
+ assert.equal(flattenPrompt("优化\nmodule"), "优化 module");
67
+ assert.equal(flattenPrompt("the\n模块"), "the 模块");
68
+ });
69
+
70
+ it("keeps a space after ASCII sentence punctuation", () => {
71
+ assert.equal(flattenPrompt("hello,\nworld"), "hello, world");
72
+ assert.equal(flattenPrompt("done.\nNext step"), "done. Next step");
73
+ });
74
+
75
+ it("does not insert a space around CJK punctuation", () => {
76
+ assert.equal(flattenPrompt("他说:\n「你好」"), "他说:「你好」");
77
+ assert.equal(flattenPrompt("好的,\n谢谢"), "好的,谢谢");
78
+ });
79
+
80
+ it("does not break paths or bracket pairs split across lines", () => {
81
+ assert.equal(flattenPrompt("working-summary\n.ts"), "working-summary.ts");
82
+ assert.equal(flattenPrompt("call(\n)"), "call()");
83
+ assert.equal(flattenPrompt("arr[\n0]"), "arr[0]");
84
+ });
85
+
86
+ it("skips blank lines without adding extra spaces", () => {
87
+ assert.equal(flattenPrompt("a\n\n\nb"), "a b");
88
+ assert.equal(flattenPrompt("a\n \n\t\nb"), "a b");
89
+ });
90
+
91
+ it("summarizes prompts that start with blank lines (old code returned nothing)", () => {
92
+ assert.equal(flattenPrompt("\n\nworld"), "world");
93
+ assert.equal(flattenPrompt(" \nworld"), "world");
94
+ });
95
+ });
96
+
97
+ describe("flattenPrompt: whitespace compaction", () => {
98
+ it("strips indentation (leading/trailing whitespace of each line)", () => {
99
+ assert.equal(flattenPrompt(" a\n\t\tb\n c"), "a b c");
100
+ });
101
+
102
+ it("collapses runs of 2+ spaces into one, keeps single spaces", () => {
103
+ assert.equal(flattenPrompt("a b"), "a b");
104
+ assert.equal(flattenPrompt("a b"), "a b");
105
+ assert.equal(flattenPrompt("a b c"), "a b c");
106
+ });
107
+
108
+ it("turns tabs into a single space instead of gluing words", () => {
109
+ assert.equal(flattenPrompt("a\tb"), "a b");
110
+ assert.equal(flattenPrompt("a\t\tb"), "a b");
111
+ });
112
+
113
+ it("normalizes unicode spaces to a regular space", () => {
114
+ assert.equal(flattenPrompt("a\u00a0b"), "a b"); // NBSP
115
+ assert.equal(flattenPrompt("a\u3000b"), "a b"); // 全角空格
116
+ assert.equal(flattenPrompt("a\u2003b"), "a b"); // em space
117
+ });
118
+
119
+ it("removes zero-width characters that would pollute width math", () => {
120
+ assert.equal(flattenPrompt("a\u200bb"), "ab");
121
+ assert.equal(flattenPrompt("\ufeffhello"), "hello");
122
+ assert.equal(flattenPrompt("a\u200db"), "ab");
123
+ });
124
+
125
+ it("trims surrounding whitespace of the whole prompt", () => {
126
+ assert.equal(flattenPrompt(" hello "), "hello");
127
+ });
128
+ });
129
+
130
+ describe("flattenPrompt: noise removal", () => {
131
+ it("strips ANSI escape sequences", () => {
132
+ assert.equal(flattenPrompt("\u001b[31mred\u001b[0m text"), "red text");
133
+ assert.equal(flattenPrompt("\u001b]0;title\u0007body"), "body");
134
+ });
135
+
136
+ it("removes control characters", () => {
137
+ assert.equal(flattenPrompt("a\u0000b\u001fc"), "abc");
138
+ });
139
+
140
+ it("strips markdown list bullets, blockquotes and headings", () => {
141
+ assert.equal(flattenPrompt("- one\n- two"), "one two");
142
+ assert.equal(flattenPrompt("* one\n* two"), "one two");
143
+ assert.equal(flattenPrompt("1. a\n2. b"), "a b");
144
+ assert.equal(flattenPrompt("> quoted\n> text"), "quoted text");
145
+ assert.equal(flattenPrompt("# Title\nbody"), "Title body");
146
+ assert.equal(flattenPrompt("> - nested item"), "nested item");
147
+ });
148
+
149
+ it("does not strip bullets when there is no following whitespace", () => {
150
+ assert.equal(flattenPrompt("-1"), "-1");
151
+ assert.equal(flattenPrompt("**bold** text"), "**bold** text");
152
+ });
153
+
154
+ it("drops code-fence marker lines but keeps the fenced content", () => {
155
+ assert.equal(flattenPrompt("```ts\nconst x = 1\n```"), "const x = 1");
156
+ assert.equal(flattenPrompt("```\nplain\n```"), "plain");
157
+ });
158
+
159
+ it("drops horizontal-rule lines", () => {
160
+ assert.equal(flattenPrompt("a\n---\nb"), "a b");
161
+ assert.equal(flattenPrompt("a\n***\nb"), "a b");
162
+ });
163
+
164
+ it("returns empty string for empty / whitespace-only / noise-only prompts", () => {
165
+ assert.equal(flattenPrompt(""), "");
166
+ assert.equal(flattenPrompt(" \n\t\n "), "");
167
+ assert.equal(flattenPrompt("```\n```"), "");
168
+ assert.equal(flattenPrompt("---"), "");
169
+ });
170
+ });
171
+
172
+ describe("flattenPrompt: scan cap", () => {
173
+ it("only scans the first MAX_PROMPT_SCAN_CHARS characters", () => {
174
+ const prompt = "x".repeat(MAX_PROMPT_SCAN_CHARS + 500);
175
+ const flat = flattenPrompt(prompt);
176
+ assert.equal(flat.length, MAX_PROMPT_SCAN_CHARS);
177
+ });
178
+
179
+ it("does not leave a lone surrogate when the cap cuts a pair", () => {
180
+ // 显式把上限设成奇数,切点必定落在代理对中间
181
+ const flat = flattenPrompt("😀😀😀", 5);
182
+ assert.equal(flat, "😀😀");
183
+ const last = flat.charCodeAt(flat.length - 1);
184
+ assert.ok(!(last >= 0xd800 && last <= 0xdbff), "no lone high surrogate at the end");
185
+ });
186
+ });
187
+
188
+ describe("fitToWidth", () => {
189
+ it("returns text unchanged when it fits", () => {
190
+ assert.equal(fitToWidth("hello", 10, fakeWidth), "hello");
191
+ assert.equal(fitToWidth("hello", 5, fakeWidth), "hello");
192
+ });
193
+
194
+ it("truncates ASCII to the longest fitting prefix", () => {
195
+ assert.equal(fitToWidth("hello world", 5, fakeWidth), "hello");
196
+ assert.equal(fitToWidth("hello", 1, fakeWidth), "h");
197
+ });
198
+
199
+ it("never splits a double-width character", () => {
200
+ // "我要优化" 每字 2 列:宽度 5 只放得下 2 个字(4 列),第 3 个字会到 6 列。
201
+ assert.equal(fitToWidth("我要优化", 5, fakeWidth), "我要");
202
+ assert.equal(fitToWidth("我要优化", 4, fakeWidth), "我要");
203
+ assert.equal(fitToWidth("我要优化", 3, fakeWidth), "我");
204
+ });
205
+
206
+ it("returns empty string for non-positive maxWidth", () => {
207
+ assert.equal(fitToWidth("hello", 0, fakeWidth), "");
208
+ assert.equal(fitToWidth("hello", -3, fakeWidth), "");
209
+ });
210
+
211
+ it("does not split surrogate pairs", () => {
212
+ const emoji = "😀😀😀"; // 每个码点 2 个 UTF-16 单元(假宽度函数按 1 列计)
213
+ const fitted = fitToWidth(emoji, 1, fakeWidth);
214
+ assert.equal(fitted, "😀");
215
+ assert.ok(!fitted.endsWith("\ud83d"));
216
+ });
217
+ });
218
+
219
+ describe("availableSummaryTextWidth", () => {
220
+ it("is the width the layout actually gives the summary text", () => {
221
+ const text = "x".repeat(300);
222
+ for (const totalWidth of [40, 60, 100, 137]) {
223
+ const avail = availableSummaryTextWidth({ leftWidth: 13, totalWidth, widthOf: fakeWidth });
224
+ const layout = layoutPromptSummary({
225
+ promptText: text,
226
+ leftWidth: 13,
227
+ totalWidth,
228
+ widthOf: fakeWidth,
229
+ });
230
+ assert.ok(layout, `width ${totalWidth}`);
231
+ // 截断后的正文(含 `…`)恰好占满可用宽度。
232
+ assert.equal(fakeWidth(layout.summaryText), avail, `width ${totalWidth}`);
233
+ }
234
+ });
235
+
236
+ it("is capped by maxWidth like the layout is", () => {
237
+ const avail = availableSummaryTextWidth({ leftWidth: 13, totalWidth: 200, maxWidth: 50, widthOf: fakeWidth });
238
+ assert.equal(avail, 50 - MARKER_WIDTH);
239
+ });
240
+
241
+ it("goes to zero / negative when there is no room left", () => {
242
+ assert.equal(availableSummaryTextWidth({ leftWidth: 70, totalWidth: 70, widthOf: fakeWidth }), -MARKER_WIDTH - DEFAULT_MIN_GAP);
243
+ assert.ok(availableSummaryTextWidth({ leftWidth: 90, totalWidth: 70, widthOf: fakeWidth }) < 0);
244
+ });
245
+
246
+ it("honours a custom marker and minGap like the layout does", () => {
247
+ const marker = "記"; // 假宽度函数下 2 列
248
+ const avail = availableSummaryTextWidth({
249
+ leftWidth: 10,
250
+ totalWidth: 60,
251
+ widthOf: fakeWidth,
252
+ marker,
253
+ minGap: 5,
254
+ });
255
+ assert.equal(avail, 60 - 10 - 5 - fakeWidth(`${marker} `));
256
+ });
257
+ });
258
+
259
+ describe("layoutPromptSummary", () => {
260
+
261
+ const leftWidth = fakeWidth("Working (23s)"); // 13
262
+
263
+ it("shows a short prompt in full, right-aligned via gap", () => {
264
+ const layout = layoutPromptSummary({
265
+ promptText: "hi",
266
+ leftWidth,
267
+ totalWidth: 60,
268
+ widthOf: fakeWidth,
269
+ });
270
+ assert.ok(layout);
271
+ assert.equal(layout.summaryText, "hi"); // 放得下 → 不补省略号
272
+ // 右对齐不变量:左段 + gap + "✦ " + 摘要 === 预算
273
+ assert.equal(leftWidth + layout.gap + MARKER_WIDTH + fakeWidth(layout.summaryText), 60);
274
+ assert.ok(layout.gap >= DEFAULT_MIN_GAP);
275
+ });
276
+
277
+ it("truncates a long prompt and appends the ellipsis", () => {
278
+ const text = "a".repeat(200);
279
+ const layout = layoutPromptSummary({
280
+ promptText: text,
281
+ leftWidth,
282
+ totalWidth: 60,
283
+ widthOf: fakeWidth,
284
+ });
285
+ assert.ok(layout);
286
+ assert.ok(layout.summaryText.endsWith("…"));
287
+ // 截断时 gap 恰好落到下限
288
+ assert.equal(layout.gap, DEFAULT_MIN_GAP);
289
+ assert.equal(leftWidth + layout.gap + MARKER_WIDTH + fakeWidth(layout.summaryText), 60);
290
+ });
291
+
292
+ it("truncates the whole flattened paragraph, not just the first line", () => {
293
+ // 第一行很短,信息在后面的行里 —— 摘要必须能越过第一行取到后面的内容。
294
+ const flat = flattenPrompt("修 bug\n" + "细节内容".repeat(200));
295
+ const layout = layoutPromptSummary({
296
+ promptText: flat,
297
+ leftWidth,
298
+ totalWidth: 60,
299
+ widthOf: fakeWidth,
300
+ });
301
+ assert.ok(layout);
302
+ assert.ok(layout.summaryText.startsWith("修 bug 细节内容"), layout.summaryText);
303
+ assert.ok(layout.summaryText.endsWith("…"));
304
+ });
305
+
306
+ it("keeps the right-align invariant when truncation lands inside a CJK char", () => {
307
+ // 全 CJK 行:截断点可能差 1 列,差值必须并进 gap 而不是超宽。
308
+ const text = "我要优化这个很长的提示词".repeat(4);
309
+ for (const totalWidth of [40, 41, 42, 43, 60, 79]) {
310
+ const layout = layoutPromptSummary({
311
+ promptText: text,
312
+ leftWidth,
313
+ totalWidth,
314
+ widthOf: fakeWidth,
315
+ });
316
+ if (layout === null) continue;
317
+ assert.equal(
318
+ leftWidth + layout.gap + MARKER_WIDTH + fakeWidth(layout.summaryText),
319
+ totalWidth,
320
+ `width ${totalWidth}`,
321
+ );
322
+ assert.ok(layout.gap >= DEFAULT_MIN_GAP, `width ${totalWidth}`);
323
+ assert.ok(fakeWidth(layout.summaryText) <= totalWidth, `width ${totalWidth}`);
324
+ }
325
+ });
326
+
327
+ it("drops trailing spaces before the ellipsis", () => {
328
+ const layout = layoutPromptSummary({
329
+ promptText: "hello world and beyond",
330
+ leftWidth,
331
+ totalWidth: 13 + DEFAULT_MIN_GAP + MARKER_WIDTH + 6, // 摘要正文预算恰好 6
332
+ widthOf: fakeWidth,
333
+ });
334
+ assert.ok(layout);
335
+ assert.equal(layout.summaryText, "hello…"); // 不是 "hello …"
336
+ });
337
+
338
+ it("returns null when the prompt is empty", () => {
339
+ assert.equal(
340
+ layoutPromptSummary({ promptText: "", leftWidth, totalWidth: 80, widthOf: fakeWidth }),
341
+ null,
342
+ );
343
+ });
344
+
345
+ it("returns null when there is no room for the summary", () => {
346
+ // 预算被左段吃光
347
+ assert.equal(
348
+ layoutPromptSummary({
349
+ promptText: "hi",
350
+ leftWidth: 70,
351
+ totalWidth: 70,
352
+ widthOf: fakeWidth,
353
+ }),
354
+ null,
355
+ );
356
+ // 剩的宽度低于摘要正文下限(marker 2 列 + minGap 1 列之后不足 MIN_SUMMARY_TEXT_WIDTH)
357
+ const tight = leftWidth + DEFAULT_MIN_GAP + MARKER_WIDTH + MIN_SUMMARY_TEXT_WIDTH - 1;
358
+ assert.equal(
359
+ layoutPromptSummary({
360
+ promptText: "hi there",
361
+ leftWidth,
362
+ totalWidth: tight,
363
+ widthOf: fakeWidth,
364
+ }),
365
+ null,
366
+ );
367
+ // 恰好到下限就能显示
368
+ const ok = tight + 1;
369
+ assert.ok(
370
+ layoutPromptSummary({
371
+ promptText: "hi there",
372
+ leftWidth,
373
+ totalWidth: ok,
374
+ widthOf: fakeWidth,
375
+ }),
376
+ );
377
+ });
378
+
379
+ it("honors a custom minGap", () => {
380
+ const layout = layoutPromptSummary({
381
+ promptText: "x".repeat(100),
382
+ leftWidth,
383
+ totalWidth: 60,
384
+ widthOf: fakeWidth,
385
+ minGap: 8,
386
+ });
387
+ assert.ok(layout);
388
+ assert.equal(layout.gap, 8);
389
+ assert.equal(leftWidth + 8 + MARKER_WIDTH + fakeWidth(layout.summaryText), 60);
390
+ });
391
+
392
+ it("caps the summary segment at maxWidth even when there is room to spare", () => {
393
+ const layout = layoutPromptSummary({
394
+ promptText: "x".repeat(200),
395
+ leftWidth,
396
+ totalWidth: 100,
397
+ widthOf: fakeWidth,
398
+ maxWidth: 50,
399
+ });
400
+ assert.ok(layout);
401
+ // 整段(marker + 正文)不超上限
402
+ assert.ok(MARKER_WIDTH + fakeWidth(layout.summaryText) <= 50);
403
+ // 右对齐不变量仍成立,富余并进 gap(gap 远大于下限)
404
+ assert.equal(leftWidth + layout.gap + MARKER_WIDTH + fakeWidth(layout.summaryText), 100);
405
+ assert.ok(layout.gap > DEFAULT_MIN_GAP);
406
+ assert.ok(layout.summaryText.endsWith("…"));
407
+ });
408
+
409
+ it("shows a short line in full when it fits within maxWidth", () => {
410
+ const layout = layoutPromptSummary({
411
+ promptText: "hi",
412
+ leftWidth,
413
+ totalWidth: 100,
414
+ widthOf: fakeWidth,
415
+ maxWidth: 50,
416
+ });
417
+ assert.ok(layout);
418
+ assert.equal(layout.summaryText, "hi");
419
+ assert.equal(leftWidth + layout.gap + MARKER_WIDTH + 2, 100);
420
+ });
421
+
422
+ it("returns null when maxWidth leaves no room for the minimum text", () => {
423
+ assert.equal(
424
+ layoutPromptSummary({
425
+ promptText: "hi there",
426
+ leftWidth,
427
+ totalWidth: 100,
428
+ widthOf: fakeWidth,
429
+ maxWidth: MARKER_WIDTH + MIN_SUMMARY_TEXT_WIDTH - 1,
430
+ }),
431
+ null,
432
+ );
433
+ // 恰好到下限就能显示
434
+ assert.ok(
435
+ layoutPromptSummary({
436
+ promptText: "hi there",
437
+ leftWidth,
438
+ totalWidth: 100,
439
+ widthOf: fakeWidth,
440
+ maxWidth: MARKER_WIDTH + MIN_SUMMARY_TEXT_WIDTH,
441
+ }),
442
+ );
443
+ });
444
+
445
+ it("honors a custom marker with a different width", () => {
446
+ const marker = "記"; // CJK,假宽度函数下 2 列(+ 空格 = 3 列前缀)
447
+ const layout = layoutPromptSummary({
448
+ promptText: "x".repeat(100),
449
+ leftWidth,
450
+ totalWidth: 60,
451
+ widthOf: fakeWidth,
452
+ marker,
453
+ });
454
+ assert.ok(layout);
455
+ assert.equal(
456
+ leftWidth + layout.gap + fakeWidth(`${marker} `) + fakeWidth(layout.summaryText),
457
+ 60,
458
+ );
459
+ });
460
+
461
+ it("holds the invariant across a sweep of widths and prompts", () => {
462
+ const lines = [
463
+ "short",
464
+ "/init 我要优化这个模块的加载过程",
465
+ "x".repeat(300),
466
+ "中英 mixed 内容 with 一些 words 和标点符号,测试截断",
467
+ flattenPrompt("多行\n提示词\nmixed with 一些内容,压平后整段参与截断测试"),
468
+ ];
469
+ for (const line of lines) {
470
+ for (let totalWidth = 10; totalWidth <= 140; totalWidth++) {
471
+ // 同时覆盖两种形态:不限宽 与 半屏上限(与 index.ts 的传参一致)
472
+ for (const maxWidth of [undefined, Math.floor(totalWidth / 2)]) {
473
+ const layout = layoutPromptSummary({
474
+ promptText: line,
475
+ leftWidth,
476
+ totalWidth,
477
+ widthOf: fakeWidth,
478
+ maxWidth,
479
+ });
480
+ if (layout === null) continue;
481
+ const tag = `line=${JSON.stringify(line.slice(0, 8))} width=${totalWidth} max=${maxWidth}`;
482
+ assert.equal(
483
+ leftWidth + layout.gap + MARKER_WIDTH + fakeWidth(layout.summaryText),
484
+ totalWidth,
485
+ tag,
486
+ );
487
+ assert.ok(layout.gap >= DEFAULT_MIN_GAP, tag);
488
+ if (maxWidth !== undefined) {
489
+ assert.ok(MARKER_WIDTH + fakeWidth(layout.summaryText) <= maxWidth, tag);
490
+ }
491
+ // 摘要要么是整行(放得下),要么以省略号结尾(截断)
492
+ if (layout.summaryText !== line) {
493
+ assert.ok(layout.summaryText.endsWith("…"), tag);
494
+ }
495
+ }
496
+ }
497
+ }
498
+ });
499
+ });