gentle-pi 3.7.0 → 4.1.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 (590) hide show
  1. package/README.md +155 -10
  2. package/assets/agents/gentle-ai-explore.md +13 -6
  3. package/assets/agents/gentle-ai-verify.md +27 -6
  4. package/assets/agents/gentle-ai-worker.md +13 -15
  5. package/assets/install-wizard/index.html +47 -0
  6. package/assets/install-wizard/wizard.css +1279 -0
  7. package/assets/install-wizard/wizard.js +951 -0
  8. package/assets/orchestrator-delegation.md +75 -169
  9. package/assets/orchestrator-memory.md +12 -26
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-skills.md +1 -1
  12. package/assets/orchestrator-tracking.md +53 -0
  13. package/assets/orchestrator-verification.md +28 -0
  14. package/assets/orchestrator-writer.md +38 -0
  15. package/assets/orchestrator.md +30 -38
  16. package/assets/sounds/LICENSE.md +52 -0
  17. package/assets/sounds/attention.wav +0 -0
  18. package/assets/sounds/error.wav +0 -0
  19. package/assets/sounds/success.wav +0 -0
  20. package/assets/support/strict-tdd-verify.md +4 -266
  21. package/assets/support/strict-tdd.md +8 -360
  22. package/bin/engram-guard +9 -0
  23. package/bin/gentle-shell-install.mjs +302 -0
  24. package/bin/gentle-shell.mjs +956 -50
  25. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  27. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  28. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  29. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  30. package/docs/bundled-install.md +397 -0
  31. package/docs/delegated-verification.md +27 -4
  32. package/docs/gentle-agents-activity.md +537 -0
  33. package/docs/gentle-shell-uninstall.md +136 -0
  34. package/docs/gentle-shell.md +165 -51
  35. package/docs/install-wizard.md +2040 -0
  36. package/docs/native-authority-architecture.md +3 -3
  37. package/docs/permission-modes.md +82 -0
  38. package/docs/prompt-history.md +280 -0
  39. package/docs/readme-reference.md +364 -231
  40. package/docs/render-cache-performance.md +28 -0
  41. package/docs/sound-notifications-proposal.md +191 -0
  42. package/docs/sound-notifications.md +114 -0
  43. package/docs/telemetry.md +1 -1
  44. package/docs/yolo-mode.md +86 -0
  45. package/extensions/child-context.ts +35 -0
  46. package/extensions/child-safety.ts +25 -0
  47. package/extensions/codegraph-tools.ts +18 -5
  48. package/extensions/gentle-agents.ts +1093 -326
  49. package/extensions/gentle-ai.ts +1636 -890
  50. package/extensions/gentle-herdr-activity.ts +81 -0
  51. package/extensions/gentle-notifications.ts +104 -0
  52. package/extensions/gentle-shell.ts +1619 -136
  53. package/extensions/gentle-stats.ts +101 -0
  54. package/extensions/gentle-todo.ts +126 -20
  55. package/extensions/history/atomic-write.ts +38 -0
  56. package/extensions/history/hide-prompts.ts +183 -0
  57. package/extensions/history/index.ts +1424 -0
  58. package/extensions/history/load-shared-history.ts +39 -0
  59. package/extensions/history/selector-helpers.ts +538 -0
  60. package/extensions/history/session-scan.ts +233 -0
  61. package/extensions/history/store.ts +1119 -0
  62. package/extensions/nan-provider.ts +6 -0
  63. package/extensions/pi-pretty.ts +39 -3
  64. package/extensions/quiet-tools.ts +254 -92
  65. package/extensions/resume-hint.ts +60 -0
  66. package/extensions/skill-registry.ts +191 -58
  67. package/extensions/startup-banner.ts +71 -33
  68. package/lib/agent-assets.ts +604 -0
  69. package/lib/agent-home.ts +10 -1
  70. package/lib/agent-model-resolution.ts +80 -0
  71. package/lib/agent-profile-pin.ts +12 -0
  72. package/lib/agent-profiles.ts +13 -3
  73. package/lib/agents-completion-delivery.ts +11 -3
  74. package/lib/agents-message-delivery.ts +181 -0
  75. package/lib/agents-messaging.ts +1 -1
  76. package/lib/agents-protocol.ts +99 -0
  77. package/lib/agents-runner.ts +180 -107
  78. package/lib/agents-view.ts +26 -3
  79. package/lib/agents-widget.ts +16 -9
  80. package/lib/append-system-prompt.ts +21 -0
  81. package/lib/auto-permission-classifier.ts +280 -0
  82. package/lib/auto-permission-runtime.ts +268 -0
  83. package/lib/background-jobs-tools.ts +202 -0
  84. package/lib/background-jobs.ts +253 -0
  85. package/lib/background-monitor.ts +154 -0
  86. package/lib/bounded-writer-admission.ts +220 -0
  87. package/lib/builtin-codemode-optout.ts +166 -0
  88. package/lib/card-content-policy.ts +85 -0
  89. package/lib/card-style-policy.ts +60 -0
  90. package/lib/child-context-files.ts +166 -0
  91. package/lib/child-package-injection.ts +62 -0
  92. package/lib/codemode-renderer.ts +195 -0
  93. package/lib/command-palette-catalog.ts +5 -10
  94. package/lib/command-palette.ts +25 -14
  95. package/lib/context-threshold.ts +26 -0
  96. package/lib/destructive-command-guard.ts +144 -0
  97. package/lib/gentle-ai-binary.ts +109 -4
  98. package/lib/gentle-ai-elapsed-store.ts +87 -0
  99. package/lib/gentle-ai-renderer.ts +196 -39
  100. package/lib/gentle-shell-bundled.ts +517 -0
  101. package/lib/gentle-shell-launcher.ts +89 -27
  102. package/lib/gentle-shell-resume-hint.ts +176 -0
  103. package/lib/gentle-shell-startup-flow.ts +54 -0
  104. package/lib/gentle-shell-startup-update.ts +92 -0
  105. package/lib/gentle-shell-uninstall.ts +840 -0
  106. package/lib/gentle-shell-update-panel.ts +231 -0
  107. package/lib/herdr-activity.ts +183 -0
  108. package/lib/history-capture-policy.ts +95 -0
  109. package/lib/inprocess-reviewer.ts +3 -0
  110. package/lib/jobs-sidebar-state.ts +20 -0
  111. package/lib/jobs-view.ts +161 -0
  112. package/lib/model-price-ratio.ts +83 -0
  113. package/lib/model-routing-authority.ts +5 -1
  114. package/lib/nan-provider.ts +249 -0
  115. package/lib/native-review-cli.ts +242 -180
  116. package/lib/notification-audio-native.ts +162 -0
  117. package/lib/notification-audio-windows.ts +399 -0
  118. package/lib/notification-audio.ts +332 -0
  119. package/lib/notification-customize.ts +241 -0
  120. package/lib/notification-events.ts +113 -0
  121. package/lib/notification-policy.ts +148 -0
  122. package/lib/notification-pulse-client.ts +367 -0
  123. package/lib/notification-pulse-protocol.ts +305 -0
  124. package/lib/notification-pulse-stream.ts +213 -0
  125. package/lib/notification-pulse-worker.ts +66 -0
  126. package/lib/notification-scheduler.ts +173 -0
  127. package/lib/notification-service.ts +202 -0
  128. package/lib/notification-ui.ts +87 -0
  129. package/lib/odd-phase-inference.ts +231 -0
  130. package/lib/odd-phase.ts +141 -0
  131. package/lib/orchestrator-alias.ts +20 -0
  132. package/lib/orchestrator-catalog.ts +79 -0
  133. package/lib/orchestrator-consultation.ts +61 -0
  134. package/lib/orchestrator-discovery.ts +49 -0
  135. package/lib/orchestrator-helper-consent.ts +122 -0
  136. package/lib/orchestrator-helper.ts +167 -0
  137. package/lib/orchestrator-message-card.ts +105 -0
  138. package/lib/orchestrator-presence.ts +115 -14
  139. package/lib/orchestrator-scope.ts +80 -0
  140. package/lib/orchestrator-state.ts +107 -0
  141. package/lib/orchestrator-tool-card.ts +280 -0
  142. package/lib/orchestrator-work-search.ts +218 -0
  143. package/lib/orchestrator-work.ts +79 -0
  144. package/lib/overlay-repaint.ts +26 -0
  145. package/lib/permission-mode-policy.ts +59 -0
  146. package/lib/pi-tui-keys.ts +53 -0
  147. package/lib/review-candidate-view-owner.ts +73 -21
  148. package/lib/review-candidate-view.ts +115 -27
  149. package/lib/review-compact-contract.ts +2 -2
  150. package/lib/review-host-relay.ts +7 -2
  151. package/lib/review-integration-v2.ts +100 -22
  152. package/lib/review-publication-gate.ts +1 -1
  153. package/lib/review-reminder-receipt.ts +48 -8
  154. package/lib/review-risk-assessment.ts +227 -56
  155. package/lib/review-sidebar-state.ts +224 -0
  156. package/lib/review-transaction.ts +72 -3
  157. package/lib/runtime-metrics-children.ts +1 -1
  158. package/lib/runtime-metrics-native.ts +3 -2
  159. package/lib/selection-engine.ts +527 -0
  160. package/lib/session-change-capture.ts +5 -4
  161. package/lib/session-changes.ts +27 -22
  162. package/lib/session-messaging-grants.ts +135 -0
  163. package/lib/session-profile-binding.ts +83 -0
  164. package/lib/session-profile-freeze.ts +266 -0
  165. package/lib/session-worktree-registry.ts +87 -9
  166. package/lib/shell-bar.ts +215 -30
  167. package/lib/shell-card.ts +287 -18
  168. package/lib/shell-changes-view.ts +42 -14
  169. package/lib/shell-changes.ts +86 -71
  170. package/lib/shell-prompt.ts +102 -6
  171. package/lib/shell-sidebar-layout.ts +83 -15
  172. package/lib/shell-sidebar.ts +15 -1
  173. package/lib/shell-todo.ts +84 -31
  174. package/lib/shell-usage-view.ts +9 -4
  175. package/lib/shell-usage.ts +70 -13
  176. package/lib/stats-collector.ts +381 -0
  177. package/lib/stats-view.ts +431 -0
  178. package/lib/status-timing-diagnostics.ts +201 -0
  179. package/lib/telemetry-trigger.ts +3 -1
  180. package/lib/theme-customization.ts +52 -0
  181. package/lib/vim-editor-adapter.ts +383 -0
  182. package/lib/vim-normal-engine.ts +154 -0
  183. package/lib/vim-operator-engine.ts +416 -0
  184. package/lib/vim-policy.ts +49 -0
  185. package/lib/vim-visual-engine.ts +107 -0
  186. package/lib/visual-customization-policy.ts +119 -0
  187. package/lib/visual-customize-view.ts +481 -0
  188. package/lib/visual-profiles.ts +228 -0
  189. package/lib/writer-surfaces.ts +162 -0
  190. package/lib/yolo-session-policy.ts +317 -0
  191. package/package.json +20 -8
  192. package/runtime/child-package-injection.mjs +63 -0
  193. package/runtime/gentle-ai-binary.mjs +109 -4
  194. package/runtime/gentle-shell-bundled.mjs +518 -0
  195. package/runtime/gentle-shell-launcher.mjs +87 -25
  196. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  197. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  198. package/runtime/gentle-shell-startup-update.mjs +93 -0
  199. package/runtime/gentle-shell-uninstall.mjs +841 -0
  200. package/runtime/gentle-shell-update-panel.mjs +232 -0
  201. package/runtime/native-review-cli.mjs +239 -177
  202. package/runtime/review-integration-v2.mjs +100 -22
  203. package/runtime/review-risk-assessment.mjs +225 -54
  204. package/runtime/status-timing-diagnostics.mjs +202 -0
  205. package/runtime/telemetry-trigger.mjs +3 -1
  206. package/scripts/bootstrap.cmd +255 -0
  207. package/scripts/bootstrap.sh +168 -0
  208. package/scripts/build-distribution.mjs +157 -0
  209. package/scripts/build-installer-bundles.mjs +136 -0
  210. package/scripts/build-runtime-modules.mjs +8 -0
  211. package/scripts/bundled-install.mjs +1308 -0
  212. package/scripts/bundled-wizard.mjs +399 -0
  213. package/scripts/check-types.mjs +4 -6
  214. package/scripts/gentle-ai-installer.mjs +83 -35
  215. package/scripts/install-gentle-ai.mjs +48 -1
  216. package/scripts/install-tui-mode-setting.mjs +10 -1
  217. package/scripts/install-wizard-preview.mjs +154 -0
  218. package/scripts/installer-downloads.mjs +648 -0
  219. package/scripts/installer-preflight.mjs +387 -0
  220. package/scripts/installer-probes.mjs +564 -0
  221. package/scripts/installer-runner.mjs +1102 -0
  222. package/scripts/installer-server.mjs +881 -0
  223. package/scripts/installer-windows-artifacts.json +15 -0
  224. package/scripts/installer-windows.mjs +752 -0
  225. package/scripts/main-channel.mjs +452 -0
  226. package/scripts/mirror-odd-routing.mjs +2 -2
  227. package/scripts/npm-pack-result.mjs +31 -0
  228. package/scripts/render-cache-performance-worker.mjs +205 -0
  229. package/scripts/render-cache-performance.mjs +115 -0
  230. package/scripts/run-test-suite.mjs +90 -0
  231. package/scripts/test-installer-acceptance.sh +161 -0
  232. package/scripts/test-packed-runner.mjs +54 -19
  233. package/scripts/types-baseline.json +2 -92
  234. package/scripts/verify-package-files.mjs +91 -38
  235. package/skills/branch-pr/SKILL.md +24 -52
  236. package/skills/chained-pr/SKILL.md +31 -15
  237. package/skills/chained-pr/references/chaining-details.md +31 -20
  238. package/skills/gentle-ai/SKILL.md +16 -21
  239. package/skills/issue-creation/SKILL.md +8 -2
  240. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  241. package/skills/work-unit-commits/SKILL.md +4 -3
  242. package/tests/agent-home.test.ts +56 -3
  243. package/tests/agent-model-resolution.test.ts +113 -0
  244. package/tests/agent-profiles.test.ts +30 -0
  245. package/tests/agent-risk-escalation.test.ts +123 -0
  246. package/tests/agents-completion-delivery.test.ts +17 -0
  247. package/tests/agents-fake-child.ts +2 -2
  248. package/tests/agents-message-delivery.test.ts +106 -0
  249. package/tests/agents-protocol.test.ts +97 -0
  250. package/tests/agents-queries.test.ts +9 -9
  251. package/tests/agents-runner-process.test.ts +31 -0
  252. package/tests/agents-runner.test.ts +467 -93
  253. package/tests/agents-view-thread-identity.test.ts +169 -0
  254. package/tests/agents-view.test.ts +8 -2
  255. package/tests/agents-widget.test.ts +154 -15
  256. package/tests/append-system-prompt-route.test.ts +160 -0
  257. package/tests/append-system-prompt.test.ts +46 -0
  258. package/tests/artifact-language.test.ts +19 -213
  259. package/tests/ask-user-question.test.ts +44 -1
  260. package/tests/asset-installation-runtime.test.ts +5 -16
  261. package/tests/auto-mode-invariants.test.ts +258 -0
  262. package/tests/auto-mode-sdk.test.ts +57 -0
  263. package/tests/auto-mode.test.ts +49 -0
  264. package/tests/auto-permission-classifier.test.ts +283 -0
  265. package/tests/auto-permission-runtime.test.ts +196 -0
  266. package/tests/autonomous-guard.test.ts +69 -1
  267. package/tests/background-cache-warming.test.ts +2 -1
  268. package/tests/background-jobs.test.ts +227 -0
  269. package/tests/background-monitor.test.ts +195 -0
  270. package/tests/background-subagents-default-mode.test.ts +28 -5
  271. package/tests/bounded-stop-contract.test.ts +85 -0
  272. package/tests/bounded-writer-admission.test.ts +146 -0
  273. package/tests/branch-pr-skill.test.ts +43 -0
  274. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  275. package/tests/bridge-wake-rendering.test.ts +56 -0
  276. package/tests/build-distribution.test.ts +269 -0
  277. package/tests/builtin-codemode-optout.test.ts +242 -0
  278. package/tests/bundled-install.test.ts +1319 -0
  279. package/tests/bundled-wizard.test.ts +505 -0
  280. package/tests/card-content-policy.test.ts +68 -0
  281. package/tests/card-style-policy.test.ts +87 -0
  282. package/tests/chained-pr-skill.test.ts +124 -0
  283. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  284. package/tests/child-context-files.test.ts +255 -0
  285. package/tests/child-package-entrypoints.test.ts +65 -0
  286. package/tests/child-package-injection.test.ts +74 -0
  287. package/tests/child-safety.test.ts +94 -0
  288. package/tests/child-web-package.test.ts +224 -0
  289. package/tests/codegraph-tools.test.ts +35 -0
  290. package/tests/codemode-rendering.test.ts +596 -0
  291. package/tests/command-palette.test.ts +43 -7
  292. package/tests/context-threshold.test.ts +31 -0
  293. package/tests/delegate-for-reason-contract.test.ts +327 -0
  294. package/tests/delegated-key-learnings-contract.test.ts +3 -78
  295. package/tests/destructive-command-guard.test.ts +84 -0
  296. package/tests/devbinary/native-review-parity.devtest.ts +171 -3
  297. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  298. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  299. package/tests/engram-safety.test.ts +288 -0
  300. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  301. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  302. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  303. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  304. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  305. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  306. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  307. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  308. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  309. package/tests/generic-agent-tools.test.ts +69 -0
  310. package/tests/gentle-agents.test.ts +2804 -346
  311. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  312. package/tests/gentle-ai-binary.test.ts +21 -4
  313. package/tests/gentle-ai-child-guards.test.ts +202 -0
  314. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  315. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  316. package/tests/gentle-ai-installer.test.ts +566 -62
  317. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  318. package/tests/gentle-ai-renderer.test.ts +493 -14
  319. package/tests/gentle-ai.test.ts +1427 -236
  320. package/tests/gentle-card-text.ts +2 -1
  321. package/tests/gentle-herdr-activity.test.ts +100 -0
  322. package/tests/gentle-notifications.test.ts +427 -0
  323. package/tests/gentle-shell-bin.test.ts +896 -115
  324. package/tests/gentle-shell-bundled.test.ts +1002 -0
  325. package/tests/gentle-shell-launcher.test.ts +333 -65
  326. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  327. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  328. package/tests/gentle-shell-startup-update.test.ts +85 -0
  329. package/tests/gentle-shell-uninstall.test.ts +978 -0
  330. package/tests/gentle-shell-update-panel.test.ts +158 -0
  331. package/tests/gentle-shell.test.ts +4216 -119
  332. package/tests/gentle-stats.test.ts +152 -0
  333. package/tests/gentle-theme.test.ts +4 -1
  334. package/tests/gentle-todo.test.ts +333 -10
  335. package/tests/herdr-activity.test.ts +243 -0
  336. package/tests/history-agent-home.test.ts +81 -0
  337. package/tests/history-atomic-write.test.ts +57 -0
  338. package/tests/history-capture-policy.test.ts +102 -0
  339. package/tests/history-child-guard.test.ts +70 -0
  340. package/tests/history-command-registration.test.ts +164 -0
  341. package/tests/history-dedupe-entries.test.ts +123 -0
  342. package/tests/history-delete-backfill.test.ts +190 -0
  343. package/tests/history-delete-confirm.test.ts +460 -0
  344. package/tests/history-dispatch.test.ts +180 -0
  345. package/tests/history-drain-hidden.test.ts +110 -0
  346. package/tests/history-drain-order.test.ts +98 -0
  347. package/tests/history-expanded-globals.test.ts +62 -0
  348. package/tests/history-gc.test.ts +832 -0
  349. package/tests/history-header-layout.test.ts +265 -0
  350. package/tests/history-hide-prompts.test.ts +275 -0
  351. package/tests/history-lazy-windowing.test.ts +508 -0
  352. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  353. package/tests/history-load-shared-history.test.ts +53 -0
  354. package/tests/history-max-results-cap.test.ts +76 -0
  355. package/tests/history-multi-reader.test.ts +203 -0
  356. package/tests/history-off-path.test.ts +170 -0
  357. package/tests/history-openflow-integration.test.ts +173 -0
  358. package/tests/history-overlay-margin.test.ts +326 -0
  359. package/tests/history-preview-layout.test.ts +93 -0
  360. package/tests/history-registry.test.ts +143 -0
  361. package/tests/history-scope-delete.test.ts +411 -0
  362. package/tests/history-search-caret-keys.test.ts +142 -0
  363. package/tests/history-seed-bootstrap.test.ts +170 -0
  364. package/tests/history-seed-regen.test.ts +129 -0
  365. package/tests/history-selector-windowing.test.ts +94 -0
  366. package/tests/history-session-scan-directory.test.ts +87 -0
  367. package/tests/history-session-scan-extract.test.ts +583 -0
  368. package/tests/history-session-writer.test.ts +351 -0
  369. package/tests/history-store-paths.test.ts +79 -0
  370. package/tests/history-tombstone-exact.test.ts +139 -0
  371. package/tests/history-wheel-mouse.test.ts +242 -0
  372. package/tests/inprocess-reviewer.test.ts +49 -19
  373. package/tests/install-gentle-ai-failure.test.ts +69 -0
  374. package/tests/install-tui-mode-setting.test.ts +15 -0
  375. package/tests/install-wizard.test.ts +1223 -0
  376. package/tests/installer-bundles.test.ts +122 -0
  377. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  378. package/tests/installer-preflight.test.ts +750 -0
  379. package/tests/installer-probes.test.ts +1106 -0
  380. package/tests/installer-runner.test.ts +2158 -0
  381. package/tests/installer-server.test.ts +1567 -0
  382. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  383. package/tests/issue-creation-skill.test.ts +61 -0
  384. package/tests/jobs-view.test.ts +160 -0
  385. package/tests/main-channel-upgrade.test.ts +389 -0
  386. package/tests/main-channel.test.ts +344 -0
  387. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  388. package/tests/model-price-ratio.test.ts +116 -0
  389. package/tests/model-profiles-preservation.test.ts +153 -0
  390. package/tests/model-routing-authority.test.ts +16 -0
  391. package/tests/model-routing-package.test.ts +10 -0
  392. package/tests/nan-provider-runtime.test.ts +156 -0
  393. package/tests/nan-provider.test.ts +481 -0
  394. package/tests/native-binary-gate.test.ts +2 -0
  395. package/tests/native-review-capability-contract.test.ts +13 -1
  396. package/tests/native-review-cli.test.ts +239 -118
  397. package/tests/native-review-consent.test.ts +2 -2
  398. package/tests/native-review-parity-runtime.test.ts +101 -4
  399. package/tests/native-review-parity.test.ts +12 -48
  400. package/tests/native-risk-evidence-parity.test.ts +21 -0
  401. package/tests/no-self-pause-contract.test.ts +87 -0
  402. package/tests/notification-audio-native.test.ts +281 -0
  403. package/tests/notification-audio-windows.test.ts +581 -0
  404. package/tests/notification-audio.test.ts +548 -0
  405. package/tests/notification-customize.test.ts +655 -0
  406. package/tests/notification-events.test.ts +115 -0
  407. package/tests/notification-policy.test.ts +201 -0
  408. package/tests/notification-pulse-client.test.ts +356 -0
  409. package/tests/notification-pulse-protocol.test.ts +213 -0
  410. package/tests/notification-pulse-stream.test.ts +320 -0
  411. package/tests/notification-scheduler.test.ts +258 -0
  412. package/tests/notification-ui.test.ts +157 -0
  413. package/tests/npm-pack-result.test.ts +41 -0
  414. package/tests/odd-integration.test.ts +76 -0
  415. package/tests/odd-phase-inference.test.ts +213 -0
  416. package/tests/odd-phase-loader.test.ts +253 -0
  417. package/tests/odd-phase.test.ts +307 -0
  418. package/tests/odd-routing-canonical-ratchet.test.ts +60 -14
  419. package/tests/odd-routing-contract.test.ts +158 -50
  420. package/tests/orchestrator-budget.test.ts +43 -45
  421. package/tests/orchestrator-catalog.test.ts +146 -0
  422. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  423. package/tests/orchestrator-consultation.test.ts +111 -0
  424. package/tests/orchestrator-discovery.test.ts +241 -0
  425. package/tests/orchestrator-helper-consent.test.ts +171 -0
  426. package/tests/orchestrator-helper.test.ts +297 -0
  427. package/tests/orchestrator-message-card.test.ts +190 -0
  428. package/tests/orchestrator-presence.test.ts +23 -0
  429. package/tests/orchestrator-rdd-ownership.test.ts +8 -7
  430. package/tests/orchestrator-scope.test.ts +107 -0
  431. package/tests/orchestrator-state.test.ts +114 -0
  432. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  433. package/tests/orchestrator-tool-card.test.ts +398 -0
  434. package/tests/orchestrator-work-search.test.ts +318 -0
  435. package/tests/orchestrator-work.test.ts +147 -0
  436. package/tests/overlay-repaint.test.ts +74 -0
  437. package/tests/package-manifest.test.ts +344 -123
  438. package/tests/packed-runner-owned-path.test.ts +80 -0
  439. package/tests/parallel-reason-contract.test.ts +21 -0
  440. package/tests/parallel-writer-admission.test.ts +210 -0
  441. package/tests/permission-mode-policy.test.ts +194 -0
  442. package/tests/persona-single-channel.test.ts +10 -18
  443. package/tests/pi-pretty.test.ts +56 -0
  444. package/tests/provider-defect-handoff.test.ts +7 -14
  445. package/tests/quiet-bash-runtime.test.ts +76 -0
  446. package/tests/quiet-tool-rendering.test.ts +569 -187
  447. package/tests/rdd-aware-verification-contract.test.ts +103 -40
  448. package/tests/rdd-status-line.test.ts +9 -4
  449. package/tests/render-cache-performance.test.ts +48 -0
  450. package/tests/resume-hint-extension.test.ts +122 -0
  451. package/tests/review-agent-end-preflight.test.ts +237 -21
  452. package/tests/review-candidate-owner-retry.test.ts +22 -1
  453. package/tests/review-candidate-view.test.ts +399 -12
  454. package/tests/review-contract-prompt.test.ts +138 -44
  455. package/tests/review-controller-lock-status.test.ts +0 -1
  456. package/tests/review-controller-native-recovery.test.ts +327 -17
  457. package/tests/review-controller-native-routing.test.ts +866 -6
  458. package/tests/review-controller-workspace-root.test.ts +206 -6
  459. package/tests/review-controller.test.ts +2 -2
  460. package/tests/review-correction-lifecycle.test.ts +2 -1
  461. package/tests/review-gate.test.ts +7 -4
  462. package/tests/review-graph-schema.test.ts +2 -2
  463. package/tests/review-host-relay-routing.test.ts +342 -4
  464. package/tests/review-host-relay.test.ts +49 -2
  465. package/tests/review-integration-v2-forward.test.ts +221 -1
  466. package/tests/review-json-arguments.test.ts +167 -0
  467. package/tests/review-last-event-closure.test.ts +157 -8
  468. package/tests/review-ledger-contract.test.ts +14 -37
  469. package/tests/review-policy-judgment-day.test.ts +1 -1
  470. package/tests/review-policy-ordinary.test.ts +2 -2
  471. package/tests/review-relay-transport-agent.test.ts +66 -1
  472. package/tests/review-reminder-receipt.test.ts +47 -1
  473. package/tests/review-risk-assessment.test.ts +594 -26
  474. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  475. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  476. package/tests/review-sidebar-state.test.ts +402 -0
  477. package/tests/review-test-fixtures.ts +7 -1
  478. package/tests/review-transaction.test.ts +18 -1
  479. package/tests/run-test-suite.test.ts +202 -0
  480. package/tests/runtime-harness.mjs +186 -800
  481. package/tests/runtime-metrics-children.test.ts +16 -23
  482. package/tests/runtime-metrics-extension.test.ts +1 -1
  483. package/tests/runtime-metrics-native.test.ts +31 -1
  484. package/tests/selection-engine.test.ts +451 -0
  485. package/tests/session-change-capture.test.ts +10 -10
  486. package/tests/session-changes-shell.test.ts +242 -48
  487. package/tests/session-changes.test.ts +17 -24
  488. package/tests/session-messaging-grants.test.ts +255 -0
  489. package/tests/session-profile-binding.test.ts +163 -0
  490. package/tests/session-profile-freeze.test.ts +381 -0
  491. package/tests/session-subject-routing-contract.test.ts +34 -0
  492. package/tests/session-worktree-registry.test.ts +134 -2
  493. package/tests/shell-bar.test.ts +422 -1
  494. package/tests/shell-card.test.ts +353 -1
  495. package/tests/shell-changes-view.test.ts +135 -32
  496. package/tests/shell-changes.test.ts +61 -62
  497. package/tests/shell-prompt.test.ts +94 -2
  498. package/tests/shell-render-cache-runtime.test.ts +69 -0
  499. package/tests/shell-render-cache.test.ts +129 -0
  500. package/tests/shell-sidebar-layout.test.ts +351 -22
  501. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  502. package/tests/shell-todo.test.ts +207 -3
  503. package/tests/shell-usage-view.test.ts +28 -1
  504. package/tests/shell-usage.test.ts +108 -0
  505. package/tests/short-check-routing-contract.test.ts +49 -0
  506. package/tests/skill-registry-watcher.test.ts +119 -0
  507. package/tests/skill-registry.test.ts +357 -2
  508. package/tests/startup-banner.test.ts +246 -4
  509. package/tests/startup-home-repo.test.ts +170 -0
  510. package/tests/stats-collector.test.ts +195 -0
  511. package/tests/stats-view.test.ts +202 -0
  512. package/tests/status-timing-diagnostics.test.ts +283 -0
  513. package/tests/support/orchestrator-modules.ts +19 -0
  514. package/tests/task-size-routing-contract.test.ts +241 -0
  515. package/tests/telemetry-trigger.test.ts +107 -20
  516. package/tests/test-strength-contract.test.ts +124 -0
  517. package/tests/theme-customization.test.ts +72 -0
  518. package/tests/verify-agent-contract.test.ts +90 -0
  519. package/tests/verify-first-launch-contract.test.ts +139 -0
  520. package/tests/verify-package-files.test.ts +56 -0
  521. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  522. package/tests/vim-editor-adapter.test.ts +804 -0
  523. package/tests/vim-normal-engine.test.ts +101 -0
  524. package/tests/vim-operator-engine.test.ts +215 -0
  525. package/tests/vim-policy.test.ts +19 -0
  526. package/tests/vim-visual-engine.test.ts +52 -0
  527. package/tests/visual-customization-policy.test.ts +139 -0
  528. package/tests/visual-customize-view.test.ts +696 -0
  529. package/tests/visual-profiles.test.ts +87 -0
  530. package/tests/writer-edit-surface-scope.test.ts +59 -2
  531. package/tests/writer-surfaces.test.ts +199 -0
  532. package/tests/yolo-customize.test.ts +271 -0
  533. package/tests/yolo-mode-runtime.test.ts +188 -0
  534. package/tests/yolo-mode.test.ts +289 -0
  535. package/tests/yolo-session-policy.test.ts +59 -0
  536. package/themes/Gentle.json +2 -1
  537. package/themes/Gentleman-Cute.json +2 -1
  538. package/themes/Gentleman-Sexy.json +2 -1
  539. package/assets/agents/sdd-apply.md +0 -159
  540. package/assets/agents/sdd-archive.md +0 -228
  541. package/assets/agents/sdd-design.md +0 -49
  542. package/assets/agents/sdd-explore.md +0 -48
  543. package/assets/agents/sdd-init.md +0 -56
  544. package/assets/agents/sdd-onboard.md +0 -52
  545. package/assets/agents/sdd-proposal.md +0 -64
  546. package/assets/agents/sdd-remediate.md +0 -37
  547. package/assets/agents/sdd-research.md +0 -49
  548. package/assets/agents/sdd-spec.md +0 -192
  549. package/assets/agents/sdd-status.md +0 -54
  550. package/assets/agents/sdd-tasks.md +0 -108
  551. package/assets/agents/sdd-verify.md +0 -124
  552. package/assets/chains/sdd-full.chain.md +0 -83
  553. package/assets/chains/sdd-plan.chain.md +0 -56
  554. package/assets/chains/sdd-verify.chain.md +0 -43
  555. package/assets/sdd-orchestrator-workflow.md +0 -319
  556. package/assets/support/sdd-status-contract.md +0 -77
  557. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  558. package/extensions/sdd-init.ts +0 -816
  559. package/lib/openspec-deltas.ts +0 -156
  560. package/lib/sdd-preflight.ts +0 -1066
  561. package/lib/sdd-research-capabilities.ts +0 -94
  562. package/lib/sdd-status.ts +0 -26
  563. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  564. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  565. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  566. package/tests/openspec-deltas.test.ts +0 -209
  567. package/tests/sdd-agent-tools.test.ts +0 -156
  568. package/tests/sdd-archive-replay.test.ts +0 -82
  569. package/tests/sdd-classical-continuation.test.ts +0 -74
  570. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  571. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  572. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  573. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  574. package/tests/sdd-odd-integration.test.ts +0 -33
  575. package/tests/sdd-optional-research.test.ts +0 -124
  576. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  577. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  578. package/tests/sdd-preflight.test.ts +0 -541
  579. package/tests/sdd-research-capabilities.test.ts +0 -114
  580. package/tests/sdd-research-live.test.ts +0 -241
  581. package/tests/sdd-selection-transport.test.ts +0 -653
  582. package/tests/sdd-status.test.ts +0 -9
  583. package/tests/sdd-task-truth.test.ts +0 -43
  584. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  585. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  586. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  587. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  588. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  589. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  590. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,69 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { initTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { Container, TuiAltScreen } from "@earendil-works/pi-tui";
5
+ import { createChatViewport } from "../node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/chat-viewport.js";
6
+ import { buildShellBarModel, type ShellFooterData } from "../extensions/gentle-shell.ts";
7
+ import { createQuietToolRenderer } from "../extensions/quiet-tools.ts";
8
+
9
+ initTheme("dark");
10
+
11
+ test("native fullscreen scroll frames reuse complete tool cards and session statistics", () => {
12
+ let decoration = 0;
13
+ let scans = 0;
14
+ let projections = 0;
15
+ const theme = {
16
+ fg: (_color: string, text: string) => { decoration++; return text; },
17
+ bg: (_color: string, text: string) => { decoration++; return `\x1b[48;5;235m${text}\x1b[49m`; },
18
+ bold: (text: string) => text,
19
+ };
20
+ const ctx = {
21
+ model: { id: "model", contextWindow: 1000 },
22
+ getContextUsage() { projections++; return { percent: 10, contextWindow: 1000 }; },
23
+ modelRegistry: { isUsingOAuth: () => false },
24
+ sessionManager: {
25
+ getEntries() { scans++; return []; }, getSessionId: () => "session", getLeafId: () => "leaf",
26
+ getCwd: () => "/repo", getSessionName: () => "session",
27
+ },
28
+ } as unknown as ExtensionContext;
29
+ const footerData = { getGitBranch: () => "main", getExtensionStatuses: () => new Map() } as unknown as ShellFooterData;
30
+ const pi = { getThinkingLevel: () => "high" } as unknown as ExtensionAPI;
31
+ const document = new Container();
32
+ const renderer = createQuietToolRenderer("read");
33
+ for (let tool = 0; tool < 50; tool++) {
34
+ const context = { args: { path: `src/file-${tool}.ts` }, state: {}, isPartial: false, executionStarted: true };
35
+ document.addChild(renderer.renderCall!(context.args, theme as never, context as never));
36
+ document.addChild(renderer.renderResult!({ content: [{ type: "text", text: `result-${tool}` }], details: {} },
37
+ { expanded: false, isPartial: false }, theme as never, context as never));
38
+ }
39
+ const footer = new Container();
40
+ footer.addChild({ render: () => [`cost ${buildShellBarModel(pi, ctx, footerData).costTotal}`], invalidate() {} });
41
+ const viewport = createChatViewport({
42
+ document, pendingMessages: new Container(), status: new Container(), widgetsAbove: new Container(),
43
+ editor: { render: () => ["prompt"], invalidate() {} }, widgetsBelow: new Container(), footer, scrollbar: "hidden",
44
+ });
45
+ const terminal = {
46
+ start() {}, stop() {}, async drainInput() {}, write() {}, columns: 100, rows: 24,
47
+ moveBy() {}, hideCursor() {}, showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {},
48
+ setTitle() {}, setProgress() {}, kittyProtocolActive: false, getBufferedInput() { return ""; }, hasPendingInput() { return false; },
49
+ } as never;
50
+ const tui = new TuiAltScreen(terminal, false);
51
+ tui.setLayoutRoot(viewport.root);
52
+ const host = tui as unknown as { doRender(): void; previousScreen: string[] };
53
+ tui.start();
54
+ try {
55
+ host.doRender();
56
+ const warmDecoration = decoration;
57
+ assert.deepEqual([scans, projections], [1, 1]);
58
+ const screens: string[] = [];
59
+ for (let frame = 0; frame < 20; frame++) {
60
+ if (frame % 2) viewport.transcript.scrollToEnd();
61
+ else viewport.transcript.scrollTo(0, { disableFollow: true });
62
+ host.doRender();
63
+ screens.push(host.previousScreen.join("\n"));
64
+ }
65
+ assert.notEqual(screens[0], screens[1], "scroll really changes the visible transcript");
66
+ assert.equal(decoration, warmDecoration, "even the native frame path reuses settled chrome");
67
+ assert.deepEqual([scans, projections], [1, 1], "scroll/pulse-like frames do not rescan unchanged session state");
68
+ } finally { tui.stop(); }
69
+ });
@@ -0,0 +1,129 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { initTheme, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { buildShellBarModel, type ShellFooterData } from "../extensions/gentle-shell.ts";
5
+ import { createQuietToolRenderer } from "../extensions/quiet-tools.ts";
6
+ import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
7
+
8
+ initTheme("dark");
9
+ const pi = { getThinkingLevel: () => "high" } as unknown as ExtensionAPI;
10
+ const footer = { getGitBranch: () => "main", getExtensionStatuses: () => new Map() } as unknown as ShellFooterData;
11
+
12
+ function sessionFixture() {
13
+ let reads = 0;
14
+ let projections = 0;
15
+ let sessionId = "session-1";
16
+ let leaf = "leaf-1";
17
+ const entries = [{ type: "message", message: { role: "assistant", usage: { cost: { total: 1 } } } }];
18
+ const ctx = {
19
+ model: { id: "model", contextWindow: 1000 },
20
+ getContextUsage() { projections++; return { percent: 10, contextWindow: this.model.contextWindow }; },
21
+ modelRegistry: { isUsingOAuth: () => false },
22
+ sessionManager: {
23
+ getEntries() { reads++; return [...entries]; },
24
+ getEntryCount: () => entries.length,
25
+ getSessionId: () => sessionId,
26
+ getLeafId: () => leaf,
27
+ getCwd: () => "/repo",
28
+ getSessionName: () => "session",
29
+ },
30
+ };
31
+ return {
32
+ ctx, entries,
33
+ build: () => buildShellBarModel(pi, ctx as unknown as ExtensionContext, footer),
34
+ counts: () => [reads, projections],
35
+ branch: () => { leaf = "other-leaf"; },
36
+ switchSession: () => { sessionId = "session-2"; },
37
+ };
38
+ }
39
+
40
+ test("settled shell chrome scans stats once while presentation stays live", () => {
41
+ const fixture = sessionFixture();
42
+ assert.equal(fixture.build().costTotal, 1);
43
+ for (let frame = 0; frame < 20; frame++) fixture.build();
44
+ assert.deepEqual(fixture.counts(), [1, 1]);
45
+ fixture.ctx.sessionManager.getSessionName = () => "renamed";
46
+ assert.equal(fixture.build().sessionName, "renamed");
47
+ assert.deepEqual(fixture.counts(), [1, 1]);
48
+ fixture.entries.push(fixture.entries[0]!);
49
+ assert.equal(fixture.build().costTotal, 2);
50
+ fixture.branch(); fixture.build();
51
+ fixture.switchSession(); fixture.build();
52
+ fixture.ctx.model = { ...fixture.ctx.model, id: "new-model" }; fixture.build();
53
+ fixture.ctx.model.contextWindow = 2000;
54
+ assert.equal(fixture.build().contextWindow, 2000);
55
+ assert.deepEqual(fixture.counts(), [6, 6]);
56
+ const other = sessionFixture(); other.build();
57
+ assert.deepEqual(other.counts(), [1, 1], "manager identities do not share statistics");
58
+ });
59
+
60
+ test("leaf-based stats work on read-only hosts without an entry-count method", () => {
61
+ const fixture = sessionFixture();
62
+ Reflect.deleteProperty(fixture.ctx.sessionManager, "getEntryCount");
63
+ fixture.build(); fixture.build();
64
+ assert.deepEqual(fixture.counts(), [1, 1]);
65
+ fixture.branch(); fixture.build();
66
+ assert.deepEqual(fixture.counts(), [2, 2]);
67
+ });
68
+
69
+ function countingTheme() {
70
+ let calls = 0;
71
+ let prefix = "";
72
+ return {
73
+ theme: {
74
+ fg: (_color: string, text: string) => { calls++; return prefix + text; },
75
+ bg: (_color: string, text: string) => { calls++; return `\x1b[48;5;235m${text}\x1b[49m`; },
76
+ bold: (text: string) => text,
77
+ },
78
+ calls: () => calls,
79
+ change: () => { prefix += "!"; },
80
+ };
81
+ }
82
+
83
+ for (const expanded of [false, true]) test(`settled tool cards cache complete rows (expanded=${expanded})`, () => {
84
+ const previous = cardStyle();
85
+ try {
86
+ setCardStyle(CARD_STYLE.NEON);
87
+ const { theme, calls, change } = countingTheme();
88
+ const renderer = createQuietToolRenderer("read");
89
+ const context = { args: { path: "src/example.ts" }, state: {}, isPartial: false, executionStarted: true };
90
+ const result = { content: [{ type: "text" as const, text: "first line\nsecond line" }], details: {} };
91
+ const cards = [renderer.renderCall!(context.args, theme as never, context as never),
92
+ renderer.renderResult!(result, { expanded, isPartial: false }, theme as never, context as never)];
93
+ for (const card of cards) {
94
+ const rows = card.render(80);
95
+ const warm = calls();
96
+ for (let frame = 0; frame < 20; frame++) assert.deepEqual(card.render(80), rows);
97
+ assert.equal(calls(), warm, "warm frames do not rebuild decorated lines");
98
+ assert.notDeepEqual(card.render(40), rows, "width invalidates layout");
99
+ setCardStyle(CARD_STYLE.FLOAT);
100
+ assert.notDeepEqual(card.render(80), rows, "style changes apply without host invalidation");
101
+ setCardStyle(CARD_STYLE.NEON);
102
+ card.render(80);
103
+ change(); card.invalidate();
104
+ assert.notDeepEqual(card.render(80), rows, "theme invalidation rebuilds colored content");
105
+ }
106
+ } finally { setCardStyle(previous); }
107
+ });
108
+
109
+ test("cached call chrome observes a result mark on its shared row state", () => {
110
+ const { theme } = countingTheme();
111
+ const renderer = createQuietToolRenderer("read");
112
+ const context = { args: { path: "file.ts" }, state: {}, isPartial: true, executionStarted: true };
113
+ const call = renderer.renderCall!(context.args, theme as never, context as never);
114
+ const running = call.render(80);
115
+ assert.ok(running.join("\n").includes("running"));
116
+ renderer.renderResult!({ content: [], details: {} }, { expanded: false, isPartial: false }, theme as never, context as never);
117
+ assert.ok(!call.render(80).join("\n").includes("running"), "result arrival removes the call's running/closing rows");
118
+ });
119
+
120
+ test("image renderers keep their live geometry instead of caching opaque components", () => {
121
+ const { theme } = countingTheme();
122
+ let renders = 0;
123
+ const renderer = createQuietToolRenderer("read", undefined, undefined, () => ({
124
+ render: () => [`image geometry ${++renders}`], invalidate() {},
125
+ }));
126
+ const card = renderer.renderResult!({ content: [{ type: "image", data: "", mimeType: "image/png" }], details: {} },
127
+ { expanded: true, isPartial: false }, theme as never, {} as never);
128
+ assert.notDeepEqual(card.render(80), card.render(80));
129
+ });
@@ -2,10 +2,11 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { CURSOR_MARKER, ScrollView, VStack, visibleWidth, type Component, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
4
4
  import { getScrollViewsAt, renderLayoutFrame, type LayoutBox } from "@earendil-works/pi-tui/dist/layout.js";
5
- import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
5
+ import { installSidebar, invalidateSidebar, narrowStatusOwner } from "../lib/shell-sidebar-layout.ts";
6
6
  import { sidebarHeader, sidebarPart, sidebarState } from "../lib/shell-sidebar.ts";
7
7
  import { renderShellSidebarBar } from "../lib/shell-bar.ts";
8
8
  import { renderTodoCard, type TodoState } from "../lib/shell-todo.ts";
9
+ import { CARD_STYLE, cardStyle, setCardStyle } from "../lib/shell-card.ts";
9
10
 
10
11
  const NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
11
12
  const theme = { fg: (_color: string, text: string) => text, bold: (text: string) => text };
@@ -38,22 +39,29 @@ function railWithHeader(f: ReturnType<typeof fixture>): ScrollView {
38
39
  return hstackOf(f).entries[1].component as ScrollView;
39
40
  }
40
41
 
41
- test("grouped Status preserves structured fields and opaque integration text", () => {
42
- const lines = renderShellSidebarBar({
43
- cwd: "/project", branch: "main", dirty: 2, sessionName: "session",
44
- modelId: "model", effort: "high", contextPercent: 45, contextWindow: 1000,
45
- costTotal: 1, subscription: false, statuses: ["opaque integration"],
46
- }, theme, 46);
47
- const text = lines.join("\n");
48
- let previous = -1;
49
- for (const heading of ["Status", "Project", "Changes", "Integrations"]) {
50
- const index = text.indexOf(heading);
51
- assert.ok(index > previous, heading);
52
- previous = index;
42
+ test("grouped Status preserves structured fields and opaque integration text", (t) => {
43
+ const found = cardStyle();
44
+ t.after(() => setCardStyle(found));
45
+ // Both styles keep the group order; float panels need a theme background.
46
+ const painted = { ...theme, bg: (_color: string, text: string) => `\x1b[48;5;22m${text}\x1b[49m` };
47
+ for (const style of [CARD_STYLE.NEON, CARD_STYLE.FLOAT]) {
48
+ setCardStyle(style);
49
+ const lines = renderShellSidebarBar({
50
+ cwd: "/project", branch: "main", dirty: 2, sessionName: "session",
51
+ modelId: "model", effort: "high", contextPercent: 45, contextWindow: 1000,
52
+ costTotal: 1, subscription: false, usage: undefined, statuses: ["opaque integration"],
53
+ }, painted, 46);
54
+ const text = lines.join("\n");
55
+ let previous = -1;
56
+ for (const heading of ["Status", "Project", "Changes", "Integrations"]) {
57
+ const index = text.indexOf(heading);
58
+ assert.ok(index > previous, `${style}: ${heading}`);
59
+ previous = index;
60
+ }
61
+ assert.match(text, /opaque integration/);
62
+ assert.match(text, /Branch.*main/);
63
+ assert.doesNotMatch(text, /Usage/);
53
64
  }
54
- assert.match(text, /opaque integration/);
55
- assert.match(text, /Branch.*main/);
56
- assert.doesNotMatch(text, /Usage/);
57
65
  });
58
66
 
59
67
  test("scrollable TODO keeps every task while bottom and collapsed cards stay bounded", () => {
@@ -82,6 +90,209 @@ test("only fullscreen at 140 columns activates; shrinking restores bottom paint"
82
90
  }
83
91
  });
84
92
 
93
+ test("bottom paint follows placement and resize before the next layout pass", (t) => {
94
+ const f = fixture("fullscreen", 140);
95
+ let placement: "auto" | "right" | "bottom" | "hidden" = "auto";
96
+ t.after(installSidebar(f.tui, theme, () => placement));
97
+ assert.equal(f.root[NODE]().type, "hstack");
98
+ assert.deepEqual(f.bottom.render(80), []);
99
+
100
+ // A preference notification can request paint before the host measures its root.
101
+ placement = "bottom";
102
+ assert.deepEqual(f.bottom.render(80), ["Status"]);
103
+ assert.equal(f.root[NODE]().type, "vstack");
104
+ placement = "right";
105
+ assert.equal(f.root[NODE]().type, "hstack");
106
+ f.host.terminal.columns = 139;
107
+ assert.deepEqual(f.bottom.render(80), ["Status"]);
108
+ assert.equal(f.root[NODE]().type, "vstack");
109
+ placement = "hidden";
110
+ f.host.terminal.columns = 180;
111
+ assert.deepEqual(f.bottom.render(80), []);
112
+ assert.equal(f.root[NODE]().type, "vstack");
113
+ placement = "auto";
114
+ assert.equal(f.root[NODE]().type, "hstack");
115
+ assert.deepEqual(f.bottom.render(80), []);
116
+ });
117
+
118
+ test("hidden status suppresses bottom paint and placement resizes responsively", (t) => {
119
+ const f = fixture("fullscreen", 140);
120
+ let placement: "auto" | "right" | "bottom" | "hidden" = "auto";
121
+ t.after(installSidebar(f.tui, theme, () => placement));
122
+ assert.equal(f.root[NODE]().type, "hstack");
123
+ placement = "hidden";
124
+ assert.equal(f.root[NODE]().type, "vstack");
125
+ assert.deepEqual(f.bottom.render(80), []);
126
+ placement = "right";
127
+ assert.equal(f.root[NODE]().type, "hstack");
128
+ f.host.terminal.columns = 139;
129
+ assert.equal(f.root[NODE]().type, "vstack");
130
+ placement = "bottom";
131
+ f.host.terminal.columns = 180;
132
+ assert.equal(f.root[NODE]().type, "vstack");
133
+ });
134
+
135
+ test("narrow status owner prefers a configured top header, otherwise the bottom bar, and leaves wide/regular untouched", () => {
136
+ for (const statusPlacement of ["auto", "right", "bottom", "hidden"] as const) {
137
+ for (const columns of [40, 100, 139]) {
138
+ assert.equal(narrowStatusOwner({ mode: "fullscreen", columns, statusPlacement, headerPlacement: "top" }), "header", `${columns}/${statusPlacement}/top`);
139
+ assert.equal(narrowStatusOwner({ mode: "fullscreen", columns, statusPlacement, headerPlacement: "below-input" }), statusPlacement === "hidden" ? "header" : "bottom", `${columns}/${statusPlacement}/below-input`);
140
+ }
141
+ for (const headerPlacement of ["top", "below-input"] as const) {
142
+ assert.equal(narrowStatusOwner({ mode: "fullscreen", columns: 140, statusPlacement, headerPlacement }), undefined, "wide keeps its layout");
143
+ assert.equal(narrowStatusOwner({ mode: "regular", columns: 60, statusPlacement, headerPlacement }), undefined, "regular mode has no top header");
144
+ }
145
+ }
146
+ });
147
+
148
+ test("narrow top header owns Status only while it actually paints, and resize restores the bottom bar before the next layout pass", (t) => {
149
+ const f = fixture("fullscreen", 100);
150
+ let label = "HEADER";
151
+ sidebarHeader(f.tui, { render: () => [label], invalidate() {} });
152
+ let headerPlacement: "top" | "below-input" = "top";
153
+ t.after(installSidebar(f.tui, theme, () => "bottom", () => headerPlacement));
154
+ f.root[NODE]();
155
+ assert.deepEqual(f.bottom.render(100), [], "top header alone at narrow width");
156
+ headerPlacement = "below-input";
157
+ assert.deepEqual(f.bottom.render(100), ["Status"], "below-input leaves the bottom bar as the only status row");
158
+ headerPlacement = "top";
159
+ f.host.terminal.columns = 180;
160
+ assert.deepEqual(f.bottom.render(180), ["Status"], "wide bottom placement keeps both bars");
161
+ f.host.terminal.columns = 100;
162
+ label = "";
163
+ f.root[NODE]();
164
+ assert.deepEqual(f.bottom.render(100), ["Status"], "a blank header never swallows the bottom bar");
165
+ });
166
+
167
+ test("regular mode keeps the bottom bar even with a top header configured", (t) => {
168
+ const f = fixture("regular", 80);
169
+ sidebarHeader(f.tui, { render: () => ["HEADER"], invalidate() {} });
170
+ t.after(installSidebar(f.tui, theme, () => "auto", () => "top"));
171
+ f.root[NODE]();
172
+ assert.deepEqual(f.bottom.render(80), ["Status"]);
173
+ });
174
+
175
+ test("top header remains above native layout at narrow widths and wide bottom/hidden without a rail", (t) => {
176
+ for (const width of [100, 60, 180]) {
177
+ for (const placement of width === 180 ? ["bottom", "hidden"] as const : ["auto"] as const) {
178
+ const f = fixture("fullscreen", width);
179
+ sidebarHeader(f.tui, { render: (available: number) => [`HEADER ${available}`, `RULE ${available}`], invalidate() {} });
180
+ const dispose = installSidebar(f.tui, theme, () => placement);
181
+ t.after(dispose);
182
+ const node = f.root[NODE]() as unknown as { type: string; entries: { component: Component & { [NODE]?(): unknown } }[] };
183
+ assert.equal(node.type, "vstack", `${width}/${placement} keeps the top header`);
184
+ assert.deepEqual(node.entries[0]?.component.render(width), [`HEADER ${width}`, `RULE ${width}`]);
185
+ assert.deepEqual(node.entries[1]?.component[NODE]?.(), f.original(), "native layout remains underneath");
186
+ assert.deepEqual(f.bottom.render(width), placement === "bottom" ? ["Status"] : [], "wide bottom keeps Status; narrow top header and hidden paint no bottom bar");
187
+ assert.equal(sidebarState(f.tui).active, false);
188
+ }
189
+ }
190
+ });
191
+
192
+ test("below-input header never duplicates into the top row, even when the rail is inactive", (t) => {
193
+ for (const [width, placement] of [[60, "auto"], [100, "auto"], [180, "bottom"], [180, "hidden"]] as const) {
194
+ const f = fixture("fullscreen", width);
195
+ sidebarHeader(f.tui, { render: () => ["HEADER"], invalidate() {} });
196
+ t.after(installSidebar(f.tui, theme, () => placement, () => "below-input"));
197
+ assert.deepEqual(f.root[NODE](), f.original());
198
+ assert.deepEqual(f.bottom.render(width), placement === "hidden" ? [] : ["Status"]);
199
+ }
200
+ });
201
+
202
+ test("hidden status suppresses bottom paint in regular, narrow and wide fullscreen while other placements paint", (t) => {
203
+ for (const [mode, width] of [["regular", 180], ["regular", 60], ["fullscreen", 60], ["fullscreen", 100], ["fullscreen", 180]] as const) {
204
+ const f = fixture(mode, width);
205
+ let placement: "auto" | "right" | "bottom" | "hidden" = "hidden";
206
+ t.after(installSidebar(f.tui, theme, () => placement));
207
+ f.root[NODE]();
208
+ assert.deepEqual(f.bottom.render(width), [], `${mode}/${width} hidden paints no bottom Status`);
209
+ assert.equal(sidebarState(f.tui).active, false);
210
+ for (const other of ["auto", "right", "bottom"] as const) {
211
+ placement = other;
212
+ f.root[NODE]();
213
+ const railOwns = mode === "fullscreen" && width >= 140 && other !== "bottom";
214
+ assert.deepEqual(f.bottom.render(width), railOwns ? [] : ["Status"], `${mode}/${width}/${other}`);
215
+ }
216
+ placement = "hidden";
217
+ assert.deepEqual(f.bottom.render(width), [], "switching back to hidden applies before the next layout pass");
218
+ }
219
+ });
220
+
221
+ test("hidden status leaves other bottom parts painting and restores Status on cleanup", () => {
222
+ const f = fixture("regular", 100);
223
+ const todo = sidebarPart(f.tui, "todo", { render: (_width: number) => ["todo"], invalidate() {} });
224
+ const dispose = installSidebar(f.tui, theme, () => "hidden");
225
+ assert.deepEqual(f.bottom.render(100), []);
226
+ assert.deepEqual(todo.render(100), ["todo"]);
227
+ dispose();
228
+ assert.deepEqual(f.bottom.render(100), ["Status"]);
229
+ });
230
+
231
+ // A native-shaped dock: the footer is the last dock entry with a reserved row.
232
+ function dockedFooterFixture(width: number) {
233
+ const transcript = { render: () => Array.from({ length: 50 }, (_, index) => `Entry ${index}`), invalidate() {} };
234
+ const primary = new ScrollView(transcript, { primary: true, follow: "end" });
235
+ const host = { mode: "fullscreen", terminal: { columns: width }, layoutRoot: undefined as unknown, requestRender() {} };
236
+ const tui = host as unknown as TUI;
237
+ const footer = sidebarPart(tui, "footer", { render: () => ["Status"], invalidate() {} });
238
+ const editor = { render: () => [`Editor${CURSOR_MARKER}`], invalidate() {} };
239
+ const dock = new VStack([{ component: editor }, { component: footer, minSize: 1 }]);
240
+ const root = new VStack([{ component: primary, basis: 0, grow: 1, shrink: 1, minSize: 1 }, { component: dock }]);
241
+ host.layoutRoot = root;
242
+ return { host, tui, root, frame: () => renderLayoutFrame(root, host.terminal.columns, 12, () => {}).lines };
243
+ }
244
+
245
+ test("hidden status reclaims the reserved footer row in narrow and wide fullscreen", (t) => {
246
+ for (const width of [60, 100, 180]) {
247
+ for (const withHeader of [false, true]) {
248
+ const f = dockedFooterFixture(width);
249
+ if (withHeader) sidebarHeader(f.tui, { render: (available: number) => [`HEADER ${available}`], invalidate() {} });
250
+ let placement: "auto" | "bottom" | "hidden" = "hidden";
251
+ t.after(installSidebar(f.tui, theme, () => placement));
252
+ let lines = f.frame();
253
+ assert.match(lines[lines.length - 1] ?? "", /Editor/, `${width}/${withHeader} hidden leaves no blank footer row`);
254
+ assert.doesNotMatch(lines.join("\n"), /Status/);
255
+ placement = "bottom";
256
+ lines = f.frame();
257
+ if (withHeader && width < 140) {
258
+ // The narrow top header owns Status, so its reserved row is reclaimed too.
259
+ assert.match(lines[lines.length - 1] ?? "", /Editor/, `${width} narrow top header leaves no blank footer row`);
260
+ assert.doesNotMatch(lines.join("\n"), /Status/);
261
+ } else {
262
+ assert.match(lines[lines.length - 1] ?? "", /Status/, `${width}/${withHeader} bottom still paints its footer row`);
263
+ }
264
+ }
265
+ }
266
+ });
267
+
268
+ test("below-input header removes only the rail's top row", (t) => {
269
+ const f = fixture();
270
+ let header: "top" | "below-input" = "top";
271
+ sidebarHeader(f.tui, { render: () => ["header"], invalidate() {} });
272
+ t.after(installSidebar(f.tui, theme, () => "auto", () => header));
273
+ assert.equal(f.root[NODE]().type, "vstack");
274
+ header = "below-input";
275
+ assert.equal(f.root[NODE]().type, "hstack");
276
+ assert.deepEqual(f.bottom.render(80), []);
277
+ header = "top";
278
+ assert.equal(f.root[NODE]().type, "vstack");
279
+ });
280
+
281
+ test("TODO visibility hides both rail and bottom without mutating the registered part", (t) => {
282
+ const f = fixture();
283
+ const todo = sidebarPart(f.tui, "todo", { render: (_width: number) => ["todo"], invalidate() {} });
284
+ t.after(installSidebar(f.tui, theme));
285
+ assert.match(rail(f).render(50).join("\n"), /todo/);
286
+ sidebarState(f.tui).visibility = { todo: false };
287
+ invalidateSidebar(f.tui);
288
+ assert.doesNotMatch(rail(f).render(50).join("\n"), /todo/);
289
+ f.host.terminal.columns = 100;
290
+ assert.equal(f.root[NODE]().type, "vstack");
291
+ assert.deepEqual(todo.render(100), []);
292
+ sidebarState(f.tui).visibility = { todo: true };
293
+ assert.deepEqual(todo.render(100), ["todo"]);
294
+ });
295
+
85
296
  test("rail orders unified Status, agents, TODO without standalone changes", (t) => {
86
297
  const f = fixture();
87
298
  for (const key of ["todo", "agents", "changes"]) {
@@ -154,6 +365,31 @@ test("wheel scrolls the rail and is consumed at both boundaries and blank space"
154
365
  assert.equal(scroll.scrollTop, 0);
155
366
  });
156
367
 
368
+ test("wheel over a bounded Todo viewport scrolls the section before the surrounding rail", (t) => {
369
+ const f = fixture();
370
+ const received: TuiMouseEvent[] = [];
371
+ let consumes = true;
372
+ sidebarPart(f.tui, "todo", {
373
+ render: () => ["Todo viewport", "Todo body"], invalidate() {},
374
+ handleMouse(event: TuiMouseEvent) {
375
+ received.push(event);
376
+ return consumes && event.type === "wheel" ? { handled: true, render: true } : undefined;
377
+ },
378
+ });
379
+ t.after(installSidebar(f.tui, theme));
380
+ const scroll = rail(f);
381
+ const content = scroll.render(50);
382
+ scroll.updateLayout(content.length + 20, content.length, () => {});
383
+ const y = content.findIndex((line) => line.includes("Todo body"));
384
+ const event: TuiMouseEvent = { type: "wheel", button: "none", wheelDelta: 2, x: 3, y, screenX: 93, screenY: y, width: 50, height: content.length, shift: false, alt: false, ctrl: false };
385
+ assert.equal(scroll.handleMouse(event)?.handled, true);
386
+ assert.equal(scroll.scrollTop, 0, "inner viewport consumes wheel without moving the rail");
387
+ assert.equal(received.at(-1)?.y, 1, "section coordinates are local");
388
+ consumes = false;
389
+ assert.equal(scroll.handleMouse(event)?.handled, true);
390
+ assert.equal(scroll.scrollTop, 2, "unbounded/collapsed sections fall back to rail scrolling");
391
+ });
392
+
157
393
  test("rail dispatches a clipped, scroll-translated left click to only the matching sidebar part", (t) => {
158
394
  const f = fixture();
159
395
  let clicks = 0;
@@ -216,7 +452,7 @@ test("rail rejects removed or replaced parts before cached geometry is prepared
216
452
  return { handled: true };
217
453
  },
218
454
  };
219
- const mounted = sidebarPart(f.tui, "todo", { render: () => ["Todo bottom"], invalidate() {} }, original);
455
+ const mounted = sidebarPart(f.tui, "todo", { render: () => ["Todo bottom"], invalidate() {}, dispose() {} }, original);
220
456
  const dispose = installSidebar(f.tui, theme);
221
457
  t.after(dispose);
222
458
  const scroll = rail(f);
@@ -464,6 +700,31 @@ test("fullscreen native transcript renders once without changing bytes, geometry
464
700
  }
465
701
  });
466
702
 
703
+ test("native fullscreen header survives narrow and bottom transitions without replacing transcript scroll", (t) => {
704
+ const f = nativeSidebarFixture();
705
+ let placement: "right" | "bottom" | "hidden" = "right";
706
+ let headerPlacement: "top" | "below-input" = "top";
707
+ // Reinstall with live preferences so the transition uses the same native root.
708
+ f.dispose();
709
+ sidebarHeader(f.host as unknown as TUI, { render: (width: number) => [`HEADER ${width}`], invalidate() {} });
710
+ const dispose = installSidebar(f.host as unknown as TUI, theme, () => placement, () => headerPlacement);
711
+ t.after(dispose);
712
+ for (const [width, status] of [[100, "right"], [60, "right"], [180, "bottom"], [180, "hidden"], [180, "right"]] as const) {
713
+ placement = status;
714
+ const { frame, transcriptRenders } = f.frame(width);
715
+ assert.equal(transcriptRenders, 1, "native transcript paints only once");
716
+ assert.equal(frame.primaryScrollView, f.primary);
717
+ assert.match(frame.lines[0] ?? "", new RegExp(`HEADER ${status === "right" && width >= 140 ? width - 2 : width}`));
718
+ assert.ok(frame.lines.some((line) => line.includes("Editor")), "native editor survives");
719
+ assert.equal(frame.root.children.some((box) => box.component instanceof ScrollView && box.component !== f.primary), false, "no extra root scroll view");
720
+ }
721
+ headerPlacement = "below-input";
722
+ placement = "bottom";
723
+ const below = f.frame(100).frame;
724
+ assert.doesNotMatch(below.lines.join("\n"), /HEADER/, "below-input owns its own widget, never the top row");
725
+ assert.equal(below.primaryScrollView, f.primary);
726
+ });
727
+
467
728
  test("native transcript and sidebar keep separate scroll routing across resize, breakpoint and mode transitions", (t) => {
468
729
  const fixture = nativeSidebarFixture();
469
730
  t.after(fixture.dispose);
@@ -580,6 +841,9 @@ test("a part replaced under the same key never reuses the previous part's cached
580
841
  // header (every fixture above) keep the exact old hstack-direct shape.
581
842
 
582
843
  test("an active header wraps the hstack in a vstack and removes the banner from the rail", (t) => {
844
+ const found = cardStyle();
845
+ t.after(() => setCardStyle(found));
846
+ setCardStyle(CARD_STYLE.NEON);
583
847
  const f = fixture();
584
848
  sidebarHeader(f.tui, { render: (width: number) => [`HEADER ${width}`], invalidate() {} });
585
849
  t.after(installSidebar(f.tui, theme));
@@ -612,6 +876,71 @@ test("an active header wraps the hstack in a vstack and removes the banner from
612
876
  assert.notEqual(rail[1]?.trim(), "", "the first card starts on the second row");
613
877
  });
614
878
 
879
+ test("float rail alignment starts Status background at the conversation body row with either header placement", (t) => {
880
+ const found = cardStyle();
881
+ t.after(() => setCardStyle(found));
882
+ setCardStyle(CARD_STYLE.FLOAT);
883
+ const painted = { ...theme, bg: (_color: string, text: string) => `\x1b[48;5;22m${text}\x1b[49m` };
884
+ for (const placement of ["top", "below-input"] as const) {
885
+ const f = fixture();
886
+ const received: TuiMouseEvent[] = [];
887
+ const status = renderShellSidebarBar({
888
+ cwd: "/project", branch: "main", dirty: 0, sessionName: undefined,
889
+ modelId: "model", effort: "high", contextPercent: 45, contextWindow: 1000,
890
+ costTotal: 1, subscription: false, usage: undefined, statuses: [],
891
+ }, painted, 48);
892
+ sidebarPart(f.tui, "footer", {
893
+ render: () => status,
894
+ invalidate() {},
895
+ handleMouse(event) { received.push(event); return { handled: true }; },
896
+ });
897
+ sidebarHeader(f.tui, { render: () => ["HEADER", "RULE"], invalidate() {} });
898
+ const transcript = { render: () => ["CONVERSATION"], invalidate() {} };
899
+ f.root[NODE] = () => ({ type: "vstack", entries: [{ component: transcript }] });
900
+ t.after(installSidebar(f.tui, painted, () => "auto", () => placement));
901
+ const frame = renderLayoutFrame(f.root, 140, 8, () => {});
902
+ const bodyY = placement === "top" ? 2 : 0;
903
+ assert.match(frame.lines[bodyY], /CONVERSATION/);
904
+ assert.match(frame.lines[bodyY], /\x1b\[48;5;22m/, `${placement}: the first painted padding row aligns with conversation content`);
905
+ assert.doesNotMatch(frame.lines.slice(0, bodyY).join("\n"), /\x1b\[48;5;22m/);
906
+ const scroll = railWithHeader(f);
907
+ const content = scroll.render(50);
908
+ assert.deepEqual(content, status.map((line) => ` ${line} `), "no external blank row; every internal padding byte survives");
909
+ const height = 8 - bodyY;
910
+ const click = (y: number): TuiMouseEvent => ({ type: "click", button: "left", x: 2, y, screenX: 92, screenY: bodyY + y, width: 50, height, shift: false, alt: false, ctrl: false });
911
+ assert.equal(scroll.handleMouse(click(0))?.handled, true);
912
+ assert.equal(received.at(-1)?.y, 0, "first painted row is also the first part hit row");
913
+ assert.equal(scroll.handleMouse(click(1))?.handled, true);
914
+ assert.equal(received.at(-1)?.y, 1, "approved internal header offset is preserved");
915
+ scroll.scrollBy(1000);
916
+ assert.equal(scroll.scrollTop, status.length - height, "scroll bounds contain only actual card rows");
917
+ assert.equal(scroll.handleMouse(click(height - 1))?.handled, true);
918
+ assert.equal(received.at(-1)?.y, status.length - 1, "last clipped viewport row hits the last card row");
919
+ const count = received.length;
920
+ assert.equal(scroll.handleMouse(click(height)), undefined, "outside the viewport is inert");
921
+ assert.equal(received.length, count);
922
+ }
923
+ });
924
+
925
+ test("float rail alignment preserves fallback banners and neon bytes across live style switches", (t) => {
926
+ const found = cardStyle();
927
+ t.after(() => setCardStyle(found));
928
+ for (const header of [undefined, "", "HEADER"]) {
929
+ setCardStyle(CARD_STYLE.NEON);
930
+ const f = fixture();
931
+ if (header !== undefined) sidebarHeader(f.tui, { render: () => [header], invalidate() {} });
932
+ sidebarPart(f.tui, "todo", { render: () => ["TODO"], invalidate() {} });
933
+ t.after(installSidebar(f.tui, theme));
934
+ const baseline = railWithHeader(f).render(50);
935
+ if (header) assert.deepEqual(baseline, ["", " Status ", "", " TODO "]);
936
+ else assert.match(baseline.join("\n"), /✿ Gentle Shell ✿/);
937
+ setCardStyle(CARD_STYLE.FLOAT);
938
+ assert.deepEqual(railWithHeader(f).render(50), header ? baseline.slice(1) : baseline, "only the active-header external gap changes");
939
+ setCardStyle(CARD_STYLE.NEON);
940
+ assert.deepEqual(railWithHeader(f).render(50), baseline, "neon is byte-identical after switching back");
941
+ }
942
+ });
943
+
615
944
  test("the header leaf carries the rule row: a two-line header renders both lines at full width", (t) => {
616
945
  const f = fixture();
617
946
  sidebarHeader(f.tui, {
@@ -626,15 +955,15 @@ test("the header leaf carries the rule row: a two-line header renders both lines
626
955
  assert.deepEqual(header.render(0), ["HEADER 138", "RULE 138"], "both the status line and the rule row reach the layout, rendered at the terminal width minus the rail's right inset");
627
956
  });
628
957
 
629
- test("below the sidebar breakpoint a rule-like header paints no rule row", (t) => {
958
+ test("below the sidebar breakpoint a rule-like header paints above native layout without a rail", (t) => {
630
959
  const f = fixture("fullscreen", 139);
631
960
  sidebarHeader(f.tui, { render: (width: number) => [`HEADER ${width}`, `RULE ${width}`], invalidate() {} });
632
961
  t.after(installSidebar(f.tui, theme));
633
962
 
634
- // Below SIDEBAR_BREAKPOINT prepare() declines, so the replacement hands
635
- // back the native root's own layout node: neither the header wrapper nor
636
- // a rule row ever paints.
637
- assert.deepEqual(f.root[NODE](), f.original(), "below the breakpoint the rule is removed with the whole sidebar");
963
+ const node = f.root[NODE]() as unknown as { type: string; entries: { component: Component }[] };
964
+ assert.equal(node.type, "vstack");
965
+ assert.deepEqual(node.entries[0]?.component.render(139), ["HEADER 139", "RULE 139"]);
966
+ assert.deepEqual(f.bottom.render(139), [], "the narrow top header is the only status row");
638
967
  });
639
968
 
640
969
  test("without a registered header the rail keeps the banner and the plain hstack", (t) => {