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,170 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import promptHistoryExtension from "../extensions/history/index.ts";
7
+ import { writeHistoryCapturePolicy } from "../lib/history-capture-policy.ts";
8
+
9
+ // The module-level selector gate reads process.env directly (that path has
10
+ // no deps.env injection); keep the suite hermetic regardless of the ambient
11
+ // shell so the off-path assertions cannot be flipped by the environment.
12
+ delete process.env.GENTLE_PI_HISTORY_CAPTURE;
13
+
14
+ function makeRoot(): string {
15
+ return fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-off-"));
16
+ }
17
+
18
+ const CWD = "/pi-history-test/project-off";
19
+
20
+ interface Harness {
21
+ commandHandler: (args: unknown, ctx: unknown) => Promise<void>;
22
+ }
23
+
24
+ /**
25
+ * Load the extension against a temp root and capture the registered
26
+ * shortcut + history command handlers from the fake pi.
27
+ */
28
+ function loadWithCommand(
29
+ env: NodeJS.ProcessEnv,
30
+ root: string,
31
+ configHome: string = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-off-config-")),
32
+ ): Harness {
33
+ const shortcuts: Array<[string, { handler: unknown }]> = [];
34
+ const commands: Array<[string, { handler: unknown }]> = [];
35
+ const pi = {
36
+ on: () => {},
37
+ registerShortcut: (key: string, def: { handler: unknown }) => {
38
+ shortcuts.push([key, def]);
39
+ },
40
+ registerCommand: (name: string, def: { handler: unknown }) => {
41
+ commands.push([name, def]);
42
+ },
43
+ };
44
+ promptHistoryExtension(pi as never, {
45
+ env,
46
+ root,
47
+ cwd: CWD,
48
+ instanceId: "inst-off",
49
+ now: () => 1700000000000,
50
+ // Keep any opted-in warm-up away from the real ~/.pi/agent.
51
+ agentDir: path.join(root, "agent"),
52
+ sessionsRoot: path.join(root, "sessions"),
53
+ // An empty config home by default: the Customize preference is unset (off).
54
+ gentlePiConfigHome: configHome,
55
+ });
56
+ const command = commands.find(([name]) => name === "history");
57
+ assert.ok(command, "the history command must be registered");
58
+ assert.equal(shortcuts.length, 1, "the shortcut must still be registered");
59
+ return {
60
+ commandHandler: command[1].handler as Harness["commandHandler"],
61
+ };
62
+ }
63
+
64
+ function fakeCtx(notifyCalls: Array<[string, string]>) {
65
+ return {
66
+ ui: {
67
+ notify: (message: string, level: string) => {
68
+ notifyCalls.push([message, level]);
69
+ },
70
+ },
71
+ };
72
+ }
73
+
74
+ // The open flow reads the injected deps (env/root/cwd), never the module
75
+ // defaults, so the enabled direction is testable against a temp root too.
76
+
77
+ test("with capture disabled, extension load writes nothing", async () => {
78
+ const root = makeRoot();
79
+ loadWithCommand({}, root);
80
+ // Flush the setImmediate warm-up.
81
+ await new Promise((resolve) => setImmediate(resolve));
82
+ // Nothing at all: no registry, no seed, no store file.
83
+ assert.deepEqual(fs.readdirSync(root), []);
84
+ });
85
+
86
+ test("with capture disabled, the history command imports nothing and warns", async () => {
87
+ const root = makeRoot();
88
+ const { commandHandler } = loadWithCommand({}, root);
89
+ await new Promise((resolve) => setImmediate(resolve));
90
+ const notifyCalls: Array<[string, string]> = [];
91
+ await commandHandler([], fakeCtx(notifyCalls));
92
+ assert.equal(notifyCalls.length, 1);
93
+ assert.equal(notifyCalls[0][1], "warning");
94
+ assert.ok(
95
+ notifyCalls[0][0].includes("GENTLE_PI_HISTORY_CAPTURE"),
96
+ `the warning must name the switch, got: ${notifyCalls[0][0]}`,
97
+ );
98
+ assert.ok(
99
+ notifyCalls[0][0].includes("Gentle → Customize"),
100
+ `the warning must name the Customize control, got: ${notifyCalls[0][0]}`,
101
+ );
102
+ // The gate must fire before the drain: no migration, no seed, no store.
103
+ assert.deepEqual(fs.readdirSync(root), []);
104
+ });
105
+
106
+ test("with capture enabled, opening the selector reads without initializing the store", async () => {
107
+ const root = makeRoot();
108
+ const { commandHandler } = loadWithCommand({ GENTLE_PI_HISTORY_CAPTURE: "1" }, root);
109
+ // Open before the opted-in warm-up tick: the open flow alone must not
110
+ // migrate, register, seed, or create a capture file.
111
+ const notifyCalls: Array<[string, string]> = [];
112
+ await commandHandler([], fakeCtx(notifyCalls));
113
+ assert.deepEqual(notifyCalls, [["No prompt history available.", "warning"]]);
114
+ assert.deepEqual(fs.readdirSync(root), []);
115
+ });
116
+
117
+ test("the Customize preference opens the selector without the env switch", async () => {
118
+ const root = makeRoot();
119
+ const configHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-off-config-"));
120
+ const { commandHandler } = loadWithCommand({}, root, configHome);
121
+ const notifyCalls: Array<[string, string]> = [];
122
+ await commandHandler([], fakeCtx(notifyCalls));
123
+ assert.equal(notifyCalls[0][1], "warning");
124
+ assert.match(notifyCalls[0][0], /disabled/);
125
+ // The same loaded extension honors a later toggle without restart.
126
+ writeHistoryCapturePolicy("on", { gentlePiConfigHome: configHome });
127
+ await commandHandler([], fakeCtx(notifyCalls));
128
+ assert.deepEqual(notifyCalls[1], ["No prompt history available.", "warning"]);
129
+ });
130
+
131
+ test("an explicit env off names the override instead of the Customize fix", async () => {
132
+ const root = makeRoot();
133
+ const configHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-off-config-"));
134
+ writeHistoryCapturePolicy("on", { gentlePiConfigHome: configHome });
135
+ const { commandHandler } = loadWithCommand({ GENTLE_PI_HISTORY_CAPTURE: "false" }, root, configHome);
136
+ const notifyCalls: Array<[string, string]> = [];
137
+ await commandHandler([], fakeCtx(notifyCalls));
138
+ assert.equal(notifyCalls.length, 1);
139
+ assert.match(notifyCalls[0][0], /disabled by GENTLE_PI_HISTORY_CAPTURE, which overrides the Gentle → Customize → History preference/);
140
+ assert.deepEqual(fs.readdirSync(root), []);
141
+ });
142
+
143
+ test("a malformed Customize preference is reported as invalid, not just off", async () => {
144
+ const root = makeRoot();
145
+ const configHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-off-config-"));
146
+ const preference = path.join(configHome, "history-capture.json");
147
+ fs.writeFileSync(preference, "{not json", "utf8");
148
+ const { commandHandler } = loadWithCommand({}, root, configHome);
149
+ const notifyCalls: Array<[string, string]> = [];
150
+ await commandHandler([], fakeCtx(notifyCalls));
151
+ assert.equal(notifyCalls.length, 1);
152
+ assert.equal(notifyCalls[0][1], "warning");
153
+ assert.match(notifyCalls[0][0], /Gentle → Customize → History preference is invalid or unreadable/);
154
+ assert.ok(notifyCalls[0][0].includes(preference), `the warning must name the file, got: ${notifyCalls[0][0]}`);
155
+ assert.ok(notifyCalls[0][0].includes("GENTLE_PI_HISTORY_CAPTURE=1"));
156
+ // Reporting never repairs: the malformed file and the store stay untouched.
157
+ assert.equal(fs.readFileSync(preference, "utf8"), "{not json");
158
+ assert.deepEqual(fs.readdirSync(root), []);
159
+ });
160
+
161
+ test("an env value that defers to a malformed preference still reports it", async () => {
162
+ const root = makeRoot();
163
+ const configHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-off-config-"));
164
+ fs.writeFileSync(path.join(configHome, "history-capture.json"), "[]", "utf8");
165
+ const { commandHandler } = loadWithCommand({ GENTLE_PI_HISTORY_CAPTURE: "maybe" }, root, configHome);
166
+ const notifyCalls: Array<[string, string]> = [];
167
+ await commandHandler([], fakeCtx(notifyCalls));
168
+ assert.equal(notifyCalls.length, 1);
169
+ assert.match(notifyCalls[0][0], /preference is invalid or unreadable/);
170
+ });
@@ -0,0 +1,173 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /**
7
+ * Stage-1 open-flow wiring tests (a22588fc T31/T32 port, adapted to the
8
+ * slice-02 DrainResult drain contract). NEVER import
9
+ * extensions/history/index.ts — it pulls the pi-tui runtime graph (§D3).
10
+ * The wiring is pinned by source-parse (command-registration pattern);
11
+ * loader behavior uses fs-only fixtures under the OS temp dir — NEVER the
12
+ * user's real ~/.pi/agent/history.
13
+ */
14
+
15
+ const indexSource = fs.readFileSync(
16
+ fileURLToPath(new URL("../extensions/history/index.ts", import.meta.url)),
17
+ "utf8",
18
+ );
19
+
20
+ function openHistorySelectorBody(): string {
21
+ const start = indexSource.indexOf("async function openHistorySelector(");
22
+ assert.ok(start >= 0, "openHistorySelector should exist");
23
+ const end = indexSource.indexOf("export default function", start);
24
+ assert.ok(end > start, "extension entry point should follow");
25
+ return indexSource.slice(start, end);
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // T31 (adapted) — store-only drain wiring over the DrainResult contract.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ test("T31 (adapted): the store drain is the entries source — DrainResult unwrapped", () => {
33
+ const body = openHistorySelectorBody();
34
+ const drainIdx = body.indexOf('const drained = drainForScope("project")');
35
+ assert.ok(
36
+ drainIdx >= 0,
37
+ "the load step must drain the store through drainForScope",
38
+ );
39
+ assert.ok(
40
+ !body.includes("mergeHistoryEntries("),
41
+ "no live transcript merge in the open flow (store-only scopes)",
42
+ );
43
+ assert.ok(
44
+ body.includes('drained.status === "blocked"'),
45
+ "the DrainResult blocked variant must be unwrapped before any records build",
46
+ );
47
+ assert.ok(
48
+ body.includes("const entries = drained.prompts"),
49
+ "the ok variant feeds the entries list",
50
+ );
51
+ });
52
+
53
+ test("T31 (adapted): records are built via recordsFromEntries over the drained entries", () => {
54
+ const body = openHistorySelectorBody();
55
+ const recIdx = body.indexOf("recordsFromEntries(entries)");
56
+ assert.ok(
57
+ recIdx >= 0,
58
+ "records build through the shared recordsFromEntries helper",
59
+ );
60
+ });
61
+
62
+ test("T31: the command-registration pins hold beside the drain contract", () => {
63
+ const definitions =
64
+ indexSource.split("async function openHistorySelector(").length - 1;
65
+ assert.equal(definitions, 1, "openHistorySelector defined exactly once");
66
+ const calls = indexSource.split("openHistorySelector(ctx)").length - 1;
67
+ assert.equal(
68
+ calls,
69
+ 2,
70
+ "exactly the two entry-point call sites — the drain contract adds no occurrence",
71
+ );
72
+ });
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // T32 (adapted) — synchronous open-path wiring (no-await source-parse).
76
+ // ---------------------------------------------------------------------------
77
+
78
+ test("T32 (adapted): the open path never awaits a records build (source-parse)", () => {
79
+ const body = openHistorySelectorBody();
80
+ assert.ok(
81
+ !body.includes("startBackgroundIndexBuild"),
82
+ "no background build kick lives in the selector",
83
+ );
84
+ assert.ok(
85
+ !/await\s+recordsFromEntries/.test(body),
86
+ "the open path never awaits the records build (sync const declaration)",
87
+ );
88
+ const drainIdx = body.indexOf('drainForScope("project")');
89
+ const emptyIdx = body.indexOf("entries.length === 0");
90
+ assert.ok(
91
+ drainIdx >= 0 && emptyIdx > drainIdx,
92
+ "the empty guard follows the drain",
93
+ );
94
+ });
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Stage-1 skeleton pins: the minimal openable unit and its policy seams.
98
+ // ---------------------------------------------------------------------------
99
+
100
+ test("the stage-1 selector skeleton wires cancel through the overlay close", () => {
101
+ assert.ok(
102
+ indexSource.includes('kb.matches(_d, "tui.select.cancel")'),
103
+ "the dispatch table handles cancel",
104
+ );
105
+ assert.ok(
106
+ indexSource.includes("this.onCancel()"),
107
+ "cancel routes to the overlay close callback",
108
+ );
109
+ });
110
+
111
+ test("the selection result pastes into the editor via pasteToEditor", () => {
112
+ const body = openHistorySelectorBody();
113
+ assert.ok(
114
+ body.includes("ctx.ui.pasteToEditor(selected.text)"),
115
+ "the selected prompt enters the editor through the paste pipeline (a22588fc)",
116
+ );
117
+ });
118
+
119
+ /** Method body slice (lazy-windowing.test.ts pattern; first "\n }" close). */
120
+ function methodBodyOf(name: string): string {
121
+ const decl = indexSource.indexOf(`private ${name}(`);
122
+ assert.ok(decl >= 0, `private ${name}() should exist in extensions/history/index.ts`);
123
+ const end = indexSource.indexOf("\n }", decl);
124
+ assert.ok(end > decl, `private ${name}() body should close`);
125
+ return indexSource.slice(decl, end);
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // T33 — AC-S6-3: merged header totals + third transient dim indexing segment.
130
+ // ---------------------------------------------------------------------------
131
+
132
+ test("T33 (AC-S6-3): header totals derive from filteredRecords — derivation untouched", () => {
133
+ const body = methodBodyOf("rebuildListWithWidth");
134
+ assert.ok(
135
+ body.includes("const count = this.filteredRecords.length;"),
136
+ "N derives from filteredRecords (merged by construction)",
137
+ );
138
+ });
139
+
140
+ test("T33 (AC-S6-3): loaded segment present, indexing segment removed", () => {
141
+ const body = methodBodyOf("rebuildListWithWidth");
142
+ const setTextAt = body.indexOf("headerRow.setText(");
143
+ assert.ok(setTextAt >= 0, "the header must keep the existing setText call");
144
+ const setTextRegion = body.slice(
145
+ setTextAt,
146
+ body.indexOf("this.listContainer.clear()"),
147
+ );
148
+ assert.ok(
149
+ setTextRegion.includes("loaded ") &&
150
+ setTextRegion.includes("this.loadedCount"),
151
+ "the loaded segment stays (user-restored)",
152
+ );
153
+ assert.ok(
154
+ !setTextRegion.includes("indexing "),
155
+ "the indexing segment stays removed",
156
+ );
157
+ });
158
+
159
+ test("T33 (AC-S6-3): Change 2 structural pins still hold beside the third segment", () => {
160
+ assert.ok(
161
+ indexSource.includes("private static readonly OVERLAY_LINES = 30;"),
162
+ "OVERLAY_LINES = 30 intact",
163
+ );
164
+ const body = methodBodyOf("rebuildListWithWidth");
165
+ const addChildCount = body.split("addChild(").length - 1;
166
+ assert.equal(addChildCount, 4, "no new addChild in rebuildListWithWidth");
167
+ const classAt = indexSource.indexOf("class PromptHistorySelector");
168
+ const ctorAt = indexSource.indexOf("constructor(", classAt);
169
+ const ctorEnd = indexSource.indexOf('this.applyFilter("")', ctorAt);
170
+ const ctorAddChild =
171
+ indexSource.slice(ctorAt, ctorEnd).split("this.addChild(").length - 1;
172
+ assert.equal(ctorAddChild, 14, "the constructor child sequence is unchanged");
173
+ });
@@ -0,0 +1,326 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import type { TUI } from "@earendil-works/pi-tui";
7
+ import promptHistoryExtension from "../extensions/history/index.ts";
8
+ import {
9
+ editorOverlayMargin,
10
+ SIDEBAR_OVERLAY_PADDING,
11
+ } from "../extensions/history/selector-helpers.ts";
12
+ import { projectHash } from "../extensions/history/store.ts";
13
+ import {
14
+ installSidebar,
15
+ SIDEBAR_RAIL_COLUMNS,
16
+ } from "../lib/shell-sidebar-layout.ts";
17
+ import { sidebarPart, sidebarState } from "../lib/shell-sidebar.ts";
18
+
19
+ // Sidebar-aware overlay margin (restored from Carolina's #1394 work,
20
+ // e2cca1f9b^). The history extension reads the terminal-owned sidebar state
21
+ // contract (lib/shell-sidebar.ts) without importing gentle-shell; the rail
22
+ // width comes from the published `railColumns` field instead of a constant
23
+ // duplicated in the history extension.
24
+
25
+ function terminalWithState(state: unknown): object {
26
+ return {
27
+ [Symbol.for("gentle-pi.experimental-sidebar.state")]: state,
28
+ } as object;
29
+ }
30
+
31
+ const OWNING = {
32
+ active: true,
33
+ ownsHost: () => true,
34
+ railColumns: SIDEBAR_RAIL_COLUMNS,
35
+ };
36
+
37
+ test("the published rail reservation pins the gentle-shell rail geometry (RAIL_WIDTH 50 + GAP 3)", () => {
38
+ assert.equal(SIDEBAR_RAIL_COLUMNS, 53);
39
+ });
40
+
41
+ test("padding constant pins the user-directed 1-column breathing room", () => {
42
+ assert.equal(SIDEBAR_OVERLAY_PADDING, 1);
43
+ });
44
+
45
+ test("returns 0 for absent, primitive, or null terminals", () => {
46
+ assert.equal(editorOverlayMargin(undefined), 0);
47
+ assert.equal(editorOverlayMargin(null), 0);
48
+ assert.equal(editorOverlayMargin(42), 0);
49
+ assert.equal(editorOverlayMargin("terminal"), 0);
50
+ });
51
+
52
+ test("returns 0 when no sidebar state is stored on the terminal", () => {
53
+ assert.equal(editorOverlayMargin({}), 0);
54
+ });
55
+
56
+ test("returns 0 for malformed state shapes", () => {
57
+ assert.equal(editorOverlayMargin(terminalWithState(undefined)), 0);
58
+ assert.equal(editorOverlayMargin(terminalWithState(null)), 0);
59
+ assert.equal(editorOverlayMargin(terminalWithState("active")), 0);
60
+ });
61
+
62
+ test("returns 0 unless active is exactly true AND ownsHost is a function", () => {
63
+ assert.equal(
64
+ editorOverlayMargin(terminalWithState({ ...OWNING, ownsHost: undefined })),
65
+ 0,
66
+ "active without ownsHost",
67
+ );
68
+ assert.equal(
69
+ editorOverlayMargin(terminalWithState({ ...OWNING, active: false })),
70
+ 0,
71
+ "inactive",
72
+ );
73
+ assert.equal(
74
+ editorOverlayMargin(terminalWithState({ ...OWNING, active: 1 })),
75
+ 0,
76
+ "non-boolean truthy active",
77
+ );
78
+ assert.equal(
79
+ editorOverlayMargin(
80
+ terminalWithState({ ...OWNING, ownsHost: "not-a-function" }),
81
+ ),
82
+ 0,
83
+ "non-function ownsHost",
84
+ );
85
+ });
86
+
87
+ test("returns 0 unless the sidebar publishes a positive integer rail reservation", () => {
88
+ for (const railColumns of [undefined, 0, -53, 53.5, "53", Number.NaN]) {
89
+ assert.equal(
90
+ editorOverlayMargin(terminalWithState({ ...OWNING, railColumns })),
91
+ 0,
92
+ `railColumns ${String(railColumns)}`,
93
+ );
94
+ }
95
+ });
96
+
97
+ test("returns the rail reservation plus padding only while the sidebar owns the host", () => {
98
+ assert.equal(editorOverlayMargin(terminalWithState(OWNING)), 54);
99
+ assert.equal(
100
+ editorOverlayMargin(terminalWithState({ ...OWNING, railColumns: 40 })),
101
+ 41,
102
+ "the margin follows the published reservation",
103
+ );
104
+ assert.equal(
105
+ editorOverlayMargin(
106
+ terminalWithState({ ...OWNING, ownsHost: () => false }),
107
+ ),
108
+ 0,
109
+ "state present but host not owned (regular mode / unpatched root)",
110
+ );
111
+ });
112
+
113
+ test("a throwing ownsHost degrades to 0 instead of breaking the picker", () => {
114
+ assert.equal(
115
+ editorOverlayMargin(
116
+ terminalWithState({
117
+ ...OWNING,
118
+ ownsHost: () => {
119
+ throw new Error("boom");
120
+ },
121
+ }),
122
+ ),
123
+ 0,
124
+ );
125
+ });
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Seam: the real gentle-shell sidebar publishes the reservation it paints.
129
+ // ---------------------------------------------------------------------------
130
+
131
+ const NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
132
+ const shellTheme = {
133
+ fg: (_color: string, text: string) => text,
134
+ bold: (text: string) => text,
135
+ };
136
+
137
+ function sidebarHost(columns: number) {
138
+ const root = {
139
+ render: () => ["transcript"],
140
+ invalidate() {},
141
+ [NODE]: () => ({ type: "vstack", entries: [] }),
142
+ };
143
+ const host = {
144
+ mode: "fullscreen",
145
+ terminal: { columns },
146
+ layoutRoot: root,
147
+ requestRender() {},
148
+ };
149
+ const tui = host as unknown as TUI;
150
+ sidebarPart(tui, "footer", {
151
+ render: (_width: number) => ["Status"],
152
+ invalidate() {},
153
+ });
154
+ return { host, tui, root };
155
+ }
156
+
157
+ test("the installed sidebar publishes its rail reservation; the margin tracks the breakpoint", (t) => {
158
+ const { host, tui, root } = sidebarHost(140);
159
+ t.after(installSidebar(tui, shellTheme));
160
+ assert.equal(sidebarState(tui).railColumns, SIDEBAR_RAIL_COLUMNS);
161
+ root[NODE]();
162
+ assert.equal(
163
+ editorOverlayMargin(host.terminal),
164
+ SIDEBAR_RAIL_COLUMNS + SIDEBAR_OVERLAY_PADDING,
165
+ "fullscreen at the breakpoint with a painting rail",
166
+ );
167
+ host.terminal.columns = 139;
168
+ root[NODE]();
169
+ assert.equal(editorOverlayMargin(host.terminal), 0, "below the breakpoint");
170
+ });
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Overlay options: the picker re-reads the margin on every render pass.
174
+ // ---------------------------------------------------------------------------
175
+
176
+ const CWD = "/pi-history-fixtures/project-overlay-margin";
177
+
178
+ interface OverlayOptions {
179
+ anchor?: string;
180
+ width?: string;
181
+ offsetY?: number;
182
+ margin?: { right: number };
183
+ visible?: (columns: number, rows: number) => boolean;
184
+ }
185
+
186
+ async function captureOverlayOptions(terminal: object): Promise<OverlayOptions> {
187
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-history-margin-"));
188
+ const dir = path.join(root, "projects", projectHash(CWD));
189
+ fs.mkdirSync(dir, { recursive: true });
190
+ fs.writeFileSync(
191
+ path.join(dir, "store.jsonl"),
192
+ `${JSON.stringify({ v: 1, text: "p00", ts: 1_700_000_000_000 })}\n`,
193
+ );
194
+ const commands: Array<[string, { handler: unknown }]> = [];
195
+ promptHistoryExtension(
196
+ {
197
+ on: () => {},
198
+ registerShortcut: () => {},
199
+ registerCommand: (name: string, def: { handler: unknown }) => {
200
+ commands.push([name, def]);
201
+ },
202
+ } as never,
203
+ {
204
+ env: { GENTLE_PI_HISTORY_CAPTURE: "1" },
205
+ gentlePiConfigHome: fs.mkdtempSync(
206
+ path.join(os.tmpdir(), "pi-history-margin-config-"),
207
+ ),
208
+ root,
209
+ cwd: CWD,
210
+ instanceId: "inst-margin",
211
+ agentDir: path.join(root, "agent"),
212
+ sessionsRoot: path.join(root, "sessions"),
213
+ },
214
+ );
215
+ const command = commands.find(([name]) => name === "history");
216
+ assert.ok(command, "the history command must be registered");
217
+ const handler = command[1].handler as (
218
+ args: unknown,
219
+ ctx: unknown,
220
+ ) => Promise<void>;
221
+ let captured: OverlayOptions | undefined;
222
+ const plain = (_color: string, text: string) => text;
223
+ await handler([], {
224
+ ui: {
225
+ notify: (message: string) => assert.fail(`unexpected: ${message}`),
226
+ pasteToEditor: () => {},
227
+ custom: (
228
+ factory: (
229
+ tui: unknown,
230
+ theme: unknown,
231
+ keybindings: unknown,
232
+ done: (result: unknown) => void,
233
+ ) => unknown,
234
+ options: { overlayOptions?: OverlayOptions | (() => OverlayOptions) },
235
+ ) => {
236
+ // Mount first, like pi: the factory captures the TUI handle, then
237
+ // the host resolves the overlay options.
238
+ factory(
239
+ { requestRender: () => {}, terminal },
240
+ { fg: plain, bg: plain, bold: (text: string) => text },
241
+ undefined,
242
+ () => {},
243
+ );
244
+ const resolved = options.overlayOptions;
245
+ captured = typeof resolved === "function" ? resolved() : resolved;
246
+ return Promise.resolve(null);
247
+ },
248
+ },
249
+ });
250
+ assert.ok(captured, "the selector must pass overlay options");
251
+ return captured;
252
+ }
253
+
254
+ test("the picker keeps the native full-window overlay without a painting sidebar", async () => {
255
+ const options = await captureOverlayOptions({});
256
+ assert.equal(options.anchor, "bottom-center");
257
+ assert.equal(options.width, "100%");
258
+ assert.equal(options.offsetY, 5);
259
+ assert.equal(options.margin, undefined);
260
+ assert.equal(options.visible?.(200, 50), true);
261
+ });
262
+
263
+ test("the picker margin stays live while the overlay is open", async () => {
264
+ const state = { ...OWNING };
265
+ const options = await captureOverlayOptions(terminalWithState(state));
266
+ assert.deepEqual(options.margin, { right: 54 });
267
+ // Resizing below the breakpoint: pi-tui calls visible() on every render
268
+ // pass, then re-reads margin while resolving the layout.
269
+ state.active = false;
270
+ assert.equal(options.visible?.(139, 50), true);
271
+ assert.equal(options.margin, undefined);
272
+ state.active = true;
273
+ assert.equal(options.visible?.(140, 50), true);
274
+ assert.deepEqual(options.margin, { right: 54 });
275
+ });
276
+
277
+ // Review advisory A4 (#1480): the live margin must follow the REAL installed
278
+ // sidebar after the overlay opened, not only a hand-built state object.
279
+ test("the open picker's margin follows the real sidebar across the breakpoint and teardown", async (t) => {
280
+ const { host, tui, root } = sidebarHost(139);
281
+ let uninstall = installSidebar(tui, shellTheme);
282
+ t.after(() => uninstall());
283
+ root[NODE]();
284
+ const options = await captureOverlayOptions(host.terminal);
285
+ assert.equal(options.margin, undefined, "opened below the breakpoint: no rail");
286
+
287
+ // Widening across the breakpoint paints the rail while the picker is open.
288
+ host.terminal.columns = 140;
289
+ root[NODE]();
290
+ assert.equal(options.visible?.(140, 50), true);
291
+ assert.deepEqual(options.margin, {
292
+ right: SIDEBAR_RAIL_COLUMNS + SIDEBAR_OVERLAY_PADDING,
293
+ });
294
+
295
+ // Narrowing below it removes the rail again.
296
+ host.terminal.columns = 139;
297
+ root[NODE]();
298
+ assert.equal(options.visible?.(139, 50), true);
299
+ assert.equal(options.margin, undefined);
300
+
301
+ // Back at the breakpoint, then the sidebar is torn down: the rail
302
+ // disappears and the picker returns to the full window.
303
+ host.terminal.columns = 140;
304
+ root[NODE]();
305
+ assert.equal(options.visible?.(140, 50), true);
306
+ assert.deepEqual(options.margin, {
307
+ right: SIDEBAR_RAIL_COLUMNS + SIDEBAR_OVERLAY_PADDING,
308
+ });
309
+ uninstall();
310
+ uninstall = () => {};
311
+ assert.equal(options.visible?.(140, 50), true);
312
+ assert.equal(options.margin, undefined);
313
+ });
314
+
315
+ test("the margin getter reports the value refreshed by the last visible() pass", async () => {
316
+ const state = { ...OWNING };
317
+ const options = await captureOverlayOptions(terminalWithState(state));
318
+ assert.deepEqual(options.margin, { right: 54 });
319
+ // pi-tui re-reads margin several times per layout; between visible()
320
+ // passes every read agrees, and the next pass picks up the change.
321
+ state.active = false;
322
+ assert.deepEqual(options.margin, { right: 54 });
323
+ assert.equal(options.visible?.(139, 50), true);
324
+ assert.equal(options.margin, undefined);
325
+ assert.equal(options.margin, undefined);
326
+ });