@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,147 @@
1
+ /**
2
+ * Tests for gap.ts — the blank-line decision between two stacked editor widgets.
3
+ *
4
+ * Run with: node --test clients/pi/extensions/simple-task/gap.test.ts
5
+ *
6
+ * Real TUI trees cannot be built headlessly, so the tree is faked with plain objects:
7
+ * a Container is anything with a `children` array, a widget is anything with `render()`.
8
+ * The module under test never imports pi / pi-tui (that is the point of gap.ts), so the
9
+ * fakes are enough to cover every branch.
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import { describe, it } from "node:test";
14
+
15
+ import { widgetGaps } from "./gap.ts";
16
+
17
+ /** A widget that renders the given lines. `width` is recorded for assertions. */
18
+ function widget(lines: string[], seen?: number[]): { render(width: number): string[] } {
19
+ return {
20
+ render(width: number): string[] {
21
+ seen?.push(width);
22
+ return lines;
23
+ },
24
+ };
25
+ }
26
+
27
+ /** pi's own leading spacer: one empty line, no content. */
28
+ const spacer = widget([""]);
29
+
30
+ function container(children: unknown[]): { children: unknown[] } {
31
+ return { children };
32
+ }
33
+
34
+ describe("widgetGaps", () => {
35
+ it("adds nothing when the list is the only widget (pi's leading spacer above)", () => {
36
+ const self = widget(["● 3 tasks (3 open)"]);
37
+ const tui = container([container([spacer, self])]);
38
+ assert.deepEqual(widgetGaps(tui, self, 80), { above: false, below: false });
39
+ });
40
+
41
+ it("adds a leading blank line when the async-subagent block sits directly above", () => {
42
+ const self = widget(["● 6 tasks (1 done, 1 in progress, 4 open)", " ✶ #2 …"]);
43
+ const above = widget(["async subagent · background ", " ⠧ reviewer · running ", " output: /var/folders/… "]);
44
+ const tui = container([container([spacer, above, self])]);
45
+ assert.deepEqual(widgetGaps(tui, self, 80), { above: true, below: false });
46
+ });
47
+
48
+ it("adds nothing when the neighbour above already ends with a blank line (recap)", () => {
49
+ const self = widget(["● 3 tasks (3 open)"]);
50
+ const recap = widget([" ✦ Recap: 修 gap 空行", ""]);
51
+ const tui = container([container([spacer, recap, self])]);
52
+ assert.deepEqual(widgetGaps(tui, self, 80), { above: false, below: false });
53
+ });
54
+
55
+ it("adds a trailing blank line when the other widget sits below the list", () => {
56
+ const self = widget(["● 3 tasks (3 open)"]);
57
+ const below = widget(["async subagent · background", " ⠧ reviewer · running"]);
58
+ const tui = container([container([spacer, self, below])]);
59
+ assert.deepEqual(widgetGaps(tui, self, 80), { above: false, below: true });
60
+ });
61
+
62
+ it("adds nothing when the widget below already starts with a blank line (recap's own gap)", () => {
63
+ const self = widget(["● 3 tasks (3 open)"]);
64
+ const recap = widget(["", " ✦ Recap: 修 gap 空行"]);
65
+ const tui = container([container([spacer, self, recap])]);
66
+ assert.deepEqual(widgetGaps(tui, self, 80), { above: false, below: false });
67
+ });
68
+
69
+ it("adds both gaps when wedged between two widgets", () => {
70
+ const self = widget(["● 3 tasks (3 open)"]);
71
+ const above = widget(["async subagent · background"]);
72
+ const below = widget(["✦ Recap: 上一轮摘要"]);
73
+ const tui = container([container([spacer, above, self, below])]);
74
+ assert.deepEqual(widgetGaps(tui, self, 80), { above: true, below: true });
75
+ });
76
+
77
+ it("finds itself in a deeply nested tree", () => {
78
+ const self = widget(["● 1 task (1 open)"]);
79
+ const above = widget(["async subagent · background"]);
80
+ const tui = container([container([container([container([spacer, above, self])])])]);
81
+ assert.deepEqual(widgetGaps(tui, self, 80), { above: true, below: false });
82
+ });
83
+
84
+ it("forwards the render width to the neighbours (pi renders a container at one width)", () => {
85
+ const widths: number[] = [];
86
+ const self = widget(["● 1 task (1 open)"]);
87
+ const above = widget(["async subagent · background"], widths);
88
+ const tui = container([container([spacer, above, self])]);
89
+ widgetGaps(tui, self, 123);
90
+ assert.deepEqual(widths, [123]);
91
+ });
92
+
93
+ it("adds nothing for a neighbour that renders no visible line at all", () => {
94
+ const self = widget(["● 1 task (1 open)"]);
95
+ const empty = widget([]);
96
+ const blank = widget(["", " ", "\x1b[2m\x1b[0m"]);
97
+ const tui = container([container([spacer, empty, self, blank])]);
98
+ assert.deepEqual(widgetGaps(tui, self, 80), { above: false, below: false });
99
+ });
100
+
101
+ it("treats an ANSI-only trailing line as blank (colour reset is not content)", () => {
102
+ const self = widget(["● 1 task (1 open)"]);
103
+ const above = widget(["async subagent · background", "\x1b[2m\x1b[0m"]);
104
+ const tui = container([container([spacer, above, self])]);
105
+ assert.deepEqual(widgetGaps(tui, self, 80), { above: false, below: false });
106
+ });
107
+
108
+ it("treats a throwing neighbour as visible content (better a gap than a missing one)", () => {
109
+ const self = widget(["● 1 task (1 open)"]);
110
+ const broken = {
111
+ render(): string[] {
112
+ throw new Error("boom");
113
+ },
114
+ };
115
+ const tui = container([container([spacer, broken, self, broken])]);
116
+ assert.deepEqual(widgetGaps(tui, self, 80), { above: true, below: true });
117
+ });
118
+
119
+ it("ignores a sibling without render()", () => {
120
+ const self = widget(["● 1 task (1 open)"]);
121
+ const notAWidget = { not: "a component" };
122
+ const tui = container([container([spacer, notAWidget, self])]);
123
+ assert.deepEqual(widgetGaps(tui, self, 80), { above: false, below: false });
124
+ });
125
+
126
+ it("adds nothing when the tree does not contain the component", () => {
127
+ const self = widget(["● 1 task (1 open)"]);
128
+ const other = widget(["async subagent · background"]);
129
+ const tui = container([container([spacer, other])]);
130
+ assert.deepEqual(widgetGaps(tui, self, 80), { above: false, below: false });
131
+ });
132
+
133
+ it("survives a cyclic tree instead of looping forever", () => {
134
+ const self = widget(["● 1 task (1 open)"]);
135
+ const outer = container([]);
136
+ const inner = container([outer]);
137
+ outer.children.push(inner);
138
+ assert.deepEqual(widgetGaps(outer, self, 80), { above: false, below: false });
139
+ });
140
+
141
+ it("distinguishes components by identity, not by shape", () => {
142
+ const self = widget(["● 1 task (1 open)"]);
143
+ const twin = widget(["● 1 task (1 open)"]);
144
+ const tui = container([container([spacer, twin, self])]);
145
+ assert.deepEqual(widgetGaps(tui, self, 80), { above: true, below: false });
146
+ });
147
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * gap.ts — 决定任务清单这一帧要不要在**上/下各补一个空行**。
3
+ *
4
+ * 为什么需要它:pi 把「编辑器上方」的所有扩展 widget 放进同一个 Container,按 Map
5
+ * 插入顺序依次 `addChild`(`interactive-mode.js` 的 `renderWidgetContainer`),
6
+ * **两个 widget 之间没有任何间隔**;而 `setExtensionWidget` 是先 `map.delete(key)` 再
7
+ * `set` —— 重新注册会把 widget 挪到末尾。simple-task 的 spinner 每 150ms 重注册一次
8
+ * (见 index.ts 的 `ensureTimer`),于是清单通常落在最下面,pi-subagents 的
9
+ * 「async subagent · background」块紧贴在它上面,两段内容糊成一片:
10
+ *
11
+ * output: /var/folders/…/pi-subagents-ui…
12
+ * ● 6 tasks (1 done, 1 in progress, 4 open)
13
+ *
14
+ * 为什么走组件树:扩展拿不到「别人注册了什么 widget」(`ctx.ui` 没有枚举接口),
15
+ * 但 pi 传进 widget 工厂的第一个参数就是 TUI 实例(`setExtensionWidget` 里的
16
+ * `content(this.ui, theme)`),而 `TUI` / `Container` 的 `children` 是 pi-tui 的公开
17
+ * 字段(`@earendil-works/pi-tui` 的 `tui.d.ts`)。于是渲染时可以从 TUI 根往下找到
18
+ * **装着本组件的那个 Container**,再看紧邻兄弟渲染成什么样。传进来的 TUI 是 pi 的
19
+ * `createInteractiveTuiReference()` 代理,永远指向当前 renderer,所以切换全屏/普通
20
+ * 模式(renderer 会被整个换掉)之后依然能走到正确的树。
21
+ *
22
+ * 规则(目标是任意两个上下相邻的 widget 之间**恰好一行**空行,且孤立时不多出一行):
23
+ * - 上方兄弟的**最后一行有可见内容** → 上方挨着东西,补一个前导空行;
24
+ * - 上方兄弟本身就以空行收尾(只有一个 widget 时它是 pi 自己加的 Spacer;
25
+ * 挂着 recap 时是 recap 固定补的行尾空行)→ 间隔已经有了,不补;
26
+ * - 下方兄弟的**第一行有可见内容** → 清单落在上面时同样要间隔,补一个结尾空行;
27
+ * - 兄弟一条可见内容都没有(空 widget)→ 没什么可隔开的,不补;
28
+ * - 找不到自己的父容器、兄弟没有 `render`(pi 换了内部结构)→ 一律不补:
29
+ * 宁可不补空行,也不要凭空多出一行。兄弟 `render` 抛异常时反过来当作
30
+ * 「有内容」补一行 —— 那种情况下它是个真 widget,漏掉间隔正是要修的 bug。
31
+ *
32
+ * 本文件**不 import 任何 pi / pi-tui 模块**(只用鸭子类型 + 一个 ANSI 剥离正则),
33
+ * 所以能用 `node --test` 直接跑 —— 与 `rewind/checkpoints.ts` 的做法一致。
34
+ */
35
+
36
+ /** 需要补空行的两侧。 */
37
+ export interface WidgetGaps {
38
+ above: boolean;
39
+ below: boolean;
40
+ }
41
+
42
+ /** widget 行里只会出现 SGR 颜色序列。 */
43
+ const SGR = /\x1b\[[0-9;]*m/g;
44
+
45
+ const NO_GAPS: WidgetGaps = { above: false, below: false };
46
+ /** 一个「渲染不出任何可见内容」的边。 */
47
+ const EDGE_NOTHING = { visible: false, blank: false };
48
+ /** 渲染失败时的兜底:当作有内容。 */
49
+ const EDGE_UNKNOWN = { visible: true, blank: false };
50
+
51
+ interface EdgeInfo {
52
+ visible: boolean;
53
+ blank: boolean;
54
+ }
55
+
56
+ /** 有非空白字符(ANSI 颜色序列不算内容)。 */
57
+ function hasContent(line: string | undefined): boolean {
58
+ return (line ?? "").replace(SGR, "").trim().length > 0;
59
+ }
60
+
61
+ /** `children` 是个数组的节点才算容器。 */
62
+ function childrenOf(node: unknown): unknown[] | undefined {
63
+ if (node === null || typeof node !== "object") return undefined;
64
+ const children = (node as { children?: unknown }).children;
65
+ return Array.isArray(children) ? children : undefined;
66
+ }
67
+
68
+ /**
69
+ * 从 TUI 根往下找「装着 self 的那个 Container」,返回它的 children。
70
+ * 深度优先 + `seen` 去重:树里不会有环,但万一有也不会转死。
71
+ */
72
+ function siblingsOf(root: unknown, self: unknown): unknown[] | undefined {
73
+ const seen = new Set<unknown>();
74
+ const stack: unknown[] = [root];
75
+ while (stack.length > 0) {
76
+ const node = stack.pop();
77
+ if (node === null || typeof node !== "object" || seen.has(node)) continue;
78
+ seen.add(node);
79
+ const children = childrenOf(node);
80
+ if (!children) continue;
81
+ if (children.includes(self)) return children;
82
+ for (const child of children) stack.push(child);
83
+ }
84
+ return undefined;
85
+ }
86
+
87
+ /** 渲染兄弟组件「面向自己」的那一行(`top` = 第一行,`bottom` = 最后一行)。 */
88
+ function facingEdge(component: unknown, width: number, edge: "top" | "bottom"): EdgeInfo {
89
+ const render = (component as { render?: unknown } | null | undefined)?.render;
90
+ if (typeof render !== "function") return EDGE_NOTHING;
91
+ let rendered: unknown;
92
+ try {
93
+ rendered = (render as (width: number) => unknown).call(component, width);
94
+ } catch {
95
+ return EDGE_UNKNOWN;
96
+ }
97
+ if (!Array.isArray(rendered) || rendered.length === 0) return EDGE_NOTHING;
98
+ const lines = rendered.map((line) => String(line));
99
+ const edgeLine = edge === "top" ? lines[0] : lines[lines.length - 1];
100
+ return { visible: lines.some(hasContent), blank: !hasContent(edgeLine) };
101
+ }
102
+
103
+ /**
104
+ * 计算本组件当前这一帧需要在上/下补的空行(见文件头)。
105
+ *
106
+ * @param root widget 工厂拿到的 TUI(pi 的 renderer 代理)
107
+ * @param self 本组件自身(用来在容器里定位自己的位置)
108
+ * @param width 当前渲染宽度,原样转发给兄弟组件 —— pi 是同一个宽度渲染同一个容器
109
+ */
110
+ export function widgetGaps(root: unknown, self: unknown, width: number): WidgetGaps {
111
+ const siblings = siblingsOf(root, self);
112
+ if (!siblings) return NO_GAPS;
113
+ const index = siblings.indexOf(self);
114
+ if (index < 0) return NO_GAPS;
115
+
116
+ const above = index > 0 ? facingEdge(siblings[index - 1], width, "bottom") : EDGE_NOTHING;
117
+ const below = index < siblings.length - 1 ? facingEdge(siblings[index + 1], width, "top") : EDGE_NOTHING;
118
+ return {
119
+ above: above.visible && !above.blank,
120
+ below: below.visible && !below.blank,
121
+ };
122
+ }