@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,219 @@
1
+ /**
2
+ * Tests for spinner-frames.ts — working spinner 幻彩帧表的排布、去重与主题指纹。
3
+ *
4
+ * Run with: node --test clients/pi/extensions/working-indicator/spinner-frames.test.ts
5
+ *
6
+ * 被测模块不 import pi / pi-tui,断言直接比对帧字符串的「颜色前缀 + 盲文字符」两段。
7
+ */
8
+
9
+ import assert from "node:assert/strict";
10
+ import { describe, it } from "node:test";
11
+
12
+ import {
13
+ DEFAULT_FRAMES_PER_COLOR,
14
+ SPINNER_COLOR_TOKENS,
15
+ SPINNER_FRAMES,
16
+ SPINNER_INTERVAL_MS,
17
+ buildSpinnerPalette,
18
+ type SpinnerColorize,
19
+ } from "./spinner-frames.ts";
20
+
21
+ /** 七个色槽各给一个可区分的「颜色」;`fg` 的形状就是「颜色前缀 + 原文」。 */
22
+ const DISTINCT: Record<string, string> = Object.fromEntries(
23
+ SPINNER_COLOR_TOKENS.map((token, index) => [token, `<c${index}>`]),
24
+ );
25
+
26
+ /** 与模块内部同一口径的独立实现,用来交叉验证帧表周期(测公倍数性质,不是抄公式)。 */
27
+ function gcd(a: number, b: number): number {
28
+ return b === 0 ? a : gcd(b, a % b);
29
+ }
30
+
31
+ /** 帧表周期的独立算法:lcm(盲文帧数, 每色帧数 × 颜色数)。 */
32
+ function expectedPeriod(framesPerColor: number, colorCount: number): number {
33
+ const colorCycle = framesPerColor * colorCount;
34
+ return (10 * colorCycle) / gcd(10, colorCycle);
35
+ }
36
+
37
+ /** 假主题:`colorize(token, text)` → `颜色 + text`(与 `theme.fg` 同形,颜色靠前缀区分)。 */
38
+ function fakeTheme(colors: Record<string, string>): SpinnerColorize {
39
+ return (token, text) => `${colors[token] ?? `<${token}>`}${text}`;
40
+ }
41
+
42
+ /** 从帧字符串里取回颜色前缀(盲文帧都在 BMP 内,各占 1 个 UTF-16 单元)。 */
43
+ function colorOf(frame: string, colors: Record<string, string>): string {
44
+ for (const color of Object.values(colors)) {
45
+ if (frame.startsWith(color) && frame.length === color.length + 1) return color;
46
+ }
47
+ throw new Error(`帧里没有已知颜色前缀:${frame}`);
48
+ }
49
+
50
+ /** 从帧字符串里取回盲文字符。 */
51
+ function frameOf(frame: string): string {
52
+ return frame.at(-1) as string;
53
+ }
54
+
55
+ /** 颜色段:连续同色帧合并成一段。 */
56
+ function colorRuns(frames: string[], colors: Record<string, string>): Array<{ color: string; length: number }> {
57
+ const runs: Array<{ color: string; length: number }> = [];
58
+ for (const frame of frames) {
59
+ const color = colorOf(frame, colors);
60
+ const last = runs.at(-1);
61
+ if (last !== undefined && last.color === color) last.length += 1;
62
+ else runs.push({ color, length: 1 });
63
+ }
64
+ return runs;
65
+ }
66
+
67
+ /**
68
+ * 换色点在圈内的**相位**(帧号 % 10):集合里只有一个元素 = 每圈都在盲文的同一位置换色,
69
+ * 多元素 = 换色点逐圈漂移(`framesPerColor` 不整除 10 时)。
70
+ */
71
+ function changePhases(frames: string[], colors: Record<string, string>): Set<number> {
72
+ const phases = new Set<number>();
73
+ for (const [index, frame] of frames.entries()) {
74
+ if (index === 0) continue;
75
+ if (colorOf(frame, colors) !== colorOf(frames[index - 1] as string, colors)) {
76
+ phases.add(index % SPINNER_FRAMES.length);
77
+ }
78
+ }
79
+ return phases;
80
+ }
81
+
82
+ describe("buildSpinnerPalette 的帧表排布", () => {
83
+ it("没有配置时用七个色槽、每色 19 帧(默认值),周期 1330 帧", () => {
84
+ const palette = buildSpinnerPalette(fakeTheme(DISTINCT));
85
+ assert.deepEqual(palette.tokens, [...SPINNER_COLOR_TOKENS]);
86
+ assert.equal(DEFAULT_FRAMES_PER_COLOR, 19);
87
+ assert.equal(SPINNER_INTERVAL_MS, 80);
88
+ // 19 帧 × 80ms = 1520ms ≈ 1.5s 一换色;周期 = lcm(10, 19 × 7) = 1330 帧 ≈ 106s。
89
+ assert.equal(palette.frames?.length, 1330);
90
+ assert.equal(palette.frames?.length, expectedPeriod(19, 7));
91
+ });
92
+
93
+ it("旋转照旧:第 i 帧的盲文就是 pi 默认序列的第 i % 10 帧", () => {
94
+ const frames = buildSpinnerPalette(fakeTheme(DISTINCT)).frames as string[];
95
+ for (const [index, frame] of frames.entries()) {
96
+ assert.equal(frameOf(frame), SPINNER_FRAMES[index % SPINNER_FRAMES.length]);
97
+ }
98
+ });
99
+
100
+ it("颜色按帧数推进:每段恰好 framesPerColor 帧,顺序就是 tokens 顺序", () => {
101
+ const runs = colorRuns(buildSpinnerPalette(fakeTheme(DISTINCT)).frames as string[], DISTINCT);
102
+ assert.equal(runs.length, 70); // 1330 帧 / 每段 19 帧
103
+ assert.ok(runs.every((run) => run.length === DEFAULT_FRAMES_PER_COLOR));
104
+ for (const [index, run] of runs.entries()) {
105
+ assert.equal(run.color, DISTINCT[SPINNER_COLOR_TOKENS[index % SPINNER_COLOR_TOKENS.length] as string]);
106
+ }
107
+ });
108
+
109
+ it("默认帧表回绕无缝:末帧是本圈末帧 + 调色板末色,换色点相位遍历全部 10 个位置", () => {
110
+ const frames = buildSpinnerPalette(fakeTheme(DISTINCT)).frames as string[];
111
+ const last = frames[frames.length - 1] as string;
112
+ assert.equal(frameOf(last), SPINNER_FRAMES[9]);
113
+ assert.equal(colorOf(last, DISTINCT), DISTINCT["toolTitle"]);
114
+ assert.equal(frameOf(frames[0] as string), SPINNER_FRAMES[0]);
115
+ assert.equal(colorOf(frames[0] as string, DISTINCT), DISTINCT["accent"]);
116
+
117
+ // 19 与盲文圈长 10 互质:换色点每换一次就往圈内错一步,相位遍历全部 10 个位置
118
+ // (帧表周期 lcm(10, 19 × 7) = 1330 帧 ≈ 106s,比一轮调色板的 133 帧长得多)。
119
+ assert.equal(frames.length, DEFAULT_FRAMES_PER_COLOR * SPINNER_COLOR_TOKENS.length * 10);
120
+ assert.equal(changePhases(frames, DISTINCT).size, SPINNER_FRAMES.length);
121
+ });
122
+
123
+ it("framesPerColor 可调:1 = 每帧换色,3 = 周期 210 帧且换色点逐圈漂移", () => {
124
+ const perFrame = buildSpinnerPalette(fakeTheme(DISTINCT), { framesPerColor: 1 });
125
+ assert.equal(perFrame.frames?.length, expectedPeriod(1, 7));
126
+ assert.ok(colorRuns(perFrame.frames as string[], DISTINCT).every((run) => run.length === 1));
127
+
128
+ const holdThree = buildSpinnerPalette(fakeTheme(DISTINCT), { framesPerColor: 3 });
129
+ assert.equal(holdThree.frames?.length, expectedPeriod(3, 7));
130
+ assert.ok(colorRuns(holdThree.frames as string[], DISTINCT).every((run) => run.length === 3));
131
+ // 3 不整除 10:换色点不在盲文的同一个位置(相位多于一个 = 彩带在圈内漂)。
132
+ assert.ok(changePhases(holdThree.frames as string[], DISTINCT).size > 1);
133
+ });
134
+
135
+ it("非法 framesPerColor:0 / 负数 / NaN / Infinity 退回默认,小数取整", () => {
136
+ for (const [value, hold] of [
137
+ [0, DEFAULT_FRAMES_PER_COLOR],
138
+ [-3, DEFAULT_FRAMES_PER_COLOR],
139
+ [Number.NaN, DEFAULT_FRAMES_PER_COLOR],
140
+ [Number.POSITIVE_INFINITY, DEFAULT_FRAMES_PER_COLOR],
141
+ [2.9, 2],
142
+ ] as const) {
143
+ const palette = buildSpinnerPalette(fakeTheme(DISTINCT), { framesPerColor: value });
144
+ assert.equal(colorRuns(palette.frames as string[], DISTINCT)[0]?.length, hold, `framesPerColor=${value}`);
145
+ assert.equal(palette.frames?.length, expectedPeriod(hold, 7), `framesPerColor=${value}`);
146
+ }
147
+ });
148
+
149
+ it("tokens 可换成子集:两个色槽、每色 5 帧 → 周期 lcm(10, 10) = 10 帧", () => {
150
+ const palette = buildSpinnerPalette(fakeTheme(DISTINCT), { framesPerColor: 5, tokens: ["accent", "warning"] });
151
+ assert.deepEqual(palette.tokens, ["accent", "warning"]);
152
+ assert.equal(palette.frames?.length, expectedPeriod(5, 2));
153
+ assert.deepEqual(
154
+ colorRuns(palette.frames as string[], DISTINCT).map((run) => run.color),
155
+ [DISTINCT["accent"], DISTINCT["warning"]],
156
+ );
157
+ });
158
+ });
159
+
160
+ describe("buildSpinnerPalette 的颜色去重与降级", () => {
161
+ it("当前主题里同色的两个槽位只留一个(不留连续两段同色)", () => {
162
+ // summer-night 实测撞车:success == toolDiffAdded、warning == toolTitle。
163
+ const theme = fakeTheme({
164
+ ...DISTINCT,
165
+ toolDiffAdded: DISTINCT["success"] as string,
166
+ toolTitle: DISTINCT["warning"] as string,
167
+ });
168
+ const palette = buildSpinnerPalette(theme);
169
+ assert.deepEqual(palette.tokens, ["accent", "success", "warning", "syntaxKeyword", "toolDiffRemoved"]);
170
+ assert.equal(palette.frames?.length, expectedPeriod(19, 5));
171
+
172
+ const runs = colorRuns(palette.frames as string[], DISTINCT);
173
+ assert.ok(runs.every((run) => run.length === 19));
174
+ for (const [index, run] of runs.entries()) {
175
+ assert.notEqual(run.color, runs[index + 1]?.color, `第 ${index} 段与下一段同色`);
176
+ }
177
+ // 撞车的是后出现的那个槽位(去重保留首次出现者)。
178
+ assert.ok(!palette.tokens.includes("toolDiffAdded"));
179
+ assert.ok(!palette.tokens.includes("toolTitle"));
180
+ });
181
+
182
+ it("只剩一种颜色时不下发帧表(调用方保持 pi 默认 spinner)", () => {
183
+ const allSame = fakeTheme(Object.fromEntries(SPINNER_COLOR_TOKENS.map((token) => [token, "<all>"])));
184
+ const palette = buildSpinnerPalette(allSame);
185
+ assert.equal(palette.frames, null);
186
+ assert.deepEqual(palette.tokens, ["accent"]);
187
+ });
188
+
189
+ it("恒等主题(NO_COLOR / 非 TUI)也是单色 → frames 为 null", () => {
190
+ const identity: SpinnerColorize = (_token, text) => text;
191
+ assert.equal(buildSpinnerPalette(identity).frames, null);
192
+ });
193
+
194
+ it("指纹随主题变、同主题稳定(换肤后靠它决定重装)", () => {
195
+ const first = buildSpinnerPalette(fakeTheme(DISTINCT)).signature;
196
+ assert.equal(buildSpinnerPalette(fakeTheme(DISTINCT)).signature, first);
197
+ assert.notEqual(buildSpinnerPalette(fakeTheme({ ...DISTINCT, accent: "<other>" })).signature, first);
198
+
199
+ // 被去重丢掉的槽位(颜色与前一个槽位撞车)改了颜色也不影响指纹 —— 它不参与轮换。
200
+ const signatureWithDropped = (dropped: string): string =>
201
+ buildSpinnerPalette(
202
+ fakeTheme({ ...DISTINCT, toolDiffAdded: dropped, toolTitle: DISTINCT["warning"] as string }),
203
+ ).signature;
204
+ assert.equal(
205
+ signatureWithDropped(DISTINCT["accent"] as string),
206
+ signatureWithDropped(DISTINCT["success"] as string),
207
+ );
208
+ });
209
+
210
+ it("取色探针是非空文本(空串取色分辨不出色槽)", () => {
211
+ const probes: string[] = [];
212
+ buildSpinnerPalette((token, text) => {
213
+ probes.push(text);
214
+ return `${token}${text}`;
215
+ });
216
+ assert.ok(probes.length >= SPINNER_COLOR_TOKENS.length);
217
+ assert.ok(probes.every((text) => text.length > 0));
218
+ });
219
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * spinner-frames.ts — working spinner 的「幻彩」帧表:同一族盲文帧按调色板轮换颜色。
3
+ *
4
+ * 抽出来的理由与 `working-summary.ts` / `bash-spinner.ts` 相同:帧表怎么排(周期长度、
5
+ * 第 i 帧配哪个颜色)、调色板怎么去重、主题指纹是什么,全是纯计算,不该活在 `index.ts`
6
+ * 的 ctx 与定时器之间。本模块**不 import pi / pi-tui**,`node --test` 直接跑每个分支
7
+ * (用例见 `spinner-frames.test.ts`);装着帧表的那半边(什么时候装、什么时候重装)在扩展里。
8
+ *
9
+ * ## 为什么只能预烘颜色
10
+ *
11
+ * pi 的 `Loader` 只有一条上色路径:默认十帧盲文交给 `spinnerColorFn`(非嵌入态就是
12
+ * `theme.fg("accent", frame)`),所以整条 spinner 永远一个颜色 —— 这正是本扩展要改的。
13
+ * 扩展的入口 `ctx.ui.setWorkingIndicator({ frames, intervalMs })` 里**自定义帧是 verbatim
14
+ * 渲染的**(pi 0.85.1 的 Loader:`renderIndicatorVerbatim = indicator !== undefined`,
15
+ * 为真时 `getRenderedIndicator()` 直接返回帧字符串、不套 `spinnerColorFn`),也就是颜色
16
+ * 必须由帧字符串自己带。于是「一帧一个颜色」只能把 ANSI 序列预烘进帧表;代价是换主题后
17
+ * 帧表不会自己更新,得重装一次(重装 = 再调一次 `setWorkingIndicator`,要不要重装由
18
+ * `signature` 判断)。
19
+ *
20
+ * ## 帧表怎么排
21
+ *
22
+ * Loader 每 `intervalMs`(默认 80)把帧表索引 +1 再取模回绕,**帧表顺序就是动画顺序**。
23
+ * 把「十帧盲文 × 若干颜色」摊平成一维数组,索引 i 的真身是
24
+ *
25
+ * 盲文 = SPINNER_FRAMES[i % 10] (旋转照旧,速度与 pi 默认一致)
26
+ * 颜色 = colors[floor(i / framesPerColor) % colors.length]
27
+ *
28
+ * 颜色是**按帧数**推进而不是每帧一换:默认 `framesPerColor = 19`。1500ms 不是 80ms 的
29
+ * 整数倍,19 帧 = 1520ms 是离需求(1.5s 一换色)最近的取值(差 1.3%);演变轨迹:初版
30
+ * 5 帧 / 400ms 偏快 → 10 帧 / 800ms(减半)→ 19 帧 / 1520ms。每帧一换
31
+ * (`framesPerColor = 1`,80ms)在终端里是频闪不是幻彩;再往上就是「半天不动」——
32
+ * `PI_SPINNER_COLOR_HOLD` 按帧数调。
33
+ *
34
+ * 一维表必须有周期,否则回绕处会跳色:周期 = lcm(10, framesPerColor × 颜色数),这样
35
+ * i 与 i+周期 的 (盲文, 颜色) 都对齐。默认 19 帧 × 7 色:一轮调色板 133 帧(10.6s,
36
+ * 盲文转 13.3 圈),帧表周期 lcm(10, 133) = 1330 帧(约 106s)。
37
+ * 19 与盲文圈长 10 互质,所以换色点每换一次就往圈内错一步、相位遍历全部 10 个位置 ——
38
+ * 观感是颜色在漂移的彩带,换色点不会总落在同一个盲文帧上。想让换色点固定在盲文同一
39
+ * 位置(每圈/每两圈换一次色)就把 `framesPerColor` 设成 10 的倍数(如 10 / 20),代价是
40
+ * 调色板周期与旋转周期锁相。
41
+ *
42
+ * ## 调色板
43
+ *
44
+ * 七个色槽按需求取:accent / success / warning / syntaxKeyword / toolDiffAdded /
45
+ * toolDiffRemoved / toolTitle(需求里的「keyword / added / removed / tool」在主题
46
+ * token 目录里的正式名字)。顺序就照需求这一串排列。换主题后**实际颜色可能撞车**
47
+ * (实测 summer-night:success 与 toolDiffAdded 同为 #8cd6a7、warning 与 toolTitle
48
+ * 同为 #e9c16f),撞车的槽位按 `fg` 的渲染结果去重丢掉 —— 留着会连续两段同一个颜色,
49
+ * 看着像动画卡住了。去重后只剩一种颜色(单色主题 / NO_COLOR)时 `frames = null`:
50
+ * 调用方保持 pi 默认 spinner,不要装一张每帧长得都一样的「动画」表。
51
+ */
52
+
53
+ /** 只用到 `fg` 的主题接口 —— 沿用 `index.ts` 的 `WorkingMessageTheme` 形状,但不反向 import。 */
54
+ export type SpinnerColorize = (token: string, text: string) => string;
55
+
56
+ /** pi 默认的十帧盲文(顺序即旋转方向)。帧表沿用它:动画读起来与改动前是同一族。 */
57
+ export const SPINNER_FRAMES: readonly string[] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
58
+
59
+ /**
60
+ * pi 默认帧间隔(毫秒)。自定义帧必须自己给间隔(不给就退回 pi 的默认值,数值一样但
61
+ * 依赖 pi 的实现细节),所以这里写死一份并与 pi 的 `DEFAULT_INTERVAL_MS` 对齐 —— 它同时
62
+ * 是「颜色持续多久」的换算基准(`framesPerColor × SPINNER_INTERVAL_MS`)。
63
+ */
64
+ export const SPINNER_INTERVAL_MS = 80;
65
+
66
+ /** 参与轮换的主题色槽,按需求顺序(「幻彩」的色序)。 */
67
+ export const SPINNER_COLOR_TOKENS: readonly string[] = [
68
+ "accent",
69
+ "success",
70
+ "warning",
71
+ "syntaxKeyword",
72
+ "toolDiffAdded",
73
+ "toolDiffRemoved",
74
+ "toolTitle",
75
+ ];
76
+
77
+ /**
78
+ * 每种颜色持续的帧数(默认 19 帧 × 80ms = 1520ms ≈ 1.5s 一换色)。
79
+ * 1500ms 不是 80ms 的整数倍,19 是最近的帧数(1520ms,差 1.3%)。
80
+ */
81
+ export const DEFAULT_FRAMES_PER_COLOR = 19;
82
+
83
+ /**
84
+ * 取色探针字符:给 `fg` 一个非空文本,拿到的就是「该色槽的染色结果」,用来比对两个
85
+ * 色槽在当前主题下是否同色(`fg` 的返回值带上 ANSI 前缀,不能只看槽位名字)。
86
+ */
87
+ const COLOR_PROBE = "x";
88
+
89
+ export interface SpinnerPalette {
90
+ /** 去重后真正参与轮换的色槽(保持原顺序)。 */
91
+ tokens: string[];
92
+ /** 主题指纹:同一主题同一配色下稳定,换主题 / 换配色后必变。 */
93
+ signature: string;
94
+ /** 帧表(已上色,逐帧字符串);`null` = 当前主题挑不出两种可区分的颜色。 */
95
+ frames: string[] | null;
96
+ }
97
+
98
+ function gcd(a: number, b: number): number {
99
+ let left = Math.abs(a);
100
+ let right = Math.abs(b);
101
+ while (right > 0) {
102
+ const next = left % right;
103
+ left = right;
104
+ right = next;
105
+ }
106
+ return left;
107
+ }
108
+
109
+ /** 帧表周期(帧数):盲文圈(10)与颜色周期(每色帧数 × 颜色数)的最小公倍数。 */
110
+ function spinnerFramePeriod(framesPerColor: number, colorCount: number): number {
111
+ const colorCycle = framesPerColor * colorCount;
112
+ return (colorCycle / gcd(SPINNER_FRAMES.length, colorCycle)) * SPINNER_FRAMES.length;
113
+ }
114
+
115
+ /** 非整数 / <1 / 非有限值一律退回默认(环境变量解析出来的数还可能是 NaN)。 */
116
+ function normalizeFramesPerColor(value: number | undefined): number {
117
+ return value !== undefined && Number.isFinite(value) && value >= 1
118
+ ? Math.floor(value)
119
+ : DEFAULT_FRAMES_PER_COLOR;
120
+ }
121
+
122
+ /**
123
+ * 按当前主题算出幻彩帧表。
124
+ *
125
+ * 颜色去重按**渲染结果**(`colorize(token, COLOR_PROBE)`)而不是槽位名字:两个槽位在当前
126
+ * 主题里可能是同一个颜色(见文件头)。去重后不足两种颜色就返回 `frames: null`。
127
+ */
128
+ export function buildSpinnerPalette(
129
+ colorize: SpinnerColorize,
130
+ options: { framesPerColor?: number; tokens?: readonly string[] } = {},
131
+ ): SpinnerPalette {
132
+ const tokens = options.tokens ?? SPINNER_COLOR_TOKENS;
133
+ const framesPerColor = normalizeFramesPerColor(options.framesPerColor);
134
+
135
+ const seen = new Set<string>();
136
+ const kept: string[] = [];
137
+ const probes: string[] = [];
138
+ for (const token of tokens) {
139
+ const probe = colorize(token, COLOR_PROBE);
140
+ if (seen.has(probe)) continue;
141
+ seen.add(probe);
142
+ kept.push(token);
143
+ probes.push(probe);
144
+ }
145
+ const signature = probes.join("\n");
146
+ if (kept.length < 2) return { tokens: kept, signature, frames: null };
147
+
148
+ const frames: string[] = [];
149
+ const period = spinnerFramePeriod(framesPerColor, kept.length);
150
+ for (let index = 0; index < period; index += 1) {
151
+ const token = kept[Math.floor(index / framesPerColor) % kept.length] as string;
152
+ const frame = SPINNER_FRAMES[index % SPINNER_FRAMES.length] as string;
153
+ frames.push(colorize(token, frame));
154
+ }
155
+ return { tokens: kept, signature, frames };
156
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Tests for summary-request.ts — 「提示词太长时请模型压成一句话」的纯逻辑。
3
+ *
4
+ * Run with: node --test clients/pi/extensions/working-indicator/summary-request.test.ts
5
+ *
6
+ * 被测模块不 import pi / pi-tui(宽度是调用方量好的列数),所以这里也喂假宽度概念:
7
+ * 参数直接就是列数,不需要任何测量函数。
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import { describe, it } from "node:test";
12
+
13
+ import {
14
+ DEFAULT_TRIGGER_RATIO,
15
+ MAX_REQUEST_INPUT_CHARS,
16
+ MAX_SUMMARY_TEXT_CHARS,
17
+ MIN_REQUEST_BUDGET,
18
+ TARGET_WIDTH_RATIO,
19
+ buildSummaryRequestPrompt,
20
+ cleanSummaryText,
21
+ planSummaryRequest,
22
+ } from "./summary-request.ts";
23
+
24
+ describe("planSummaryRequest: trigger", () => {
25
+ it("does not request when the prompt fits the available width", () => {
26
+ assert.equal(planSummaryRequest({ promptWidth: 30, budgetWidth: 40 }).needed, false);
27
+ assert.equal(planSummaryRequest({ promptWidth: 40, budgetWidth: 40 }).needed, false);
28
+ });
29
+
30
+ it("does not request for a mild overflow (pure truncation is good enough)", () => {
31
+ // 显式倍数 2:截断后仍能显示一半原文,不值得请求。
32
+ assert.equal(planSummaryRequest({ promptWidth: 41, budgetWidth: 40, triggerRatio: 2 }).needed, false);
33
+ assert.equal(planSummaryRequest({ promptWidth: 79, budgetWidth: 40, triggerRatio: 2 }).needed, false);
34
+ });
35
+
36
+ it("requests once the prompt exceeds the budget by the trigger ratio", () => {
37
+ // 默认倍数 1.2:40 列的格子放下 48 列以上就请求。
38
+ assert.equal(planSummaryRequest({ promptWidth: 49, budgetWidth: 40 }).needed, true);
39
+ assert.equal(planSummaryRequest({ promptWidth: 4000, budgetWidth: 40 }).needed, true);
40
+ });
41
+
42
+ it("is strict about the boundary (equal to budget * ratio does not request)", () => {
43
+ const threshold = 40 * DEFAULT_TRIGGER_RATIO;
44
+ assert.equal(planSummaryRequest({ promptWidth: threshold, budgetWidth: 40 }).needed, false);
45
+ assert.equal(planSummaryRequest({ promptWidth: threshold + 1, budgetWidth: 40 }).needed, true);
46
+ });
47
+
48
+ it("honours a custom trigger ratio", () => {
49
+ assert.equal(planSummaryRequest({ promptWidth: 60, budgetWidth: 40, triggerRatio: 1.5 }).needed, false);
50
+ assert.equal(planSummaryRequest({ promptWidth: 61, budgetWidth: 40, triggerRatio: 1.5 }).needed, true);
51
+ // ratio 1 = 只要放不下就请求。
52
+ assert.equal(planSummaryRequest({ promptWidth: 41, budgetWidth: 40, triggerRatio: 1 }).needed, true);
53
+ });
54
+
55
+ it("never requests when the available width is too narrow to hold a useful summary", () => {
56
+ const plan = planSummaryRequest({ promptWidth: 10_000, budgetWidth: MIN_REQUEST_BUDGET - 1 });
57
+ assert.equal(plan.needed, false);
58
+ // 恰好到下限就允许(只要原文够长)。
59
+ assert.equal(planSummaryRequest({ promptWidth: 1000, budgetWidth: MIN_REQUEST_BUDGET }).needed, true);
60
+ });
61
+
62
+ it("honours a custom minBudget", () => {
63
+ assert.equal(planSummaryRequest({ promptWidth: 10_000, budgetWidth: 20, minBudget: 30 }).needed, false);
64
+ });
65
+
66
+ it("defaults the trigger ratio to 1.2", () => {
67
+ assert.equal(DEFAULT_TRIGGER_RATIO, 1.2);
68
+ });
69
+ });
70
+
71
+ describe("planSummaryRequest: target width", () => {
72
+ it("leaves headroom below the available width", () => {
73
+ const plan = planSummaryRequest({ promptWidth: 1000, budgetWidth: 40 });
74
+ assert.equal(plan.targetWidth, Math.floor(40 * TARGET_WIDTH_RATIO));
75
+ assert.ok(plan.targetWidth < 40, "目标必须小于可用宽度,否则模型略超一点就被截");
76
+ });
77
+
78
+ it("floors fractional budgets and keeps a tenth of the width as headroom", () => {
79
+ assert.equal(planSummaryRequest({ promptWidth: 1000, budgetWidth: 40.9 }).targetWidth, 36);
80
+ // 最窄的可请求格子:10 列预算 → 目标 9 列。
81
+ assert.equal(planSummaryRequest({ promptWidth: 1000, budgetWidth: MIN_REQUEST_BUDGET }).targetWidth, 9);
82
+ });
83
+ });
84
+
85
+ describe("buildSummaryRequestPrompt", () => {
86
+ it("states the target width and the display-column unit", () => {
87
+ const prompt = buildSummaryRequestPrompt("把 working 摘要改成模型压缩", 48);
88
+ assert.match(prompt, /48 display columns/);
89
+ assert.match(prompt, /CJK\/full-width character counts as 2 columns/);
90
+ });
91
+
92
+ it("asks for a single line without decoration and mirrors the input language", () => {
93
+ const prompt = buildSummaryRequestPrompt("hello", 30);
94
+ assert.match(prompt, /Output a single line/);
95
+ assert.match(prompt, /No line breaks, no quotes, no markdown/);
96
+ assert.match(prompt, /same language as the input/);
97
+ });
98
+
99
+ it("embeds the input prompt verbatim", () => {
100
+ const input = "优化 summary 截断逻辑\n第二行说明";
101
+ const prompt = buildSummaryRequestPrompt(input, 30);
102
+ assert.ok(prompt.includes(input), "原文应原样带上(压平等加工由调用方负责)");
103
+ assert.ok(prompt.trimEnd().endsWith(input));
104
+ });
105
+
106
+ it("clips a very long prompt to the request cap", () => {
107
+ const prompt = buildSummaryRequestPrompt("x".repeat(MAX_REQUEST_INPUT_CHARS + 500), 30);
108
+ assert.ok(prompt.includes("x".repeat(MAX_REQUEST_INPUT_CHARS)));
109
+ assert.ok(!prompt.includes("x".repeat(MAX_REQUEST_INPUT_CHARS + 1)), "超过上限的部分应被截掉");
110
+ assert.ok(prompt.includes("…"), "截断处应有省略号");
111
+ });
112
+
113
+ it("does not split a surrogate pair when clipping", () => {
114
+ const prompt = buildSummaryRequestPrompt("😀".repeat(MAX_REQUEST_INPUT_CHARS + 20), 30);
115
+ const clipped = prompt.slice(prompt.lastIndexOf("Input:") + "Input:".length + 1);
116
+ assert.ok(clipped.endsWith("😀…"), `截断点不能落在半个代理对上(实际结尾 ${JSON.stringify(clipped.slice(-3))})`);
117
+ });
118
+ });
119
+
120
+ describe("cleanSummaryText", () => {
121
+ it("passes a plain one-liner through", () => {
122
+ assert.equal(cleanSummaryText("优化提示词摘要的截断逻辑"), "优化提示词摘要的截断逻辑");
123
+ });
124
+
125
+ it("strips a label prefix in ASCII and Chinese", () => {
126
+ assert.equal(cleanSummaryText("摘要:优化摘要逻辑"), "优化摘要逻辑");
127
+ assert.equal(cleanSummaryText("Summary: refactor the loader"), "refactor the loader");
128
+ assert.equal(cleanSummaryText("概括 : 修 bug"), "修 bug");
129
+ });
130
+
131
+ it("strips a bold-wrapped label and its closing markers", () => {
132
+ assert.equal(cleanSummaryText("**摘要:** 优化摘要逻辑"), "优化摘要逻辑");
133
+ assert.equal(cleanSummaryText("**摘要**:优化摘要逻辑"), "优化摘要逻辑");
134
+ });
135
+
136
+ it("skips a label-only first line and uses the next line", () => {
137
+ assert.equal(cleanSummaryText("摘要:\n优化提示词摘要逻辑"), "优化提示词摘要逻辑");
138
+ assert.equal(cleanSummaryText("**Summary**\nrefactor the loader"), "refactor the loader");
139
+ });
140
+
141
+ it("takes only the first meaningful line", () => {
142
+ const reply = "优化摘要逻辑\n\n这段代码做的事情是把很长的提示词交给模型压缩,具体来说……";
143
+ assert.equal(cleanSummaryText(reply), "优化摘要逻辑");
144
+ });
145
+
146
+ it("strips wrapping quotes and book-title marks", () => {
147
+ assert.equal(cleanSummaryText('"优化摘要逻辑"'), "优化摘要逻辑");
148
+ assert.equal(cleanSummaryText("「优化摘要逻辑」"), "优化摘要逻辑");
149
+ assert.equal(cleanSummaryText("《优化摘要逻辑》"), "优化摘要逻辑");
150
+ assert.equal(cleanSummaryText("`优化摘要逻辑`"), "优化摘要逻辑");
151
+ });
152
+
153
+ it("strips whole-text bold wrapping", () => {
154
+ assert.equal(cleanSummaryText("**优化摘要逻辑**"), "优化摘要逻辑");
155
+ assert.equal(cleanSummaryText("__refactor the loader__"), "refactor the loader");
156
+ });
157
+
158
+ it("unwraps stacked decoration (label + quotes + bold)", () => {
159
+ assert.equal(cleanSummaryText('**"摘要:优化摘要逻辑"**'), "优化摘要逻辑");
160
+ });
161
+
162
+ it("strips list bullets and markdown chrome via flattenPrompt", () => {
163
+ assert.equal(cleanSummaryText("- 优化摘要逻辑"), "优化摘要逻辑");
164
+ assert.equal(cleanSummaryText("# 优化摘要逻辑"), "优化摘要逻辑");
165
+ });
166
+
167
+ it("keeps the fenced content when the reply is a code fence", () => {
168
+ assert.equal(cleanSummaryText("```\n优化摘要逻辑\n```"), "优化摘要逻辑");
169
+ });
170
+
171
+ it("removes ANSI escapes, zero-width characters and control chars", () => {
172
+ assert.equal(cleanSummaryText("\u001b[31m优化摘要逻辑\u001b[0m"), "优化摘要逻辑");
173
+ assert.equal(cleanSummaryText("优化\u200b摘要逻辑"), "优化摘要逻辑");
174
+ });
175
+
176
+ it("returns empty for empty / decorative-only replies", () => {
177
+ assert.equal(cleanSummaryText(""), "");
178
+ assert.equal(cleanSummaryText(" \n\t\n "), "");
179
+ assert.equal(cleanSummaryText("摘要:"), "");
180
+ assert.equal(cleanSummaryText("```\n```"), "");
181
+ });
182
+
183
+ it("caps runaway replies at MAX_SUMMARY_TEXT_CHARS without splitting a surrogate pair", () => {
184
+ const reply = "😀".repeat(MAX_SUMMARY_TEXT_CHARS + 50);
185
+ const cleaned = cleanSummaryText(reply);
186
+ // 上限 + 表示被截的 `…`,按码点算恰好一个。
187
+ assert.equal([...cleaned].length, MAX_SUMMARY_TEXT_CHARS + 1);
188
+ assert.ok(cleaned.endsWith("😀…"));
189
+ });
190
+
191
+ it("keeps CJK text intact", () => {
192
+ const text = "把右侧摘要换成模型压缩后的一句话";
193
+ assert.equal(cleanSummaryText(text), text);
194
+ });
195
+ });