gentle-pi 2.5.0 → 2.6.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 (189) hide show
  1. package/README.md +139 -30
  2. package/assets/agents/gentle-ai-worker.md +4 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +21 -3
  25. package/assets/sdd-orchestrator-workflow.md +54 -21
  26. package/assets/support/sdd-status-contract.md +34 -90
  27. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  28. package/docs/telemetry.md +57 -1
  29. package/docs/windows-startup-console-visibility.md +18 -0
  30. package/extensions/ask-user-choice.ts +143 -15
  31. package/extensions/codegraph-tools.ts +1 -0
  32. package/extensions/gentle-agents.ts +799 -50
  33. package/extensions/gentle-ai.ts +2033 -322
  34. package/extensions/gentle-shell.ts +145 -42
  35. package/extensions/gentle-todo.ts +47 -12
  36. package/extensions/quiet-tools.ts +1 -0
  37. package/extensions/runtime-metrics.ts +130 -0
  38. package/extensions/sdd-init.ts +2 -2
  39. package/extensions/startup-banner.ts +52 -75
  40. package/lib/agent-profiles.ts +550 -0
  41. package/lib/agents-completion-delivery.ts +72 -0
  42. package/lib/agents-config.ts +7 -10
  43. package/lib/agents-history.ts +9 -1
  44. package/lib/agents-messaging.ts +187 -0
  45. package/lib/agents-protocol.ts +77 -5
  46. package/lib/agents-runner.ts +548 -26
  47. package/lib/agents-thread-view.ts +57 -0
  48. package/lib/agents-view-layout.ts +40 -0
  49. package/lib/agents-view.ts +548 -191
  50. package/lib/agents-widget.ts +33 -14
  51. package/lib/gentle-ai-binary.ts +3 -1
  52. package/lib/gentle-ai-renderer.ts +8 -6
  53. package/lib/native-review-cli.ts +283 -1
  54. package/lib/orchestrator-presence.ts +337 -0
  55. package/lib/profiles-orchestrator.ts +203 -0
  56. package/lib/review-candidate-view-owner.ts +296 -46
  57. package/lib/review-candidate-view.ts +30 -20
  58. package/lib/review-consent-component.ts +247 -0
  59. package/lib/review-consent-ui.ts +53 -8
  60. package/lib/review-host-relay.ts +28 -0
  61. package/lib/review-integration-v2.ts +187 -5
  62. package/lib/review-last-event-controller.ts +7 -4
  63. package/lib/review-reminder-receipt.ts +74 -0
  64. package/lib/review-session-standing-permission.ts +27 -6
  65. package/lib/runtime-metrics-children.ts +199 -0
  66. package/lib/runtime-metrics-delivery.ts +68 -0
  67. package/lib/runtime-metrics-native.ts +166 -0
  68. package/lib/runtime-metrics-pi-identity.ts +113 -0
  69. package/lib/runtime-metrics-policy.ts +51 -0
  70. package/lib/runtime-metrics.ts +255 -0
  71. package/lib/sdd-preflight.ts +362 -81
  72. package/lib/sdd-research-capabilities.ts +228 -0
  73. package/lib/sdd-status.ts +29 -7
  74. package/lib/session-worktree-registry.ts +118 -0
  75. package/lib/shell-bar.ts +47 -1
  76. package/lib/shell-card.ts +1 -4
  77. package/lib/shell-changes-view.ts +362 -37
  78. package/lib/shell-changes.ts +81 -1
  79. package/lib/shell-prompt.ts +11 -15
  80. package/lib/shell-sidebar-banner.ts +11 -0
  81. package/lib/shell-sidebar-layout.ts +213 -0
  82. package/lib/shell-sidebar.ts +41 -0
  83. package/lib/shell-todo.ts +28 -11
  84. package/lib/telemetry-trigger.ts +2 -0
  85. package/package.json +6 -3
  86. package/runtime/gentle-ai-binary.mjs +3 -1
  87. package/runtime/native-review-cli.mjs +283 -1
  88. package/runtime/review-integration-v2.mjs +187 -5
  89. package/runtime/telemetry-trigger.mjs +2 -0
  90. package/scripts/build-runtime-modules.mjs +9 -1
  91. package/scripts/check-types.mjs +125 -0
  92. package/scripts/gentle-ai-installer.mjs +10 -10
  93. package/scripts/install-gentle-ai.mjs +12 -0
  94. package/scripts/install-tui-mode-setting.mjs +114 -0
  95. package/scripts/test-packed-runner.mjs +16 -2
  96. package/scripts/types-baseline.json +99 -0
  97. package/scripts/verify-package-files.mjs +4 -2
  98. package/skills/_shared/review-ledger-contract.md +17 -1
  99. package/skills/issue-creation/SKILL.md +3 -3
  100. package/skills/judgment-day/SKILL.md +17 -3
  101. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  102. package/tests/agent-profiles.test.ts +722 -0
  103. package/tests/agents-completion-delivery.test.ts +94 -0
  104. package/tests/agents-config.test.ts +62 -0
  105. package/tests/agents-fake-child.ts +15 -1
  106. package/tests/agents-grouping.test.ts +179 -0
  107. package/tests/agents-integration.test.ts +100 -0
  108. package/tests/agents-messaging.test.ts +94 -0
  109. package/tests/agents-protocol.test.ts +45 -0
  110. package/tests/agents-queries.test.ts +190 -0
  111. package/tests/agents-responsive.test.ts +43 -0
  112. package/tests/agents-runner.test.ts +571 -14
  113. package/tests/agents-thread-view.test.ts +45 -0
  114. package/tests/agents-view.test.ts +476 -65
  115. package/tests/agents-widget.test.ts +31 -1
  116. package/tests/artifact-language.test.ts +25 -2
  117. package/tests/ask-user-choice.test.ts +169 -3
  118. package/tests/asset-installation-runtime.test.ts +108 -0
  119. package/tests/autonomous-guard.test.ts +116 -1
  120. package/tests/codegraph-tools.test.ts +2 -1
  121. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  122. package/tests/devbinary/native-review-parity.devtest.ts +2 -0
  123. package/tests/feature-request-form.test.ts +67 -0
  124. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  125. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  126. package/tests/gentle-agents.test.ts +1460 -33
  127. package/tests/gentle-ai-binary.test.ts +7 -2
  128. package/tests/gentle-ai-installer.test.ts +47 -47
  129. package/tests/gentle-ai-renderer.test.ts +38 -0
  130. package/tests/gentle-ai.test.ts +944 -4
  131. package/tests/gentle-shell.test.ts +303 -12
  132. package/tests/gentle-todo.test.ts +54 -10
  133. package/tests/install-tui-mode-setting.test.ts +324 -0
  134. package/tests/issue-creation-skill.test.ts +22 -0
  135. package/tests/model-routing-authority.test.ts +12 -0
  136. package/tests/native-review-capability-contract.test.ts +12 -1
  137. package/tests/native-review-cli.test.ts +277 -3
  138. package/tests/native-review-parity.test.ts +14 -7
  139. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  140. package/tests/orchestrator-presence.test.ts +389 -0
  141. package/tests/package-manifest.test.ts +232 -7
  142. package/tests/profiles-orchestrator.test.ts +208 -0
  143. package/tests/quiet-tool-rendering.test.ts +1 -0
  144. package/tests/rdd-aware-verification-contract.test.ts +10 -0
  145. package/tests/review-agent-end-preflight.test.ts +332 -24
  146. package/tests/review-candidate-view.test.ts +304 -6
  147. package/tests/review-consent-ui.test.ts +352 -0
  148. package/tests/review-contract-prompt.test.ts +14 -0
  149. package/tests/review-controller-native-routing.test.ts +563 -3
  150. package/tests/review-controller.test.ts +1 -1
  151. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  152. package/tests/review-host-relay-routing.test.ts +364 -4
  153. package/tests/review-host-relay.test.ts +29 -0
  154. package/tests/review-integration-v2-forward.test.ts +44 -0
  155. package/tests/review-integration-v2.test.ts +164 -0
  156. package/tests/review-last-event-closure.test.ts +105 -1
  157. package/tests/review-ledger-contract.test.ts +61 -6
  158. package/tests/review-reminder-receipt.test.ts +62 -0
  159. package/tests/review-session-standing-permission-controller.test.ts +52 -4
  160. package/tests/review-session-standing-permission.test.ts +30 -0
  161. package/tests/runtime-harness.mjs +447 -39
  162. package/tests/runtime-metrics-children.test.ts +206 -0
  163. package/tests/runtime-metrics-delivery.test.ts +85 -0
  164. package/tests/runtime-metrics-extension.test.ts +187 -0
  165. package/tests/runtime-metrics-native.test.ts +209 -0
  166. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  167. package/tests/runtime-metrics-policy.test.ts +62 -0
  168. package/tests/runtime-metrics.test.ts +184 -0
  169. package/tests/sdd-agent-tools.test.ts +10 -1
  170. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  171. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  172. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  173. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  174. package/tests/sdd-preflight.test.ts +252 -8
  175. package/tests/sdd-research-capabilities.test.ts +256 -0
  176. package/tests/sdd-research-live.test.ts +241 -0
  177. package/tests/sdd-selection-transport.test.ts +504 -0
  178. package/tests/sdd-status.test.ts +51 -0
  179. package/tests/session-worktree-registry.test.ts +135 -0
  180. package/tests/shell-card.test.ts +24 -3
  181. package/tests/shell-changes-view.test.ts +471 -8
  182. package/tests/shell-changes.test.ts +168 -0
  183. package/tests/shell-prompt.test.ts +28 -6
  184. package/tests/shell-sidebar-banner.test.ts +23 -0
  185. package/tests/shell-sidebar-layout.test.ts +387 -0
  186. package/tests/shell-sidebar.test.ts +50 -0
  187. package/tests/shell-todo.test.ts +100 -11
  188. package/tests/startup-banner.test.ts +126 -0
  189. package/tests/telemetry-trigger.test.ts +3 -1
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { TUI } from "@earendil-works/pi-tui";
4
+ import { sidebarPart, sidebarState } from "../lib/shell-sidebar.ts";
5
+
6
+ const host = (terminal?: object) => ({ terminal }) as TUI;
7
+ const component = () => ({ render: (_width = 80) => ["bottom"], invalidate() {} });
8
+
9
+ test("unsupported hosts retain the original bottom widget and disposal", () => {
10
+ for (const tui of [host(), host(null as unknown as object)]) {
11
+ let disposed = 0;
12
+ const bottom = { ...component(), dispose() { disposed++; } };
13
+ const part = sidebarPart(tui, "todo", bottom);
14
+ assert.equal(part, bottom);
15
+ assert.deepEqual(part.render(), ["bottom"]);
16
+ part.dispose();
17
+ assert.equal(disposed, 1);
18
+ }
19
+ });
20
+
21
+ test("terminal state survives host replacement without leaking across terminals", () => {
22
+ const terminal = {};
23
+ assert.equal(sidebarState(host(terminal)), sidebarState(host(terminal)));
24
+ assert.notEqual(sidebarState(host(terminal)), sidebarState(host({})));
25
+ });
26
+
27
+ test("bottom paint is suppressed only while the sidebar owns the host", () => {
28
+ const tui = host({});
29
+ const state = sidebarState(tui);
30
+ const part = sidebarPart(tui, "todo", component());
31
+ state.active = true;
32
+ assert.deepEqual(part.render(80), ["bottom"]);
33
+ state.ownsHost = () => true;
34
+ assert.deepEqual(part.render(80), []);
35
+ state.active = false;
36
+ assert.deepEqual(part.render(80), ["bottom"]);
37
+ });
38
+
39
+ test("disposing an old part preserves its replacement and releases its bottom", () => {
40
+ const tui = host({});
41
+ let disposed = 0;
42
+ const first = sidebarPart(tui, "todo", { ...component(), dispose() { disposed++; } });
43
+ const replacement = component();
44
+ const second = sidebarPart(tui, "todo", replacement);
45
+ first.dispose?.();
46
+ assert.equal(disposed, 1);
47
+ assert.equal(sidebarState(tui).parts.get("todo"), replacement);
48
+ (second as typeof second & { dispose?(): void }).dispose?.();
49
+ assert.equal(sidebarState(tui).parts.size, 0);
50
+ });
@@ -134,7 +134,7 @@ test("renderTodoCard draws the framed list with status glyphs and keeps every li
134
134
  const lines = renderTodoCard(seeded(), plainTheme, 60, { collapsed: false, staleTurns: 0, collapseKey: "ctrl+shift+t" });
135
135
  for (const line of lines) assert.equal(visibleWidth(line), 60, `"${stripAnsi(line)}" is not 60 wide`);
136
136
  const plain = lines.map(stripAnsi);
137
- assert.match(plain[0], /^╭─ ❀ Todos · 1 of 3 ─+╮$/);
137
+ assert.match(plain[0], /^╭─ ❀ Todos ▾ Collapse · 1 of 3 ─+ ctrl\+shift\+t collapse ╮$/);
138
138
  assert.match(plain[1], /^│ ✓ ~Add quiet tool rendering~ +│$/);
139
139
  assert.match(plain[2], /^│ ◐ Fix quiet tools conflict · fixing conflict +│$/);
140
140
  assert.match(plain[3], /^│ ○ Show git bash tails +│$/);
@@ -146,7 +146,7 @@ test("renderTodoCard folds a long list: done tasks become one row and the open o
146
146
  const state = applyTodo(emptyTodo(), { action: "write", tasks }, 1).state;
147
147
  const plain = renderTodoCard(state, plainTheme, 60, { collapsed: false, staleTurns: 0 }).map(stripAnsi);
148
148
  assert.equal(plain.length, 14, "top rule, twelve rows, bottom rule");
149
- assert.match(plain[0], /Todos · 25 of 40/);
149
+ assert.match(plain[0], /Todos ▾ Collapse · 25 of 40/);
150
150
  assert.match(plain[1], /^│ ✓ 25 done +│$/);
151
151
  assert.match(plain[2], /^│ ◐ Task 26 +│$/);
152
152
  assert.match(plain[11], /^│ ○ Task 35 +│$/);
@@ -157,14 +157,103 @@ test("renderTodoCard folds a long list: done tasks become one row and the open o
157
157
  assert.match(folded[1], /^│ ✓ 40 done +│$/);
158
158
  });
159
159
 
160
- test("renderTodoCard marks a stale list in the top rule and collapses to the task in progress", () => {
161
- const stale = renderTodoCard(seeded(), plainTheme, 70, { collapsed: false, staleTurns: 2, collapseKey: "ctrl+shift+t" }).map(stripAnsi);
162
- assert.match(stale[0], /^╭─ ❀ Todos · 1 of 3 ─+ stale · 2 turns ╮$/);
163
- const collapsed = renderTodoCard(seeded(), plainTheme, 70, { collapsed: true, staleTurns: 0, collapseKey: "ctrl+shift+t" }).map(stripAnsi);
164
- assert.equal(collapsed.length, 3);
165
- assert.match(collapsed[0], /^╭─ ❀ Todos · 1 of 3 ─+ ctrl\+shift\+t expand ╮$/);
166
- assert.match(collapsed[1], /^│ ◐ Fix quiet tools conflict · fixing conflict +│$/);
167
- const idle = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "Only pending" }] }, 1).state;
168
- assert.match(renderTodoCard(idle, plainTheme, 70, { collapsed: true, staleTurns: 0 }).map(stripAnsi)[1], /^│ ○ 1 open +│$/);
160
+ test("renderTodoCard keeps the configured collapse shortcut in the header while stale state remains visible", () => {
161
+ const freshExpanded = renderTodoCard(seeded(), plainTheme, 70, { collapsed: false, staleTurns: 0, collapseKey: "ctrl+shift+t" }).map(stripAnsi);
162
+ assert.match(freshExpanded[0], /^╭─ ❀ Todos ▾ Collapse · 1 of 3 ─+ ctrl\+shift\+t collapse ╮$/);
163
+
164
+ const freshCollapsed = renderTodoCard(seeded(), plainTheme, 70, { collapsed: true, staleTurns: 0, collapseKey: "ctrl+shift+t" }).map(stripAnsi);
165
+ assert.equal(freshCollapsed.length, 3);
166
+ assert.match(freshCollapsed[0], /^╭─ ❀ Todos ▸ Expand · 1 of 3 ─+ ctrl\+shift\+t expand ╮$/);
167
+ assert.match(freshCollapsed[1], /^│ ◐ Fix quiet tools conflict · fixing conflict +│$/);
168
+
169
+ const staleExpanded = renderTodoCard(seeded(), plainTheme, 70, { collapsed: false, staleTurns: 2, collapseKey: "ctrl+shift+t" }).map(stripAnsi);
170
+ assert.match(staleExpanded[0], /^╭─ ❀ Todos ▾ Collapse · 1 of 3 ─+ ctrl\+shift\+t collapse ╮$/);
171
+ assert.match(staleExpanded[1], /^│ stale · 2 turns +│$/);
172
+
173
+ const staleCollapsed = renderTodoCard(seeded(), plainTheme, 70, { collapsed: true, staleTurns: 2, collapseKey: "ctrl+shift+t" }).map(stripAnsi);
174
+ assert.match(staleCollapsed[0], /^╭─ ❀ Todos ▸ Expand · 1 of 3 ─+ ctrl\+shift\+t expand ╮$/);
175
+ assert.match(staleCollapsed[1], /^│ stale · 2 turns +│$/);
176
+ assert.match(staleCollapsed[2], /^│ ◐ Fix quiet tools conflict · fixing conflict +│$/);
177
+
178
+ const fallback = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "Finished first", status: "done" }, { title: "First pending" }, { title: "Later pending" }] }, 1).state;
179
+ assert.match(renderTodoCard(fallback, plainTheme, 70, { collapsed: true, staleTurns: 0 }).map(stripAnsi)[1], /^│ ○ First pending +│$/);
180
+
181
+ const activeAfterPending = applyTodo(emptyTodo(), { action: "write", tasks: [{ title: "Pending first" }, { title: "Active second", status: "in_progress" }, { title: "Pending third" }] }, 1).state;
182
+ assert.match(renderTodoCard(activeAfterPending, plainTheme, 70, { collapsed: true, staleTurns: 0 }).map(stripAnsi)[1], /^│ ◐ Active second +│$/);
169
183
  assert.deepEqual(renderTodoCard(emptyTodo(), plainTheme, 70, { collapsed: false, staleTurns: 0 }), []);
170
184
  });
185
+
186
+ test("renderTodoCard keeps English controls visible and falls back to their icons at narrow widths", () => {
187
+ const expanded = renderTodoCard(seeded(), plainTheme, 70, { collapsed: false, staleTurns: 0, collapseKey: "ctrl+shift+t" }).map(stripAnsi);
188
+ assert.match(expanded[0], /Todos ▾ Collapse/);
189
+ assert.match(expanded[0], /ctrl\+shift\+t collapse/);
190
+ const collapsed = renderTodoCard(seeded(), plainTheme, 70, { collapsed: true, staleTurns: 0, collapseKey: "ctrl+shift+t" }).map(stripAnsi);
191
+ assert.match(collapsed[0], /Todos ▸ Expand/);
192
+ for (const [collapsedState, icon] of [[false, "▾"], [true, "▸"]] as const) {
193
+ const line = renderTodoCard(seeded(), plainTheme, 16, { collapsed: collapsedState, staleTurns: 0, collapseKey: "ctrl+shift+t" })[0]!;
194
+ assert.match(stripAnsi(line), new RegExp(`Todos ${icon}`));
195
+ assert.equal(visibleWidth(line), 16);
196
+ }
197
+ });
198
+
199
+ test("completed titles strike only title cells across wrapped lines, never padding or rails", () => {
200
+ const theme = {
201
+ fg: (_color: string, text: string) => `\x1b[32m${text}\x1b[39m`,
202
+ strikethrough: (text: string) => `\x1b[9m${text}\x1b[29m`,
203
+ };
204
+ const state = applyTodo(emptyTodo(), { action: "write", tasks: [
205
+ { title: "Alpha beta gamma delta epsilon", status: "done" },
206
+ { title: "Pending", status: "pending" },
207
+ ] }, 1).state;
208
+ for (const width of [12, 20, 60]) {
209
+ const lines = renderTodoCard(state, theme, width, { collapsed: false, staleTurns: 0 });
210
+ let struckLetters = "";
211
+ for (const line of lines) {
212
+ let strike = false, column = 0;
213
+ const plain = stripAnsi(line);
214
+ const titleEnd = plain.slice(0, -1).trimEnd().length;
215
+ for (const token of line.match(/\x1b\[[\d;]*m|[^\x1b]/gu) ?? []) {
216
+ if (token.startsWith("\x1b")) {
217
+ for (const code of token.slice(2, -1).split(";").map(Number)) {
218
+ if (code === 0 || code === 29) strike = false;
219
+ if (code === 9) strike = true;
220
+ }
221
+ } else {
222
+ if (column < 2 || column >= titleEnd || /[│╭╮╰╯─✓]/u.test(token)) assert.equal(strike, false, `struck frame/padding: ${JSON.stringify(line)}`);
223
+ if (strike && /[A-Za-z]/.test(token)) struckLetters += token;
224
+ column += visibleWidth(token);
225
+ }
226
+ }
227
+ assert.equal(strike, false, "SGR 9 must close before the host appends padding");
228
+ assert.equal(visibleWidth(line), width);
229
+ }
230
+ assert.equal(struckLetters, "Alphabetagammadeltaepsilon");
231
+ }
232
+ });
233
+
234
+ test("renderTodoCard keeps stale indicators and collapse hints in scrollable lists", () => {
235
+ const tasks = Array.from({ length: 40 }, (_, index) => ({ title: `Task ${index + 1}`, status: index < 25 ? "done" : "pending" }));
236
+ const state = applyTodo(emptyTodo(), { action: "write", tasks }, 1).state;
237
+ const lines = renderTodoCard(state, plainTheme, 70, { scrollable: true, collapsed: false, staleTurns: 2, collapseKey: "alt+t" }).map(stripAnsi);
238
+ assert.equal(lines.length, 43, "top rule, stale row, every task, bottom rule");
239
+ assert.match(lines[0], /^╭─ ❀ Todos ▾ Collapse · 25 of 40 ─+ alt\+t collapse ╮$/);
240
+ assert.match(lines[1], /^│ stale · 2 turns +│$/);
241
+ assert.match(lines[2], /^│ ✓ ~Task 1~ +│$/);
242
+ assert.match(lines[41], /^│ ○ Task 40 +│$/);
243
+ for (const line of lines) assert.equal(visibleWidth(line), 70);
244
+ });
245
+
246
+ test("renderTodoCard uses custom shortcuts, omits disabled shortcuts, and remains width-safe when hints cannot fit", () => {
247
+ const custom = renderTodoCard(seeded(), plainTheme, 70, { collapsed: false, staleTurns: 2, collapseKey: "alt+t" }).map(stripAnsi);
248
+ assert.match(custom[0], /alt\+t collapse/);
249
+ assert.match(custom[1], /stale · 2 turns/);
250
+
251
+ const disabled = renderTodoCard(seeded(), plainTheme, 70, { collapsed: true, staleTurns: 2 }).map(stripAnsi);
252
+ assert.doesNotMatch(disabled[0], /(?:collapse|expand|ctrl\+shift\+t)/);
253
+ assert.match(disabled[1], /stale · 2 turns/);
254
+
255
+ for (const width of [0, 1, 2, 3, 4, 5, 8, 16]) {
256
+ const lines = renderTodoCard(seeded(), plainTheme, width, { collapsed: false, staleTurns: 2, collapseKey: "ctrl+shift+t" });
257
+ for (const line of lines) assert.equal(visibleWidth(line), width, `"${stripAnsi(line)}" is not ${width} wide`);
258
+ }
259
+ });
@@ -0,0 +1,126 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { syncBuiltinESMExports } from "node:module";
4
+ import fs from "node:fs/promises";
5
+ import startup, { readGitBranch } from "../extensions/startup-banner.ts";
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import { visibleWidth } from "@earendil-works/pi-tui";
8
+ import { stripAnsi } from "../lib/terminal-theme.ts";
9
+
10
+ test("startup branch lookup uses direct git argv and hides its Windows child", async () => {
11
+ const calls: Array<{ command: string; args: readonly string[]; options: Record<string, unknown> }> = [];
12
+ const run = ((command: string, args: readonly string[], options: Record<string, unknown>, callback: (error: Error | null, stdout: string) => void) => {
13
+ calls.push({ command, args, options });
14
+ callback(null, "main\n");
15
+ }) as typeof import("node:child_process").execFile;
16
+ assert.equal(await readGitBranch("/repo with spaces & metacharacters", run), "On branch main");
17
+ assert.deepEqual(calls, [{
18
+ command: "git",
19
+ args: ["-C", "/repo with spaces & metacharacters", "branch", "--show-current"],
20
+ options: { encoding: "utf8", shell: false, windowsHide: true },
21
+ }]);
22
+ });
23
+
24
+ test("startup banner keeps animating after invalidate and cleans up on dispose", async (t) => {
25
+ t.mock.timers.enable({ apis: ["setTimeout", "setInterval", "Date"] });
26
+ t.mock.method(fs, "readFile", async () => JSON.stringify({ showRose: true, showTextLogo: true, color: "pink" }));
27
+ syncBuiltinESMExports();
28
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
29
+ const argv = process.argv;
30
+ process.argv = ["node"];
31
+ t.after(() => { process.argv = argv; });
32
+ for (const [key, value] of [["rows", 40], ["columns", 160]] as const) {
33
+ const descriptor = Object.getOwnPropertyDescriptor(process.stdout, key);
34
+ Object.defineProperty(process.stdout, key, { configurable: true, writable: true, value });
35
+ t.after(() => descriptor ? Object.defineProperty(process.stdout, key, descriptor) : Reflect.deleteProperty(process.stdout, key));
36
+ }
37
+ let start: Function;
38
+ let shutdown: Function;
39
+ let header: { render(width: number): string[]; invalidate(): void; dispose(): void };
40
+ let renders = 0;
41
+ startup({ on: (name: string, fn: Function) => {
42
+ if (name === "session_start") start = fn;
43
+ if (name === "session_shutdown") shutdown = fn;
44
+ }, registerCommand() {}, getCommands: () => [], getAllTools: () => [] } as unknown as ExtensionAPI);
45
+ await start!({}, { hasUI: true, cwd: "/fixture", ui: { setHeader: (factory: Function) => {
46
+ header = factory({ requestRender() { renders++; } }, { fg: (_role: string, text: string) => text });
47
+ } } });
48
+ t.mock.timers.tick(50);
49
+ const afterBoot = renders;
50
+ t.mock.timers.tick(25);
51
+ assert.ok(renders > afterBoot, "animation timer requests renders");
52
+ header!.invalidate();
53
+ const afterInvalidate = renders;
54
+ t.mock.timers.tick(25);
55
+ assert.ok(renders > afterInvalidate, "invalidate() must not stop the animation timer");
56
+ header!.dispose();
57
+ const afterDispose = renders;
58
+ t.mock.timers.tick(25);
59
+ assert.equal(renders, afterDispose, "dispose() stops the animation timer");
60
+ shutdown!();
61
+ t.mock.timers.tick(25);
62
+ assert.equal(renders, afterDispose, "session_shutdown cleanup stays idle");
63
+ });
64
+
65
+ // Drive the real header factory; background git/home reads never run.
66
+ for (const showRose of [false, true]) for (const showTextLogo of [false, true]) {
67
+ test(`startup art respects rose=${showRose}, logo=${showTextLogo} and cyan palette`, async (t) => {
68
+ t.mock.timers.enable({ apis: ["setTimeout", "setInterval", "Date"] });
69
+ t.mock.method(fs, "readFile", async () => JSON.stringify({ showRose, showTextLogo, color: "cyan" }));
70
+ syncBuiltinESMExports();
71
+ t.after(() => { t.mock.restoreAll(); syncBuiltinESMExports(); });
72
+ const argv = process.argv;
73
+ process.argv = ["node"];
74
+ t.after(() => { process.argv = argv; });
75
+ for (const [key, value] of [["rows", 40], ["columns", 160]] as const) {
76
+ const descriptor = Object.getOwnPropertyDescriptor(process.stdout, key);
77
+ Object.defineProperty(process.stdout, key, { configurable: true, writable: true, value });
78
+ t.after(() => descriptor ? Object.defineProperty(process.stdout, key, descriptor) : Reflect.deleteProperty(process.stdout, key));
79
+ }
80
+ let start: Function;
81
+ let shutdown: Function;
82
+ let header: { render(width: number): string[]; dispose(): void };
83
+ const writes: string[] = [];
84
+ startup({ on: (name: string, fn: Function) => {
85
+ if (name === "session_start") start = fn;
86
+ if (name === "session_shutdown") shutdown = fn;
87
+ }, registerCommand() {}, getCommands: () => [], getAllTools: () => [] } as unknown as ExtensionAPI);
88
+ const write = t.mock.method(process.stdout, "write", (text: string) => { writes.push(String(text)); return true; });
89
+ await start!({}, { hasUI: true, cwd: "/fixture", ui: { setHeader: (factory: Function) => {
90
+ header = factory({ requestRender() {} }, { fg: (_role: string, text: string) => text });
91
+ } } });
92
+ t.mock.timers.tick(50);
93
+ try {
94
+ for (const width of [40, 80, 160]) {
95
+ const lines = header!.render(width);
96
+ assert.ok(lines.every((line) => visibleWidth(line) <= width));
97
+ const text = stripAnsi(lines.join("\n"));
98
+ assert.match(text, /GIT:/);
99
+ assert.match(text, /PATH:/);
100
+ if (width === 160) {
101
+ assert.equal(/[\u2800-\u28ff]/.test(text), showRose);
102
+ assert.equal(/[▒▄▀█]/.test(text), showTextLogo);
103
+ }
104
+ assert.match(lines.join("\n"), /\x1b\[38;2;85;170;205m/, "startup labels use the saved cyan palette");
105
+ }
106
+ // Cancel pending context reads before advancing the resize clock.
107
+ t.mock.timers.reset();
108
+ t.mock.timers.enable({ apis: ["setTimeout", "Date"], now: Date.now() + 1000 });
109
+ process.stdout.rows = 10;
110
+ process.stdout.emit("resize");
111
+ t.mock.timers.tick(150);
112
+ assert.deepEqual(header!.render(80), []);
113
+ process.stdout.rows = 25;
114
+ process.stdout.columns = 80;
115
+ process.stdout.emit("resize");
116
+ t.mock.timers.tick(150);
117
+ const minimal = stripAnsi(header!.render(80).join("\n"));
118
+ assert.doesNotMatch(minimal, /[\u2800-\u28ff]/);
119
+ assert.equal(/[▒▄▀█]/.test(minimal), showTextLogo);
120
+ assert.deepEqual(writes, [], "Pi owns stdout during startup and resize");
121
+ } finally {
122
+ shutdown!();
123
+ write.mock.restore();
124
+ }
125
+ });
126
+ }
@@ -73,7 +73,7 @@ test("shouldTriggerTelemetry: pure predicate table", () => {
73
73
  }
74
74
  });
75
75
 
76
- test("spawnTelemetryTrigger: launches the expected argv, detached and stdio-ignored", () => {
76
+ test("spawnTelemetryTrigger: launches the expected argv, detached, hidden, and stdio-ignored", () => {
77
77
  const fake = fakeSpawn();
78
78
  const result = spawnTelemetryTrigger({
79
79
  executable: "/opt/gentle-ai/gentle-ai",
@@ -88,6 +88,7 @@ test("spawnTelemetryTrigger: launches the expected argv, detached and stdio-igno
88
88
  assert.deepEqual(call.args, ["telemetry", "trigger", "--json"]);
89
89
  assert.equal(call.options.cwd, "/work/project");
90
90
  assert.equal(call.options.detached, true);
91
+ assert.equal(call.options.windowsHide, true);
91
92
  assert.equal(call.options.stdio, "ignore");
92
93
  });
93
94
 
@@ -212,6 +213,7 @@ test("activation: spawns the telemetry trigger exactly once for a primary sessio
212
213
  assert.deepEqual(call.args, ["telemetry", "trigger", "--json"]);
213
214
  assert.equal(call.options.cwd, "/work/project");
214
215
  assert.equal(call.options.detached, true);
216
+ assert.equal(call.options.windowsHide, true);
215
217
  assert.equal(call.options.stdio, "ignore");
216
218
  });
217
219