gentle-pi 3.7.0 → 4.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 (316) hide show
  1. package/README.md +37 -6
  2. package/assets/agents/gentle-ai-explore.md +4 -4
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -9
  5. package/assets/orchestrator-delegation.md +40 -41
  6. package/assets/orchestrator-memory.md +1 -22
  7. package/assets/orchestrator-skills.md +1 -1
  8. package/assets/orchestrator.md +12 -24
  9. package/assets/support/strict-tdd-verify.md +4 -266
  10. package/assets/support/strict-tdd.md +8 -360
  11. package/bin/gentle-shell.mjs +254 -29
  12. package/docs/delegated-verification.md +26 -1
  13. package/docs/gentle-agents-activity.md +24 -0
  14. package/docs/gentle-shell.md +92 -28
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +112 -206
  18. package/docs/telemetry.md +1 -1
  19. package/docs/yolo-mode.md +86 -0
  20. package/extensions/child-context.ts +26 -0
  21. package/extensions/child-safety.ts +23 -0
  22. package/extensions/gentle-agents.ts +429 -262
  23. package/extensions/gentle-ai.ts +781 -683
  24. package/extensions/gentle-shell.ts +1375 -88
  25. package/extensions/gentle-stats.ts +101 -0
  26. package/extensions/gentle-todo.ts +18 -7
  27. package/extensions/history/atomic-write.ts +38 -0
  28. package/extensions/history/hide-prompts.ts +183 -0
  29. package/extensions/history/index.ts +1419 -0
  30. package/extensions/history/load-shared-history.ts +39 -0
  31. package/extensions/history/selector-helpers.ts +538 -0
  32. package/extensions/history/session-scan.ts +233 -0
  33. package/extensions/history/store.ts +1119 -0
  34. package/extensions/nan-provider.ts +6 -0
  35. package/extensions/quiet-tools.ts +179 -87
  36. package/extensions/resume-hint.ts +60 -0
  37. package/extensions/skill-registry.ts +16 -12
  38. package/extensions/startup-banner.ts +60 -31
  39. package/lib/agent-assets.ts +604 -0
  40. package/lib/agent-profile-pin.ts +12 -0
  41. package/lib/agents-message-delivery.ts +181 -0
  42. package/lib/agents-protocol.ts +60 -0
  43. package/lib/agents-runner.ts +96 -98
  44. package/lib/agents-view.ts +26 -3
  45. package/lib/agents-widget.ts +16 -9
  46. package/lib/append-system-prompt.ts +21 -0
  47. package/lib/bounded-writer-admission.ts +147 -0
  48. package/lib/card-style-policy.ts +60 -0
  49. package/lib/child-context-files.ts +166 -0
  50. package/lib/codemode-renderer.ts +185 -0
  51. package/lib/command-palette-catalog.ts +3 -9
  52. package/lib/command-palette.ts +25 -14
  53. package/lib/destructive-command-guard.ts +144 -0
  54. package/lib/gentle-ai-elapsed-store.ts +87 -0
  55. package/lib/gentle-ai-renderer.ts +196 -39
  56. package/lib/gentle-shell-launcher.ts +24 -13
  57. package/lib/gentle-shell-resume-hint.ts +176 -0
  58. package/lib/history-capture-policy.ts +95 -0
  59. package/lib/model-routing-authority.ts +5 -1
  60. package/lib/nan-provider.ts +227 -0
  61. package/lib/native-review-cli.ts +49 -108
  62. package/lib/odd-phase-inference.ts +231 -0
  63. package/lib/odd-phase.ts +141 -0
  64. package/lib/overlay-repaint.ts +26 -0
  65. package/lib/pi-tui-keys.ts +53 -0
  66. package/lib/review-candidate-view-owner.ts +67 -17
  67. package/lib/review-candidate-view.ts +112 -25
  68. package/lib/review-reminder-receipt.ts +48 -8
  69. package/lib/review-risk-assessment.ts +156 -11
  70. package/lib/review-sidebar-state.ts +223 -0
  71. package/lib/selection-engine.ts +515 -0
  72. package/lib/session-messaging-grants.ts +135 -0
  73. package/lib/session-worktree-registry.ts +14 -2
  74. package/lib/shell-bar.ts +179 -24
  75. package/lib/shell-card.ts +287 -18
  76. package/lib/shell-changes-view.ts +2 -1
  77. package/lib/shell-prompt.ts +102 -6
  78. package/lib/shell-sidebar-layout.ts +78 -14
  79. package/lib/shell-sidebar.ts +15 -1
  80. package/lib/shell-todo.ts +23 -13
  81. package/lib/shell-usage-view.ts +9 -4
  82. package/lib/shell-usage.ts +66 -10
  83. package/lib/stats-collector.ts +381 -0
  84. package/lib/stats-view.ts +431 -0
  85. package/lib/theme-customization.ts +52 -0
  86. package/lib/vim-editor-adapter.ts +379 -0
  87. package/lib/vim-normal-engine.ts +154 -0
  88. package/lib/vim-operator-engine.ts +416 -0
  89. package/lib/vim-policy.ts +49 -0
  90. package/lib/vim-visual-engine.ts +107 -0
  91. package/lib/visual-customization-policy.ts +108 -0
  92. package/lib/visual-customize-view.ts +330 -0
  93. package/lib/visual-profiles.ts +228 -0
  94. package/lib/yolo-session-policy.ts +240 -0
  95. package/package.json +20 -8
  96. package/runtime/gentle-shell-launcher.mjs +23 -12
  97. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  98. package/runtime/native-review-cli.mjs +49 -108
  99. package/runtime/review-risk-assessment.mjs +154 -9
  100. package/scripts/build-runtime-modules.mjs +1 -0
  101. package/scripts/gentle-ai-installer.mjs +14 -13
  102. package/scripts/mirror-odd-routing.mjs +2 -2
  103. package/scripts/run-test-suite.mjs +76 -0
  104. package/scripts/test-packed-runner.mjs +31 -14
  105. package/scripts/verify-package-files.mjs +11 -22
  106. package/skills/branch-pr/SKILL.md +24 -52
  107. package/skills/chained-pr/SKILL.md +31 -15
  108. package/skills/chained-pr/references/chaining-details.md +31 -20
  109. package/skills/gentle-ai/SKILL.md +9 -15
  110. package/skills/issue-creation/SKILL.md +8 -2
  111. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  112. package/skills/work-unit-commits/SKILL.md +4 -3
  113. package/tests/agent-profiles.test.ts +18 -0
  114. package/tests/agents-fake-child.ts +2 -2
  115. package/tests/agents-message-delivery.test.ts +106 -0
  116. package/tests/agents-protocol.test.ts +40 -0
  117. package/tests/agents-runner.test.ts +378 -89
  118. package/tests/agents-view-thread-identity.test.ts +169 -0
  119. package/tests/agents-view.test.ts +8 -2
  120. package/tests/agents-widget.test.ts +154 -15
  121. package/tests/append-system-prompt-route.test.ts +160 -0
  122. package/tests/append-system-prompt.test.ts +46 -0
  123. package/tests/artifact-language.test.ts +19 -213
  124. package/tests/ask-user-question.test.ts +44 -1
  125. package/tests/asset-installation-runtime.test.ts +5 -16
  126. package/tests/autonomous-guard.test.ts +69 -1
  127. package/tests/bounded-writer-admission.test.ts +95 -0
  128. package/tests/branch-pr-skill.test.ts +43 -0
  129. package/tests/card-style-policy.test.ts +55 -0
  130. package/tests/chained-pr-skill.test.ts +124 -0
  131. package/tests/child-context-files.test.ts +255 -0
  132. package/tests/child-safety.test.ts +82 -0
  133. package/tests/codemode-rendering.test.ts +491 -0
  134. package/tests/command-palette.test.ts +39 -3
  135. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  136. package/tests/destructive-command-guard.test.ts +84 -0
  137. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  138. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  139. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  140. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  145. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  146. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  148. package/tests/generic-agent-tools.test.ts +54 -0
  149. package/tests/gentle-agents.test.ts +1047 -252
  150. package/tests/gentle-ai-binary.test.ts +3 -3
  151. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  152. package/tests/gentle-ai-installer.test.ts +68 -54
  153. package/tests/gentle-ai-renderer.test.ts +487 -8
  154. package/tests/gentle-ai.test.ts +288 -65
  155. package/tests/gentle-card-text.ts +2 -1
  156. package/tests/gentle-shell-bin.test.ts +651 -114
  157. package/tests/gentle-shell-launcher.test.ts +99 -52
  158. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  159. package/tests/gentle-shell.test.ts +3839 -187
  160. package/tests/gentle-stats.test.ts +152 -0
  161. package/tests/gentle-theme.test.ts +4 -1
  162. package/tests/gentle-todo.test.ts +80 -8
  163. package/tests/history-atomic-write.test.ts +57 -0
  164. package/tests/history-capture-policy.test.ts +102 -0
  165. package/tests/history-command-registration.test.ts +164 -0
  166. package/tests/history-dedupe-entries.test.ts +123 -0
  167. package/tests/history-delete-backfill.test.ts +190 -0
  168. package/tests/history-delete-confirm.test.ts +460 -0
  169. package/tests/history-dispatch.test.ts +180 -0
  170. package/tests/history-drain-hidden.test.ts +110 -0
  171. package/tests/history-drain-order.test.ts +98 -0
  172. package/tests/history-expanded-globals.test.ts +62 -0
  173. package/tests/history-gc.test.ts +832 -0
  174. package/tests/history-header-layout.test.ts +265 -0
  175. package/tests/history-hide-prompts.test.ts +275 -0
  176. package/tests/history-lazy-windowing.test.ts +508 -0
  177. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  178. package/tests/history-load-shared-history.test.ts +53 -0
  179. package/tests/history-max-results-cap.test.ts +76 -0
  180. package/tests/history-multi-reader.test.ts +203 -0
  181. package/tests/history-off-path.test.ts +170 -0
  182. package/tests/history-openflow-integration.test.ts +173 -0
  183. package/tests/history-overlay-margin.test.ts +326 -0
  184. package/tests/history-preview-layout.test.ts +93 -0
  185. package/tests/history-registry.test.ts +143 -0
  186. package/tests/history-scope-delete.test.ts +411 -0
  187. package/tests/history-search-caret-keys.test.ts +142 -0
  188. package/tests/history-seed-bootstrap.test.ts +170 -0
  189. package/tests/history-seed-regen.test.ts +129 -0
  190. package/tests/history-selector-windowing.test.ts +94 -0
  191. package/tests/history-session-scan-directory.test.ts +87 -0
  192. package/tests/history-session-scan-extract.test.ts +583 -0
  193. package/tests/history-session-writer.test.ts +351 -0
  194. package/tests/history-store-paths.test.ts +79 -0
  195. package/tests/history-tombstone-exact.test.ts +139 -0
  196. package/tests/history-wheel-mouse.test.ts +242 -0
  197. package/tests/inprocess-reviewer.test.ts +29 -19
  198. package/tests/issue-creation-skill.test.ts +61 -0
  199. package/tests/model-routing-authority.test.ts +16 -0
  200. package/tests/nan-provider.test.ts +471 -0
  201. package/tests/native-review-capability-contract.test.ts +7 -1
  202. package/tests/native-review-cli.test.ts +6 -120
  203. package/tests/native-review-parity-runtime.test.ts +100 -3
  204. package/tests/odd-integration.test.ts +67 -0
  205. package/tests/odd-phase-inference.test.ts +213 -0
  206. package/tests/odd-phase-loader.test.ts +253 -0
  207. package/tests/odd-phase.test.ts +307 -0
  208. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  209. package/tests/odd-routing-contract.test.ts +86 -35
  210. package/tests/orchestrator-budget.test.ts +14 -39
  211. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  212. package/tests/overlay-repaint.test.ts +74 -0
  213. package/tests/package-manifest.test.ts +252 -115
  214. package/tests/packed-runner-owned-path.test.ts +46 -0
  215. package/tests/persona-single-channel.test.ts +6 -6
  216. package/tests/provider-defect-handoff.test.ts +3 -11
  217. package/tests/quiet-bash-runtime.test.ts +76 -0
  218. package/tests/quiet-tool-rendering.test.ts +409 -184
  219. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  220. package/tests/rdd-status-line.test.ts +9 -4
  221. package/tests/resume-hint-extension.test.ts +122 -0
  222. package/tests/review-agent-end-preflight.test.ts +176 -12
  223. package/tests/review-candidate-owner-retry.test.ts +22 -1
  224. package/tests/review-candidate-view.test.ts +298 -0
  225. package/tests/review-contract-prompt.test.ts +108 -43
  226. package/tests/review-controller-lock-status.test.ts +0 -1
  227. package/tests/review-controller-native-routing.test.ts +611 -5
  228. package/tests/review-controller-workspace-root.test.ts +163 -4
  229. package/tests/review-host-relay-routing.test.ts +338 -2
  230. package/tests/review-integration-v2-forward.test.ts +200 -0
  231. package/tests/review-ledger-contract.test.ts +10 -34
  232. package/tests/review-reminder-receipt.test.ts +47 -1
  233. package/tests/review-risk-assessment.test.ts +498 -6
  234. package/tests/review-sidebar-state.test.ts +402 -0
  235. package/tests/run-test-suite.test.ts +124 -0
  236. package/tests/runtime-harness.mjs +145 -786
  237. package/tests/runtime-metrics-children.test.ts +16 -23
  238. package/tests/selection-engine.test.ts +421 -0
  239. package/tests/session-messaging-grants.test.ts +255 -0
  240. package/tests/session-worktree-registry.test.ts +77 -0
  241. package/tests/shell-bar.test.ts +382 -1
  242. package/tests/shell-card.test.ts +353 -1
  243. package/tests/shell-changes-view.test.ts +52 -0
  244. package/tests/shell-prompt.test.ts +94 -2
  245. package/tests/shell-sidebar-layout.test.ts +325 -21
  246. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  247. package/tests/shell-todo.test.ts +87 -1
  248. package/tests/shell-usage-view.test.ts +27 -0
  249. package/tests/shell-usage.test.ts +73 -0
  250. package/tests/skill-registry.test.ts +50 -1
  251. package/tests/startup-banner.test.ts +130 -2
  252. package/tests/stats-collector.test.ts +195 -0
  253. package/tests/stats-view.test.ts +202 -0
  254. package/tests/telemetry-trigger.test.ts +81 -20
  255. package/tests/theme-customization.test.ts +72 -0
  256. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  257. package/tests/vim-editor-adapter.test.ts +804 -0
  258. package/tests/vim-normal-engine.test.ts +101 -0
  259. package/tests/vim-operator-engine.test.ts +215 -0
  260. package/tests/vim-policy.test.ts +19 -0
  261. package/tests/vim-visual-engine.test.ts +52 -0
  262. package/tests/visual-customization-policy.test.ts +110 -0
  263. package/tests/visual-customize-view.test.ts +418 -0
  264. package/tests/visual-profiles.test.ts +87 -0
  265. package/tests/yolo-customize.test.ts +256 -0
  266. package/tests/yolo-mode-runtime.test.ts +161 -0
  267. package/tests/yolo-mode.test.ts +261 -0
  268. package/tests/yolo-session-policy.test.ts +59 -0
  269. package/themes/Gentle.json +2 -1
  270. package/themes/Gentleman-Cute.json +2 -1
  271. package/themes/Gentleman-Sexy.json +2 -1
  272. package/assets/agents/sdd-apply.md +0 -159
  273. package/assets/agents/sdd-archive.md +0 -228
  274. package/assets/agents/sdd-design.md +0 -49
  275. package/assets/agents/sdd-explore.md +0 -48
  276. package/assets/agents/sdd-init.md +0 -56
  277. package/assets/agents/sdd-onboard.md +0 -52
  278. package/assets/agents/sdd-proposal.md +0 -64
  279. package/assets/agents/sdd-remediate.md +0 -37
  280. package/assets/agents/sdd-research.md +0 -49
  281. package/assets/agents/sdd-spec.md +0 -192
  282. package/assets/agents/sdd-status.md +0 -54
  283. package/assets/agents/sdd-tasks.md +0 -108
  284. package/assets/agents/sdd-verify.md +0 -124
  285. package/assets/chains/sdd-full.chain.md +0 -83
  286. package/assets/chains/sdd-plan.chain.md +0 -56
  287. package/assets/chains/sdd-verify.chain.md +0 -43
  288. package/assets/sdd-orchestrator-workflow.md +0 -319
  289. package/assets/support/sdd-status-contract.md +0 -77
  290. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  291. package/extensions/sdd-init.ts +0 -816
  292. package/lib/openspec-deltas.ts +0 -156
  293. package/lib/sdd-preflight.ts +0 -1066
  294. package/lib/sdd-research-capabilities.ts +0 -94
  295. package/lib/sdd-status.ts +0 -26
  296. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  297. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  298. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  299. package/tests/openspec-deltas.test.ts +0 -209
  300. package/tests/sdd-agent-tools.test.ts +0 -156
  301. package/tests/sdd-archive-replay.test.ts +0 -82
  302. package/tests/sdd-classical-continuation.test.ts +0 -74
  303. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  304. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  305. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  306. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  307. package/tests/sdd-odd-integration.test.ts +0 -33
  308. package/tests/sdd-optional-research.test.ts +0 -124
  309. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  310. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  311. package/tests/sdd-preflight.test.ts +0 -541
  312. package/tests/sdd-research-capabilities.test.ts +0 -114
  313. package/tests/sdd-research-live.test.ts +0 -241
  314. package/tests/sdd-selection-transport.test.ts +0 -653
  315. package/tests/sdd-status.test.ts +0 -9
  316. package/tests/sdd-task-truth.test.ts +0 -43
@@ -0,0 +1,169 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import test from "node:test";
7
+ import { TaskStore, type TaskThread } from "../lib/agents-protocol.ts";
8
+ import { AgentsView } from "../lib/agents-view.ts";
9
+
10
+ // Regression suite for #1011: every presence poll re-reads remote activity from
11
+ // disk, so thread items arrive as fresh objects even when nothing changed.
12
+ // refreshPresence must reuse the prior ThreadItem object when the sanitized new
13
+ // item is deeply equal at the same position (WeakMap render cache hit), while
14
+ // changed items keep a new identity so cached renderings cannot go stale.
15
+
16
+ interface FixtureSummary {
17
+ id: string;
18
+ agent: string;
19
+ label: string;
20
+ status: string;
21
+ model: string;
22
+ createdAt: number;
23
+ startedAt: number | null;
24
+ endedAt: number | null;
25
+ lastActivityAt: number;
26
+ }
27
+
28
+ type FixtureItem = Record<string, unknown>;
29
+ type FixtureTask = { summary: FixtureSummary; thread: { version: number; dropped: number; items: FixtureItem[] } };
30
+ type FixtureSession = { sessionId: string; incarnation: string };
31
+
32
+ function summary(id: string): FixtureSummary {
33
+ return { id, agent: "worker", label: `task ${id}`, status: "running", model: "test-model", createdAt: 1000, startedAt: 1000, endedAt: null, lastActivityAt: 1000 };
34
+ }
35
+
36
+ function sampleItems(): FixtureItem[] {
37
+ return [
38
+ { kind: "text", text: "alpha" },
39
+ { kind: "tool", callId: "c1", name: "grep", output: "matches", running: false, isError: false },
40
+ { kind: "note", text: "note" },
41
+ ];
42
+ }
43
+
44
+ // Writes the presence pair directly (same shape projectActivity publishes) so
45
+ // tests control content and timing without the publisher's coalescing debounce.
46
+ function publish(profile: string, session: FixtureSession, tasks: FixtureTask[]): void {
47
+ const root = join(profile, "gentle-agents", "presence");
48
+ mkdirSync(root, { recursive: true, mode: 0o700 });
49
+ const sessionHash = createHash("sha256").update(session.sessionId).digest("hex");
50
+ const bytes = Buffer.from(JSON.stringify({ schema: 1, sessionHash, incarnation: session.incarnation, generation: 1, activity: { tasks } }), "utf8");
51
+ const header = { schema: 1, sessionHash, incarnation: session.incarnation, label: "peer", heartbeat: Date.now(), generation: 1, counts: { running: tasks.length, queued: 0, waiting: 0, finished: 0 }, digest: createHash("sha256").update(bytes).digest("hex"), unavailable: null };
52
+ // Header last: readers validate activity bytes against the header digest.
53
+ writeFileSync(join(root, `${sessionHash}.${session.incarnation}.activity.json`), bytes);
54
+ writeFileSync(join(root, `${sessionHash}.${session.incarnation}.header.json`), JSON.stringify(header));
55
+ }
56
+
57
+ function taskKey(session: FixtureSession, summaryId: string): string {
58
+ const sessionHash = createHash("sha256").update(session.sessionId).digest("hex");
59
+ return `peer:${sessionHash}:${session.incarnation}:${summaryId}`;
60
+ }
61
+
62
+ function harness(profile: string): { view: AgentsView; remoteThreads(): Map<string, TaskThread> } {
63
+ const view = new AgentsView({
64
+ theme: { fg: (_color, text) => text },
65
+ rows: 10,
66
+ store: new TaskStore(),
67
+ sessionId: "local",
68
+ now: () => 61_000,
69
+ onCancel: () => {},
70
+ onOpen: () => {},
71
+ onClose: () => {},
72
+ requestRender: () => {},
73
+ presence: { profile },
74
+ });
75
+ return { view, remoteThreads: () => (view as unknown as { remoteThreads: Map<string, TaskThread> }).remoteThreads };
76
+ }
77
+
78
+ async function withPresenceFixture(run: (profile: string) => Promise<void>): Promise<void> {
79
+ // Presence rejects symlinked profile ancestors, and macOS tmpdir() lives under
80
+ // the /var -> /private/var symlink, so the fixture must use the canonical path.
81
+ const profile = mkdtempSync(join(realpathSync(tmpdir()), "agents-view-identity-"));
82
+ try {
83
+ await run(profile);
84
+ } finally {
85
+ rmSync(profile, { recursive: true, force: true });
86
+ }
87
+ }
88
+
89
+ const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
90
+
91
+ async function until<T>(probe: () => T | undefined, description: string, timeoutMs = 5_000): Promise<T> {
92
+ const deadline = Date.now() + timeoutMs;
93
+ for (;;) {
94
+ const found = probe();
95
+ if (found !== undefined) return found;
96
+ if (Date.now() > deadline) throw new Error(`timed out waiting for ${description}`);
97
+ await sleep(5);
98
+ }
99
+ }
100
+
101
+ // The view replaces its remoteThreads map on every applied poll, so map
102
+ // identity is a reliable witness that a subsequent poll has been applied.
103
+ function nextApplication(h: ReturnType<typeof harness>, appliedOnce: Map<string, TaskThread>, key: string): Promise<TaskThread> {
104
+ return until(() => {
105
+ const map = h.remoteThreads();
106
+ return map === appliedOnce ? undefined : map.get(key);
107
+ }, "a subsequent presence poll");
108
+ }
109
+
110
+ test("unchanged remote thread items keep object identity across presence polls", async () => {
111
+ await withPresenceFixture(async (profile) => {
112
+ const session = { sessionId: "peer session", incarnation: randomUUID() };
113
+ publish(profile, session, [{ summary: summary("t1"), thread: { version: 1, dropped: 0, items: sampleItems() } }]);
114
+ const h = harness(profile);
115
+ try {
116
+ const first = await until(() => h.remoteThreads().get(taskKey(session, "t1")), "the first presence poll");
117
+ const firstItems = [...first.items];
118
+ const appliedOnce = h.remoteThreads();
119
+ // Same files on disk: the second poll still re-parses them, which is
120
+ // exactly the unchanged-content case that must reuse prior identities.
121
+ const second = await nextApplication(h, appliedOnce, taskKey(session, "t1"));
122
+ assert.notEqual(second, first, "the thread container is still rebuilt per poll");
123
+ assert.equal(second.items.length, firstItems.length);
124
+ second.items.forEach((item, index) => assert.equal(item, firstItems[index],
125
+ `unchanged item ${index} (kind ${String((firstItems[index] as { kind?: string }).kind)}) must reuse the prior object identity`));
126
+ } finally {
127
+ h.view.dispose();
128
+ }
129
+ });
130
+ });
131
+
132
+ test("a changed remote item takes a new identity while unchanged siblings keep theirs", async () => {
133
+ await withPresenceFixture(async (profile) => {
134
+ const session = { sessionId: "peer session", incarnation: randomUUID() };
135
+ publish(profile, session, [{ summary: summary("t1"), thread: { version: 1, dropped: 0, items: sampleItems() } }]);
136
+ const h = harness(profile);
137
+ try {
138
+ const first = await until(() => h.remoteThreads().get(taskKey(session, "t1")), "the first presence poll");
139
+ const firstItems = [...first.items];
140
+ const appliedOnce = h.remoteThreads();
141
+ const [, tool, note] = sampleItems();
142
+ publish(profile, session, [{ summary: summary("t1"), thread: { version: 1, dropped: 0, items: [{ kind: "text", text: "alpha changed" }, tool, note] } }]);
143
+ const second = await nextApplication(h, appliedOnce, taskKey(session, "t1"));
144
+ assert.notEqual(second.items[0], firstItems[0], "a changed item must yield a new object identity");
145
+ assert.equal(second.items[1], firstItems[1], "the unchanged tool sibling must reuse the prior identity");
146
+ assert.equal(second.items[2], firstItems[2], "the unchanged note sibling must reuse the prior identity");
147
+ } finally {
148
+ h.view.dispose();
149
+ }
150
+ });
151
+ });
152
+
153
+ test("tool items compare after args sanitization, so unchanged tools are reused with an empty args record", async () => {
154
+ await withPresenceFixture(async (profile) => {
155
+ const session = { sessionId: "peer session", incarnation: randomUUID() };
156
+ publish(profile, session, [{ summary: summary("t1"), thread: { version: 1, dropped: 0, items: [sampleItems()[1]] } }]);
157
+ const h = harness(profile);
158
+ try {
159
+ const first = await until(() => h.remoteThreads().get(taskKey(session, "t1")), "the first presence poll");
160
+ const appliedOnce = h.remoteThreads();
161
+ publish(profile, session, [{ summary: summary("t1"), thread: { version: 1, dropped: 0, items: [sampleItems()[1]] } }]);
162
+ const second = await nextApplication(h, appliedOnce, taskKey(session, "t1"));
163
+ assert.equal(second.items[0], first.items[0], "an unchanged tool item must reuse the prior sanitized object");
164
+ assert.deepEqual((second.items[0] as { args?: unknown }).args, {}, "the stored tool item keeps the sanitized args record");
165
+ } finally {
166
+ h.view.dispose();
167
+ }
168
+ });
169
+ });
@@ -242,7 +242,13 @@ test("renderThreadItem renders labeled text, thinking, tool-output, and note blo
242
242
  const wide = renderThreadItem({ kind: "tool", callId: "c", name: "bash", args: {}, output: "alpha beta gamma delta", running: false, isError: false }, plainTheme, 12);
243
243
  assert.deepEqual(wide.slice(2), [" alpha", " beta", " gamma", " delta"]);
244
244
  assert.deepEqual(renderThreadItem({ kind: "note", text: "error: boom" }, plainTheme, 30), ["Note", " error: boom"]);
245
- assert.equal(taskHeader(task("a"), 61_000), "explore · running · gpt-5.6-terra · 34k · $0.27 · 1m00s");
245
+ assert.equal(taskHeader(task("a"), 61_000), "explore · running · gpt-5.6-terra · 34k · $0.270 · 1m00s");
246
+ });
247
+
248
+ test("taskHeader formats subagent cost with formatCost (three decimals below $1, two at or above $1)", () => {
249
+ assert.match(taskHeader(task("small", { cost: 0.09 }), 1000), /\$0\.090/);
250
+ assert.match(taskHeader(task("large", { cost: 12.5 }), 1000), /\$12\.50/);
251
+ assert.doesNotMatch(taskHeader(task("zero", { cost: 0 }), 1000), /\$/);
246
252
  });
247
253
 
248
254
  test("AgentsView renders the frame with the task list and the selected thread's tail, at width", () => {
@@ -255,7 +261,7 @@ test("AgentsView renders the frame with the task list and the selected thread's
255
261
  const plain = lines.map(stripAnsi);
256
262
  assert.equal(plain.length, 8);
257
263
  assert.match(plain[0], /^╭─ ❀ Agents · this session · 1 active · 1 finished ─+ \[F Fullscreen\] \[× Close\]╮$/);
258
- assert.match(plain[1], /▸ └ ◐ Subagent explore.*explore · running · gpt-5\.6-terra · 34k · \$0\.27 · 1m00s/);
264
+ assert.match(plain[1], /▸ └ ◐ Subagent explore.*explore · running · gpt-5\.6-terra · 34k · \$0\.270 · 1m00s/);
259
265
  assert.match(plain[2], /line 3/, "the thread window follows the tail");
260
266
  assert.match(plain[3], /line 4/);
261
267
  assert.match(plain.join("\n"), /Subagent worker/, "the session's own finished task now stays listed as history");
@@ -1,8 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
- import test from "node:test";
2
+ import test, { after, before, type TestContext } from "node:test";
3
3
  import { visibleWidth } from "@earendil-works/pi-tui";
4
4
  import { TASK_STATUS, type TaskRecord } from "../lib/agents-protocol.ts";
5
5
  import { formatElapsed, renderAgentsCard, widgetExpiryMs, widgetRows, widgetTasks } from "../lib/agents-widget.ts";
6
+ import { CARD_STYLE, cardStyle, setCardStyle, type CardStyle } from "../lib/shell-card.ts";
6
7
  import { stripAnsi } from "../lib/terminal-theme.ts";
7
8
 
8
9
  // Gentle Agents widget: the card above the editor that shows what the
@@ -11,6 +12,42 @@ import { stripAnsi } from "../lib/terminal-theme.ts";
11
12
 
12
13
  const plainTheme = { fg: (_color: string, text: string) => text };
13
14
 
15
+ // The card style defaults to float; these assertions pin the outlined (neon)
16
+ // panels unless a test switches the style itself.
17
+ const initialCardStyle = cardStyle();
18
+ before(() => setCardStyle(CARD_STYLE.NEON));
19
+ after(() => setCardStyle(initialCardStyle));
20
+
21
+ function useCardStyle(t: TestContext, style: CardStyle): void {
22
+ const found = cardStyle();
23
+ t.after(() => setCardStyle(found));
24
+ setCardStyle(style);
25
+ }
26
+
27
+ const BG_OPEN = "\x1b[48;5;22m";
28
+ const BG_CLOSE = "\x1b[49m";
29
+
30
+ /** The same theme with a background, so the float style applies (without one panels keep the frame). */
31
+ function withBackground<T extends object>(theme: T): T & { bg(color: string, text: string): string } {
32
+ return { ...theme, bg: (_color: string, text: string) => `${BG_OPEN}${text}${BG_CLOSE}` };
33
+ }
34
+
35
+ /** Float panel rows: a painted panel inside transparent one-column margins, between padding rows that keep the accent bar. */
36
+ function assertFloatRows(lines: readonly string[], width: number): void {
37
+ for (const line of lines) {
38
+ assert.equal(visibleWidth(line), width, `"${stripAnsi(line)}" is not ${width} wide`);
39
+ assert.ok(line.startsWith(` ${BG_OPEN}`) && line.endsWith(`${BG_CLOSE} `), `painted inside the margins: ${JSON.stringify(line)}`);
40
+ }
41
+ const padding = ` ▎${" ".repeat(width - 3)} `;
42
+ assert.equal(stripAnsi(lines[0]!), padding, "a padding row with the accent bar sits above the header");
43
+ assert.equal(stripAnsi(lines.at(-1)!), padding, "a padding row with the accent bar replaces the bottom rule");
44
+ }
45
+
46
+ /** The text of a body row, without the neon side rails or the float accent bar. */
47
+ function bodyText(row: string): string {
48
+ return stripAnsi(row).replace(/^ ?[│▎] /u, "").replace(/ ?│? ?$/u, "").trimEnd();
49
+ }
50
+
14
51
  function task(overrides: Partial<TaskRecord>): TaskRecord {
15
52
  return { id: "t", agent: "sdd-explore", mode: "task", prompt: "map footer data sources", label: "map footer data sources", cwd: "/r", parentSessionId: "s", status: TASK_STATUS.RUNNING, createdAt: 1000, startedAt: 1000, endedAt: null, model: "anthropic/claude-sonnet-5", thinking: undefined, sessionPath: null, error: null, result: null, lastStep: "grep", lastActivityAt: 1000, turns: 0, toolCalls: 0, tokens: 34_000, cost: 0.27, ...overrides };
16
53
  }
@@ -59,8 +96,8 @@ test("renderAgentsCard draws columns for agent, task, and model · tokens · cos
59
96
  for (const line of lines) assert.equal(visibleWidth(line), 84, `"${stripAnsi(line)}" is not 84 wide`);
60
97
  const plain = lines.map(stripAnsi);
61
98
  assert.match(plain[0], /^╭─ ❀ Agents · 1 active · 1 done ─+ 1m24s ╮$/);
62
- assert.match(plain[1], /^│ ✓ sdd-explore map footer data sources +claude-sonnet-5 · 34k · \$0\.27 · 25s │$/);
63
- assert.match(plain[2], /^│ ◐ sdd-apply write gentle-shell footer +claude-sonnet-5 · 12k · \$0\.09 · 41s │$/);
99
+ assert.match(plain[1], /^│ ✓ sdd-explore map footer data sources +claude-sonnet-5 · 34k · \$0\.270 · 25s │$/);
100
+ assert.match(plain[2], /^│ ◐ sdd-apply write gentle-shell footer +claude-sonnet-5 · 12k · \$0\.090 · 41s │$/);
64
101
  assert.match(plain[3], /^╰─+╯$/);
65
102
  assert.deepEqual(renderAgentsCard([], plainTheme, 60, 0, { collapsed: false }), []);
66
103
  });
@@ -74,14 +111,14 @@ test("renderAgentsCard right-aligns model·effort, tokens, cost, and elapsed in
74
111
  const [, rowA, rowB] = lines as [string, string, string];
75
112
  assert.match(rowA, /34k/);
76
113
  assert.match(rowB, /1\.2M/);
77
- assert.match(rowA, /\$0\.27/);
114
+ assert.match(rowA, /\$0\.270/);
78
115
  assert.match(rowB, /\$12\.50/);
79
116
  // Each column has a fixed width, so a shorter value in one row (e.g. "34k"
80
117
  // next to "1.2M") still ends at the exact same offset as the wider one.
81
118
  const tokensEndA = rowA.indexOf("34k") + "34k".length;
82
119
  const tokensEndB = rowB.indexOf("1.2M") + "1.2M".length;
83
120
  assert.equal(tokensEndA, tokensEndB, "the tokens column ends at the same offset on every row");
84
- const costEndA = rowA.indexOf("$0.27") + "$0.27".length;
121
+ const costEndA = rowA.indexOf("$0.270") + "$0.270".length;
85
122
  const costEndB = rowB.indexOf("$12.50") + "$12.50".length;
86
123
  assert.equal(costEndA, costEndB, "the cost column ends at the same offset on every row");
87
124
  assert.equal(visibleWidth(rowA), visibleWidth(rowB));
@@ -118,13 +155,13 @@ test("renderAgentsCard keeps every task on one line, clipping long labels, and d
118
155
  const tasks = [task({ id: "a", label: "write the gentle shell footer and all of its tests before lunch" })];
119
156
  const wide = renderAgentsCard(tasks, plainTheme, 84, 5_000, { collapsed: false }).map(stripAnsi);
120
157
  assert.equal(wide.length, 3);
121
- assert.match(wide[1], /^│ ◐ sdd-explore write the gentle shell foot… +claude-sonnet-5 · 34k · \$0\.27 · 4s │$/);
158
+ assert.match(wide[1], /^│ ◐ sdd-explore write the gentle shell foo… +claude-sonnet-5 · 34k · \$0\.270 · 4s │$/);
122
159
  // Narrow cards degrade per column: task text first, then the model name,
123
160
  // then tokens and cost. Elapsed is the one value the reader cannot rebuild
124
161
  // from anything else on screen, so it is the last to go.
125
162
  const narrow = renderAgentsCard(tasks, plainTheme, 44, 5_000, { collapsed: false }).map(stripAnsi);
126
163
  assert.equal(narrow.length, 3);
127
- assert.match(narrow[1], /^│ ◐ sdd-explore +34k · \$0\.27 · 4s │$/, "the model name goes before tokens, cost and elapsed");
164
+ assert.match(narrow[1], /^│ ◐ sdd-explore +34k · \$0\.270 · 4s │$/, "the model name goes before tokens, cost and elapsed");
128
165
  });
129
166
 
130
167
  // gentle-shell#1143: one long model id used to flip the whole card to the
@@ -133,9 +170,9 @@ test("renderAgentsCard keeps tokens, cost and elapsed when a long model name no
133
170
  const tasks = [task({ id: "a", model: "anthropic/claude-sonnet-4-5-20250929", tokens: 12_345, cost: 0.42, startedAt: 5_000 - 184_000 })];
134
171
  const lines = renderAgentsCard(tasks, plainTheme, 46, 5_000, { collapsed: false }).map(stripAnsi);
135
172
  assert.equal(lines.length, 3);
136
- assert.match(lines[1], /^│ ◐ sdd-explore +12k · \$0\.42 · 3m04s │$/, "tokens, cost and elapsed survive; the model name is what gives way");
173
+ assert.match(lines[1], /^│ ◐ sdd-explore +12k · \$0\.420 · 3m04s │$/, "tokens, cost and elapsed survive; the model name is what gives way");
137
174
  const tighter = renderAgentsCard(tasks, plainTheme, 34, 5_000, { collapsed: false }).map(stripAnsi);
138
- assert.match(tighter[1], /^│ ◐ sdd-explore +\$0\.42 · 3m04s │$/, "then tokens go, then cost, elapsed last");
175
+ assert.match(tighter[1], /^│ ◐ sdd-explore +\$0\.420 · 3m04s │$/, "then tokens go, then cost, elapsed last");
139
176
  const tightest = renderAgentsCard(tasks, plainTheme, 28, 5_000, { collapsed: false }).map(stripAnsi);
140
177
  assert.match(tightest[1], /^│ ◐ sdd-explore +3m04s │$/, "elapsed is the last column standing");
141
178
  });
@@ -146,8 +183,8 @@ test("renderAgentsCard degrades every row of a mixed card together so columns st
146
183
  task({ id: "b", agent: "writer", model: "openai/gpt-5", tokens: 900, cost: 0.01 }),
147
184
  ];
148
185
  const lines = renderAgentsCard(tasks, plainTheme, 46, 5_000, { collapsed: false }).map(stripAnsi);
149
- assert.match(lines[1], /12k · \$0\.42 · 4s │$/);
150
- assert.match(lines[2], /900 · \$0\.01 · 4s │$/);
186
+ assert.match(lines[1], /12k · \$0\.420 · 4s │$/);
187
+ assert.match(lines[2], /900 · \$0\.010 · 4s │$/);
151
188
  assert.equal(lines[1].indexOf("· 4s"), lines[2].indexOf("· 4s"), "elapsed stays in one column across rows");
152
189
  });
153
190
 
@@ -162,11 +199,11 @@ test("renderAgentsCard shows questions and failures in place of the task, and co
162
199
  // The waiting row carries no tokens/cost of its own, but the failed row
163
200
  // below it does, so those columns stay reserved (blank) rather than
164
201
  // collapsing — the whole point of fixed columns over the old per-row join.
165
- assert.match(plain[1], /^│ \? sdd-explore asked: Delete\? +claude-sonnet-5 · {5}· {7}· {5}2s │$/);
166
- assert.match(plain[2], /^│ ✗ sdd-explore pi exited with code… +claude-sonnet-5 · 34k · \$0\.27 · {5}1s │$/);
202
+ assert.match(plain[1], /^│ \? sdd-explore asked: Delete\? +claude-sonnet-5 · {5}· {8}· {5}2s │$/);
203
+ assert.match(plain[2], /^│ ✗ sdd-explore pi exited with cod… +claude-sonnet-5 · 34k · \$0\.270 · {5}1s │$/);
167
204
  // Queued fills only the elapsed column with the literal word; model,
168
205
  // tokens, and cost stay blank rather than the row's text spilling past them.
169
- assert.match(plain[3], /^│ ○ sdd-explore map footer data sou… +· {5}· {7}· queued │$/);
206
+ assert.match(plain[3], /^│ ○ sdd-explore map footer data so… +· {5}· {8}· queued │$/);
170
207
  const collapsed = renderAgentsCard(tasks, plainTheme, 80, 3000, { collapsed: true, collapseKey: "ctrl+shift+a" }).map(stripAnsi);
171
208
  assert.equal(collapsed.length, 3);
172
209
  assert.match(collapsed[0], /ctrl\+shift\+a expand ╮$/);
@@ -181,7 +218,7 @@ test("usage outranks the model label at narrow widths without inventing unknown
181
218
  const lines = renderAgentsCard([task({ agent: "worker", model: "openai/gpt-5", thinking: "high" })], plainTheme, width, 5000, { collapsed: false });
182
219
  assert.equal(lines.length, 3);
183
220
  assert.match(lines[1], /worker/);
184
- assert.match(lines[1], /gpt-5 · high · 34k · \$0\.27 · 4s/, "wide enough for every column");
221
+ assert.match(lines[1], /gpt-5 · high · 34k · \$0\.270 · 4s/, "wide enough for every column");
185
222
  for (const line of lines) assert.equal(visibleWidth(line), width);
186
223
  }
187
224
  for (const width of [32, 44]) {
@@ -227,3 +264,105 @@ test("renderAgentsCard caps the rows at maxRows, keeps active tasks ahead of fin
227
264
  const waiting = [...tasks, task({ id: "ask", status: TASK_STATUS.WAITING, lastStep: "asked: Delete?", createdAt: 4000, startedAt: 4000 })];
228
265
  assert.match(renderAgentsCard(waiting, plainTheme, 80, 5000, { collapsed: false, maxRows: 2 }).map(stripAnsi)[1], /^│ \? sdd-explore asked: Delete\?/, "a question is never hidden");
229
266
  });
267
+
268
+ test("renderAgentsCard in the float style spends its padding and separator rows from maxRows, so a capped card is exactly as tall as neon", (t) => {
269
+ const tasks = [
270
+ task({ id: "done", status: TASK_STATUS.COMPLETED, startedAt: 500, endedAt: 2000 }),
271
+ ...Array.from({ length: 6 }, (_, index) => task({ id: `run${index}`, label: `job ${index}`, createdAt: 1000 + index, startedAt: 1000 + index })),
272
+ ];
273
+ const theme = withBackground(plainTheme);
274
+ const neon = renderAgentsCard(tasks, theme, 80, 5000, { collapsed: false, maxRows: 4, viewKey: "alt+a" });
275
+ useCardStyle(t, CARD_STYLE.FLOAT);
276
+ const float = renderAgentsCard(tasks, theme, 80, 5000, { collapsed: false, maxRows: 4, viewKey: "alt+a" }).map(stripAnsi);
277
+ assert.equal(neon.length, 6, "neon: frame plus four rows");
278
+ assert.equal(float.length, neon.length, "float: padding, header, separator, two rows, padding");
279
+ assert.match(float[1]!, /6 active · 1 done/, "the title still counts every shown task");
280
+ assert.match(float[2]!, /^ ▎ +$/, "a blank separator row follows the header");
281
+ assert.match(float[3]!, /◐ sdd-explore job 0/);
282
+ assert.match(float[4]!, /^ ▎ … 6 more · alt\+a to view +$/, "two more tasks fold into the overflow row");
283
+ for (const maxRows of [3, 4, 5, 7, 8, 9]) {
284
+ const capped = renderAgentsCard(tasks, theme, 80, 5000, { collapsed: false, maxRows });
285
+ assert.ok(capped.length <= maxRows + 2, `maxRows ${maxRows}: ${capped.length} rows exceed the neon cap`);
286
+ }
287
+ assert.match(stripAnsi(renderAgentsCard(tasks, theme, 80, 5000, { collapsed: false, maxRows: 8 }).at(-2)!), /… 2 more/, "seven tasks no longer fit eight rows once the padding and separator rows are spent");
288
+ assert.equal(renderAgentsCard(tasks, theme, 80, 5000, { collapsed: false, maxRows: 9 }).length, 11, "at the cap no row is hidden");
289
+ });
290
+
291
+ test("the minimum widget budget fits an overflow-only float body without changing neon", (t) => {
292
+ const maxRows = widgetRows(12);
293
+ assert.equal(maxRows, 3, "small terminals reach the three-row task budget");
294
+ const theme = withBackground(plainTheme);
295
+ for (const count of [1, 2, 6]) {
296
+ const tasks = Array.from({ length: count }, (_, index) => task({ id: `run${index}`, label: `job ${index}`, startedAt: 1000 + index }));
297
+ const options = { collapsed: false, maxRows, viewKey: "alt+a" };
298
+ setCardStyle(CARD_STYLE.NEON);
299
+ const neon = renderAgentsCard(tasks, plainTheme, 80, 5000, options);
300
+ assert.deepEqual(renderAgentsCard(tasks, theme, 80, 5000, options), neon, "neon stays byte-identical with background support");
301
+ useCardStyle(t, CARD_STYLE.FLOAT);
302
+ assert.deepEqual(renderAgentsCard(tasks, plainTheme, 80, 5000, options), neon, "missing-background fallback keeps neon rows");
303
+ setCardStyle(CARD_STYLE.NEON);
304
+ const neonNarrow = renderAgentsCard(tasks, theme, 9, 5000, options);
305
+ setCardStyle(CARD_STYLE.FLOAT);
306
+ assert.deepEqual(renderAgentsCard(tasks, theme, 9, 5000, options), neonNarrow, "narrow fallback keeps neon rows");
307
+ const rows = renderAgentsCard(tasks, theme, 80, 5000, options);
308
+ assert.equal(rows.length, maxRows + 2, `${count} tasks must fit five total float rows`);
309
+ assertFloatRows(rows, 80);
310
+ assert.match(stripAnsi(rows[2]!), /^ ▎ +$/, "separator stays intact");
311
+ if (count === 1) assert.match(stripAnsi(rows[3]!), /job 0/, "a single task fits directly");
312
+ else assert.match(stripAnsi(rows[3]!), new RegExp(`^ ▎ … ${count} more · alt\\+a to view +$`), "the only body row truthfully counts every hidden task");
313
+ assert.equal(renderAgentsCard(tasks, theme, 80, 5000, { ...options, collapsed: true }).length, maxRows + 2, "collapsed cards keep one task and fit");
314
+ }
315
+ });
316
+
317
+ test("float row budgets still prioritize a question when a task and overflow both fit", (t) => {
318
+ useCardStyle(t, CARD_STYLE.FLOAT);
319
+ const tasks = [
320
+ task({ id: "first", label: "running first", startedAt: 1000 }),
321
+ task({ id: "second", label: "running second", startedAt: 2000 }),
322
+ task({ id: "question", status: TASK_STATUS.WAITING, lastStep: "asked: Delete?", startedAt: 3000 }),
323
+ ];
324
+ const rows = renderAgentsCard(tasks, withBackground(plainTheme), 80, 5000, { collapsed: false, maxRows: 4, viewKey: "alt+a" });
325
+ assert.equal(rows.length, 6);
326
+ assert.match(stripAnsi(rows[3]!), /^ ▎ \? sdd-explore asked: Delete\?/);
327
+ assert.match(stripAnsi(rows[4]!), /^ ▎ … 2 more · alt\+a to view +$/);
328
+ });
329
+
330
+ test("renderAgentsCard formats subagent cost with formatCost (three decimals below $1, two at or above $1)", () => {
331
+ const tasks = [
332
+ task({ id: "small", agent: "scout", cost: 0.09, tokens: 1000, startedAt: 1000, endedAt: 2000 }),
333
+ task({ id: "large", agent: "builder", cost: 12.5, tokens: 50000, startedAt: 1000, endedAt: 5000 }),
334
+ ];
335
+ const card = renderAgentsCard(tasks, plainTheme, 80, 5000, { collapsed: false });
336
+ assert.ok(card.some((line) => line.includes("$0.090")), "cost below $1 shows 3 decimals");
337
+ assert.ok(card.some((line) => line.includes("$12.50")), "cost at or above $1 shows 2 decimals");
338
+ });
339
+
340
+ test("renderAgentsCard in the float style is a float panel two rows taller than neon with unclipped columns", (t) => {
341
+ const tasks = [
342
+ task({ id: "a", status: TASK_STATUS.COMPLETED, startedAt: 1000, endedAt: 26_000 }),
343
+ task({ id: "b", agent: "sdd-apply", label: "write gentle-shell footer", startedAt: 44_000, tokens: 12_000, cost: 0.09 }),
344
+ ];
345
+ const theme = withBackground(plainTheme);
346
+ for (const options of [{ collapsed: false }, { collapsed: true, collapseKey: "ctrl+a" }, { collapsed: false, maxRows: 1, viewKey: "ctrl+v" }]) {
347
+ const neon = renderAgentsCard(tasks, theme, 84, 85_000, options);
348
+ useCardStyle(t, CARD_STYLE.FLOAT);
349
+ const float = renderAgentsCard(tasks, theme, 84, 85_000, options);
350
+ setCardStyle(CARD_STYLE.NEON);
351
+ assert.equal(float.length, neon.length + 2, "the top padding and separator rows add two rows");
352
+ assert.match(stripAnsi(float[1]!), /^ ▎ ❀ Agents 1 active · 1 done +\S.*\S {3}$/, "header on row 1, hint right-aligned");
353
+ assert.match(stripAnsi(float[2]!), /^ ▎ +$/, "a blank separator row follows the header");
354
+ assertFloatRows(float, 84);
355
+ for (const [index, row] of float.slice(3, -1).entries()) {
356
+ assert.match(stripAnsi(row), /^ ▎ \S/u);
357
+ if (bodyText(row).startsWith("…")) continue;
358
+ // Task columns fit the float body, so the right-aligned time is never clipped.
359
+ const tail = bodyText(neon[index + 1]!).split(" ").at(-1)!;
360
+ assert.ok(bodyText(row).endsWith(tail), `"${stripAnsi(row)}" keeps "${tail}"`);
361
+ assert.match(stripAnsi(row), /\S {3}$/u, "the time ends where the float body ends");
362
+ }
363
+ }
364
+ useCardStyle(t, CARD_STYLE.FLOAT);
365
+ const tagged = withBackground({ fg: (color: string, text: string) => `<${color}>${text}</${color}>` });
366
+ const [, header] = renderAgentsCard([task({ status: TASK_STATUS.WAITING })], tagged, 120, 5000, { collapsed: true, collapseKey: "ctrl+a" });
367
+ assert.match(stripAnsi(header!), /^ <warning>▎<\/warning> <warning>❀ Agents<\/warning> <muted>1 waiting<\/muted> +<muted>ctrl\+a expand<\/muted> {3}$/);
368
+ });
@@ -0,0 +1,160 @@
1
+ import assert from "node:assert/strict";
2
+ import test, { after, before } from "node:test";
3
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import { createGentleAiExtension } from "../extensions/gentle-ai.ts";
8
+ import gentleTodo from "../extensions/gentle-todo.ts";
9
+
10
+ // gentle-shell#1485: pi-claude-bridge forwards only the structured
11
+ // systemPromptOptions parts of before_agent_start (contextFiles, skills,
12
+ // customPrompt, appendSystemPrompt) after Claude Code's own preset, so a
13
+ // handler-returned replacement systemPrompt never reaches the model on that
14
+ // provider. These tests exercise both extensions against one shared
15
+ // systemPromptOptions object, the same object every before_agent_start
16
+ // handler observes within a single pi emission (packages/coding-agent
17
+ // extensions/runner.js emitBeforeAgentStart: one `currentOptions` per call).
18
+
19
+ type Handler = (event: unknown, ctx: ExtensionContext) => unknown;
20
+
21
+ let fixtureCwd: string;
22
+ let fixtureRoot: string | undefined;
23
+ const fixtureEnvironment: NodeJS.ProcessEnv = {};
24
+ const previousEnvironment = new Map<string, string | undefined>();
25
+ before(async () => {
26
+ const root = await mkdtemp(join(tmpdir(), "gentle-pi-append-route-"));
27
+ fixtureRoot = root;
28
+ fixtureCwd = join(root, "project");
29
+ const home = join(root, "home");
30
+ for (const directory of [fixtureCwd, home]) await mkdir(directory);
31
+ Object.assign(fixtureEnvironment, {
32
+ HOME: home, USERPROFILE: home,
33
+ GENTLE_PI_CONFIG_HOME: join(home, "config"),
34
+ GENTLE_PI_AGENT_HOME: join(home, "agents"),
35
+ PI_CODING_AGENT_DIR: join(home, "pi"),
36
+ XDG_CONFIG_HOME: join(home, "xdg"),
37
+ });
38
+ for (const [key, value] of Object.entries(fixtureEnvironment)) {
39
+ previousEnvironment.set(key, process.env[key]);
40
+ process.env[key] = value;
41
+ }
42
+ });
43
+ after(async () => {
44
+ for (const [key, value] of previousEnvironment) {
45
+ if (value === undefined) delete process.env[key];
46
+ else process.env[key] = value;
47
+ }
48
+ if (fixtureRoot !== undefined) await rm(fixtureRoot, { recursive: true, force: true });
49
+ });
50
+
51
+ function gentleAiHandlers(processEnv: NodeJS.ProcessEnv = {}): Map<string, Handler> {
52
+ const handlers = new Map<string, Handler>();
53
+ const pi = {
54
+ on(name: string, handler: Handler) {
55
+ handlers.set(name, handler);
56
+ },
57
+ events: { emit() {} },
58
+ registerCommand() {},
59
+ registerTool() {},
60
+ } as unknown as ExtensionAPI;
61
+ createGentleAiExtension({
62
+ nativeReviewCli: null,
63
+ processEnv: { ...fixtureEnvironment, GENTLE_PI_AGENTS_CHILD: "0", ...processEnv, GENTLE_AI_TELEMETRY: "0" },
64
+ resolveTelemetryTriggerBinary: () => join(fixtureCwd, "never-executed"),
65
+ telemetryTriggerSpawn: () => assert.fail("Route fixtures must not spawn telemetry"),
66
+ })(pi);
67
+ return handlers;
68
+ }
69
+
70
+ function gentleTodoHandlers(): { handlers: Map<string, Handler[]>; tools: Map<string, { execute: (...args: unknown[]) => Promise<unknown> }> } {
71
+ const handlers = new Map<string, Handler[]>();
72
+ const tools = new Map<string, { execute: (...args: unknown[]) => Promise<unknown>; name: string }>();
73
+ const pi = {
74
+ on(event: string, handler: Handler) {
75
+ handlers.set(event, [...(handlers.get(event) ?? []), handler]);
76
+ },
77
+ registerTool(tool: { execute: (...args: unknown[]) => Promise<unknown>; name: string }) {
78
+ tools.set(tool.name, tool);
79
+ },
80
+ registerShortcut() {},
81
+ } as unknown as ExtensionAPI;
82
+ gentleTodo(pi, {});
83
+ return { handlers, tools };
84
+ }
85
+
86
+ function ctx(): ExtensionContext {
87
+ return {
88
+ cwd: fixtureCwd,
89
+ hasUI: true,
90
+ ui: { notify() {}, setWidget() {} },
91
+ sessionManager: { getSessionId: () => "append-route-session", getBranch: () => [] },
92
+ } as unknown as ExtensionContext;
93
+ }
94
+
95
+ test("both extensions land their block in appendSystemPrompt on one shared options object, and neither returns a replacement systemPrompt", async () => {
96
+ const aiHandlers = gentleAiHandlers();
97
+ const { handlers: todoHandlers, tools } = gentleTodoHandlers();
98
+ const session = ctx();
99
+
100
+ for (const handler of todoHandlers.get("session_start") ?? []) await handler({}, session);
101
+ await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Fix the bug" }] }, undefined, undefined, session);
102
+ for (const handler of todoHandlers.get("tool_execution_end") ?? []) await handler({ toolName: "todo" }, session);
103
+
104
+ const event = { systemPrompt: "base", systemPromptOptions: { appendSystemPrompt: "" } };
105
+
106
+ // Every handler registered for before_agent_start observes the same
107
+ // systemPromptOptions object within one emission, exactly as pi's runner
108
+ // does for the real event.
109
+ const aiResult = await aiHandlers.get("before_agent_start")!(event, session);
110
+ let todoResult: unknown;
111
+ for (const handler of todoHandlers.get("before_agent_start") ?? []) todoResult = await handler(event, session);
112
+
113
+ assert.equal(aiResult, undefined, "gentle-ai must not return a replacement systemPrompt");
114
+ assert.equal(todoResult, undefined, "gentle-todo must not return a replacement systemPrompt");
115
+
116
+ const appended = event.systemPromptOptions.appendSystemPrompt;
117
+ assert.match(appended, /el Gentleman Identity and Harness/);
118
+ assert.match(appended, /## Todo list/);
119
+ assert.match(appended, /1\. \[pending\] Fix the bug/);
120
+ assert.ok(
121
+ appended.indexOf("el Gentleman Identity and Harness") < appended.indexOf("## Todo list"),
122
+ "gentle-ai's block must precede gentle-todo's, matching handler registration order",
123
+ );
124
+ });
125
+
126
+ test("re-running both handlers on the same already-populated options object does not duplicate either block", async () => {
127
+ const aiHandlers = gentleAiHandlers();
128
+ const { handlers: todoHandlers, tools } = gentleTodoHandlers();
129
+ const session = ctx();
130
+
131
+ for (const handler of todoHandlers.get("session_start") ?? []) await handler({}, session);
132
+ await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "Fix the bug" }] }, undefined, undefined, session);
133
+ for (const handler of todoHandlers.get("tool_execution_end") ?? []) await handler({ toolName: "todo" }, session);
134
+
135
+ const event = { systemPrompt: "base", systemPromptOptions: { appendSystemPrompt: "" } };
136
+ await aiHandlers.get("before_agent_start")!(event, session);
137
+ const gentleAiOccurrencesAfterFirstRun = event.systemPromptOptions.appendSystemPrompt.split("el Gentleman Identity and Harness").length - 1;
138
+ assert.equal(gentleAiOccurrencesAfterFirstRun, 1);
139
+
140
+ // A defensive re-run of gentle-ai's own handler against an options object
141
+ // that already carries its exact block must not append it again.
142
+ await aiHandlers.get("before_agent_start")!(event, session);
143
+ const gentleAiOccurrencesAfterSecondRun = event.systemPromptOptions.appendSystemPrompt.split("el Gentleman Identity and Harness").length - 1;
144
+ assert.equal(gentleAiOccurrencesAfterSecondRun, 1, "a second gentle-ai run on the same options object must not duplicate the harness");
145
+ });
146
+
147
+ for (const scenario of ["child", "named-agent"] as const) {
148
+ test(`${scenario} start leaves shared prompt options unchanged and returns no replacement`, async () => {
149
+ const handlers = gentleAiHandlers(scenario === "child" ? { GENTLE_PI_AGENTS_CHILD: "1" } : {});
150
+ const event = {
151
+ systemPrompt: "base",
152
+ systemPromptOptions: { appendSystemPrompt: "Existing provider section" },
153
+ ...(scenario === "named-agent" ? { agentName: "gentle-ai-worker" } : {}),
154
+ };
155
+ const result = await handlers.get("before_agent_start")!(event, ctx());
156
+ assert.equal(result, undefined, "excluded sessions must not return a replacement system prompt");
157
+ assert.equal(event.systemPromptOptions.appendSystemPrompt, "Existing provider section");
158
+ assert.doesNotMatch(event.systemPromptOptions.appendSystemPrompt, /el Gentleman Identity and Harness/);
159
+ });
160
+ }
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { appendSystemPromptOnce } from "../lib/append-system-prompt.ts";
4
+
5
+ // gentle-shell#1485: pi-claude-bridge forwards only the structured
6
+ // systemPromptOptions parts of before_agent_start, so extensions must mutate
7
+ // options.appendSystemPrompt instead of returning a replacement systemPrompt.
8
+
9
+ test("appends to an empty appendSystemPrompt, stripped of a leading blank line", () => {
10
+ const options = { appendSystemPrompt: "" };
11
+ appendSystemPromptOnce(options, "\n\nHarness block");
12
+ assert.equal(options.appendSystemPrompt, "Harness block");
13
+ });
14
+
15
+ test("appends after existing content with a blank-line separator", () => {
16
+ const options = { appendSystemPrompt: "user APPEND_SYSTEM.md content" };
17
+ appendSystemPromptOnce(options, "\n\nHarness block");
18
+ assert.equal(options.appendSystemPrompt, "user APPEND_SYSTEM.md content\n\nHarness block");
19
+ });
20
+
21
+ test("is idempotent: the same text is never appended twice to the same options object", () => {
22
+ const options = { appendSystemPrompt: "" };
23
+ appendSystemPromptOnce(options, "\n\nHarness block");
24
+ appendSystemPromptOnce(options, "\n\nHarness block");
25
+ assert.equal(options.appendSystemPrompt, "Harness block");
26
+ assert.equal(options.appendSystemPrompt.split("Harness block").length - 1, 1);
27
+ });
28
+
29
+ test("two different callers accumulate without erasing each other", () => {
30
+ const options = { appendSystemPrompt: "" };
31
+ appendSystemPromptOnce(options, "\n\nGentle AI harness");
32
+ appendSystemPromptOnce(options, "\n\nTodo list block");
33
+ assert.equal(options.appendSystemPrompt, "Gentle AI harness\n\nTodo list block");
34
+ });
35
+
36
+ test("empty or blank text is a no-op", () => {
37
+ const options = { appendSystemPrompt: "kept" };
38
+ appendSystemPromptOnce(options, "");
39
+ appendSystemPromptOnce(options, "\n\n");
40
+ assert.equal(options.appendSystemPrompt, "kept");
41
+ });
42
+
43
+ test("a missing options object never throws", () => {
44
+ assert.doesNotThrow(() => appendSystemPromptOnce(undefined, "\n\nHarness block"));
45
+ assert.doesNotThrow(() => appendSystemPromptOnce(null, "\n\nHarness block"));
46
+ });