@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,316 @@
1
+ /**
2
+ * Tests for line.ts — the statusline main line and the extension-status line.
3
+ *
4
+ * Run with: node --test clients/pi/extensions/statusline/line.test.ts
5
+ *
6
+ * line.ts never imports pi / pi-tui, so plain fakes reach every branch: `plain` drops
7
+ * colours (assert exact visible text), `painted` wraps each run as `color(text)` (assert
8
+ * which theme colour slot every part uses).
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { describe, it } from "node:test";
13
+
14
+ import {
15
+ type StatuslineGitSource,
16
+ type StatuslineSource,
17
+ type StatuslineState,
18
+ type StatuslineTheme,
19
+ BRANCH_ICON,
20
+ composeFooterLines,
21
+ ELLIPSIS,
22
+ formatExtensionStatuses,
23
+ formatMainLine,
24
+ } from "./line.ts";
25
+
26
+ const plain: StatuslineTheme = { fg: (_color, text) => text };
27
+ const painted: StatuslineTheme = { fg: (color, text) => `${color}(${text})` };
28
+
29
+ const FLASH = { id: "qwen3.8-flash" };
30
+ const DOC = "⚡️ qwen3.8-flash/xhigh | Ctx 0.0%";
31
+
32
+ function sourceOf(
33
+ percent: number | null = 0,
34
+ model: { id?: string } | null = FLASH,
35
+ level = "xhigh",
36
+ ): StatuslineSource {
37
+ const resolved = model === null ? undefined : model;
38
+ return {
39
+ model: resolved,
40
+ thinkingLevel: level,
41
+ getContextUsage: () => (percent === null ? undefined : { percent }),
42
+ };
43
+ }
44
+
45
+ function gitOf(
46
+ branch: string | null,
47
+ statuses: ReadonlyMap<string, string> = new Map(),
48
+ ): StatuslineGitSource {
49
+ return { getGitBranch: () => branch, getExtensionStatuses: () => statuses };
50
+ }
51
+
52
+ function stateOf(overrides: Partial<StatuslineState> = {}): StatuslineState {
53
+ return { streaming: false, activeTools: new Map(), diffStat: undefined, ...overrides };
54
+ }
55
+
56
+ describe("BRANCH_ICON", () => {
57
+ it("pins the exact private-use code point of the branch glyph", () => {
58
+ // 私有区字形在字体缺字形时只是看不见的方块,贴错成近形码位也 review 不出来,
59
+ // 所以这里用转义把码位钉死,而不依赖源码里的裸字形。
60
+ assert.equal(BRANCH_ICON, "\ue0a0");
61
+ assert.equal(BRANCH_ICON.codePointAt(0), 0xe0a0);
62
+ assert.equal([...BRANCH_ICON].length, 1);
63
+ });
64
+ });
65
+
66
+ describe("formatMainLine", () => {
67
+ it("renders the documented line inside a repo", () => {
68
+ assert.equal(
69
+ formatMainLine(plain, sourceOf(0), gitOf("main"), stateOf({ diffStat: { added: 0, deleted: 0 } })),
70
+ `${DOC} | \ue0a0 main | (+0,-0)`,
71
+ );
72
+ });
73
+
74
+ it("shows real diff counts", () => {
75
+ assert.equal(
76
+ formatMainLine(plain, sourceOf(), gitOf("main"), stateOf({ diffStat: { added: 12, deleted: 3 } })),
77
+ `${DOC} | \ue0a0 main | (+12,-3)`,
78
+ );
79
+ });
80
+
81
+ it("shows zeros before the first git read lands", () => {
82
+ assert.equal(formatMainLine(plain, sourceOf(), gitOf("main"), stateOf()), `${DOC} | \ue0a0 main | (+0,-0)`);
83
+ });
84
+
85
+ it("replaces both git segments outside a repo", () => {
86
+ assert.equal(
87
+ formatMainLine(plain, sourceOf(), gitOf(null), stateOf({ diffStat: { added: 9, deleted: 9 } })),
88
+ `${DOC} | \ue0a0 no git | (no git)`,
89
+ );
90
+ });
91
+
92
+ it("renders a detached head", () => {
93
+ assert.ok(formatMainLine(plain, sourceOf(), gitOf("detached"), stateOf()).includes("\ue0a0 detached"));
94
+ });
95
+
96
+ it("appends thinking while streaming and the tool name instead once a tool runs", () => {
97
+ assert.equal(
98
+ formatMainLine(plain, sourceOf(), gitOf("main"), stateOf({ streaming: true })),
99
+ `${DOC} | \ue0a0 main | (+0,-0) | thinking`,
100
+ );
101
+ assert.equal(
102
+ formatMainLine(
103
+ plain,
104
+ sourceOf(),
105
+ gitOf("main"),
106
+ stateOf({ streaming: true, activeTools: new Map([["bash", 1]]) }),
107
+ ),
108
+ `${DOC} | \ue0a0 main | (+0,-0) | bash`,
109
+ );
110
+ });
111
+
112
+ it("annotates repeated and additional concurrent tools", () => {
113
+ const twice = formatMainLine(plain, sourceOf(), gitOf("main"), stateOf({ activeTools: new Map([["read", 2]]) }));
114
+ const several = formatMainLine(
115
+ plain,
116
+ sourceOf(),
117
+ gitOf("main"),
118
+ stateOf({ activeTools: new Map([["read", 1], ["bash", 1]]) }),
119
+ );
120
+ assert.ok(twice.endsWith("| read×2"), twice);
121
+ assert.ok(several.endsWith("| read+1"), several);
122
+ });
123
+
124
+ it("questions the context when usage is unknown", () => {
125
+ assert.equal(
126
+ formatMainLine(plain, sourceOf(null, { id: "mystery" }), gitOf("main"), stateOf()),
127
+ "⚡️ mystery/xhigh | Ctx ? | \ue0a0 main | (+0,-0)",
128
+ );
129
+ });
130
+
131
+ it("survives a missing model", () => {
132
+ assert.equal(
133
+ formatMainLine(plain, sourceOf(null, null), gitOf(null), stateOf()),
134
+ "⚡️ no-model/xhigh | Ctx ? | \ue0a0 no git | (no git)",
135
+ );
136
+ });
137
+
138
+ it("appends the current thinking level to the model id", () => {
139
+ for (const level of ["off", "low", "high", "max"]) {
140
+ assert.ok(
141
+ formatMainLine(plain, sourceOf(0, FLASH, level), gitOf("main"), stateOf()).startsWith(
142
+ `⚡️ qwen3.8-flash/${level} | `,
143
+ ),
144
+ level,
145
+ );
146
+ }
147
+ });
148
+
149
+ it("drops the level suffix when it is empty or the getter throws", () => {
150
+ assert.ok(
151
+ formatMainLine(plain, sourceOf(0, FLASH, ""), gitOf("main"), stateOf()).startsWith(
152
+ "⚡️ qwen3.8-flash | ",
153
+ ),
154
+ );
155
+ const throwing: StatuslineSource = {
156
+ model: FLASH,
157
+ get thinkingLevel(): never {
158
+ throw new Error("stale context");
159
+ },
160
+ getContextUsage: () => ({ percent: 0 }),
161
+ };
162
+ assert.ok(
163
+ formatMainLine(plain, throwing, gitOf("main"), stateOf()).startsWith("⚡️ qwen3.8-flash | "),
164
+ );
165
+ });
166
+
167
+ it("treats throwing context getters as unknown", () => {
168
+ const stale: StatuslineSource = {
169
+ get model(): never {
170
+ throw new Error("stale context");
171
+ },
172
+ get thinkingLevel(): never {
173
+ throw new Error("stale context");
174
+ },
175
+ getContextUsage(): never {
176
+ throw new Error("stale context");
177
+ },
178
+ };
179
+ assert.equal(formatMainLine(plain, stale, gitOf("main"), stateOf()), "⚡️ no-model | Ctx ? | \ue0a0 main | (+0,-0)");
180
+ });
181
+
182
+ it("paints labels and separators dim, model / branch / percent by role", () => {
183
+ assert.equal(
184
+ formatMainLine(painted, sourceOf(0), gitOf("main"), stateOf({ diffStat: { added: 1, deleted: 2 } })),
185
+ "⚡️ accent(qwen3.8-flash)dim(/)syntaxFunction(xhigh)dim( | )dim(Ctx) success(0.0%)dim( | )" +
186
+ "dim(\ue0a0) accent(main)dim( | )dim(()success(+1)dim(,)error(-2)dim())",
187
+ );
188
+ });
189
+
190
+ it("shifts the context colour at 70% and 90%", () => {
191
+ const colored = (percent: number | null) => formatMainLine(painted, sourceOf(percent), gitOf("main"), stateOf());
192
+ assert.ok(colored(69.9).includes("success(69.9%)"));
193
+ assert.ok(colored(70).includes("warning(70.0%)"));
194
+ assert.ok(colored(95.4).includes("error(95.4%)"));
195
+ assert.ok(colored(null).includes("dim(?)"));
196
+ });
197
+
198
+ it("paints the state segment warning and nothing when idle", () => {
199
+ assert.ok(
200
+ formatMainLine(painted, sourceOf(), gitOf("main"), stateOf({ streaming: true })).endsWith("dim( | )warning(thinking)"),
201
+ );
202
+ assert.ok(
203
+ formatMainLine(
204
+ painted,
205
+ sourceOf(),
206
+ gitOf("main"),
207
+ stateOf({ activeTools: new Map([["bash", 1]]) }),
208
+ ).endsWith("dim( | )warning(bash)"),
209
+ );
210
+ assert.equal(formatMainLine(painted, sourceOf(), gitOf("main"), stateOf()).includes("thinking"), false);
211
+ });
212
+ });
213
+
214
+ describe("formatExtensionStatuses", () => {
215
+ it("joins statuses set by other extensions", () => {
216
+ const statuses = new Map([
217
+ ["cwd", " 📁 /Users/bachi/jaylli/litellm-any"],
218
+ ["rewind", "◆ 3 checkpoints"],
219
+ ]);
220
+ assert.equal(
221
+ formatExtensionStatuses(plain, gitOf("main", statuses)),
222
+ "📁 /Users/bachi/jaylli/litellm-any | ◆ 3 checkpoints",
223
+ );
224
+ });
225
+
226
+ it("keeps pre-coloured status text verbatim and mutes plain text", () => {
227
+ const esc = String.fromCharCode(27);
228
+ const coloured = `${esc}[2m` + "◆ " + "checkpoints" + `${esc}[0m`;
229
+ const statuses = new Map([
230
+ ["rewind", coloured],
231
+ ["cwd", " 📁 /tmp/repo"],
232
+ ]);
233
+ const rendered = formatExtensionStatuses(painted, gitOf("main", statuses));
234
+ assert.ok(rendered.startsWith(coloured + "dim( | )"), rendered);
235
+ assert.ok(rendered.endsWith("muted(📁 /tmp/repo)"), rendered);
236
+ });
237
+
238
+ it("skips blank values and its own statusline key", () => {
239
+ const statuses = new Map([
240
+ ["statusline", "stale"],
241
+ ["retry", " "],
242
+ ["cwd", " 📁 /tmp/repo"],
243
+ ]);
244
+ assert.equal(formatExtensionStatuses(plain, gitOf("main", statuses)), "📁 /tmp/repo");
245
+ assert.equal(formatExtensionStatuses(plain, gitOf("main", new Map())), "");
246
+ });
247
+
248
+ it("caps the status line at five entries", () => {
249
+ const statuses = new Map(
250
+ Array.from({ length: 7 }, (_, i): [string, string] => [`k${i}`, `v${i}`]),
251
+ );
252
+ const rendered = formatExtensionStatuses(plain, gitOf("main", statuses));
253
+ assert.equal(rendered.split(" | ").length, 5);
254
+ assert.ok(rendered.endsWith("v4") && !rendered.includes("v5"), rendered);
255
+ });
256
+ });
257
+
258
+ describe("composeFooterLines", () => {
259
+ function recorder() {
260
+ const calls: Array<{ text: string; width: number; ellipsis: string }> = [];
261
+ const truncate = (text: string, max: number, ellipsis: string) => {
262
+ calls.push({ text, width: max, ellipsis });
263
+ return [...text].length > max ? `${[...text].slice(0, max - 1).join("")}${ellipsis}` : text;
264
+ };
265
+ return { calls, truncate };
266
+ }
267
+
268
+ it("indents every line by exactly one space and never wraps", () => {
269
+ const { truncate } = recorder();
270
+ const statuses = new Map([["cwd", " 📁 /tmp/repo"]]);
271
+ const lines = composeFooterLines(
272
+ plain,
273
+ sourceOf(),
274
+ gitOf("main", statuses),
275
+ stateOf(),
276
+ 200,
277
+ truncate,
278
+ );
279
+ assert.equal(lines.length, 2);
280
+ for (const line of lines) {
281
+ assert.ok(line.startsWith(" "), JSON.stringify(line));
282
+ assert.ok(!line.startsWith(" "), JSON.stringify(line));
283
+ assert.equal(line.includes("\n"), false);
284
+ }
285
+ assert.ok(lines[0]?.includes(`${DOC} | \ue0a0 main | (+0,-0)`), lines[0]);
286
+ assert.equal(lines[1], " 📁 /tmp/repo");
287
+ });
288
+
289
+ it("emits a single line when no other extension set a status", () => {
290
+ const lines = composeFooterLines(plain, sourceOf(), gitOf("main"), stateOf(), 200, (t) => t);
291
+ assert.equal(lines.length, 1);
292
+ });
293
+
294
+ it("truncates an over-long line and leaves a short one intact", () => {
295
+ const { calls, truncate } = recorder();
296
+ const lines = composeFooterLines(
297
+ plain,
298
+ sourceOf(),
299
+ gitOf("main", new Map([["cwd", " 📁 /tmp/repo"]])),
300
+ stateOf(),
301
+ 20,
302
+ truncate,
303
+ );
304
+ assert.equal(calls.length, 2);
305
+ assert.equal(calls[0]?.width, 20);
306
+ assert.equal(calls[0]?.ellipsis, ELLIPSIS);
307
+ assert.equal([...(lines[0] ?? "")].length, 20, lines[0]);
308
+ assert.ok(lines[0]?.endsWith(ELLIPSIS), lines[0]);
309
+ assert.equal(lines[0]?.includes("\n"), false);
310
+ assert.equal(lines[1], " 📁 /tmp/repo");
311
+ });
312
+
313
+ it("renders nothing for a non-positive width", () => {
314
+ assert.deepEqual(composeFooterLines(plain, sourceOf(), gitOf("main"), stateOf(), 0, (t) => t), []);
315
+ });
316
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * line.ts — statusline 主行 / 状态行的纯格式化
3
+ *
4
+ * 这个模块**不 import pi / pi-tui**(只用结构化最小接口描述需要的字段),所以
5
+ * `node --test` 能直接拿假对象跑满每个分支;pi 的真实 `Theme` / `ExtensionContext` /
6
+ * `ReadonlyFooterDataProvider` 结构兼容,index.ts 里原样传进来即可。
7
+ */
8
+
9
+ /** pi 的 Theme.fg 子集(方法声明双变,真实 Theme 可直接赋值)。 */
10
+ export interface StatuslineTheme {
11
+ fg(color: string, text: string): string;
12
+ }
13
+
14
+ /** ctx 里渲染主行需要的部分(`thinkingLevel` 在 pi 的 ctx 上是 live getter,可选且可能抛)。 */
15
+ export interface StatuslineSource {
16
+ model: { id?: string } | undefined;
17
+ thinkingLevel?: string;
18
+ getContextUsage(): { percent: number | null | undefined } | undefined;
19
+ }
20
+
21
+ /** footerData 里需要的部分。 */
22
+ export interface StatuslineGitSource {
23
+ getGitBranch(): string | null;
24
+ getExtensionStatuses(): ReadonlyMap<string, string>;
25
+ }
26
+
27
+ export interface DiffStat {
28
+ added: number;
29
+ deleted: number;
30
+ }
31
+
32
+ export interface StatuslineState {
33
+ streaming: boolean;
34
+ /** 渲染路径只读,只有 index.ts 的事件回调会改。 */
35
+ activeTools: Map<string, number>;
36
+ /** undefined = 还没读到 / 不在 git 仓库。 */
37
+ diffStat: DiffStat | undefined;
38
+ }
39
+
40
+ export const SEPARATOR = " | ";
41
+ export const ELLIPSIS = "…";
42
+ /**
43
+ * 模型段前缀图标(取代早期的 `Model:` 文字标签,省 4 列)。
44
+ * 刻意**不上色**:emoji 是自带颜色的字形,`theme.fg` 包一层只多出一对没有视觉效果的
45
+ * ANSI 码。宽度按 pi-tui `visibleWidth` 是 2 列(RGI emoji),与终端实际渲染一致,
46
+ * 所以 `truncateToWidth` 的截断数学不受影响。
47
+ * 带 VS16(U+FE0F)强制 emoji 呈现:裸 `\u26a1` 在 pi-tui 里同样量 2 列,但 VS16
48
+ * 能保证个别终端/字体不把它渲染成单色窄字形(那会和宽度口径错位 1 列)。
49
+ */
50
+ export const MODEL_ICON = "⚡️";
51
+ /**
52
+ * 分支段前缀图标:U+E0A0(Powerline / Nerd Font 的 git branch 字形),取代早期的 `⎇`(U+2387)。
53
+ * 这码位在私有区,源码里只写转义不写裸字形 —— 裸字形在字体缺字形时是看不见的方块,贴错成
54
+ * 近形码位(U+E0A2 / U+E0B0)review 时也看不出来,所以测试用转义把码位钉死。
55
+ * 宽度按 pi-tui `visibleWidth` 是 1 列,与旧图标相同,`truncateToWidth` 的截断预算不用动。
56
+ * 前提是终端用 Nerd Font —— 本机 Ghostty 配的 JetBrainsMonoNL Nerd Font Mono 即是。
57
+ */
58
+ export const BRANCH_ICON = "\ue0a0";
59
+ /** 行首缩进:整行不顶格。 */
60
+ export const LEADING_INDENT = " ";
61
+ /** 本扩展自己的 setStatus key(清残留用,渲染时也跳过)。 */
62
+ export const STATUSLINE_KEY = "statusline";
63
+ const MAX_STATUS_ITEMS = 5;
64
+
65
+ /** 主行:`⚡️ x/xhigh | Ctx 0.0% | \ue0a0 branch | (+a,-b)[ | 状态]`,各段已着色。 */
66
+ export function formatMainLine(
67
+ theme: StatuslineTheme,
68
+ source: StatuslineSource,
69
+ git: StatuslineGitSource,
70
+ state: StatuslineState,
71
+ ): string {
72
+ const branch = git.getGitBranch();
73
+ const segments = [
74
+ formatModelSegment(theme, source),
75
+ formatContextSegment(theme, source),
76
+ formatBranchSegment(theme, branch),
77
+ formatDiffSegment(theme, branch, state.diffStat),
78
+ formatStateSegment(theme, state),
79
+ ].filter((segment): segment is string => Boolean(segment));
80
+ return segments.join(dim(theme, SEPARATOR));
81
+ }
82
+
83
+ /**
84
+ * footer 的完整输出:一个主行(+ 可选状态行),行首恒缩进一格,超长只截断省略、绝不折行。
85
+ * `truncate` 由 index.ts 注入 pi-tui 的 `truncateToWidth`(ANSI / 宽字符安全),
86
+ * 这样这条关键约定也能在单测里直接断言。
87
+ */
88
+ export function composeFooterLines(
89
+ theme: StatuslineTheme,
90
+ source: StatuslineSource,
91
+ git: StatuslineGitSource,
92
+ state: StatuslineState,
93
+ width: number,
94
+ truncate: (text: string, width: number, ellipsis: string) => string,
95
+ ): string[] {
96
+ if (width <= 0) return [];
97
+ const lines = [`${LEADING_INDENT}${formatMainLine(theme, source, git, state)}`];
98
+ const statuses = formatExtensionStatuses(theme, git);
99
+ if (statuses) lines.push(`${LEADING_INDENT}${statuses}`);
100
+ return lines.map((line) => truncate(line, width, ELLIPSIS));
101
+ }
102
+
103
+ /**
104
+ * 第二行:其它扩展 `ctx.ui.setStatus()` 的文本(cwd-statusline / rewind / simple-task)。
105
+ * 自带 ANSI 的原样渲染(那些扩展已经自己配过色),没色的统一给 muted。
106
+ */
107
+ export function formatExtensionStatuses(theme: StatuslineTheme, git: StatuslineGitSource): string {
108
+ const visible = [...git.getExtensionStatuses().entries()]
109
+ .filter(([key, value]) => key !== STATUSLINE_KEY && value.trim().length > 0)
110
+ .slice(0, MAX_STATUS_ITEMS)
111
+ .map(([, value]) => (hasAnsi(value) ? value : theme.fg("muted", value.trim())));
112
+ return visible.join(dim(theme, SEPARATOR));
113
+ }
114
+
115
+ /** 模型图标 + id + 推理强度:`⚡️ qwen3.8-flash/xhigh`(id 用 accent,斜杠 dim,level 用 syntaxFunction);level 读不到(stale ctx)时只报 id。 */
116
+ function formatModelSegment(theme: StatuslineTheme, source: StatuslineSource): string {
117
+ const id = readModel(source)?.id ?? "no-model";
118
+ const level = readThinkingLevel(source);
119
+ const suffix = level ? `${dim(theme, "/")}${theme.fg("syntaxFunction", level)}` : "";
120
+ return `${MODEL_ICON} ${theme.fg("accent", id)}${suffix}`;
121
+ }
122
+
123
+ function formatContextSegment(theme: StatuslineTheme, source: StatuslineSource): string {
124
+ const percent = readContextUsage(source)?.percent ?? null;
125
+ const percentText = percent === null ? "?" : `${percent.toFixed(1)}%`;
126
+ return `${dim(theme, "Ctx")} ${theme.fg(contextColor(percent), percentText)}`;
127
+ }
128
+
129
+ function formatBranchSegment(theme: StatuslineTheme, branch: string | null): string {
130
+ if (!branch) return dim(theme, `${BRANCH_ICON} no git`);
131
+ return `${dim(theme, BRANCH_ICON)} ${theme.fg("accent", branch)}`;
132
+ }
133
+
134
+ function formatDiffSegment(
135
+ theme: StatuslineTheme,
136
+ branch: string | null,
137
+ diffStat: DiffStat | undefined,
138
+ ): string {
139
+ if (!branch) return dim(theme, "(no git)");
140
+ return `${dim(theme, "(")}${theme.fg("success", `+${diffStat?.added ?? 0}`)}${dim(theme, ",")}${theme.fg("error", `-${diffStat?.deleted ?? 0}`)}${dim(theme, ")")}`;
141
+ }
142
+
143
+ /** 末段状态:有工具在跑 → 工具名(并发带计数),否则流式中 → thinking,空闲 → 整段不出现;整段用 warning 着色。 */
144
+ function formatStateSegment(theme: StatuslineTheme, state: StatuslineState): string | undefined {
145
+ const active = [...state.activeTools.entries()];
146
+ if (active.length > 0) {
147
+ const [name, count] = active[0] ?? ["tool", 1];
148
+ const suffix = count > 1 ? `×${count}` : active.length > 1 ? `+${active.length - 1}` : "";
149
+ return theme.fg("warning", `${name}${suffix}`);
150
+ }
151
+ return state.streaming ? theme.fg("warning", "thinking") : undefined;
152
+ }
153
+
154
+ function contextColor(percent: number | null): string {
155
+ if (percent === null) return "dim";
156
+ if (percent >= 90) return "error";
157
+ if (percent >= 70) return "warning";
158
+ return "success";
159
+ }
160
+
161
+ /** ctx 的 getter 在会话被换掉后可能抛(stale ctx),状态栏不值得为此挂掉渲染。 */
162
+ function readContextUsage(
163
+ source: StatuslineSource,
164
+ ): { percent: number | null | undefined } | undefined {
165
+ try {
166
+ return source.getContextUsage();
167
+ } catch {
168
+ return undefined;
169
+ }
170
+ }
171
+
172
+ /** thinkingLevel 在 stale ctx 上同样可能抛,一律兜底成「无 level」。 */
173
+ function readThinkingLevel(source: StatuslineSource): string | undefined {
174
+ try {
175
+ const level = source.thinkingLevel;
176
+ return typeof level === "string" && level.length > 0 ? level : undefined;
177
+ } catch {
178
+ return undefined;
179
+ }
180
+ }
181
+
182
+ /** ctx.model 在 stale ctx 上也可能抛,一律走这里取。 */
183
+ function readModel(
184
+ source: StatuslineSource,
185
+ ): { id?: string; contextWindow?: number } | undefined {
186
+ try {
187
+ return source.model;
188
+ } catch {
189
+ return undefined;
190
+ }
191
+ }
192
+
193
+ const ANSI_PATTERN = /\u001b\[[0-9;]*m/;
194
+
195
+ function hasAnsi(value: string): boolean {
196
+ return ANSI_PATTERN.test(value);
197
+ }
198
+
199
+ function dim(theme: StatuslineTheme, text: string): string {
200
+ return theme.fg("dim", text);
201
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Tests for filter.ts — the "[pi-subagents] …" stderr-chunk classifier behind subagent-log-guard.
3
+ *
4
+ * Run with: node --test clients/pi/extensions/subagent-log-guard/filter.test.ts
5
+ *
6
+ * filter.ts never imports pi / pi-tui (that is the point of the split), so these tests need
7
+ * nothing but node:test. The classifier is deliberately prefix-only: pi-subagents formats one
8
+ * console.warn call into one "text + newline" write, so a real diagnostic always starts the
9
+ * chunk — and a `[pi-subagents]` mention in the middle of someone else's write must survive.
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import { describe, it } from "node:test";
14
+
15
+ import { chunkToText, splitSubagentDiagnostics, SUBAGENT_LOG_PREFIX } from "./filter.ts";
16
+
17
+ /** The real line that smeared a live session (pulled verbatim from a runner.stderr.log). */
18
+ const REAL_DIAGNOSTIC =
19
+ `${SUBAGENT_LOG_PREFIX} Agent 'researcher': host runtime tool availability omitted [web_search, fetch_content, ` +
20
+ "get_search_content, source_check]. Requested tool names: [read, write, web_search, fetch_content, " +
21
+ "get_search_content, source_check, contact_supervisor]; effective tool allowlist: [read, write, " +
22
+ "contact_supervisor]. This is a non-fatal tool-plan diagnostic, not verification of the child's runtime " +
23
+ "tool menu.\n";
24
+
25
+ describe("splitSubagentDiagnostics", () => {
26
+ it("captures a real diagnostic and strips the prefix plus the trailing newline", () => {
27
+ const { passThrough, captured } = splitSubagentDiagnostics(REAL_DIAGNOSTIC);
28
+ assert.equal(passThrough, "");
29
+ assert.equal(captured.length, 1);
30
+ assert.ok(captured[0]?.startsWith("Agent 'researcher': host runtime tool availability omitted"));
31
+ assert.ok(captured[0]?.endsWith("runtime tool menu."));
32
+ assert.ok(!captured[0]?.includes(SUBAGENT_LOG_PREFIX));
33
+ });
34
+
35
+ it("passes ordinary stderr through untouched, byte for byte", () => {
36
+ const text = "Error: something exploded\n";
37
+ const { passThrough, captured } = splitSubagentDiagnostics(text);
38
+ assert.equal(passThrough, text);
39
+ assert.deepEqual(captured, []);
40
+ });
41
+
42
+ it("keeps a mid-chunk mention of the prefix instead of swallowing the whole write", () => {
43
+ // e.g. an extension echoing a log file line, or the model quoting the package name.
44
+ const text = `see ${SUBAGENT_LOG_PREFIX} lines in runner.stderr.log\n`;
45
+ const { passThrough, captured } = splitSubagentDiagnostics(text);
46
+ assert.equal(passThrough, text);
47
+ assert.deepEqual(captured, []);
48
+ });
49
+
50
+ it("captures a multi-line diagnostic as one entry (no continuation line leaks out)", () => {
51
+ const text = `${SUBAGENT_LOG_PREFIX} first line\nsecond line\n`;
52
+ const { passThrough, captured } = splitSubagentDiagnostics(text);
53
+ assert.equal(passThrough, "");
54
+ assert.deepEqual(captured, ["first line\nsecond line"]);
55
+ });
56
+
57
+ it("handles a prefix-only write without inventing content", () => {
58
+ const { passThrough, captured } = splitSubagentDiagnostics(`${SUBAGENT_LOG_PREFIX}\n`);
59
+ assert.equal(passThrough, "");
60
+ assert.deepEqual(captured, [""]);
61
+ });
62
+
63
+ it("handles an empty write", () => {
64
+ const { passThrough, captured } = splitSubagentDiagnostics("");
65
+ assert.equal(passThrough, "");
66
+ assert.deepEqual(captured, []);
67
+ });
68
+ });
69
+
70
+ describe("chunkToText", () => {
71
+ it("returns string chunks as-is", () => {
72
+ assert.equal(chunkToText("plain"), "plain");
73
+ });
74
+
75
+ it("decodes Buffer / Uint8Array chunks the way console writes them", () => {
76
+ const buffer = Buffer.from(REAL_DIAGNOSTIC, "utf8");
77
+ assert.equal(chunkToText(buffer), REAL_DIAGNOSTIC);
78
+ const { captured } = splitSubagentDiagnostics(chunkToText(buffer));
79
+ assert.equal(captured.length, 1);
80
+ });
81
+
82
+ it("keeps multi-byte characters intact across a Buffer boundary", () => {
83
+ assert.equal(chunkToText(Buffer.from("工具调用失败\n", "utf8")), "工具调用失败\n");
84
+ });
85
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * subagent-log-guard 的纯判定层:一段写向 stderr 的文本,是 pi-subagents 的诊断行吗?
3
+ * 不 import pi / pi-tui / 任何运行时依赖,所以 `node --test` 可以直接跑(见 filter.test.ts)。
4
+ */
5
+
6
+ /** pi-subagents 的诊断前缀:它的 console.warn / console.error 调用点统一以它开头。 */
7
+ export const SUBAGENT_LOG_PREFIX = "[pi-subagents]";
8
+
9
+ export interface ChunkSplit {
10
+ /** 原样放行给终端的内容(非诊断时就是原文,可能为空串) */
11
+ passThrough: string;
12
+ /** 拦下的诊断(已去掉前缀与首尾空白);长度 > 0 表示这一段是诊断 */
13
+ captured: string[];
14
+ }
15
+
16
+ /** process.stderr.write 的 chunk 可能是 string 也可能是 Uint8Array,统一成文本再比。 */
17
+ export function chunkToText(chunk: string | Uint8Array): string {
18
+ return typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
19
+ }
20
+
21
+ /**
22
+ * 只看**开头**:Node 的 console.warn / console.error 会把整条消息格式化成「文本 + 换行」
23
+ * 一次写入,所以 pi-subagents 的诊断必然落在 chunk 的开头。
24
+ * 反过来,出现在 chunk 中间的 `[pi-subagents]` 不是诊断开头(可能是模型输出、别的扩展
25
+ * 打印的引用文本),放行才不会误吞别人的内容。
26
+ */
27
+ export function splitSubagentDiagnostics(text: string): ChunkSplit {
28
+ if (!text.startsWith(SUBAGENT_LOG_PREFIX)) {
29
+ return { passThrough: text, captured: [] };
30
+ }
31
+ return { passThrough: "", captured: [text.slice(SUBAGENT_LOG_PREFIX.length).trim()] };
32
+ }