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,1424 @@
1
+ // SPDX-FileCopyrightText: 2026 ExoPro. Inspired by @jasonish/pi-prompt-history
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ // Prompt-history extension entry: the selector open flow over the slice-1
5
+ // writer and slice-2 drains, with the search input (filterPrompts +
6
+ // forwardToSearch fallthrough), the lazy loaded window, the project<->global
7
+ // scope toggle, the preview panel + wheel handling, the slice-4 import
8
+ // (legacy migration + seed bootstrap inside getWriter), the slice-5
9
+ // modal delete (store sweep + exact tombstone), and the slice-6
10
+ // session_shutdown GC/compaction.
11
+ //
12
+ // Capture is OPT-IN: nothing is recorded unless an explicit
13
+ // GENTLE_PI_HISTORY_CAPTURE=1|true|on, or (with no explicit env value) the
14
+ // persisted Gentle → Customize preference, turns it on. The selector honors
15
+ // the same gate: with capture off, opening the selector is a no-op — no
16
+ // registry entry, no writer init, no store reads, no deletes. Turning
17
+ // capture off only stops NEW captures; files already written stay on disk
18
+ // (docs/prompt-history.md).
19
+
20
+ import { randomUUID } from "node:crypto";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ import {
24
+ DynamicBorder,
25
+ type ExtensionAPI,
26
+ type ExtensionCommandContext,
27
+ type Theme,
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import {
30
+ Container,
31
+ type Focusable,
32
+ getKeybindings,
33
+ Input,
34
+ matchesKey,
35
+ stripTerminalSequences,
36
+ type TUI,
37
+ type TuiMouseEvent,
38
+ truncateToWidth,
39
+ } from "@earendil-works/pi-tui";
40
+ import { bundledGentlePiAgentHome, gentlePiConfigHome } from "../../lib/agent-home.ts";
41
+ import {
42
+ historyCaptureEnabled,
43
+ historyCaptureEnvOverride,
44
+ resolveHistoryCapturePolicy,
45
+ } from "../../lib/history-capture-policy.ts";
46
+ import { hidePrompt } from "./hide-prompts.ts";
47
+ import {
48
+ appendSessionCapture,
49
+ bootstrapProjectSeed,
50
+ deleteFromGlobal,
51
+ deleteFromProject,
52
+ type DrainResult,
53
+ drainGlobal,
54
+ drainProject,
55
+ ensureRegistryEntry,
56
+ gcProjectDir,
57
+ migrateLegacyStores,
58
+ openSessionWriter,
59
+ type SessionWriterState,
60
+ sessionFilePath,
61
+ type SweepResult,
62
+ } from "./store.ts";
63
+ import {
64
+ buildPromptRecords,
65
+ clampPreviewOffset,
66
+ clampSelectedIndex,
67
+ dedupePromptEntries,
68
+ deleteConfirmFooterText,
69
+ deleteConfirmStep,
70
+ deletionActionsFor,
71
+ editorOverlayMargin,
72
+ filterPrompts,
73
+ getVisiblePromptRecords,
74
+ type HeaderLayoutMode,
75
+ initialLoadedCount,
76
+ loadedCountAfterDelete,
77
+ loadedCountForQuery,
78
+ loadedCountForTarget,
79
+ moveSelectedIndex,
80
+ nextLoadedCount,
81
+ pageSelectedIndex,
82
+ planHeaderLayout,
83
+ scopeRadioText,
84
+ shouldGrowWindow,
85
+ STORE_DELETE_FAILED_TEXT,
86
+ storeDeleteFollowUp,
87
+ storeDeleteNotice,
88
+ withExpandedHistoryGlobals,
89
+ type PiHistoryGlobals,
90
+ type PromptEntry,
91
+ type PromptRecord,
92
+ } from "./selector-helpers.ts";
93
+
94
+ const SHORTCUT = "ctrl+shift+r";
95
+ const MAX_VISIBLE = 10;
96
+ const PREVIEW_ROWS = 10;
97
+ // Lazy windowing (design §D3; user-tuned 2026-09-08). PRELOAD_BUFFER=3
98
+ // fires growth as the cursor enters the final 3 loaded rows; BATCH_SIZE=10
99
+ // loads exactly one viewport per growth; INITIAL_BATCH=10 paints one
100
+ // viewport at open. PRELOAD_BUFFER <= MAX_VISIBLE keeps a jump within one
101
+ // viewport covered by the catch-up loop; review all three together.
102
+ const INITIAL_BATCH = 10;
103
+ const BATCH_SIZE = 10;
104
+ const PRELOAD_BUFFER = 3;
105
+ // Wheel regions over the fixed 30-row overlay geometry (design §D6): the
106
+ // preview container always renders at rows 17-26. The list region is
107
+ // mode-dependent (see listWheelFirstRow): the responsive header reclaims
108
+ // rows without changing the 30-row total, and only the compact mode both
109
+ // shifts the list start (border at row 5) and paints one list row fewer.
110
+ const LIST_WHEEL_Y_FIRST = 5;
111
+ const LIST_WHEEL_Y_LAST = 14;
112
+ const PREVIEW_WHEEL_Y_FIRST = 17;
113
+ const PREVIEW_WHEEL_Y_LAST = 26;
114
+ /** Minimum columns between the counts text and a right-flushed radio before shrinking deletes the spacer and stacks the header (user-directed). */
115
+ const HEADER_INLINE_MIN_GAP = 4;
116
+
117
+ // Default selector footer line (PR #1393): shown whenever a delete is not
118
+ // armed; the armed state swaps it for the confirmation copy.
119
+ const SELECTOR_FOOTER_HELP =
120
+ "↑↓ move • PgUp/PgDn page • tab scope • enter select and quit • ctrl+shift+↑/↓ preview • ctrl+shift+backspace delete • esc cancel";
121
+
122
+ /** Width of the "→ " / " " prefix on each entry line. */
123
+ const ENTRY_PREFIX_WIDTH = 2;
124
+
125
+ // Legacy agent dir: pre-v1 editor-history files live directly here and are
126
+ // migrated into the store root by migrateLegacyStores().
127
+ const AGENT_DIR = join(homedir(), ".pi", "agent");
128
+ // v2 multi-concurrency store root (design: tmp/multi-concurrency-design.md).
129
+ // It is also the tombstone state dir: <root>/hidden.json.
130
+ const PI_HISTORY_ROOT = join(AGENT_DIR, "history");
131
+ // Sessions root for the one-level transcript scan (spec C1, design §D5).
132
+ // Read-only by invariant — transcripts are never written by this extension.
133
+ const SESSIONS_ROOT = join(AGENT_DIR, "sessions");
134
+
135
+ export interface HistoryDeps {
136
+ env?: NodeJS.ProcessEnv;
137
+ /** Gentle config home holding the Customize preference (default: from env). */
138
+ gentlePiConfigHome?: string;
139
+ root?: string;
140
+ cwd?: string;
141
+ instanceId?: string;
142
+ now?: () => number;
143
+ agentDir?: string;
144
+ sessionsRoot?: string;
145
+ }
146
+
147
+ /**
148
+ * Strict opt-in: an explicit GENTLE_PI_HISTORY_CAPTURE value (1|true|on or
149
+ * 0|false|off, case-insensitive) wins; otherwise the persisted Customize
150
+ * preference under the Gentle config home decides; a missing, malformed or
151
+ * unreadable preference is off. Read per call so a Customize toggle applies
152
+ * without restart. Turning capture off stops new captures; files already on
153
+ * disk are left untouched (deletes run from the selector while capture is on).
154
+ */
155
+ export function captureEnabled(
156
+ env: NodeJS.ProcessEnv = process.env,
157
+ configHome: string = gentlePiConfigHome(env),
158
+ ): boolean {
159
+ return historyCaptureEnabled({ env, gentlePiConfigHome: configHome });
160
+ }
161
+
162
+ /**
163
+ * Why capture is off, naming the control that actually decides it. A
164
+ * malformed or unreadable preference is reported as such: Customize refuses
165
+ * to rewrite it, so "turn it on in Customize" would not help.
166
+ */
167
+ function captureDisabledMessage(
168
+ env: NodeJS.ProcessEnv,
169
+ configHome: string,
170
+ ): string {
171
+ if (historyCaptureEnvOverride(env) === "off") {
172
+ return "Prompt history is disabled by GENTLE_PI_HISTORY_CAPTURE, which overrides the Gentle → Customize → History preference.";
173
+ }
174
+ const preference = resolveHistoryCapturePolicy({ gentlePiConfigHome: configHome });
175
+ if (preference.malformed) {
176
+ return `Prompt history is disabled because the Gentle → Customize → History preference is invalid or unreadable: ${preference.globalFile}. Fix or remove that file, or set GENTLE_PI_HISTORY_CAPTURE=1.`;
177
+ }
178
+ return "Prompt history is disabled. Turn on \"Prompt history capture\" in Gentle → Customize → History, or set GENTLE_PI_HISTORY_CAPTURE=1.";
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Sanitization
183
+ // ---------------------------------------------------------------------------
184
+
185
+ /**
186
+ * Replace control characters with visible escape notation so the terminal
187
+ * renders them as text instead of interpreting them as commands.
188
+ * Preserves \n (newlines) and \t (tabs).
189
+ */
190
+ function sanitizeForDisplay(text: string): string {
191
+ let out = "";
192
+ for (let i = 0; i < text.length; i++) {
193
+ const cp = text.codePointAt(i);
194
+ if (cp === undefined) break;
195
+ if (cp === 0x0a) {
196
+ out += "\n";
197
+ } else if (cp === 0x09) {
198
+ out += "\t";
199
+ } else if (cp < 0x20 || cp === 0x7f) {
200
+ out += "\\x" + cp.toString(16).padStart(2, "0");
201
+ } else if (cp >= 0x80 && cp < 0xa0) {
202
+ out += "\\x" + cp.toString(16).padStart(2, "0");
203
+ } else {
204
+ // Astral code points (> 0xFFFF) span a surrogate pair; append the
205
+ // full code point, not just the high surrogate at text[i], so emoji
206
+ // and other non-BMP characters survive sanitization intact.
207
+ out += cp > 0xffff ? String.fromCodePoint(cp) : text[i];
208
+ }
209
+ if (cp > 0xffff) i++; // skip low surrogate of astral pair
210
+ }
211
+ return out;
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Types
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /** Keybinding lookup returned by getKeybindings(). */
219
+ interface Keybindings {
220
+ matches(data: string, action: string): boolean;
221
+ }
222
+
223
+ type InputMatcher = (data: string, kb: Keybindings) => boolean;
224
+ type InputHandler = () => void;
225
+
226
+ interface DispatchEntry {
227
+ match: InputMatcher;
228
+ handler: InputHandler;
229
+ }
230
+
231
+ /** Notification sink for selector feedback; an absent callback drops notifications. */
232
+ type SelectorNotify = (message: string, level: "error" | "warning" | "info") => void;
233
+
234
+ /**
235
+ * Store access the open flow hands the selector (per-load deps root/cwd):
236
+ * the selector never resolves store paths itself, so a bare constructor
237
+ * (tests, tooling) cannot touch any store. `root` is also the tombstone
238
+ * state dir (hidden.json); `drain` is the fail-closed scope drain.
239
+ */
240
+ interface SelectorStore {
241
+ root: string;
242
+ cwd: string;
243
+ drain: (scope: HistoryScope) => DrainResult;
244
+ }
245
+
246
+ /** Single rendered row; always occupies exactly one terminal row. */
247
+ class FixedRowText {
248
+ private text: string;
249
+ private readonly centered: boolean;
250
+
251
+ constructor(text: string = "", centered = false) {
252
+ this.text = text;
253
+ this.centered = centered;
254
+ }
255
+
256
+ /** Replace the row content in place; padding contract comes from render(). */
257
+ setText(next: string): void {
258
+ this.text = next;
259
+ }
260
+
261
+ invalidate(): void {}
262
+
263
+ render(width: number): string[] {
264
+ if (width <= 0) return [" "] as string[];
265
+ if (this.text.length === 0) {
266
+ // Use a space so the terminal always renders this as a visible row
267
+ // and differential rendering correctly detects it as a changed line.
268
+ return [" ".repeat(width)] as string[];
269
+ }
270
+ const rendered = this.centered
271
+ ? (() => {
272
+ // Truncate first so an overlong help row can never exceed width,
273
+ // then center the truncated copy (design §C hardening).
274
+ const truncated = truncateToWidth(this.text, width, "…");
275
+ const visible = stripTerminalSequences(truncated);
276
+ const pad = Math.max(0, Math.floor((width - visible.length) / 2));
277
+ return " ".repeat(pad) + truncated;
278
+ })()
279
+ : truncateToWidth(this.text, width, "…");
280
+ // Pad to full terminal width so the overlay fully overwrites
281
+ // whatever is beneath it and leaves no ghost characters on dismiss.
282
+ // Measure the VISIBLE width: SGR escape sequences (colored rows from
283
+ // rebuildListWithWidth) occupy no terminal cells.
284
+ const visible = rendered.replace(/\x1b\[[0-9;]*m/g, "");
285
+ return [rendered + " ".repeat(Math.max(0, width - visible.length))];
286
+ }
287
+ }
288
+
289
+ /** A row that renders as ZERO lines when its text is empty, letting the fixed 30-row overlay reclaim the row instead of pushing content out the bottom. */
290
+ class OptionalRow {
291
+ private text = "";
292
+
293
+ setText(next: string): void {
294
+ this.text = next;
295
+ }
296
+
297
+ invalidate(): void {}
298
+
299
+ render(width: number): string[] {
300
+ if (this.text.length === 0) return [];
301
+ return [truncateToWidth(this.text, width, "…")];
302
+ }
303
+ }
304
+
305
+ /** Word-wrap plain text so each line fits within maxWidth characters. */
306
+ function wordWrapText(text: string, maxWidth: number): string[] {
307
+ if (maxWidth <= 0) return [text || " "];
308
+ const paragraphs = text.split("\n");
309
+ const result: string[] = [];
310
+ for (const para of paragraphs) {
311
+ if (para.length === 0) {
312
+ result.push("");
313
+ continue;
314
+ }
315
+ let remaining = para;
316
+ while (remaining.length > 0) {
317
+ if (remaining.length <= maxWidth) {
318
+ result.push(remaining);
319
+ break;
320
+ }
321
+ const breakAt = remaining.lastIndexOf(" ", maxWidth);
322
+ if (breakAt <= 0) {
323
+ result.push(remaining.substring(0, maxWidth));
324
+ remaining = remaining.substring(maxWidth);
325
+ } else {
326
+ result.push(remaining.substring(0, breakAt));
327
+ remaining = remaining.substring(breakAt + 1);
328
+ }
329
+ }
330
+ }
331
+ return result.length > 0 ? result : [""];
332
+ }
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // TUI Selector
336
+ // ---------------------------------------------------------------------------
337
+
338
+ class PromptHistorySelector extends Container implements Focusable {
339
+ private readonly searchInput: Input;
340
+ private readonly previewContainer: Container;
341
+ private readonly listContainer: Container;
342
+ private readonly headerRow: FixedRowText;
343
+ private readonly headerLine2: OptionalRow;
344
+ private readonly headerLine3: OptionalRow;
345
+ private readonly hintRow: OptionalRow;
346
+ private readonly hintText: string;
347
+ /** Current responsive header mode; drives the list wheel region. */
348
+ private headerMode: HeaderLayoutMode = "inline";
349
+ private readonly previewLabelRow: FixedRowText;
350
+ private readonly footerRow: FixedRowText;
351
+ private records: PromptRecord[];
352
+ private readonly theme: Theme;
353
+ private readonly tui: TUI;
354
+ private readonly onSelect: (record: PromptRecord) => void;
355
+ private readonly onCancel: () => void;
356
+ /** Notification sink for selector feedback (wired by the factory). */
357
+ private readonly onNotify?: SelectorNotify;
358
+ /** Injected store access; null disables scope drains and deletes. */
359
+ private readonly store: SelectorStore | null;
360
+ private filteredRecords: PromptRecord[] = [];
361
+ private selectedIndex = 0;
362
+ /** Number of records loaded (newest-first) from the top of `records`. */
363
+ private loadedCount = 0;
364
+ /** Active scope (design v2): project (default) or global. */
365
+ private scope: HistoryScope = "project";
366
+ /** Last render width, used for entry truncation. */
367
+ private lastWidth = 800;
368
+ /** Word-wrapped lines of the currently selected prompt. */
369
+ private wrappedPreviewLines: string[] = [];
370
+ /** Scroll offset into wrappedPreviewLines for the preview viewport. */
371
+ private previewScrollOffset = 0;
372
+ /**
373
+ * Modal delete confirmation (PR #1393 follow-up): armed by the first
374
+ * ctrl+shift+backspace press; while armed, y executes, n/Esc cancels,
375
+ * and every other key is swallowed. Nothing is deleted on the arming
376
+ * press.
377
+ */
378
+ private confirmArmed = false;
379
+
380
+ /** Dispatch table: first match wins, fallthrough last. */
381
+ private readonly dispatch: readonly DispatchEntry[] = [
382
+ {
383
+ match: (_d, kb) => kb.matches(_d, "tui.select.up"),
384
+ handler: () => this.moveUp(),
385
+ },
386
+ {
387
+ match: (_d, kb) => kb.matches(_d, "tui.select.down"),
388
+ handler: () => this.moveDown(),
389
+ },
390
+ {
391
+ match: (_d, kb) => kb.matches(_d, "tui.select.pageUp"),
392
+ handler: () => this.pageListUp(),
393
+ },
394
+ {
395
+ match: (_d, kb) => kb.matches(_d, "tui.select.pageDown"),
396
+ handler: () => this.pageListDown(),
397
+ },
398
+ {
399
+ match: (d, kb) => d === "\r" || kb.matches(d, "tui.select.confirm"),
400
+ handler: () => this.selectCurrent(),
401
+ },
402
+ { match: (d, _kb) => d === "\t", handler: () => this.toggleScope() },
403
+ {
404
+ match: (_d, kb) => kb.matches(_d, "tui.select.cancel"),
405
+ handler: () => this.onCancel(),
406
+ },
407
+ // Home/End jump the list only while the search box is empty; with any
408
+ // text they fall through to the search input and move its caret.
409
+ {
410
+ match: (d, _kb) => matchesKey(d, "home") && this.listOwnsHomeEnd(),
411
+ handler: () => this.jumpToFirst(),
412
+ },
413
+ {
414
+ match: (d, _kb) => matchesKey(d, "end") && this.listOwnsHomeEnd(),
415
+ handler: () => this.jumpToLast(),
416
+ },
417
+ {
418
+ match: (d, _kb) => matchesKey(d, "ctrl+shift+backspace"),
419
+ handler: () => this.deleteCurrent(),
420
+ },
421
+ {
422
+ match: (d, _kb) => matchesKey(d, "ctrl+shift+up"),
423
+ handler: () => this.previewPageUp(),
424
+ },
425
+ {
426
+ match: (d, _kb) => matchesKey(d, "ctrl+shift+down"),
427
+ handler: () => this.previewPageDown(),
428
+ },
429
+ ];
430
+
431
+ private _focused = false;
432
+ get focused(): boolean {
433
+ return this._focused;
434
+ }
435
+ set focused(value: boolean) {
436
+ this._focused = value;
437
+ this.searchInput.focused = value;
438
+ }
439
+
440
+ constructor(
441
+ tui: TUI,
442
+ theme: Theme,
443
+ records: PromptRecord[],
444
+ onSelect: (record: PromptRecord) => void,
445
+ onCancel: () => void,
446
+ onNotify?: SelectorNotify,
447
+ store?: SelectorStore,
448
+ ) {
449
+ super();
450
+ this.tui = tui;
451
+ this.theme = theme;
452
+ this.records = records;
453
+ this.loadedCount = initialLoadedCount(records.length, INITIAL_BATCH);
454
+ this.onSelect = onSelect;
455
+ this.onCancel = onCancel;
456
+ this.onNotify = onNotify;
457
+ this.store = store ?? null;
458
+
459
+ // ── Search panel (top) ──
460
+ this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
461
+ this.headerRow = new FixedRowText(
462
+ theme.fg("accent", theme.bold(" History Search ")),
463
+ );
464
+ this.addChild(this.headerRow);
465
+ this.headerLine2 = new OptionalRow();
466
+ this.headerLine3 = new OptionalRow();
467
+ this.addChild(this.headerLine2);
468
+ this.addChild(this.headerLine3);
469
+ this.hintText =
470
+ "Type to filter (multi-word AND substring, case-insensitive)";
471
+ this.hintRow = new OptionalRow();
472
+ this.hintRow.setText(this.theme.fg("dim", this.hintText));
473
+ this.addChild(this.hintRow);
474
+ this.searchInput = new Input();
475
+ this.searchInput.onSubmit = () => this.selectCurrent();
476
+ this.searchInput.onEscape = () => this.onCancel();
477
+ this.addChild(this.searchInput);
478
+ this.addChild(new DynamicBorder((s: string) => theme.fg("dim", s)));
479
+
480
+ this.listContainer = new Container();
481
+ this.addChild(this.listContainer);
482
+
483
+ // ── Preview panel (bottom) ──
484
+ this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
485
+ this.previewLabelRow = new FixedRowText(
486
+ theme.fg("accent", theme.bold(" Preview ")),
487
+ );
488
+ this.addChild(this.previewLabelRow);
489
+ this.previewContainer = new Container();
490
+ this.addChild(this.previewContainer);
491
+
492
+ this.addChild(new DynamicBorder((s: string) => theme.fg("dim", s)));
493
+ this.footerRow = new FixedRowText(
494
+ theme.fg("dim", SELECTOR_FOOTER_HELP),
495
+ true /* centered */,
496
+ );
497
+ this.addChild(this.footerRow);
498
+ this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
499
+
500
+ this.applyFilter("");
501
+ }
502
+
503
+ // -- Filtering & list building ------------------------------------------
504
+
505
+ private applyFilter(query: string): void {
506
+ // AC-L2-3r (user-directed 2026-09-08): a non-empty query implies
507
+ // full-snapshot visibility — one-shot and idempotent, never a batch —
508
+ // so per-keypress incremental loads remain impossible (C2).
509
+ this.loadedCount = loadedCountForQuery(
510
+ this.loadedCount,
511
+ this.records.length,
512
+ query,
513
+ );
514
+ this.filteredRecords = filterPrompts(
515
+ this.records.slice(0, this.loadedCount),
516
+ query,
517
+ );
518
+ this.selectedIndex = clampSelectedIndex(
519
+ this.selectedIndex,
520
+ this.filteredRecords.length,
521
+ );
522
+ this.previewScrollOffset = 0;
523
+ this.rebuildList();
524
+ this.rebuildPreview();
525
+ }
526
+
527
+ private rebuildList(): void {
528
+ this.rebuildListWithWidth(this.lastWidth);
529
+ }
530
+
531
+ /** Styled title + position + loaded-counts prefix shared by the inline and stacked header layouts. */
532
+ private headerCountsText(
533
+ titleText: string,
534
+ positionText: string,
535
+ loadedText: string,
536
+ ): string {
537
+ return (
538
+ this.theme.fg("accent", this.theme.bold(titleText)) +
539
+ this.theme.fg("dim", positionText) +
540
+ this.theme.fg("dim", loadedText)
541
+ );
542
+ }
543
+
544
+ /** Rebuild list rows: header counter + entries. Always MAX_VISIBLE rows (MAX_VISIBLE - 1 in compact mode). */
545
+ private rebuildListWithWidth(width: number): void {
546
+ const count = this.filteredRecords.length;
547
+ const position = count === 0 ? 0 : this.selectedIndex + 1;
548
+ const titleText = " History Search ";
549
+ const positionText = ` · ${position} of ${count} `;
550
+ const loadedText = ` · loaded ${this.loadedCount} of ${this.records.length} `;
551
+ const leftWidth =
552
+ titleText.length + positionText.length + loadedText.length;
553
+ const radioFull = scopeRadioText(this.scope, false);
554
+ // Radio label compaction is fit-driven too: abbreviate only when the
555
+ // full radio cannot fit the row it would occupy (user-directed paste).
556
+ // Stacked and compact rows print it after one leading space; inline
557
+ // mode always fits it by construction.
558
+ const radioText =
559
+ width >= radioFull.length + 1
560
+ ? radioFull
561
+ : scopeRadioText(this.scope, true);
562
+ const mode = planHeaderLayout(
563
+ width,
564
+ leftWidth,
565
+ radioFull.length,
566
+ HEADER_INLINE_MIN_GAP,
567
+ );
568
+ this.headerMode = mode;
569
+ if (mode === "inline") {
570
+ this.headerRow.setText(
571
+ this.headerCountsText(titleText, positionText, loadedText) +
572
+ // Right-aligned scope radio: pad from plain-text lengths so the
573
+ // radio ends flush at the header's last column at any width.
574
+ " ".repeat(Math.max(1, width - leftWidth - radioText.length)) +
575
+ this.theme.fg("dim", radioText),
576
+ );
577
+ this.headerLine2.setText("");
578
+ this.headerLine3.setText("");
579
+ } else if (mode === "stacked") {
580
+ // Tablet: the spacer is deleted — the radio wraps to its own row
581
+ // under the full counts line (user-directed paste, leading space).
582
+ this.headerRow.setText(
583
+ this.headerCountsText(titleText, positionText, loadedText),
584
+ );
585
+ this.headerLine2.setText(` ${this.theme.fg("dim", radioText)}`);
586
+ this.headerLine3.setText("");
587
+ } else {
588
+ // Compact (mobile): three rows — counts split off, radio abbreviated
589
+ // (user-directed paste).
590
+ this.headerRow.setText(
591
+ this.theme.fg("accent", this.theme.bold(titleText)) +
592
+ this.theme.fg("dim", ` · ${position} of ${count}`),
593
+ );
594
+ // Leading space aligns both rows with the title's own left padding
595
+ // space (user-directed compact paste).
596
+ this.headerLine2.setText(
597
+ this.theme.fg(
598
+ "dim",
599
+ ` loaded ${this.loadedCount} of ${this.records.length}`,
600
+ ),
601
+ );
602
+ this.headerLine3.setText(` ${this.theme.fg("dim", radioText)}`);
603
+ }
604
+ // Stacked modes reclaim the hint row so the overlay stays 30 rows.
605
+ this.hintRow.setText(
606
+ mode === "inline" ? this.theme.fg("dim", this.hintText) : "",
607
+ );
608
+ this.listContainer.clear();
609
+
610
+ if (count === 0) {
611
+ this.listContainer.addChild(
612
+ new FixedRowText(this.theme.fg("warning", "No matching prompts")),
613
+ );
614
+ // Compact still paints one list row fewer in the empty state, or the
615
+ // 3-row header would push the fixed 30-row overlay to 31 rows.
616
+ const listRows = mode === "compact" ? MAX_VISIBLE - 1 : MAX_VISIBLE;
617
+ for (let i = 1; i < listRows; i++) {
618
+ this.listContainer.addChild(new FixedRowText());
619
+ }
620
+ return;
621
+ }
622
+
623
+ // Compact paints one list row fewer (reclaimed by the 3-row header);
624
+ // the preview block keeps PREVIEW_ROWS so the 30-row total holds.
625
+ const listRows = mode === "compact" ? MAX_VISIBLE - 1 : MAX_VISIBLE;
626
+ const entryMax = Math.floor(width * 0.95) - ENTRY_PREFIX_WIDTH;
627
+
628
+ const visible = getVisiblePromptRecords(
629
+ this.filteredRecords,
630
+ this.selectedIndex,
631
+ listRows,
632
+ );
633
+
634
+ for (const { record, isSelected } of visible) {
635
+ const prefix = isSelected ? "→ " : " ";
636
+ // Armed delete (PR #1393): the armed row repaints in the error color
637
+ // while the confirmation is pending, then reverts on disarm.
638
+ const color = isSelected
639
+ ? this.confirmArmed
640
+ ? "error"
641
+ : "accent"
642
+ : "text";
643
+ const compacted = sanitizeForDisplay(record.text)
644
+ .replace(/\s+/g, " ")
645
+ .trim();
646
+ const truncated = truncateToWidth(compacted, entryMax, "…");
647
+ const line = prefix + this.theme.fg(color, truncated);
648
+ this.listContainer.addChild(new FixedRowText(line));
649
+ }
650
+
651
+ for (let i = visible.length; i < listRows; i++) {
652
+ this.listContainer.addChild(new FixedRowText());
653
+ }
654
+ }
655
+
656
+ /** List wheel region start: compact shifts the list down one row. */
657
+ private get listWheelFirstRow(): number {
658
+ return this.headerMode === "compact"
659
+ ? LIST_WHEEL_Y_FIRST + 1
660
+ : LIST_WHEEL_Y_FIRST;
661
+ }
662
+
663
+ /**
664
+ * Rebuild preview: word-wrap the full selected prompt text and show
665
+ * a PREVIEW_ROWS-tall viewport starting at previewScrollOffset.
666
+ * Content starts immediately below the "Preview" label (no top padding).
667
+ * PgUp/PgDn scroll through the wrapped lines.
668
+ */
669
+ private rebuildPreviewWithWidth(width: number): void {
670
+ this.previewContainer.clear();
671
+
672
+ const wrapWidth = Math.max(1, width - 2);
673
+ const selected = this.filteredRecords[this.selectedIndex];
674
+ if (selected) {
675
+ const safeText = sanitizeForDisplay(selected.text);
676
+ this.wrappedPreviewLines = wordWrapText(safeText, wrapWidth);
677
+ this.previewScrollOffset = clampPreviewOffset(
678
+ this.previewScrollOffset,
679
+ this.wrappedPreviewLines.length,
680
+ PREVIEW_ROWS,
681
+ );
682
+ } else {
683
+ this.wrappedPreviewLines = [];
684
+ this.previewScrollOffset = 0;
685
+ }
686
+
687
+ // P1-3 indicator: fresh wrap is known here — one update site covers all
688
+ // paths; the label appends the 1-based range only when content overflows.
689
+ this.previewLabelRow.setText(this.previewLabelRowText());
690
+
691
+ for (let i = 0; i < PREVIEW_ROWS; i++) {
692
+ const lineIdx = this.previewScrollOffset + i;
693
+ if (lineIdx < this.wrappedPreviewLines.length) {
694
+ // Pad the plain text to wrapWidth so FixedRowText.render()
695
+ // never truncates — the visible width is always ≤ width-2.
696
+ const raw = this.wrappedPreviewLines[lineIdx];
697
+ const padded = raw + " ".repeat(Math.max(0, wrapWidth - raw.length));
698
+ this.previewContainer.addChild(
699
+ new FixedRowText(this.theme.fg("text", padded)),
700
+ );
701
+ } else {
702
+ this.previewContainer.addChild(new FixedRowText());
703
+ }
704
+ }
705
+ }
706
+
707
+ /** " Preview " label; appends the 1-based visible range only on overflow. */
708
+ private previewLabelRowText(): string {
709
+ const total = this.wrappedPreviewLines.length;
710
+ if (total <= PREVIEW_ROWS) {
711
+ return this.theme.fg("accent", this.theme.bold(" Preview "));
712
+ }
713
+ const start = this.previewScrollOffset + 1;
714
+ const end = Math.min(this.previewScrollOffset + PREVIEW_ROWS, total);
715
+ return this.theme.fg(
716
+ "accent",
717
+ this.theme.bold(` Preview — ${start}–${end}/${total} `),
718
+ );
719
+ }
720
+
721
+ private rebuildPreview(): void {
722
+ this.rebuildPreviewWithWidth(this.lastWidth);
723
+ }
724
+
725
+ // -- Selection actions --------------------------------------------------
726
+
727
+ private selectCurrent(): void {
728
+ const selected = this.filteredRecords[this.selectedIndex];
729
+ if (selected) this.onSelect(selected);
730
+ }
731
+
732
+ /**
733
+ * Toggle project <-> global (design v2): re-drain the other scope,
734
+ * rebuild the records, reset the window. Tab's only role. Fail-closed
735
+ * (slice-02 contract): a blocked drain keeps the working scope and
736
+ * surfaces the recovery warning instead of a blocked (entry-less) list.
737
+ */
738
+ private toggleScope(): void {
739
+ const previous = this.scope;
740
+ this.scope = this.scope === "project" ? "global" : "project";
741
+ const drained: DrainResult = this.store?.drain(this.scope) ?? {
742
+ status: "ok",
743
+ prompts: [],
744
+ };
745
+ if (drained.status === "blocked") {
746
+ this.scope = previous;
747
+ this.onNotify?.(drained.message, "error");
748
+ return;
749
+ }
750
+ this.records = recordsFromEntries(drained.prompts);
751
+ this.loadedCount = initialLoadedCount(this.records.length, INITIAL_BATCH);
752
+ this.applyFilter(this.searchInput.getValue());
753
+ }
754
+
755
+ /**
756
+ * Delete-combo entry (slice-05 D3): the FIRST press arms the modal
757
+ * confirm for the selected row; while armed, the modal router in
758
+ * handleInput calls executeDelete() on `y`. Session-derived rows are
759
+ * read-only (slice-05 D1): a delete press on one is a silent no-op.
760
+ */
761
+ private deleteCurrent(): void {
762
+ const selected = this.filteredRecords[this.selectedIndex];
763
+ if (!selected) return;
764
+
765
+ // Session rows are read-only: session transcripts are immutable and
766
+ // owned by Pi core — the extension never deletes from or writes to
767
+ // them. Silent no-op: no arm, no footer change, no tombstone.
768
+ if ((selected.source ?? "editor") === "session") return;
769
+
770
+ if (!this.confirmArmed) {
771
+ this.armDelete();
772
+ return;
773
+ }
774
+ this.executeDelete();
775
+ }
776
+
777
+ /** Arm the confirm: footer copy + error-colored row, nothing executes. */
778
+ private armDelete(): void {
779
+ this.confirmArmed = true;
780
+ this.refreshDeleteFooter();
781
+ this.rebuildList(); // repaint the armed-row highlight
782
+ }
783
+
784
+ /** The executing half of the delete: leave the armed state, then mutate. */
785
+ private executeDelete(): void {
786
+ // Leave the armed state first: help footer back, highlight dropped.
787
+ this.confirmArmed = false;
788
+ this.refreshDeleteFooter();
789
+ this.rebuildList(); // drop the highlight before the flow mutates rows
790
+
791
+ const selected = this.filteredRecords[this.selectedIndex];
792
+ if (!selected || !this.store) return;
793
+ const { root, cwd } = this.store;
794
+
795
+ // C4 delete flows (design §F): the record's provenance decides the
796
+ // actions via the pure planner over the injected store root/cwd.
797
+ const actions = deletionActionsFor(selected.source ?? "editor");
798
+
799
+ let sweep: SweepResult | null = null;
800
+ if (actions.deleteFromEditorStore) {
801
+ // Store path: physically remove EVERY copy from the JSONL store, one
802
+ // atomic rewrite per file. A thrown store failure is contained here
803
+ // (PR #1393): toast + abort — nothing was removed and no tombstone is
804
+ // written, so the delete never lies about state.
805
+ try {
806
+ sweep =
807
+ this.scope === "global"
808
+ ? deleteFromGlobal(root, selected.text)
809
+ : deleteFromProject(root, cwd, selected.text);
810
+ } catch {
811
+ this.onNotify?.(STORE_DELETE_FAILED_TEXT, "error");
812
+ return;
813
+ }
814
+ // Files that could not be read or rewritten may still hold a copy:
815
+ // still write the tombstone that hides them; the notice waits for
816
+ // the hide result below.
817
+ const followUp = storeDeleteFollowUp(sweep);
818
+ if (!followUp.proceed) return;
819
+ }
820
+
821
+ // Tombstone ALWAYS: the session transcripts are immutable and would
822
+ // re-supply the deleted prompt on the next merge (hide-file suppresses
823
+ // the twin). Only the session path aborts on a hide error — the store
824
+ // row is already gone on the editor path, so the splice proceeds; its
825
+ // toast says exactly that (PR #1393).
826
+ const hide = hidePrompt(root, selected.text);
827
+ if (hide.status === "error") {
828
+ if (!actions.deleteFromEditorStore) {
829
+ this.onNotify?.(hide.message, "error");
830
+ return;
831
+ }
832
+ }
833
+ // One notice for the editor path, stating both halves: a partial sweep
834
+ // only says "hidden" when the tombstone was actually written.
835
+ if (sweep) {
836
+ const notice = storeDeleteNotice(sweep, hide.status === "error");
837
+ if (notice) this.onNotify?.(notice, "error");
838
+ }
839
+ // Remove from the master records array so a subsequent filter doesn't
840
+ // bring it back.
841
+ const idx = this.records.indexOf(selected);
842
+ if (idx !== -1) {
843
+ this.records.splice(idx, 1);
844
+ // C4 delete backfill (design §B3): shrink the window with the splice,
845
+ // then pull the next unloaded row while any remain — genuine shrink
846
+ // only at exhaustion.
847
+ this.loadedCount = loadedCountAfterDelete(
848
+ this.loadedCount,
849
+ this.records.length,
850
+ );
851
+ }
852
+
853
+ // Re-apply current filter (rebuilds filteredRecords, list, preview).
854
+ this.applyFilter(this.searchInput.getValue());
855
+ }
856
+
857
+ /** Footer line: confirm copy while armed, help otherwise. */
858
+ private refreshDeleteFooter(): void {
859
+ if (!this.confirmArmed) {
860
+ this.footerRow.setText(this.theme.fg("dim", SELECTOR_FOOTER_HELP));
861
+ return;
862
+ }
863
+ this.footerRow.setText(
864
+ this.theme.fg("warning", deleteConfirmFooterText()),
865
+ );
866
+ }
867
+
868
+ /** Leave the armed state: restore the help footer and the plain row. */
869
+ private disarmDeleteConfirm(): void {
870
+ this.confirmArmed = false;
871
+ this.refreshDeleteFooter();
872
+ this.rebuildList();
873
+ }
874
+
875
+ // -- Navigation ---------------------------------------------------------
876
+
877
+ private moveUp(): void {
878
+ this.selectedIndex = moveSelectedIndex(
879
+ this.selectedIndex,
880
+ this.filteredRecords.length,
881
+ -1,
882
+ );
883
+ if (
884
+ shouldGrowWindow(
885
+ this.selectedIndex,
886
+ this.loadedCount,
887
+ this.records.length,
888
+ PRELOAD_BUFFER,
889
+ )
890
+ ) {
891
+ this.loadedCount = nextLoadedCount(
892
+ this.loadedCount,
893
+ this.records.length,
894
+ BATCH_SIZE,
895
+ );
896
+ this.applyFilter(this.searchInput.getValue());
897
+ }
898
+ this.previewScrollOffset = 0;
899
+ this.rebuildList();
900
+ this.rebuildPreview();
901
+ }
902
+
903
+ private moveDown(): void {
904
+ // Grow-before-move (design §D1): the C2 trigger fires while the cursor
905
+ // sits in the final PRELOAD_BUFFER rows of the loaded window, so the
906
+ // modulo below moves into freshly loaded rows — a wrap to index 0 is
907
+ // reachable only on the exhausted set.
908
+ if (
909
+ shouldGrowWindow(
910
+ this.selectedIndex,
911
+ this.loadedCount,
912
+ this.records.length,
913
+ PRELOAD_BUFFER,
914
+ )
915
+ ) {
916
+ this.loadedCount = nextLoadedCount(
917
+ this.loadedCount,
918
+ this.records.length,
919
+ BATCH_SIZE,
920
+ );
921
+ this.applyFilter(this.searchInput.getValue());
922
+ }
923
+ this.selectedIndex = moveSelectedIndex(
924
+ this.selectedIndex,
925
+ this.filteredRecords.length,
926
+ 1,
927
+ );
928
+ this.previewScrollOffset = 0;
929
+ this.rebuildList();
930
+ this.rebuildPreview();
931
+ }
932
+
933
+ /** Page the LIST up by MAX_VISIBLE with clamping (no wrap). */
934
+ private pageListUp(): void {
935
+ this.selectedIndex = pageSelectedIndex(
936
+ this.selectedIndex,
937
+ this.filteredRecords.length,
938
+ -MAX_VISIBLE,
939
+ );
940
+ this.previewScrollOffset = 0;
941
+ this.rebuildList();
942
+ this.rebuildPreview();
943
+ }
944
+
945
+ /** Page the LIST down by MAX_VISIBLE with clamping (no wrap). */
946
+ private pageListDown(): void {
947
+ // PgDn catch-up (design §D7): grow in whole batches until the paged-to
948
+ // row is loaded BEFORE the selection lands on it.
949
+ const grown = loadedCountForTarget(
950
+ this.loadedCount,
951
+ this.records.length,
952
+ this.selectedIndex + MAX_VISIBLE,
953
+ BATCH_SIZE,
954
+ );
955
+ if (grown !== this.loadedCount) {
956
+ this.loadedCount = grown;
957
+ this.applyFilter(this.searchInput.getValue());
958
+ }
959
+ this.selectedIndex = pageSelectedIndex(
960
+ this.selectedIndex,
961
+ this.filteredRecords.length,
962
+ MAX_VISIBLE,
963
+ );
964
+ this.previewScrollOffset = 0;
965
+ this.rebuildList();
966
+ this.rebuildPreview();
967
+ }
968
+
969
+ private previewPageUp(): void {
970
+ this.previewScrollOffset = Math.max(
971
+ 0,
972
+ this.previewScrollOffset - PREVIEW_ROWS,
973
+ );
974
+ this.rebuildPreview();
975
+ }
976
+
977
+ private previewPageDown(): void {
978
+ this.previewScrollOffset = clampPreviewOffset(
979
+ this.previewScrollOffset + PREVIEW_ROWS,
980
+ this.wrappedPreviewLines.length,
981
+ PREVIEW_ROWS,
982
+ );
983
+ this.rebuildPreview();
984
+ }
985
+
986
+ /**
987
+ * An empty search box has no caret to move, so Home/End keep their list
988
+ * jumps; any text (whitespace included) routes them to the caret, and End
989
+ * then never loads the whole list.
990
+ */
991
+ private listOwnsHomeEnd(): boolean {
992
+ return this.searchInput.getValue().length === 0;
993
+ }
994
+
995
+ private jumpToFirst(): void {
996
+ if (this.filteredRecords.length === 0) return;
997
+ this.selectedIndex = 0;
998
+ this.previewScrollOffset = 0;
999
+ this.rebuildList();
1000
+ this.rebuildPreview();
1001
+ }
1002
+
1003
+ private jumpToLast(): void {
1004
+ // End full-jump (design §D7): one-shot load of everything BEFORE the
1005
+ // empty guard, so End also surfaces matches beyond the window.
1006
+ if (this.loadedCount < this.records.length) {
1007
+ this.loadedCount = this.records.length;
1008
+ this.applyFilter(this.searchInput.getValue());
1009
+ }
1010
+ if (this.filteredRecords.length === 0) return;
1011
+ this.selectedIndex = this.filteredRecords.length - 1;
1012
+ this.previewScrollOffset = 0;
1013
+ this.rebuildList();
1014
+ this.rebuildPreview();
1015
+ }
1016
+
1017
+ // -- Input handling -----------------------------------------------------
1018
+
1019
+ private forwardToSearch(data: string): void {
1020
+ this.searchInput.handleInput(data);
1021
+ this.selectedIndex = 0;
1022
+ this.applyFilter(this.searchInput.getValue());
1023
+ }
1024
+
1025
+ handleInput(data: string): void {
1026
+ // Modal armed confirm (slice-05 D3): while a delete is armed the pure
1027
+ // router consumes EVERY key — y executes, n/Esc cancels (esc must NOT
1028
+ // close the overlay here), anything else stays armed and is swallowed
1029
+ // — so no key reaches the dispatch table or the search input. When not
1030
+ // armed, behavior is unchanged.
1031
+ if (this.confirmArmed) {
1032
+ const step = deleteConfirmStep(
1033
+ this.confirmArmed,
1034
+ matchesKey(data, "ctrl+shift+backspace"),
1035
+ matchesKey(data, "escape"),
1036
+ data,
1037
+ );
1038
+ if (step.execute) this.executeDelete();
1039
+ else if (step.cancel) this.disarmDeleteConfirm();
1040
+ this.tui.requestRender();
1041
+ return;
1042
+ }
1043
+ const kb = getKeybindings();
1044
+ let handled = false;
1045
+ for (const { match, handler } of this.dispatch) {
1046
+ if (match(data, kb)) {
1047
+ handler();
1048
+ handled = true;
1049
+ break;
1050
+ }
1051
+ }
1052
+ if (!handled) this.forwardToSearch(data);
1053
+ this.tui.requestRender();
1054
+ }
1055
+
1056
+ // -- Mouse (wheel-only) -------------------------------------------------
1057
+
1058
+ /**
1059
+ * Wheel-only mouse handling over the fixed 30-row geometry (design
1060
+ * §D6). Non-wheel events stay host-owned (undefined = Container child
1061
+ * dispatch); EVERY wheel path — including the no-op regions — reaches
1062
+ * the single consumed return, closing the pre-existing SGR-fallthrough
1063
+ * hazard where raw wheel bytes were typed into the search box.
1064
+ */
1065
+ override handleMouse(
1066
+ event: TuiMouseEvent,
1067
+ ): ReturnType<Container["handleMouse"]> {
1068
+ if (event.type !== "wheel") return undefined;
1069
+ // A wheel scroll can move the selection off the armed row — disarm so
1070
+ // the next delete press re-arms for the NEW row first (PR #1393).
1071
+ if (this.confirmArmed) this.disarmDeleteConfirm();
1072
+ const delta = event.wheelDelta ?? 0;
1073
+ if (event.y >= this.listWheelFirstRow && event.y <= LIST_WHEEL_Y_LAST) {
1074
+ const steps = Math.min(Math.abs(delta), this.filteredRecords.length);
1075
+ for (let i = 0; i < steps; i++) {
1076
+ if (delta > 0) this.moveDown();
1077
+ else this.moveUp();
1078
+ }
1079
+ } else if (
1080
+ event.y >= PREVIEW_WHEEL_Y_FIRST &&
1081
+ event.y <= PREVIEW_WHEEL_Y_LAST
1082
+ ) {
1083
+ if (delta !== 0) {
1084
+ this.previewScrollOffset = clampPreviewOffset(
1085
+ this.previewScrollOffset + (delta > 0 ? 1 : -1),
1086
+ this.wrappedPreviewLines.length,
1087
+ PREVIEW_ROWS,
1088
+ );
1089
+ this.rebuildPreview();
1090
+ }
1091
+ }
1092
+ return {
1093
+ handled: true,
1094
+ target: {
1095
+ component: this,
1096
+ originX: event.screenX - event.x,
1097
+ originY: event.screenY - event.y,
1098
+ width: event.width,
1099
+ height: event.height,
1100
+ },
1101
+ };
1102
+ }
1103
+
1104
+ // -- Render override for dynamic entry width ---------------------------
1105
+
1106
+ /** Fixed overlay height so the TUI never repositions the panel. */
1107
+ private static readonly OVERLAY_LINES = 30;
1108
+
1109
+ override render(width: number): string[] {
1110
+ if (width !== this.lastWidth) {
1111
+ // Pre-clamp against the previous wrap so a width change can never
1112
+ // drive the rebuilds with a stale selection/offset (AC-P1-4.1/4.2).
1113
+ this.selectedIndex = clampSelectedIndex(
1114
+ this.selectedIndex,
1115
+ this.filteredRecords.length,
1116
+ );
1117
+ this.previewScrollOffset = clampPreviewOffset(
1118
+ this.previewScrollOffset,
1119
+ this.wrappedPreviewLines.length,
1120
+ PREVIEW_ROWS,
1121
+ );
1122
+ }
1123
+ this.lastWidth = width;
1124
+ this.rebuildListWithWidth(width);
1125
+ this.rebuildPreviewWithWidth(width);
1126
+ const raw = super.render(width);
1127
+ // Pad or trim to exactly OVERLAY_LINES so the overlay never shifts.
1128
+ const blank = " ".repeat(Math.max(1, width));
1129
+ while (raw.length < PromptHistorySelector.OVERLAY_LINES) raw.push(blank);
1130
+ return raw.slice(0, PromptHistorySelector.OVERLAY_LINES);
1131
+ }
1132
+ }
1133
+
1134
+ // ---------------------------------------------------------------------------
1135
+ // Overlay glue
1136
+ // ---------------------------------------------------------------------------
1137
+
1138
+ type SelectorDone = (result: PromptRecord | null) => void;
1139
+
1140
+ type SelectorFactory = (
1141
+ tui: unknown,
1142
+ theme: unknown,
1143
+ keybindings: unknown,
1144
+ done: SelectorDone,
1145
+ ) => PromptHistorySelector;
1146
+
1147
+ function castSelectorArgs(tui: unknown, theme: unknown): [TUI, Theme] {
1148
+ return [tui as TUI, theme as Theme];
1149
+ }
1150
+
1151
+ /** TUI handle captured when the selector overlay mounts. `terminal` feeds the sidebar overlay margin. */
1152
+ let selectorTui: { requestRender(): void; terminal?: unknown } | null = null;
1153
+
1154
+ /** Stored close callback for the currently-open overlay. Null when closed. */
1155
+ let activeOverlayClose: (() => void) | null = null;
1156
+
1157
+ function createPromptHistorySelectorFactory(
1158
+ records: PromptRecord[],
1159
+ onNotify?: SelectorNotify,
1160
+ store?: SelectorStore,
1161
+ ): SelectorFactory {
1162
+ return (tui, theme, _keybindings, done) => {
1163
+ selectorTui = tui as { requestRender(): void; terminal?: unknown };
1164
+ const finish = (result: PromptRecord | null) => {
1165
+ activeOverlayClose = null;
1166
+ done(result);
1167
+ };
1168
+ // Expose close so the tool_call handler can dismiss the overlay.
1169
+ activeOverlayClose = () => finish(null);
1170
+ const [typedTui, typedTheme] = castSelectorArgs(tui, theme);
1171
+ return new PromptHistorySelector(
1172
+ typedTui,
1173
+ typedTheme,
1174
+ records,
1175
+ (record) => finish(record),
1176
+ () => finish(null),
1177
+ onNotify,
1178
+ store,
1179
+ );
1180
+ };
1181
+ }
1182
+
1183
+ async function runPromptHistorySelection(
1184
+ ctx: Pick<ExtensionCommandContext, "ui">,
1185
+ records: PromptRecord[],
1186
+ store?: SelectorStore,
1187
+ ): Promise<PromptRecord | null> {
1188
+ const historyGlobals: PiHistoryGlobals = globalThis as Record<
1189
+ string,
1190
+ unknown
1191
+ >;
1192
+ return withExpandedHistoryGlobals(historyGlobals, async () =>
1193
+ ctx.ui.custom<PromptRecord | null>(
1194
+ createPromptHistorySelectorFactory(
1195
+ records,
1196
+ (message, level) => ctx.ui.notify(message, level),
1197
+ store,
1198
+ ),
1199
+ {
1200
+ overlay: true,
1201
+ // pi-tui keeps the options object from showOverlay time, but calls
1202
+ // visible() on EVERY render pass before resolving the overlay layout
1203
+ // (compositeOverlays filters visible entries first), and re-reads
1204
+ // margin per layout resolution — the getter below therefore stays
1205
+ // live: resizing across the sidebar breakpoint re-seats the picker
1206
+ // while it stays open. While the gentle-shell fullscreen sidebar
1207
+ // paints, the margin confines width "100%" (and the bottom-center
1208
+ // anchor) to the editor column, less 1 column of padding; 0 keeps
1209
+ // the native full-window behavior.
1210
+ overlayOptions: () => {
1211
+ let rightMargin = editorOverlayMargin(selectorTui?.terminal);
1212
+ return {
1213
+ anchor: "bottom-center" as const,
1214
+ width: "100%" as const,
1215
+ offsetY: 5,
1216
+ get margin() {
1217
+ return rightMargin > 0 ? { right: rightMargin } : undefined;
1218
+ },
1219
+ visible: () => {
1220
+ rightMargin = editorOverlayMargin(selectorTui?.terminal);
1221
+ return true;
1222
+ },
1223
+ };
1224
+ },
1225
+ },
1226
+ ),
1227
+ );
1228
+ }
1229
+
1230
+ /** Build selector records from drain entries (shared by both scopes). */
1231
+ function recordsFromEntries(
1232
+ entries: Array<string | PromptEntry>,
1233
+ ): PromptRecord[] {
1234
+ return buildPromptRecords(dedupePromptEntries(entries));
1235
+ }
1236
+
1237
+ // ---------------------------------------------------------------------------
1238
+ // Open flow: capture gate → fail-closed drain → records → overlay
1239
+ // ---------------------------------------------------------------------------
1240
+
1241
+ type HistoryScope = "project" | "global";
1242
+
1243
+ /**
1244
+ * Per-load open flow over the slice-1 deps (env/root/cwd): the selector is
1245
+ * a pure store reader plus the explicit delete action, so it never
1246
+ * initializes the capture writer — the capture gate in openHistorySelector
1247
+ * runs before any store access and a capture-off session performs no
1248
+ * registry/writer/delete side effects on the open path.
1249
+ */
1250
+ function createOpenFlow(
1251
+ env: NodeJS.ProcessEnv,
1252
+ configHome: string,
1253
+ root: string,
1254
+ cwd: string,
1255
+ ) {
1256
+ /**
1257
+ * Scope drain for the selector: project scope drains the project's store
1258
+ * files; global scope is the store-only cross-project view (all project
1259
+ * dirs + the legacy global seed). Both apply the fail-closed tombstone
1260
+ * filter — the state dir is the store root itself (hidden.json contract).
1261
+ * The DrainResult is returned verbatim: `blocked` must stop the open flow
1262
+ * before any records build, and the selector's scope toggle surfaces it.
1263
+ */
1264
+ function drainForScope(scope: HistoryScope): DrainResult {
1265
+ return scope === "project"
1266
+ ? drainProject(root, cwd, 1000, root)
1267
+ : drainGlobal(root, 1000, root);
1268
+ }
1269
+
1270
+ const store: SelectorStore = { root, cwd, drain: drainForScope };
1271
+
1272
+ async function openHistorySelector(
1273
+ ctx: Pick<ExtensionCommandContext, "ui">,
1274
+ ): Promise<void> {
1275
+ // Capture gate (#1390) FIRST: with capture off the selector is a no-op —
1276
+ // no registry writes, no writer init, no store reads, no overlay.
1277
+ if (!captureEnabled(env, configHome)) {
1278
+ ctx.ui.notify(captureDisabledMessage(env, configHome), "warning");
1279
+ return;
1280
+ }
1281
+
1282
+ // Store-only drain (user-directed): the selector reads the store files —
1283
+ // no live transcript merge. `blocked` (untrusted hidden.json) fails
1284
+ // CLOSED: surface the recovery message and stop before building records.
1285
+ const drained = drainForScope("project");
1286
+ if (drained.status === "blocked") {
1287
+ ctx.ui.notify(drained.message, "error");
1288
+ return;
1289
+ }
1290
+ const entries = drained.prompts;
1291
+ if (entries.length === 0) {
1292
+ // a22588fc empty-store policy: no history warns and skips the overlay.
1293
+ // A later slice changes this, not this one.
1294
+ ctx.ui.notify("No prompt history available.", "warning");
1295
+ return;
1296
+ }
1297
+
1298
+ const records = recordsFromEntries(entries);
1299
+ const selected = await runPromptHistorySelection(ctx, records, store);
1300
+ if (selected) {
1301
+ // pasteToEditor routes through the editor's input pipeline (bracketed
1302
+ // paste), so the text renders immediately (a22588fc).
1303
+ ctx.ui.pasteToEditor(selected.text);
1304
+ // The overlay teardown can race the paste render: force one more
1305
+ // frame on the next tick so the editor box shows the text at once.
1306
+ setTimeout(() => selectorTui?.requestRender(), 0);
1307
+ }
1308
+ }
1309
+
1310
+ return { openHistorySelector };
1311
+ }
1312
+
1313
+ export default function promptHistoryExtension(
1314
+ pi: ExtensionAPI,
1315
+ deps: HistoryDeps = {},
1316
+ ): void {
1317
+ const env = deps.env ?? process.env;
1318
+ const configHome = deps.gentlePiConfigHome ?? gentlePiConfigHome(env);
1319
+ // Per-prompt gate: re-read so a Customize toggle applies live. A delegated
1320
+ // child never captures: its prompt is a delegation brief, not user history,
1321
+ // and the parent owns the store's init and GC (gentle-shell#1690).
1322
+ const capturing = () => env.GENTLE_PI_AGENTS_CHILD !== "1" && captureEnabled(env, configHome);
1323
+ // S9: a bundled Gentle Shell keeps the whole store in the agent home Pi runs
1324
+ // with; everywhere else it stays under ~/.pi/agent, where existing ones live.
1325
+ const bundledAgentDir = bundledGentlePiAgentHome(env);
1326
+ const root = deps.root ?? (bundledAgentDir === undefined ? PI_HISTORY_ROOT : join(bundledAgentDir, "history"));
1327
+ const cwd = deps.cwd ?? process.cwd();
1328
+ const instanceId = deps.instanceId ?? randomUUID();
1329
+ const now = deps.now ?? Date.now;
1330
+ const agentDir = deps.agentDir ?? bundledAgentDir ?? AGENT_DIR;
1331
+ const sessionsRoot = deps.sessionsRoot ?? (bundledAgentDir === undefined ? SESSIONS_ROOT : join(bundledAgentDir, "sessions"));
1332
+ let writerState: SessionWriterState | null = null;
1333
+
1334
+ /**
1335
+ * One-time init per extension load: migrate legacy stores, register the
1336
+ * project, bootstrap the seed, then open this instance's exclusive file.
1337
+ */
1338
+ const getWriter = (): SessionWriterState => {
1339
+ if (!writerState) {
1340
+ try {
1341
+ migrateLegacyStores(root, agentDir);
1342
+ } catch {
1343
+ // migration is best-effort; the gate keeps it one-shot
1344
+ }
1345
+ try {
1346
+ ensureRegistryEntry(root, cwd);
1347
+ } catch {
1348
+ // registry is advisory
1349
+ }
1350
+ try {
1351
+ bootstrapProjectSeed(
1352
+ root,
1353
+ cwd,
1354
+ sessionsRoot,
1355
+ 500,
1356
+ root,
1357
+ );
1358
+ } catch {
1359
+ // bootstrap is a rebuildable cache
1360
+ }
1361
+ writerState = openSessionWriter(root, cwd, instanceId);
1362
+ }
1363
+ return writerState;
1364
+ };
1365
+
1366
+ // Warm migrate/registry/seed OFF the first-prompt path, but only for
1367
+ // opted-in sessions: with capture disabled nothing may be written —
1368
+ // no registry entry, no seed files, no store (docs/prompt-history.md).
1369
+ setImmediate(() => {
1370
+ if (!capturing()) return;
1371
+ try {
1372
+ getWriter();
1373
+ } catch {
1374
+ // init is best-effort; the lazy path retries on the next prompt
1375
+ }
1376
+ });
1377
+
1378
+ // Persist every delivered user prompt (write-through, append-only JSONL),
1379
+ // but only for opted-in sessions — see captureEnabled(). The local
1380
+ // ExtensionAPI stub types handler args as unknown; narrow here.
1381
+ pi.on("before_agent_start", (...args: unknown[]) => {
1382
+ if (!capturing()) return;
1383
+ try {
1384
+ const event = args[0] as { prompt?: string } | undefined;
1385
+ appendSessionCapture(getWriter(), event?.prompt ?? "", now());
1386
+ } catch {
1387
+ // A capture failure must never break the agent loop or unregister
1388
+ // the handler - swallow and keep the next prompt capturable.
1389
+ }
1390
+ });
1391
+
1392
+ // Consolidate this project's store files on graceful shutdown (slice 6),
1393
+ // only for opted-in sessions: with capture off the store is never
1394
+ // rewritten. This instance's own capture file is never a merge candidate.
1395
+ pi.on("session_shutdown", () => {
1396
+ if (!capturing()) return;
1397
+ try {
1398
+ gcProjectDir(root, cwd, {
1399
+ keepFiles: [sessionFilePath(root, cwd, instanceId)],
1400
+ });
1401
+ } catch {
1402
+ // GC is best-effort and never blocks shutdown
1403
+ }
1404
+ });
1405
+
1406
+ // When a tool asks for user input while the history overlay is open,
1407
+ // dismiss the overlay so the tool can take over the UI.
1408
+ pi.on("tool_call", () => {
1409
+ activeOverlayClose?.();
1410
+ });
1411
+
1412
+ // Selector open flow (slice 3, stage 3): both entry points share it.
1413
+ const { openHistorySelector } = createOpenFlow(env, configHome, root, cwd);
1414
+
1415
+ pi.registerShortcut(SHORTCUT, {
1416
+ description: "Search prompt history",
1417
+ handler: async (ctx) => openHistorySelector(ctx),
1418
+ });
1419
+
1420
+ pi.registerCommand("history", {
1421
+ description: "Search prompt history",
1422
+ handler: async (_args, ctx) => openHistorySelector(ctx),
1423
+ });
1424
+ }