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
@@ -1,22 +1,113 @@
1
1
  import { CustomEditor, keyHint, type ExtensionAPI, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
- import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
2
+ import { isHomeOrBroadUserDirectory } from "../lib/bounded-writer-admission.ts";
3
+ import { Editor, decodeKittyPrintable, isKeyRelease, matchesKey, parseKey, truncateToWidth, visibleWidth, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
3
4
  import { execFile, spawnSync } from "node:child_process";
4
- import { statSync } from "node:fs";
5
- import { profilesFilePath, readProfilesFileResult } from "../lib/agent-profiles.ts";
5
+ import { existsSync, realpathSync, statSync } from "node:fs";
6
+ import { lstat, readFile } from "node:fs/promises";
7
+ import { fileURLToPath } from "node:url";
8
+ import { profilesFilePath, profileRoleEntries, readProfilesFileResult } from "../lib/agent-profiles.ts";
9
+ import { readSessionProfileBinding } from "../lib/session-profile-binding.ts";
10
+ import { resolveProfilePin } from "../lib/agent-profile-pin.ts";
6
11
  import * as os from "node:os";
7
- import { join } from "node:path";
8
- import { buildShellHeaderModel, renderShellBar, renderShellHeaderBar, renderShellHeaderRule, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
9
- import { CHANGE_STATUS, RootBranchLabels, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type WorktreeChanges } from "../lib/shell-changes.ts";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { buildShellHeaderModel, renderShellBar, renderShellBelowInputFloat, renderShellBottomOnlyBar, renderShellHeaderChrome, renderShellSidebarBar, keepNativeWorkingRow, shellEnabled, shellHeaderUsageHit, shellJobsCount, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
14
+ import { CHANGE_STATUS, DIFF_MAX_BYTES, DIFF_MAX_LINES, DIFF_TOO_LARGE, WorktreeChangesTracker, renderChangesWidget, type ChangedFile, type ChangesModel, type FileRevision, type GitRunner, type LineCounter, type WorktreeChanges } from "../lib/shell-changes.ts";
10
15
  import { WorktreeChangesView } from "../lib/shell-changes-view.ts";
11
- import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
12
- import { CARD_TONE, renderCard, type Card, type CardTheme } from "../lib/shell-card.ts";
16
+ import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver, type WorktreeIdentity } from "../lib/session-worktree-registry.ts";
17
+ import { CARD_STYLE, CARD_TONE, cardStyle, renderCard, setCardStyle, type Card, type CardTheme } from "../lib/shell-card.ts";
18
+ import { resolveCardStyle, writeCardStyle } from "../lib/card-style-policy.ts";
19
+ import { CARD_CONTENT, resolveCardContent, setCardContent, writeCardContent } from "../lib/card-content-policy.ts";
13
20
  import { CommandPalette, commandsKey, type CommandPaletteResult } from "../lib/command-palette.ts";
14
21
  import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts";
22
+ import { discoverYoloUiAdapter, YOLO_DISPLAY, type YoloDisplay, type YoloUiAdapter } from "../lib/yolo-session-policy.ts";
23
+ import { VisualCustomizeView, type CustomizeCategory, type CustomizeRow, type ProfileActions } from "../lib/visual-customize-view.ts";
24
+ import { deleteVisualProfile, getVisualProfile, listVisualProfiles, resetVisualProfiles, saveVisualProfile } from "../lib/visual-profiles.ts";
25
+ import { sourcePalettePreview } from "../lib/theme-customization.ts";
26
+ import { DEFAULT_VISUAL_SETTINGS, DENSITY, HEADER_PLACEMENT, STATUS_PLACEMENT, VISUAL_SECTION_KEYS, resolveVisualSettings, writeVisualSettings } from "../lib/visual-customization-policy.ts";
27
+ import { BANNER_COLORS, DEFAULT_BANNER_CONFIG, readBannerConfig, readBannerConfigForEdit, writeBannerConfig } from "./startup-banner.ts";
15
28
  import { agentsViewKey } from "../lib/agents-keys.ts";
16
29
  import { GentleAiDevBinaryOverrideError, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
17
- import { DOUBLE_ESC_CANCEL_HINT, framePromptLines, IDLE_ESC_CLEAR_HINT, PROMPT_HINT, PROMPT_STATE, SHELL_PULSE_MS, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
30
+ import { DOUBLE_ESC_CANCEL_HINT, floatPromptRow, framePromptLines, resolvePromptLayout, IDLE_ESC_CLEAR_HINT, PROMPT_HINT, PROMPT_STATE, SHELL_PULSE_MS, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
31
+ import { oddPhaseRegistry } from "../lib/odd-phase.ts";
32
+ import { inferOddPhase } from "../lib/odd-phase-inference.ts";
33
+ import { isInteractiveMode } from "../lib/rpc-host.ts";
18
34
  import { gentlePiConfigHome } from "../lib/agent-home.ts";
35
+ import { bundledGentlePiConfigHome } from "../lib/gentle-shell-bundled.ts";
36
+ import { isNanAdaptiveReasoningModel } from "../lib/nan-provider.ts";
19
37
  import { resolveAnimationPolicy, writeAnimationPolicy, type AnimationPolicy } from "../lib/animation-policy.ts";
38
+ import { resolveVimPolicy, writeVimPolicy, type VimPolicy } from "../lib/vim-policy.ts";
39
+ import { resolveHistoryCapture, writeHistoryCapturePolicy } from "../lib/history-capture-policy.ts";
40
+ import { createRequire } from "node:module";
41
+ import { createVimEditorAdapter, isAuditedPiEditorVersion } from "../lib/vim-editor-adapter.ts";
42
+ import { VimNormalEngine } from "../lib/vim-normal-engine.ts";
43
+ import { VimOperatorEngine, type OperatorResult } from "../lib/vim-operator-engine.ts";
44
+ import { VimVisualEngine } from "../lib/vim-visual-engine.ts";
45
+
46
+ // Canonical entrypoint and index patterns across POSIX and Windows separators.
47
+ // Exported for cross-platform unit testing of candidate path resolution.
48
+ export const VIM_CLI_ENTRY_PATTERN = /(?:^|[\\/])dist[\\/]bundle[\\/]cli\.js$/;
49
+ export const VIM_AGENT_INDEX_PATTERN = /(?:^|[\\/])dist[\\/]index\.js$/;
50
+
51
+ // Candidate paths provide only package roots, never version authority. Both
52
+ // constructors must come from that same canonical agent/TUI pair before its
53
+ // metadata can admit private editing (jiti may alias imports to host modules).
54
+ export function resolveVimRuntime(entry = process.argv[1], customClass: typeof CustomEditor = CustomEditor):
55
+ { version: string; editorClass: typeof Editor } | undefined {
56
+ const editorPrototype = Object.getPrototypeOf(customClass.prototype) as typeof Editor.prototype | undefined;
57
+ const editorClass = editorPrototype?.constructor as typeof Editor | undefined;
58
+ const candidates: string[] = [];
59
+ // Audited CLIs (AUDITED_PI_EDITOR_VERSIONS) use the bundled virtual module graph.
60
+ // Its public index exports the very CustomEditor class supplied to extensions by that graph.
61
+ try {
62
+ if (entry) {
63
+ const cli = realpathSync(entry);
64
+ if (VIM_CLI_ENTRY_PATTERN.test(cli)) {
65
+ const root = resolve(dirname(cli), "../..");
66
+ const bundlePath = resolve(root, "dist", "bundle", "index.js");
67
+ if (realpathSync(bundlePath) === bundlePath) {
68
+ const requireFromBundle = createRequire(bundlePath);
69
+ const bundled = requireFromBundle(bundlePath) as { CustomEditor?: typeof CustomEditor; VERSION?: string };
70
+ const metadata = requireFromBundle(resolve(root, "package.json")) as { name?: string; version?: string };
71
+ if (metadata.name === "@earendil-works/pi-coding-agent" && isAuditedPiEditorVersion(metadata.version) &&
72
+ bundled.VERSION === metadata.version && bundled.CustomEditor === customClass &&
73
+ typeof editorClass === "function" && editorClass.name === "Editor" &&
74
+ editorPrototype === editorClass.prototype &&
75
+ Object.getPrototypeOf(customClass) === editorClass) {
76
+ return { version: metadata.version, editorClass };
77
+ }
78
+ }
79
+ }
80
+ }
81
+ } catch { /* Unknown bundled host: do not authorize private editing. */ }
82
+ try {
83
+ if (entry) {
84
+ const cli = realpathSync(entry);
85
+ if (VIM_CLI_ENTRY_PATTERN.test(cli)) candidates.push(resolve(dirname(cli), "../.."));
86
+ }
87
+ } catch { /* The CLI is only a candidate, not proof. */ }
88
+ try {
89
+ const localIndex = realpathSync(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent")));
90
+ if (VIM_AGENT_INDEX_PATTERN.test(localIndex)) candidates.push(resolve(dirname(localIndex), ".."));
91
+ } catch { /* No local candidate; do not trust extension-relative metadata. */ }
92
+ for (const root of new Set(candidates)) {
93
+ try {
94
+ const agentIndex = realpathSync(resolve(root, "dist", "index.js"));
95
+ if (agentIndex !== resolve(root, "dist", "index.js")) continue;
96
+ const requireFromRuntime = createRequire(agentIndex);
97
+ const agent = requireFromRuntime(agentIndex) as { CustomEditor?: typeof CustomEditor };
98
+ const agentMetadata = requireFromRuntime(resolve(root, "package.json")) as { version?: string; name?: string };
99
+ const tuiPath = realpathSync(requireFromRuntime.resolve("@earendil-works/pi-tui"));
100
+ const tui = requireFromRuntime(tuiPath) as { Editor?: typeof Editor };
101
+ const tuiRoot = resolve(dirname(tuiPath), "..");
102
+ const tuiMetadata = requireFromRuntime(resolve(tuiRoot, "package.json")) as { version?: string; name?: string };
103
+ if (agent.CustomEditor === customClass && tui.Editor === editorClass &&
104
+ agentMetadata.name === "@earendil-works/pi-coding-agent" && tuiMetadata.name === "@earendil-works/pi-tui" &&
105
+ isAuditedPiEditorVersion(agentMetadata.version) &&
106
+ agentMetadata.version === tuiMetadata.version) return { version: tuiMetadata.version, editorClass };
107
+ } catch { /* Unknown package or constructor: ordinary editing stays active. */ }
108
+ }
109
+ return undefined;
110
+ }
20
111
  import {
21
112
  DOUBLE_ESC_CANCEL_WINDOW_MS,
22
113
  resolveDoubleEscCancelPolicy,
@@ -24,12 +115,17 @@ import {
24
115
  type DoubleEscCancelPolicy,
25
116
  type DoubleEscCancelResolution,
26
117
  } from "../lib/double-esc-cancel-policy.ts";
27
- import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, NAN_PROVIDER, NAN_QUOTA_URL, parseCodexUsage, parseNanQuota, parseProviderUsage, parseUsageHeaders, parseUsageSource, UsageSourceRegistry, UsageStore, USAGE_SOURCE_EVENT, type ProviderUsage, type UsageSource } from "../lib/shell-usage.ts";
118
+ import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, NAN_PROVIDER, NAN_QUOTA_URL, parseCodexUsage, parseNanQuota, parseProviderUsage, parseUsageHeaders, parseUsageSource, usageScopeProviders, UsageSourceRegistry, UsageStore, USAGE_SOURCE_EVENT, type ProviderUsage, type UsageSource } from "../lib/shell-usage.ts";
28
119
  import { UsageView } from "../lib/shell-usage-view.ts";
29
- import { sidebarHeader, sidebarPart } from "../lib/shell-sidebar.ts";
30
- import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
31
- import { SessionChanges, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
120
+ import { sidebarHeader, sidebarPart, sidebarState, VISUAL_SETTINGS_CHANGED, type SidebarRail } from "../lib/shell-sidebar.ts";
121
+ import { installSidebar, invalidateSidebar, narrowStatusOwner, SIDEBAR_BREAKPOINT, STATUS_OWNER } from "../lib/shell-sidebar-layout.ts";
122
+ import { sessionChangeRoots } from "../lib/session-changes.ts";
123
+ import { REVIEW_SIDEBAR_EVENT, isReviewSidebarSnapshot, type ReviewSidebarSnapshot } from "../lib/review-sidebar-state.ts";
124
+ import { JOBS_SIDEBAR_EVENT, JOBS_STATUS_KEY, isJobsSidebarSnapshot, type RunningJobDisplay } from "../lib/jobs-sidebar-state.ts";
32
125
  import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
126
+ import { SelectionEngine } from "../lib/selection-engine.ts";
127
+ import { withOverlayRepaint } from "../lib/overlay-repaint.ts";
128
+ import { buildNotificationRows } from "../lib/notification-customize.ts";
33
129
 
34
130
  // Gentle Shell: the visual layer gentle-pi puts on top of pi. It installs the
35
131
  // status bar, the petal prompt, the working-tree changes widget and overlay,
@@ -54,6 +150,7 @@ interface ShellBarComponent {
54
150
  }
55
151
 
56
152
  interface BuildOptions {
153
+ jobs?: RunningJobDisplay[];
57
154
  profile?: string;
58
155
  home?: string;
59
156
  dirty?: number;
@@ -68,17 +165,31 @@ export interface ShellDeps {
68
165
  now(): number;
69
166
  devBinary(): DevBinaryNotice | undefined;
70
167
  resolveWorktree: WorktreeResolver;
71
- gitRunner(cwd: string): GitRunner;
168
+ /** maxBuffer bounds one diff; status and discovery stay unbounded. */
169
+ gitRunner(cwd: string, maxBuffer?: number): GitRunner;
170
+ /** A root Changes may scan: removed worktrees and leftover directories are skipped. */
171
+ rootExists(root: string): boolean;
172
+ vimRuntimeVersion?(): string | undefined;
72
173
  }
73
174
 
74
- // The rail digest runs every frame. Cache parsing by file identity and metadata,
75
- // not just mtime: profile writes replace the store atomically. Keep the cache
76
- // local to this shell instance and recheck on the next frame after panel edits.
77
- export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env): () => string | undefined {
78
- const path = profilesFilePath(env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"));
175
+ export type ActiveProfileReader = (() => string | undefined) & {
176
+ bind(cwd: string, resolveWorktree: WorktreeResolver, sessionId?: string): boolean;
177
+ refresh(): boolean;
178
+ reset(): void;
179
+ };
180
+
181
+ // Unbound reads retain the global file-identity cache, including atomic replacements.
182
+ // Bound reads are snapshots: no filesystem or Git work is done during a frame.
183
+ export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env): ActiveProfileReader {
184
+ const path = profilesFilePath(bundledGentlePiConfigHome(env) ?? env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"));
79
185
  let fingerprint: string | undefined;
80
186
  let name: string | undefined;
81
- return () => {
187
+ let bound = false;
188
+ let cwd: string | undefined;
189
+ let identity: WorktreeIdentity | undefined;
190
+ let sessionId: string | undefined;
191
+ let effective: string | undefined;
192
+ const global = () => {
82
193
  try {
83
194
  const stat = statSync(path, { bigint: true });
84
195
  const next = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
@@ -94,6 +205,40 @@ export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env):
94
205
  return undefined;
95
206
  }
96
207
  };
208
+ const reader = (() => bound ? effective : global()) as ActiveProfileReader;
209
+ reader.refresh = () => {
210
+ if (!bound) return false;
211
+ // gentle-shell#1064 slice 1: a parent-session profile binding outranks
212
+ // both the pin layers and the global active profile, with the same
213
+ // "name (scope)" spelling the pin uses. The shared precedence rule lives
214
+ // in one place: session → p (local pin) → P (repo declaration) → global.
215
+ const session = sessionId === undefined ? undefined : readSessionProfileBinding(sessionId);
216
+ if (session !== undefined) {
217
+ const next = `${session.name} (session)`;
218
+ const changed = next !== effective;
219
+ effective = next;
220
+ return changed;
221
+ }
222
+ const pin = identity && cwd ? resolveProfilePin({
223
+ cwd,
224
+ configHome: bundledGentlePiConfigHome(env) ?? env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"),
225
+ resolveWorktree: () => identity!,
226
+ }) : undefined;
227
+ const next = pin ? `${pin.profile} (${pin.source})` : global();
228
+ const changed = next !== effective;
229
+ effective = next;
230
+ return changed;
231
+ };
232
+ reader.bind = (nextCwd, resolveWorktree, nextSessionId) => {
233
+ reader.reset();
234
+ cwd = nextCwd;
235
+ sessionId = nextSessionId;
236
+ try { identity = resolveWorktree(nextCwd, nextCwd); } catch { identity = undefined; }
237
+ bound = true;
238
+ return reader.refresh();
239
+ };
240
+ reader.reset = () => { bound = false; cwd = undefined; identity = undefined; sessionId = undefined; effective = undefined; };
241
+ return reader;
97
242
  }
98
243
 
99
244
  function ambientDevBinary(): DevBinaryNotice | undefined {
@@ -106,7 +251,7 @@ function ambientDevBinary(): DevBinaryNotice | undefined {
106
251
  }
107
252
  }
108
253
 
109
- const defaultShellDeps: Omit<ShellDeps, "activeProfile"> = { fetch: (...args) => globalThis.fetch(...args), now: () => Date.now(), devBinary: ambientDevBinary, resolveWorktree: resolveSessionWorktree, gitRunner: shellGitRunner };
254
+ const defaultShellDeps: Omit<ShellDeps, "activeProfile"> = { fetch: (input, init) => globalThis.fetch(input, init), now: () => Date.now(), devBinary: ambientDevBinary, resolveWorktree: resolveSessionWorktree, gitRunner: (cwd, maxBuffer) => shellGitRunner(cwd, process.env, execFile, maxBuffer), rootExists: (root) => existsSync(join(root, ".git")) };
110
255
 
111
256
  interface AssistantUsageEntry {
112
257
  type: string;
@@ -132,6 +277,37 @@ function sessionCost(ctx: ExtensionContext): number {
132
277
  return total;
133
278
  }
134
279
 
280
+ type ShellSessionStats = { costTotal: number; usage: ReturnType<ExtensionContext["getContextUsage"]> };
281
+ const shellSessionStats = new WeakMap<object, {
282
+ sessionId: string | undefined;
283
+ leafId: string | null;
284
+ entryCount: number | undefined;
285
+ model: ExtensionContext["model"];
286
+ contextWindow: number | undefined;
287
+ stats: ShellSessionStats;
288
+ }>();
289
+
290
+ function sessionStats(ctx: ExtensionContext): ShellSessionStats {
291
+ const manager = ctx.sessionManager as ExtensionContext["sessionManager"] & { getEntryCount?(): number };
292
+ const compute = (): ShellSessionStats => ({ costTotal: sessionCost(ctx), usage: ctx.getContextUsage() });
293
+ // Append-only session entries move the leaf, including compaction and branch
294
+ // switches. A virtual model's routed physical model comes from the latest
295
+ // response, which is appended as an entry, so the leaf covers it as well.
296
+ // Older/test hosts without that identity must stay uncached.
297
+ if (typeof manager.getLeafId !== "function") return compute();
298
+ const sessionId = manager.getSessionId();
299
+ const leafId = manager.getLeafId();
300
+ const entryCount = manager.getEntryCount?.();
301
+ const model = ctx.model;
302
+ const contextWindow = model?.contextWindow;
303
+ const cached = shellSessionStats.get(manager);
304
+ if (cached && cached.sessionId === sessionId && cached.leafId === leafId &&
305
+ cached.entryCount === entryCount && cached.model === model && cached.contextWindow === contextWindow) return cached.stats;
306
+ const stats = compute();
307
+ shellSessionStats.set(manager, { sessionId, leafId, entryCount, model, contextWindow, stats });
308
+ return stats;
309
+ }
310
+
135
311
  export function buildShellBarModel(
136
312
  pi: ExtensionAPI,
137
313
  ctx: ExtensionContext,
@@ -139,9 +315,11 @@ export function buildShellBarModel(
139
315
  options: BuildOptions = {},
140
316
  ): ShellBarModel {
141
317
  const home = options.home ?? os.homedir();
142
- const usage = ctx.getContextUsage();
318
+ const { usage, costTotal } = sessionStats(ctx);
143
319
  const model = ctx.model;
320
+ const adaptiveReasoning = isNanAdaptiveReasoningModel(model);
144
321
  const statuses = Array.from(footerData.getExtensionStatuses().entries())
322
+ .filter(([key]) => options.jobs === undefined || key !== JOBS_STATUS_KEY)
145
323
  .sort(([a], [b]) => a.localeCompare(b))
146
324
  .map(([, text]) => text);
147
325
  return {
@@ -151,12 +329,13 @@ export function buildShellBarModel(
151
329
  dirty: options.dirty,
152
330
  sessionName: ctx.sessionManager.getSessionName(),
153
331
  modelId: model?.id ?? "no-model",
154
- effort: model?.reasoning ? pi.getThinkingLevel() : undefined,
332
+ effort: model?.reasoning ? (adaptiveReasoning ? "auto" : pi.getThinkingLevel()) : undefined,
155
333
  contextPercent: usage?.percent ?? null,
156
334
  contextWindow: usage?.contextWindow ?? model?.contextWindow ?? 0,
157
- costTotal: sessionCost(ctx),
335
+ costTotal,
158
336
  subscription: model ? ctx.modelRegistry.isUsingOAuth(model) : false,
159
337
  usage: options.usage,
338
+ ...(options.jobs !== undefined ? { jobs: options.jobs } : {}),
160
339
  statuses,
161
340
  };
162
341
  }
@@ -169,6 +348,7 @@ export function createShellBarComponent(
169
348
  footerData: ShellFooterData,
170
349
  dirty: () => number | undefined = () => undefined,
171
350
  usage: () => ProviderUsage | undefined = () => undefined,
351
+ presentation?: () => ReturnType<typeof resolveVisualSettings>["settings"],
172
352
  ): ShellBarComponent {
173
353
  const unsubscribe = footerData.onBranchChange(() => {
174
354
  host.invalidateSidebar?.();
@@ -176,7 +356,7 @@ export function createShellBarComponent(
176
356
  });
177
357
  return {
178
358
  render(width: number) {
179
- return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage() }), theme, width);
359
+ return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage() }), theme, width, presentation?.());
180
360
  },
181
361
  invalidate() {},
182
362
  dispose() {
@@ -187,6 +367,7 @@ export function createShellBarComponent(
187
367
 
188
368
  interface PromptEditorDeps {
189
369
  fg: (color: string, text: string) => string;
370
+ bg?: (color: string, text: string) => string;
190
371
  bold: (text: string) => string;
191
372
  requestRender(): void;
192
373
  pending(): boolean;
@@ -195,12 +376,26 @@ interface PromptEditorDeps {
195
376
  doubleEscCancelEnabled(): boolean;
196
377
  /** Hand off text reconstructed from Pi's Esc-abort restore so it is sent as the next turn instead of sitting in the editor. */
197
378
  dispatchQueuedText(text: string): void;
379
+ /** Override only for isolated adapter compatibility tests; production reads installed metadata. */
380
+ tuiVersion?: () => string | undefined;
381
+ /** Report a single warning if this editor cannot safely provide modal editing. */
382
+ notifyCompatibility?: (message: string) => void;
383
+ /**
384
+ * Read fresh on every render while WORKING: the explicit, orchestrator-reported
385
+ * ODD phase label for the current session, or undefined to fall back to the
386
+ * generic "working…" label. Never applied to IDLE or QUEUED.
387
+ */
388
+ workingLabel?: () => string | undefined;
198
389
  }
199
390
 
200
391
  const PROMPT_FRAME_ROLE = "border";
201
392
  // Matches Pi's own idle double-Esc window (empty editor -> /tree or /fork);
202
393
  // this is the same muscle memory applied to clearing a non-empty draft.
203
394
  const IDLE_ESC_CLEAR_WINDOW_MS = 500;
395
+ const BRACKETED_PASTE_START = "\x1b[200~";
396
+ const BRACKETED_PASTE_END = "\x1b[201~";
397
+ // Bound our framing buffer even when a terminal never sends the closing marker.
398
+ const MAX_BRACKETED_PASTE_CHARS = 1024 * 1024;
204
399
 
205
400
  /**
206
401
  * Pi's own Esc-abort handler (`restoreQueuedMessagesToEditor({ abort: true
@@ -231,8 +426,28 @@ export class GentlePromptEditor extends CustomEditor {
231
426
  private promptState: PromptState = PROMPT_STATE.IDLE;
232
427
  private tick = 0;
233
428
  private animationPolicy: AnimationPolicy = "quality";
429
+ private vimPolicy: VimPolicy = "off";
430
+ private vimRejected = false;
431
+ private vimNormal = false;
432
+ private compatibilityNotified = false;
433
+ private readonly vimEngine = new VimNormalEngine();
434
+ private readonly vimOperator = new VimOperatorEngine();
435
+ private vimInsertIntent?: { operator?: OperatorResult; command?: string };
436
+ private readonly vimVisual = new VimVisualEngine();
437
+ private pasteFrame: string | undefined;
438
+ private pasteOverflow = false;
439
+ private pasteTail = "";
440
+ private visualReplacePending = false;
441
+ private visualObjectPending: "i" | "a" | undefined;
442
+ private visualAnchor: { line: number; col: number } | undefined;
234
443
  private pulse: NodeJS.Timeout | undefined;
235
444
  private readonly deps: PromptEditorDeps;
445
+ private floatMouseLayout?: ReturnType<typeof resolvePromptLayout>;
446
+ // Native selection engine (shift+home/end, alt+a, replace-on-key): ported
447
+ // from pi-select-del so the petal prompt owns the feature without factory
448
+ // composition. Constructed with `this`; the internals probe degrades to
449
+ // passthrough on pi drift, costing only the selection features.
450
+ private readonly selectionEngine: SelectionEngine;
236
451
  // CustomEditor keeps its own `keybindings` private, so this class holds
237
452
  // its own reference to run the same app.interrupt match before deciding
238
453
  // whether to swallow the keystroke.
@@ -246,6 +461,7 @@ export class GentlePromptEditor extends CustomEditor {
246
461
 
247
462
  constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, deps: PromptEditorDeps) {
248
463
  super(tui, theme, keybindings);
464
+ this.selectionEngine = new SelectionEngine(this);
249
465
  this.deps = deps;
250
466
  this.keybindingsManager = keybindings;
251
467
  }
@@ -258,6 +474,87 @@ export class GentlePromptEditor extends CustomEditor {
258
474
  this.deps.requestRender();
259
475
  }
260
476
 
477
+ get effectiveVimPolicy(): VimPolicy { return this.vimPolicy; }
478
+
479
+ setVimPolicy(policy: VimPolicy): void {
480
+ this.vimRejected = false;
481
+ if (policy === "on") {
482
+ try { this.vimAdapter(); }
483
+ catch {
484
+ this.vimRejected = true;
485
+ if (!this.compatibilityNotified) {
486
+ this.compatibilityNotified = true;
487
+ this.deps.notifyCompatibility?.("Gentle Vim unsupported by this Pi editor layout/version; ordinary editing remains active.");
488
+ }
489
+ policy = "off";
490
+ }
491
+ }
492
+ if (this.vimPolicy === policy) return;
493
+ this.pasteFrame = undefined;
494
+ this.pasteOverflow = false;
495
+ this.pasteTail = "";
496
+ if (this.vimPolicy === "on" && !this.disablingUnsupportedVim) {
497
+ try { this.vimAdapter().endInsertSession(); } catch { /* Unsupported editor cannot group inserts. */ }
498
+ }
499
+ this.vimInsertIntent = undefined;
500
+ this.vimPolicy = policy;
501
+ this.vimNormal = false;
502
+ this.vimOperator.forgetRepeat();
503
+ this.vimOperator.cancel();
504
+ this.visualAnchor = undefined;
505
+ this.vimVisual.cancel();
506
+ this.visualReplacePending = false;
507
+ this.visualObjectPending = undefined;
508
+ this.deps.requestRender();
509
+ }
510
+
511
+ private notifyUnsafeVisual(stage: "range" | "readRange" | "beginInsertSession" | "replace" | "move" | "operation", error?: unknown): void {
512
+ // Only fixed reason codes reach UI; never include a draft, path, or raw host error.
513
+ const message = error instanceof Error ? error.message : "";
514
+ const reason = /duplicate registered paste marker/i.test(message) ? "duplicate-marker" :
515
+ /paste marker/i.test(message) ? "paste-marker" :
516
+ /unsupported pi editor layout/i.test(message) ? "unsupported-layout" :
517
+ /valid grapheme or paste boundary|invalid cursor boundary/i.test(message) ? "invalid-boundary" :
518
+ /invalid selection range|invalid operator range/i.test(message) ? "invalid-range" :
519
+ /insert session already active/i.test(message) ? "session-active" :
520
+ error === undefined ? "no-range" : "unexpected";
521
+ this.deps.notifyCompatibility?.((reason === "paste-marker"
522
+ ? "Vim selection contains a registered paste marker; edit it in INSERT mode instead."
523
+ : "Vim selection cannot be edited safely; try a different selection.") + ` [${stage}:${reason}]`);
524
+ }
525
+
526
+ private disablingUnsupportedVim = false;
527
+ private vimAdapter(): ReturnType<typeof createVimEditorAdapter> {
528
+ const runtime = resolveVimRuntime();
529
+ try {
530
+ return createVimEditorAdapter(this, this.deps.tuiVersion?.() ?? runtime?.version ?? "unknown", runtime?.editorClass ?? Editor, runtime?.version);
531
+ } catch (error) {
532
+ if (this.vimPolicy === "on" && !this.disablingUnsupportedVim) {
533
+ this.disablingUnsupportedVim = true;
534
+ try { this.setVimPolicy("off"); this.vimRejected = true; } finally { this.disablingUnsupportedVim = false; }
535
+ if (!this.compatibilityNotified) {
536
+ this.compatibilityNotified = true;
537
+ this.deps.notifyCompatibility?.("Gentle Vim unsupported by this Pi editor layout/version; ordinary editing remains active.");
538
+ }
539
+ }
540
+ throw error;
541
+ }
542
+ }
543
+
544
+ private enterVimInsert(resetEngine = true, cancelOperator = true, beginSession = true): void {
545
+ if (this.vimNormal && beginSession) {
546
+ try { this.vimAdapter().beginInsertSession(); } catch { /* Unknown layout: normal edits remain disabled. */ }
547
+ }
548
+ this.vimNormal = false;
549
+ if (cancelOperator) this.vimOperator.cancel();
550
+ if (resetEngine) this.vimEngine.reset();
551
+ this.visualAnchor = undefined;
552
+ this.vimVisual.cancel();
553
+ this.visualReplacePending = false;
554
+ this.visualObjectPending = undefined;
555
+ this.deps.requestRender();
556
+ }
557
+
261
558
  private startPulse(): void {
262
559
  if (this.animationPolicy === "potato") return;
263
560
  this.pulse = setInterval(() => {
@@ -294,12 +591,337 @@ export class GentlePromptEditor extends CustomEditor {
294
591
  * and never reach this branch.
295
592
  */
296
593
  override handleInput(data: string): void {
297
- // Any keystroke that is not the confirming Esc ends the pending idle
298
- // clear, even one that leaves the text identical (type, then delete).
594
+ // Selection and Vim handlers can consume input before the native chain;
595
+ // any intervening key invalidates an idle Esc confirmation.
299
596
  if (this.pendingIdleClearDeadline !== undefined && !this.keybindingsManager.matches(data, "app.interrupt")) {
300
597
  this.pendingIdleClearDeadline = undefined;
301
598
  this.pendingIdleClearText = undefined;
302
599
  }
600
+ // Vim owns its modal keys and paste frames. Ordinary editing retains
601
+ // native selection before the prompt's existing input chain, but a
602
+ // registered extension shortcut still owns its chord (gentle-shell#1565).
603
+ if (this.vimPolicy === "on") this.handleInputNative(data);
604
+ else this.selectionEngine.handleInput(data, (d) => this.handleInputNative(d), (d) => this.onExtensionShortcut?.(d) === true);
605
+ }
606
+
607
+ private handleInputNative(data: string): void {
608
+ if ((this.vimPolicy === "on" || this.vimRejected) && this.handleVimPasteFrame(data)) return;
609
+ if (this.vimPolicy === "on" && isKeyRelease(data)) return;
610
+ if (this.vimPolicy === "on" && this.vimNormal && matchesKey(data, "escape") && this.vimVisual.active) {
611
+ if (this.isShowingAutocomplete()) {
612
+ try { this.vimAdapter().dismissAutocomplete(); } catch { return; }
613
+ }
614
+ this.vimVisual.cancel(); this.visualAnchor = undefined;
615
+ this.visualReplacePending = false; this.visualObjectPending = undefined;
616
+ this.vimOperator.cancel(); this.vimEngine.cancelPendingFind();
617
+ this.deps.requestRender();
618
+ return;
619
+ }
620
+ if (this.vimPolicy === "on" && this.vimNormal && matchesKey(data, "escape") &&
621
+ (this.vimOperator.cancel() || this.vimEngine.cancelPendingFind())) {
622
+ this.deps.requestRender();
623
+ return;
624
+ }
625
+ // Pi owns autocomplete and shell input except while a VISUAL command
626
+ // owns the key. Defer that handoff until after shortcut priority.
627
+ const bashMode = this.getText().trimStart().startsWith("!");
628
+ if (this.vimPolicy === "on" && this.vimNormal && ((this.isShowingAutocomplete() && !this.vimVisual.active) ||
629
+ (bashMode && !this.keybindingsManager.matches(data, "app.interrupt") && !matchesKey(data, "escape")))) {
630
+ this.enterVimInsert();
631
+ }
632
+ if (this.vimPolicy === "on" && !this.vimNormal && !this.isShowingAutocomplete() &&
633
+ (this.keybindingsManager.matches(data, "app.interrupt") || matchesKey(data, "escape"))) {
634
+ try {
635
+ const inserted = this.vimAdapter().endInsertSession();
636
+ if (matchesKey(data, "escape") && inserted && this.vimInsertIntent?.operator)
637
+ this.vimOperator.rememberInsert(this.vimInsertIntent.operator, inserted);
638
+ else if (matchesKey(data, "escape") && inserted && this.vimInsertIntent?.command)
639
+ this.vimOperator.rememberLiteralInsert(this.vimInsertIntent.command, inserted);
640
+ } catch { /* Unknown layout: no grouping or repeat. */ }
641
+ this.vimInsertIntent = undefined;
642
+ this.vimNormal = true;
643
+ this.visualAnchor = undefined;
644
+ this.vimVisual.cancel();
645
+ this.pendingEscapeCancelDeadline = undefined;
646
+ this.pendingIdleClearDeadline = undefined;
647
+ this.pendingIdleClearText = undefined;
648
+ this.deps.requestRender();
649
+ return;
650
+ }
651
+ if (this.vimPolicy === "on" && (!this.isShowingAutocomplete() || this.vimVisual.active) && !bashMode) {
652
+ // CustomEditor gives extension, clipboard and app shortcuts priority
653
+ // over editor input. Preserve that order even when a shortcut shares
654
+ // a modal command key, and transfer ownership before its callback.
655
+ if (this.vimNormal && !this.keybindingsManager.matches(data, "app.interrupt") && !matchesKey(data, "escape")) {
656
+ if (this.onExtensionShortcut) {
657
+ const draft = this.getText();
658
+ const anchor = this.visualAnchor;
659
+ const visualMode = this.vimVisual.kind;
660
+ const replacePending = this.visualReplacePending;
661
+ const objectPending = this.visualObjectPending;
662
+ const restoreMode = () => {
663
+ this.vimNormal = true;
664
+ this.visualAnchor = anchor;
665
+ this.visualReplacePending = replacePending;
666
+ this.visualObjectPending = objectPending;
667
+ if (anchor) this.vimVisual.start(visualMode, anchor);
668
+ this.deps.requestRender();
669
+ };
670
+ this.enterVimInsert(false, false);
671
+ let consumed: boolean;
672
+ try { consumed = this.onExtensionShortcut(data); }
673
+ catch (error) {
674
+ if (this.getText() === draft) {
675
+ try { this.vimAdapter().endInsertSession(); } catch { /* Preserve the callback failure. */ }
676
+ restoreMode();
677
+ } else {
678
+ this.vimOperator.cancel();
679
+ this.vimEngine.reset();
680
+ }
681
+ throw error;
682
+ }
683
+ if (consumed || this.getText() !== draft) {
684
+ this.vimOperator.cancel();
685
+ this.vimEngine.reset();
686
+ return;
687
+ }
688
+ // A non-owning shortcut probe temporarily entered INSERT; close
689
+ // its session before restoring NORMAL and dispatching this key.
690
+ try { this.vimAdapter().endInsertSession(); } catch { return; }
691
+ restoreMode();
692
+ }
693
+ if (this.keybindingsManager.matches(data, "app.clipboard.pasteImage")) {
694
+ this.enterVimInsert();
695
+ this.onPasteImage?.();
696
+ return;
697
+ }
698
+ if (this.keybindingsManager.matches(data, "tui.editor.historyPrevious") ||
699
+ this.keybindingsManager.matches(data, "tui.editor.historyNext")) {
700
+ this.enterVimInsert();
701
+ super.handleInput(data);
702
+ return;
703
+ }
704
+ for (const [action, handler] of this.actionHandlers) {
705
+ if (action !== "app.interrupt" && this.keybindingsManager.matches(data, action)) {
706
+ if (action === "app.exit" && this.getText().length !== 0) continue;
707
+ this.enterVimInsert();
708
+ handler();
709
+ return;
710
+ }
711
+ }
712
+ }
713
+ const parsed = parseKey(data);
714
+ const singleGrapheme = (value: string): boolean =>
715
+ [...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(value)].length === 1;
716
+ const printable = decodeKittyPrintable(data) ??
717
+ (parsed && singleGrapheme(parsed) ? parsed : undefined) ??
718
+ (singleGrapheme(data) ? data : undefined);
719
+ // Only unmodified presses become modal commands; other shortcuts
720
+ // remain available to Pi's application keybindings.
721
+ const command = printable && (matchesKey(data, printable as Parameters<typeof matchesKey>[1]) || data === printable)
722
+ ? printable : matchesKey(data, "space") ? "Space" : undefined;
723
+ // A stale Pi menu must never take a VISUAL motion, count or
724
+ // operator. Dismiss it only after app/extension shortcut priority;
725
+ // outside VISUAL Pi retains its ordinary completion ownership.
726
+ if (this.vimNormal && this.vimVisual.active && this.isShowingAutocomplete()) {
727
+ try { this.vimAdapter().dismissAutocomplete(); } catch { return; }
728
+ }
729
+ if (this.vimNormal && !this.vimVisual.active && command === "/") {
730
+ // Pi 0.85.1 opens slash completion only at the start of the
731
+ // first line. Let its editor insert the slash and decide whether
732
+ // to open the menu; never relocate or replace an existing draft.
733
+ this.enterVimInsert();
734
+ super.handleInput(data);
735
+ return;
736
+ }
737
+ if (this.vimNormal && command) {
738
+ if (this.visualObjectPending && this.vimVisual.active) {
739
+ const around = this.visualObjectPending;
740
+ this.visualObjectPending = undefined;
741
+ try {
742
+ const adapter = this.vimAdapter();
743
+ const stops = adapter.motionBoundaries();
744
+ const range = this.vimVisual.object(this.getText(), this.getCursor(), stops, around, command);
745
+ if (range) {
746
+ adapter.readRange(range.start, range.end);
747
+ const endStops = stops[range.end.line]!;
748
+ const endIndex = endStops.indexOf(range.end.col);
749
+ const last = endIndex > 0 ? { line: range.end.line, col: endStops[endIndex - 1]! } :
750
+ range.end.line > 0 ? { line: range.end.line - 1, col: stops[range.end.line - 1]!.at(-1)! } : undefined;
751
+ if (last) {
752
+ this.visualAnchor = range.start;
753
+ this.vimVisual.start("char", range.start);
754
+ adapter.move(last);
755
+ }
756
+ }
757
+ } catch { /* Registered paste markers and unknown layouts leave the visual range intact. */ }
758
+ this.deps.requestRender(); return;
759
+ }
760
+ if (this.visualReplacePending && this.vimVisual.active) {
761
+ this.visualReplacePending = false;
762
+ if (singleGrapheme(command) && !command.includes("\n")) {
763
+ try {
764
+ const adapter = this.vimAdapter();
765
+ const range = this.vimVisual.range(this.getText(), this.getCursor(), adapter.motionBoundaries());
766
+ if (range) {
767
+ adapter.readRange(range.start, range.end);
768
+ const edit = this.vimOperator.visualEdit("r" + command, this.getText(), range).edit;
769
+ if (edit) { adapter.replace(edit.start, edit.end, edit.text); adapter.move(edit.cursor); }
770
+ this.vimVisual.cancel(); this.visualAnchor = undefined;
771
+ this.vimOperator.cancel(); this.vimEngine.reset();
772
+ }
773
+ } catch { /* Preserve selection when a paste marker or layout is unsafe. */ }
774
+ }
775
+ this.deps.requestRender(); return;
776
+ }
777
+ if (command === "v" || command === "V") {
778
+ if (this.vimVisual.active && this.vimVisual.kind === (command === "v" ? "char" : "line")) {
779
+ this.vimVisual.cancel(); this.visualAnchor = undefined;
780
+ } else {
781
+ let anchor = this.visualAnchor ?? this.getCursor();
782
+ if (command === "v" && !this.vimVisual.active) {
783
+ try {
784
+ const adapter = this.vimAdapter();
785
+ const stops = adapter.motionBoundaries()[anchor.line];
786
+ if (!stops) return;
787
+ if (stops.length > 1 && anchor.col === stops[stops.length - 1]) {
788
+ anchor = { line: anchor.line, col: stops[stops.length - 2]! };
789
+ adapter.move(anchor);
790
+ }
791
+ } catch { return; }
792
+ }
793
+ this.visualAnchor = anchor;
794
+ this.vimVisual.start(command === "v" ? "char" : "line", anchor);
795
+ }
796
+ this.vimOperator.cancel();
797
+ this.vimEngine.reset();
798
+ this.deps.requestRender();
799
+ return;
800
+ }
801
+ let adapter;
802
+ try { adapter = this.vimAdapter(); } catch { return; }
803
+ if (this.vimVisual.active) {
804
+ if (command === "i" || command === "a") { this.visualObjectPending = command; return; }
805
+ if (command === "r") { this.visualReplacePending = true; return; }
806
+ if (command === "o") {
807
+ const target = this.vimVisual.swap(this.getCursor());
808
+ if (target) { this.visualAnchor = this.getCursor(); adapter.move(target); }
809
+ this.deps.requestRender(); return;
810
+ }
811
+ if (["d", "x", "c", "s", "y", "p", "~", "u", "U", ">", "<", "J"].includes(command)) {
812
+ let range;
813
+ try { range = this.vimVisual.range(this.getText(), this.getCursor(), adapter.motionBoundaries()); }
814
+ catch (error) { this.notifyUnsafeVisual("range", error); return; }
815
+ if (!range) { this.notifyUnsafeVisual("range"); return; }
816
+ let stage: "readRange" | "operation" | "beginInsertSession" | "replace" | "move" = "readRange";
817
+ let beganInsert = false;
818
+ try {
819
+ const selected = adapter.readRange(range.start, range.end);
820
+ if (!selected && (!range.linewise || !this.getText())) {
821
+ this.vimVisual.cancel(); this.visualAnchor = undefined;
822
+ this.vimEngine.reset(); this.deps.requestRender();
823
+ return;
824
+ }
825
+ stage = "operation";
826
+ const operation = this.vimOperator.visualEdit(command === "x" ? "d" : command === "s" ? "c" : command, this.getText(), range);
827
+ if (command === "p" && !operation.edit) {
828
+ this.deps.notifyCompatibility?.("Vim register is empty; yank text before visual paste.");
829
+ return;
830
+ }
831
+ if (operation.edit) {
832
+ this.vimOperator.forgetRepeat();
833
+ if (operation.edit.insert) { stage = "beginInsertSession"; adapter.beginInsertSession(); beganInsert = true; }
834
+ if (operation.edit.start.line !== operation.edit.end.line || operation.edit.start.col !== operation.edit.end.col || operation.edit.text) {
835
+ stage = "replace"; adapter.replace(operation.edit.start, operation.edit.end, operation.edit.text);
836
+ }
837
+ stage = "move"; adapter.move(operation.edit.cursor);
838
+ }
839
+ if (["d", "x", "c", "s", "y", "p"].includes(command)) this.vimOperator.visualRegister(range.linewise ? selected + "\n" : selected, range.linewise);
840
+ this.vimVisual.cancel(); this.visualAnchor = undefined;
841
+ this.vimOperator.cancel(); this.vimEngine.reset();
842
+ if (operation.edit?.insert) this.enterVimInsert(true, true, false);
843
+ } catch (error) {
844
+ if (beganInsert) { try { adapter.endInsertSession(); } catch { /* Retain the original failure. */ } }
845
+ this.notifyUnsafeVisual(stage, error);
846
+ }
847
+ this.deps.requestRender(); return;
848
+ }
849
+ }
850
+ if (command === "." && !this.vimVisual.active && !this.vimOperator.isPending) {
851
+ const count = this.vimOperator.takeRepeatCount();
852
+ this.vimEngine.reset();
853
+ for (let i = 0; i < count; i++) {
854
+ const literal = this.vimOperator.repeatInsertCommand;
855
+ const inserted = this.vimOperator.repeatInsertion;
856
+ const destination = literal ? new VimNormalEngine().input(literal, this.getText(), this.getCursor(), adapter.motionBoundaries()) : undefined;
857
+ const change = literal ? undefined : this.vimOperator.repeatAt(this.getText(), this.getCursor(), adapter.motionBoundaries());
858
+ if (!change?.edit && !(destination?.handled && inserted)) break;
859
+ try {
860
+ if (inserted && /[\r\t]/.test(inserted)) break;
861
+ if (change?.edit?.insert) {
862
+ adapter.readRange(change.edit.start, change.edit.end);
863
+ adapter.beginInsertSession();
864
+ }
865
+ if (change?.edit) {
866
+ if (change.edit.text || change.edit.start.line !== change.edit.end.line || change.edit.start.col !== change.edit.end.col)
867
+ adapter.replace(change.edit.start, change.edit.end, change.edit.text);
868
+ adapter.move(change.edit.cursor);
869
+ }
870
+ if (inserted && (change?.edit?.insert || destination)) {
871
+ const at = destination?.cursor ?? change!.edit!.cursor;
872
+ adapter.replace(at, at, inserted);
873
+ }
874
+ if (change?.edit?.insert) adapter.endInsertSession();
875
+ else if (change?.edit) adapter.move(change.edit.cursor);
876
+ } catch { if (change?.edit?.insert) adapter.endInsertSession(); break; /* Unsafe paste marker: stop replay. */ }
877
+ }
878
+ this.deps.requestRender(); return;
879
+ }
880
+ const wasPending = this.vimOperator.isPending;
881
+ const operator = this.vimVisual.active ? null : this.vimOperator.input(command, this.getText(), this.getCursor(), adapter.motionBoundaries());
882
+ if (operator && (wasPending || !/^[0-9]$/.test(command))) {
883
+ if (!wasPending) this.vimEngine.reset();
884
+ if (operator.edit) {
885
+ try {
886
+ if (operator.edit.insert) adapter.beginInsertSession();
887
+ if (operator.edit.text || operator.edit.start.line !== operator.edit.end.line ||
888
+ operator.edit.start.col !== operator.edit.end.col) {
889
+ adapter.replace(operator.edit.start, operator.edit.end, operator.edit.text);
890
+ }
891
+ adapter.move(operator.edit.cursor);
892
+ if (operator.edit.insert) {
893
+ adapter.markInsertAnchor();
894
+ this.vimInsertIntent = { operator };
895
+ } else this.vimOperator.remember(operator);
896
+ if (operator.edit.insert) this.enterVimInsert(true, true, false);
897
+ } catch { if (operator.edit.insert) adapter.endInsertSession(); this.vimOperator.cancel(); }
898
+ }
899
+ this.deps.requestRender();
900
+ return;
901
+ }
902
+ if (command === "u" && !this.vimVisual.active) { adapter.undo(); this.vimEngine.reset(); this.deps.requestRender(); return; }
903
+ const motion = this.vimEngine.input(command, this.getText(), this.getCursor(), adapter.motionBoundaries());
904
+ if (motion.handled) {
905
+ if (this.vimVisual.active && motion.insert !== undefined) return;
906
+ try {
907
+ adapter.move(motion.cursor);
908
+ if (motion.insert !== undefined) {
909
+ this.enterVimInsert();
910
+ this.vimInsertIntent = { command };
911
+ }
912
+ if (motion.insert === "\n") {
913
+ this.insertTextAtCursor("\n");
914
+ if (command === "O") this.vimAdapter().move({ line: motion.cursor.line, col: 0 });
915
+ }
916
+ this.deps.requestRender();
917
+ } catch { this.vimEngine.reset(); /* Unsupported private layout: no modal mutation. */ }
918
+ return;
919
+ }
920
+ }
921
+ // Encoded Return, Tab, IME and paste are editor input, not
922
+ // app actions. Never forward an unowned NORMAL byte to insertion.
923
+ if (this.vimNormal && !this.keybindingsManager.matches(data, "app.interrupt") && !matchesKey(data, "escape")) return;
924
+ }
303
925
  if (
304
926
  this.promptState === PROMPT_STATE.WORKING &&
305
927
  !this.isShowingAutocomplete() &&
@@ -355,6 +977,47 @@ export class GentlePromptEditor extends CustomEditor {
355
977
  super.handleInput(data);
356
978
  }
357
979
 
980
+ /** Buffer a whole paste before any bytes reach Vim's command router. Pi's
981
+ * editor receives the complete frame in INSERT so it retains native paste
982
+ * normalization, marker registration and undo behavior. In NORMAL, discard
983
+ * the entire frame rather than interpreting its contents as keystrokes.
984
+ */
985
+ private handleVimPasteFrame(data: string): boolean {
986
+ if (this.pasteFrame === undefined) {
987
+ const start = data.indexOf(BRACKETED_PASTE_START);
988
+ if (start < 0) return false;
989
+ // A frame is an atomic event: bytes before its marker must not be
990
+ // accidentally interpreted as a Vim command either.
991
+ this.pasteFrame = "";
992
+ this.pasteOverflow = false;
993
+ data = data.slice(start + BRACKETED_PASTE_START.length);
994
+ }
995
+ // Retain only the terminator's possible prefix between events. This
996
+ // avoids rescanning the entire paste for each single-character chunk.
997
+ const chunk = this.pasteTail + data;
998
+ const end = chunk.indexOf(BRACKETED_PASTE_END);
999
+ const content = end < 0 ? chunk.slice(0, -(BRACKETED_PASTE_END.length - 1)) : chunk.slice(0, end);
1000
+ if (!this.pasteOverflow) {
1001
+ if (this.pasteFrame.length + content.length > MAX_BRACKETED_PASTE_CHARS) {
1002
+ this.pasteFrame = "";
1003
+ this.pasteOverflow = true;
1004
+ } else this.pasteFrame += content;
1005
+ }
1006
+ if (end < 0) {
1007
+ this.pasteTail = chunk.slice(-(BRACKETED_PASTE_END.length - 1));
1008
+ return true;
1009
+ }
1010
+ const paste = this.pasteFrame;
1011
+ const overflow = this.pasteOverflow;
1012
+ this.pasteFrame = undefined;
1013
+ this.pasteOverflow = false;
1014
+ this.pasteTail = "";
1015
+ if (!overflow && !this.vimNormal) super.handleInput(BRACKETED_PASTE_START + paste + BRACKETED_PASTE_END);
1016
+ const remaining = chunk.slice(end + BRACKETED_PASTE_END.length);
1017
+ if (remaining) this.handleInput(remaining);
1018
+ return true;
1019
+ }
1020
+
358
1021
  /**
359
1022
  * Runs the Esc that actually aborts the turn. Pi's own onEscape (invoked
360
1023
  * synchronously by `super.handleInput`) restores `queuedText + draft`
@@ -386,24 +1049,76 @@ export class GentlePromptEditor extends CustomEditor {
386
1049
  this.deps.dispatchQueuedText(queued);
387
1050
  }
388
1051
 
1052
+ handleMouse(event: Parameters<CustomEditor["handleMouse"]>[0]): ReturnType<CustomEditor["handleMouse"]> {
1053
+ const layout = this.floatMouseLayout;
1054
+ if (!layout) return super.handleMouse(event);
1055
+ // Row coordinates stay native: padding=0, content=1…N, hint=N+1.
1056
+ // Only float translates x; the existing neon/fallback path is unchanged.
1057
+ if (event.x < layout.prefixWidth || event.x >= layout.width - 1) return undefined;
1058
+ return super.handleMouse({ ...event, x: event.x - layout.prefixWidth, width: layout.nativeWidth });
1059
+ }
1060
+
389
1061
  render(width: number): string[] {
390
- const lines = super.render(Math.max(1, width - 2));
1062
+ const layout = resolvePromptLayout(width, cardStyle(), this.deps.bg);
1063
+ this.floatMouseLayout = layout.background ? layout : undefined;
1064
+ const inner = layout.nativeWidth;
1065
+ const lines = super.render(inner);
391
1066
  if (this.getText() === "" && lines.length === 3) lines[1] = withPromptHint(lines[1], PROMPT_HINT, this.deps.fg);
392
1067
  const state = this.promptState === PROMPT_STATE.WORKING && this.deps.pending() ? PROMPT_STATE.QUEUED : this.promptState;
393
- // The frame keeps the theme's border color rather than pi's thinking-level
394
- // color, so the prompt reads as one panel with the cards around it.
395
- return framePromptLines(lines, width, {
1068
+ // Neon keeps the shell's border role; float follows Pi's live public
1069
+ // borderColor callback so thinking/bash mode accents survive.
1070
+ // Selection is applied only to the owned Pi editor rows, before Gentle
1071
+ // frames them. No second editor factory or private layout mutation.
1072
+ let editorLines = lines;
1073
+ if (this.vimPolicy === "on" && this.vimVisual.active) {
1074
+ try {
1075
+ const adapter = this.vimAdapter();
1076
+ const range = this.vimVisual.range(this.getText(), this.getCursor(), adapter.motionBoundaries());
1077
+ if (range) editorLines = adapter.renderSelection(inner, range.start, range.end, lines);
1078
+ } catch { /* Unknown layout: keep the original rendered prompt. */ }
1079
+ }
1080
+ if (this.vimPolicy !== "on") editorLines = this.selectionEngine.decorateRows(editorLines, inner, 0);
1081
+ // SAFETY: this optional host hint is accepted only as an integer within the
1082
+ // rendered row bounds below; absent/invalid hints fall back to all rows.
1083
+ const visibleCount = (this as unknown as { renderedVisibleLineCount?: number }).renderedVisibleLineCount;
1084
+ const borderEnd = Number.isInteger(visibleCount) && visibleCount! >= 1 && visibleCount! + 2 <= editorLines.length
1085
+ ? visibleCount! + 2 : editorLines.length;
1086
+ const framed = framePromptLines(editorLines.slice(0, borderEnd), width, {
396
1087
  state,
397
1088
  tick: this.tick,
398
- borderColor: (text) => this.deps.fg(PROMPT_FRAME_ROLE, text),
1089
+ borderColor: (text) => layout.background ? this.borderColor(text) : this.deps.fg(PROMPT_FRAME_ROLE, text),
399
1090
  fg: this.deps.fg,
400
1091
  bold: this.deps.bold,
401
- escHint: this.promptState === PROMPT_STATE.WORKING && this.isPendingEscapeCancel()
402
- ? DOUBLE_ESC_CANCEL_HINT
403
- : this.promptState === PROMPT_STATE.IDLE && this.isPendingIdleClear()
404
- ? IDLE_ESC_CLEAR_HINT
405
- : undefined,
406
- });
1092
+ workingLabel: this.promptState === PROMPT_STATE.WORKING ? this.deps.workingLabel?.() : undefined,
1093
+ escHint: [
1094
+ this.vimPolicy === "on" ? (this.vimVisual.active ? (this.vimVisual.kind === "line" ? "VISUAL LINE" : "VISUAL") : this.vimNormal ? "NORMAL" : "INSERT") : undefined,
1095
+ this.promptState === PROMPT_STATE.WORKING && this.isPendingEscapeCancel()
1096
+ ? DOUBLE_ESC_CANCEL_HINT
1097
+ : this.promptState === PROMPT_STATE.IDLE && this.isPendingIdleClear()
1098
+ ? IDLE_ESC_CLEAR_HINT
1099
+ : undefined,
1100
+ ].filter(Boolean).join(" · ") || undefined,
1101
+ }, layout);
1102
+ if (this.vimPolicy !== "on") {
1103
+ framed[framed.length - 1] = layout.background
1104
+ ? floatPromptRow(this.selectionEngine.decorateBottomRule("", layout.nativeWidth, ""), layout, (text) => this.borderColor(text))
1105
+ : this.selectionEngine.decorateBottomRule(framed[framed.length - 1] ?? "", width, "╯");
1106
+ }
1107
+ // Pi places autocomplete after its bottom border. Keep those rows below
1108
+ // Gentle's frame and preserve their terminal width and row coordinates.
1109
+ for (const line of editorLines.slice(borderEnd)) {
1110
+ if (layout.background) {
1111
+ framed.push(floatPromptRow(line, layout, (text) => this.borderColor(text)));
1112
+ continue;
1113
+ }
1114
+ const available = Math.max(0, width);
1115
+ const border = available >= 2 ? "│" : "";
1116
+ const inner = available - border.length * 2;
1117
+ const clipped = truncateToWidth(line, inner, "");
1118
+ framed.push(this.deps.fg(PROMPT_FRAME_ROLE, border) + clipped +
1119
+ " ".repeat(Math.max(0, inner - visibleWidth(clipped))) + this.deps.fg(PROMPT_FRAME_ROLE, border));
1120
+ }
1121
+ return framed;
407
1122
  }
408
1123
 
409
1124
  dispose(): void {
@@ -436,20 +1151,30 @@ type PromptFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorCom
436
1151
  function installPrompt(
437
1152
  ctx: ExtensionContext,
438
1153
  onCreated: (prompt: GentlePromptEditor) => void,
439
- promptDeps: { now: () => number; doubleEscCancelEnabled: () => boolean; dispatchQueuedText: (text: string) => void },
1154
+ promptDeps: { now: () => number; doubleEscCancelEnabled: () => boolean; dispatchQueuedText: (text: string) => void; vimRuntimeVersion?: () => string | undefined },
440
1155
  ): boolean {
441
1156
  const previous = ctx.ui.getEditorComponent() as PromptFactory | undefined;
442
1157
  if (previous && !previous[PROMPT_OWNER]) return false;
443
1158
  const factory: PromptFactory = (tui, theme, keybindings) => {
444
1159
  const prompt = new GentlePromptEditor(tui, theme, keybindings, {
445
1160
  fg: (color, text) => ctx.ui.theme.fg(color as Parameters<typeof ctx.ui.theme.fg>[0], text),
1161
+ bg: (color, text) => ctx.ui.theme.bg(color as Parameters<typeof ctx.ui.theme.bg>[0], text),
446
1162
  bold: (text) => ctx.ui.theme.bold(text),
447
1163
  requestRender: () => tui.requestRender(),
448
1164
  pending: () => ctx.hasPendingMessages(),
449
1165
  now: promptDeps.now,
450
1166
  doubleEscCancelEnabled: promptDeps.doubleEscCancelEnabled,
451
1167
  dispatchQueuedText: promptDeps.dispatchQueuedText,
1168
+ notifyCompatibility: (message) => ctx.ui.notify(message, "warning"),
1169
+ tuiVersion: promptDeps.vimRuntimeVersion,
1170
+ workingLabel: () => oddPhaseRegistry.label(ctx.sessionManager.getSessionId()),
452
1171
  });
1172
+ // Pi's own docs require an explicit requestRender() after a state change
1173
+ // (docs/tui.md: "Call tui.requestRender() after state changes"); no host
1174
+ // re-render is implicitly guaranteed, and the WORKING pulse loop does not
1175
+ // run at all under the "potato" animation policy. Register this session's
1176
+ // redraw so a phase reported by the tool is visible immediately.
1177
+ oddPhaseRegistry.setRenderRequest(ctx.sessionManager.getSessionId(), () => tui.requestRender());
453
1178
  onCreated(prompt);
454
1179
  return prompt;
455
1180
  };
@@ -498,6 +1223,7 @@ function renderDoubleEscCancelReport(
498
1223
  }
499
1224
 
500
1225
  const CHANGES_WIDGET_KEY = "gentle-shell-changes";
1226
+ const HEADER_WIDGET_KEY = "gentle-shell-below-input-header";
501
1227
  const CHANGES_COMMAND_NAME = "gentle:changes";
502
1228
  const CHANGES_SHORTCUT_DEFAULT = "alt+g";
503
1229
  const CHANGES_POLL_DEFAULT_MS = 2000;
@@ -506,7 +1232,7 @@ const COMMANDS_COMMAND_NAME = "gentle:commands";
506
1232
  const OVERLAY_HEIGHT_RATIO = 0.8;
507
1233
  const OVERLAY_MIN_ROWS = 8;
508
1234
 
509
- export function shellGitRunner(cwd: string, env: NodeJS.ProcessEnv = process.env, run: typeof execFile = execFile): GitRunner {
1235
+ export function shellGitRunner(cwd: string, env: NodeJS.ProcessEnv = process.env, run: typeof execFile = execFile, maxBuffer = Infinity): GitRunner {
510
1236
  // Pi exec cannot replace the inherited environment. Use argv directly and
511
1237
  // a complete sanitized environment for discovery, status, and lazy diffs.
512
1238
  const childEnv = worktreeGitEnvironment(env);
@@ -519,8 +1245,10 @@ export function shellGitRunner(cwd: string, env: NodeJS.ProcessEnv = process.env
519
1245
  timeout: GIT_TIMEOUT_MS,
520
1246
  // Pi exec accumulates output without a maxBuffer cap. In particular,
521
1247
  // large porcelain inventories must not become partial successful scans.
522
- maxBuffer: Infinity,
1248
+ // Only a single diff passes a bound, and is told when it hit it.
1249
+ maxBuffer,
523
1250
  }, (error, stdout) => {
1251
+ if ((error as { code?: unknown } | null)?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return resolve({ stdout, code: 1, truncated: true });
524
1252
  resolve({ stdout, code: error ? typeof error.code === "number" ? error.code : 1 : 0 });
525
1253
  });
526
1254
  });
@@ -529,9 +1257,24 @@ export function shellGitRunner(cwd: string, env: NodeJS.ProcessEnv = process.env
529
1257
  export async function loadFileDiff(git: GitRunner, file: ChangedFile): Promise<string> {
530
1258
  const args = file.status === CHANGE_STATUS.UNTRACKED ? ["diff", "--no-index", "--", "/dev/null", file.path] : ["diff", "HEAD", "--", file.path];
531
1259
  const result = await git(args);
1260
+ if (result.truncated || result.stdout.length > DIFF_MAX_BYTES || result.stdout.split("\n", DIFF_MAX_LINES + 2).length > DIFF_MAX_LINES + 1) return DIFF_TOO_LARGE;
532
1261
  return result.code === 0 || result.code === 1 ? result.stdout : "";
533
1262
  }
534
1263
 
1264
+ // Untracked files have no numstat; count their lines without reading past the diff bound.
1265
+ const untrackedLines = (root: string): LineCounter => async (path) => {
1266
+ const target = join(root, path);
1267
+ const info = await lstat(target);
1268
+ if (!info.isFile() || info.size > DIFF_MAX_BYTES) return 0;
1269
+ const text = await readFile(target, "utf8");
1270
+ return text === "" ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
1271
+ };
1272
+ // Same-count edits still change size or mtime, so an open diff reloads.
1273
+ const fileRevision = (root: string): FileRevision => async (path) => {
1274
+ const info = await lstat(join(root, path));
1275
+ return `${info.size}:${info.mtimeMs}`;
1276
+ };
1277
+
535
1278
  export interface ExternalEditorHost {
536
1279
  stop(): void;
537
1280
  start(): void;
@@ -573,49 +1316,47 @@ function changesPollMs(env: NodeJS.ProcessEnv): number {
573
1316
  return positiveMs(env.GENTLE_PI_SHELL_CHANGES_POLL_MS, CHANGES_POLL_DEFAULT_MS);
574
1317
  }
575
1318
 
1319
+ // One bounded window per provider refresh: a credential lookup or fetch that
1320
+ // never answers must not hold the other providers — or the panel opening on
1321
+ // them — past it. Tunable (tests, slow networks); invalid values fall back.
1322
+ const USAGE_FETCH_TIMEOUT_DEFAULT_MS = 10_000;
1323
+ const usageFetchTimeoutMs = (env: NodeJS.ProcessEnv): number => positiveMs(env.GENTLE_PI_SHELL_USAGE_TIMEOUT_MS, USAGE_FETCH_TIMEOUT_DEFAULT_MS);
1324
+
576
1325
  function changesFingerprint(model: ChangesModel): string {
577
- return [model.notice ?? "", ...model.files.map((file) => `${file.path}:${file.status}:${file.added}:${file.deleted}:${file.diffRevision ?? ""}:${file.countsUnavailable ?? ""}`)].join("|");
1326
+ return model.files.map((file) => `${file.path}:${file.status}:${file.added}:${file.deleted}:${file.diffRevision ?? ""}:${file.countsUnavailable ?? ""}`).join("|");
578
1327
  }
579
1328
 
580
1329
  interface OverlayDeps {
581
- loadDiff(root: string, file: ChangedFile): string;
1330
+ loadDiff(root: string, file: ChangedFile): Promise<string>;
582
1331
  worktrees(): WorktreeChanges[];
583
1332
  refresh(): Promise<ChangesModel>;
584
1333
  apply(ctx: ExtensionContext, model: ChangesModel): void;
585
1334
  pollMs: number;
586
- gitForRoot(root: string): GitRunner;
587
1335
  }
588
1336
 
589
- // Refresh only the captured session model. Never read live files here; the
590
- // only Git the overlay touches is each root's HEAD, to label its tree.
1337
+ // The overlay polls each root's Git state while it is open; diffs load lazily.
591
1338
  async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Promise<void> {
592
1339
  let host: ExternalEditorHost | undefined;
593
1340
  let view: WorktreeChangesView | undefined;
594
- // Session evidence knows roots, not branches; label them while the overlay
595
- // is open and repaint when Git answers.
596
- const labels = new RootBranchLabels(deps.gitForRoot, () => {
597
- view?.update(labels.decorate(deps.worktrees()));
598
- host?.requestRender();
599
- });
600
- const worktrees = () => labels.decorate(deps.worktrees());
601
1341
  const refresh = async () => {
602
1342
  const latest = await deps.refresh();
603
- view?.update(worktrees());
1343
+ view?.update(deps.worktrees());
604
1344
  deps.apply(ctx, latest);
605
1345
  };
606
- const poll = setInterval(() => void refresh(), deps.pollMs);
1346
+ const poll = setInterval(() => void refresh().catch(() => {}), deps.pollMs);
607
1347
  poll.unref();
608
1348
  try {
609
1349
  const chosen = await ctx.ui.custom<{ root: string; file: ChangedFile } | null>(
610
1350
  (tui, theme, _keybindings, done) => {
1351
+ const close = withOverlayRepaint(tui, done);
611
1352
  host = tui;
612
- view = new WorktreeChangesView(worktrees(), {
1353
+ view = new WorktreeChangesView(deps.worktrees(), {
613
1354
  theme,
614
1355
  rows: () => Math.max(OVERLAY_MIN_ROWS, Math.floor(tui.terminal.rows * OVERLAY_HEIGHT_RATIO)),
615
- loadDiff: (root, file) => Promise.resolve(deps.loadDiff(root, file)),
616
- onOpen: (root, file) => done({ root, file }),
617
- onRefresh: () => void refresh(),
618
- onClose: () => done(null),
1356
+ loadDiff: deps.loadDiff,
1357
+ onOpen: (root, file) => close({ root, file }),
1358
+ onRefresh: () => void refresh().catch(() => {}),
1359
+ onClose: () => close(null),
619
1360
  requestRender: () => tui.requestRender(),
620
1361
  });
621
1362
  return view;
@@ -648,14 +1389,14 @@ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext, env:
648
1389
  return;
649
1390
  }
650
1391
  const result = await ctx.ui.custom<CommandPaletteResult>(
651
- (tui, theme, _keybindings, done) => new CommandPalette(groups, done, theme, () => Math.max(0, tui.terminal.rows)),
1392
+ (tui, theme, _keybindings, done) => new CommandPalette(groups, withOverlayRepaint(tui, done), theme, () => Math.max(0, tui.terminal.rows)),
652
1393
  { overlay: true, overlayOptions: { anchor: "center", width: "70%", minWidth: 60, maxHeight: "85%" } },
653
1394
  );
654
1395
  if (result?.type === "run") pi.sendUserMessage(`/${result.name}`, { expandPromptTemplates: true });
655
1396
  }
656
1397
 
657
- function showChanges(ctx: ExtensionContext, model: ChangesModel): void {
658
- if (model.files.length === 0) {
1398
+ function showChanges(ctx: ExtensionContext, model: ChangesModel, visible = true, grouped: (width: number) => boolean = () => false): void {
1399
+ if (!visible || model.files.length === 0) {
659
1400
  ctx.ui.setWidget(CHANGES_WIDGET_KEY, undefined);
660
1401
  return;
661
1402
  }
@@ -663,7 +1404,7 @@ function showChanges(ctx: ExtensionContext, model: ChangesModel): void {
663
1404
  CHANGES_WIDGET_KEY,
664
1405
  (tui, theme) => sidebarPart(tui, "changes", {
665
1406
  render(width: number) {
666
- return renderChangesWidget(model, theme, width);
1407
+ return grouped(width) ? [] : renderChangesWidget(model, theme, width);
667
1408
  },
668
1409
  invalidate() {},
669
1410
  }),
@@ -684,6 +1425,7 @@ function messageText(content: string | Array<{ type: string; text?: string }>):
684
1425
 
685
1426
  interface CardComponentOptions {
686
1427
  expanded: boolean;
1428
+ previewRows?: number;
687
1429
  hint?: string;
688
1430
  }
689
1431
 
@@ -707,15 +1449,19 @@ function spaced(component: { render(width: number): string[]; invalidate(): void
707
1449
  };
708
1450
  }
709
1451
 
1452
+ // Same rose identity as the Gentle AI tool cards (lib/gentle-ai-renderer.ts).
1453
+ const GENTLE_AI_GLYPH = "\u{1F339}";
1454
+
710
1455
  export function devBinaryCard(notice: DevBinaryNotice): Card {
711
1456
  if (notice.state === "invalid") {
712
- return { title: "Gentle AI", subtitle: "dev binary override invalid", body: [notice.reason], tone: CARD_TONE.ERROR };
1457
+ return { title: "gentle-ai", subtitle: "dev binary override invalid", body: [notice.reason], tone: CARD_TONE.ERROR, glyph: GENTLE_AI_GLYPH };
713
1458
  }
714
1459
  return {
715
- title: "Gentle AI",
1460
+ title: "gentle-ai",
716
1461
  subtitle: "dev binary override · field-test only",
717
1462
  body: [`${notice.path} · sha256:${notice.sha256.slice(0, SHA_PREFIX_LENGTH)}`],
718
1463
  tone: CARD_TONE.WARNING,
1464
+ glyph: GENTLE_AI_GLYPH,
719
1465
  };
720
1466
  }
721
1467
 
@@ -770,10 +1516,35 @@ async function fetchFromSource(source: UsageSource, apiKey: string | undefined,
770
1516
  }
771
1517
  }
772
1518
 
1519
+ // Changes refreshes started without awaiting (turn end, worktree registration)
1520
+ // are tracked so tests can wait for them instead of guessing a delay.
1521
+ const backgroundChangeRefreshes = new Set<Promise<void>>();
1522
+ function trackChangeRefresh(refresh: Promise<void>): void {
1523
+ const tracked = refresh.catch(() => {}).finally(() => { backgroundChangeRefreshes.delete(tracked); });
1524
+ backgroundChangeRefreshes.add(tracked);
1525
+ }
1526
+
1527
+ /** Resolves once every background Changes refresh has finished, or after `timeoutMs` for one that never settles. */
1528
+ export async function backgroundChangeRefreshesSettled(timeoutMs = 2000): Promise<void> {
1529
+ let timer: ReturnType<typeof setTimeout> | undefined;
1530
+ const deadline = new Promise<void>((resolve) => { timer = setTimeout(resolve, timeoutMs); });
1531
+ const settled = (async () => { while (backgroundChangeRefreshes.size > 0) await Promise.all([...backgroundChangeRefreshes]); })();
1532
+ await Promise.race([settled, deadline]);
1533
+ clearTimeout(timer);
1534
+ }
1535
+
773
1536
  export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<ShellDeps> = {}): void {
774
1537
  installSessionChangeCapture(pi, env, overrides.resolveWorktree ?? resolveSessionWorktree);
775
1538
  if (!shellEnabled(env)) return;
776
- const deps: ShellDeps = { ...defaultShellDeps, activeProfile: createActiveProfileReader(env), ...overrides };
1539
+ const profileReader = createActiveProfileReader(env);
1540
+ const deps: ShellDeps = { ...defaultShellDeps, activeProfile: profileReader, ...overrides };
1541
+ let closeCustomize: (() => void) | undefined;
1542
+ let profilePoll: ReturnType<typeof setInterval> | undefined;
1543
+ const stopProfilePoll = () => {
1544
+ if (profilePoll) clearInterval(profilePoll);
1545
+ profilePoll = undefined;
1546
+ profileReader.reset();
1547
+ };
777
1548
  const usage = new UsageStore();
778
1549
  // Providers gentle-shell has never heard of get a usage source too, when
779
1550
  // the extension that owns them registers one on pi.events; see the
@@ -783,30 +1554,151 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
783
1554
  // The 5-minute rule is per provider: one provider's fetch cannot leave the
784
1555
  // next one waiting for an interval it never used.
785
1556
  const usageFetchedAt = new Map<string, number>();
786
- const refreshUsage = async (ctx: ExtensionContext, force: boolean) => {
787
- const provider = ctx.model?.provider;
788
- if (!provider) return;
789
- const source = usageSources.get(provider);
790
- if (!source && provider !== CODEX_PROVIDER && provider !== NAN_PROVIDER) return;
791
- const now = deps.now();
792
- if (!force && now - (usageFetchedAt.get(provider) ?? 0) < USAGE_REFRESH_MS) return;
793
- usageFetchedAt.set(provider, now);
794
- const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider).catch(() => undefined);
795
- const fetched = source
796
- ? await fetchFromSource(source, apiKey, deps.fetch, deps.now())
797
- : provider === NAN_PROVIDER
798
- ? await fetchNanUsage(apiKey, deps.fetch, deps.now())
799
- : await fetchCodexUsage(apiKey, deps.fetch, deps.now());
800
- if (!fetched) return;
801
- // A registered source can be replaced while its own fetch is still in
802
- // flight; the identity captured above is this call's source, so a stale
803
- // answer that outlives its replacement is discarded instead of
804
- // overwriting whatever the replacement already recorded.
805
- if (source && usageSources.get(provider) !== source) return;
806
- usage.record(fetched);
1557
+ // Actual failures, per provider: a refresh that settled without a snapshot.
1558
+ // In-flight fetches are never in here, so they cannot read as failed; a
1559
+ // successful refresh (or a snapshot otherwise recorded) clears the provider.
1560
+ const usageFailures = new Set<string>();
1561
+ // Monotonic per-provider generation: each dispatched refresh takes the next
1562
+ // number, and only the newest one for a provider may mutate its state. Two
1563
+ // overlapping refreshes of the SAME source (or of builtins) can otherwise
1564
+ // let an older failure settle after a newer success and poison it.
1565
+ const usageGenerations = new Map<string, number>();
1566
+ // NaN accepts a thinking level for DeepSeek V4 Flash, Qwen 3.8 Flash, and MiMo
1567
+ // but manages their depth itself, so the header reports `auto` and the shell says
1568
+ // why once per model instead of repeating it on every level change.
1569
+ const adaptiveReasoningNotices = new Set<string>();
1570
+ const announceAdaptiveReasoning = (ctx: ExtensionContext, model: { provider: string; id: string; name?: string } | undefined) => {
1571
+ if (!ctx.hasUI || !isNanAdaptiveReasoningModel(model)) return;
1572
+ const key = `${model.provider}/${model.id}`;
1573
+ if (adaptiveReasoningNotices.has(key)) return;
1574
+ adaptiveReasoningNotices.add(key);
1575
+ ctx.ui.notify(`${model.name || model.id} chooses its reasoning depth automatically; the selected thinking level is accepted but does not change it.`, "info");
1576
+ };
1577
+ // One spelling of the config home, so the pin resolver, the global profiles
1578
+ // store and the profile reader cannot drift onto two different stores.
1579
+ const usageConfigHome = gentlePiConfigHome(env);
1580
+ const usageFetchTimeout = usageFetchTimeoutMs(env);
1581
+ // The subagent routing in force for this session: a session binding first
1582
+ // (gentle-shell#1064 slice 1), then a repository pin, then the global active
1583
+ // profile. Only the profile's own role entries count; the reserved
1584
+ // orchestrator key is not a route.
1585
+ const activeRoutingModels = (ctx: ExtensionContext): Array<string | undefined> => {
1586
+ const session = readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.modelProfiles;
1587
+ if (session) return [...profileRoleEntries(session).map(([, entry]) => entry.model)];
1588
+ const pin = resolveProfilePin({ cwd: ctx.cwd, configHome: usageConfigHome, resolveWorktree: deps.resolveWorktree });
1589
+ const config = pin ? pin.modelProfiles : undefined;
1590
+ if (config) return [...profileRoleEntries(config).map(([, entry]) => entry.model)];
1591
+ const store = readProfilesFileResult(profilesFilePath(usageConfigHome));
1592
+ const active = store.status === "valid" && store.file.active !== undefined ? store.file.profiles[store.file.active] : undefined;
1593
+ return active ? profileRoleEntries(active).map(([, entry]) => entry.model) : [];
1594
+ };
1595
+ // A bare model id names a provider only when exactly one provider in the
1596
+ // registry carries that id; anything else stays untargeted rather than guessed.
1597
+ const bareModelProvider = (ctx: ExtensionContext, modelId: string): string | undefined => {
1598
+ try {
1599
+ const providers = new Set(ctx.modelRegistry.getAll().filter((model) => model.id === modelId).map((model) => model.provider));
1600
+ return providers.size === 1 ? [...providers][0] : undefined;
1601
+ } catch {
1602
+ return undefined;
1603
+ }
1604
+ };
1605
+ const usageScopeFor = (ctx: ExtensionContext): string[] =>
1606
+ usageScopeProviders(ctx.model?.provider, activeRoutingModels(ctx), (modelId) => bareModelProvider(ctx, modelId));
1607
+ let usageScope: string[] = [];
1608
+ // The scope is Git/filesystem work, so it is resolved once per refresh and
1609
+ // per registration — never per panel render, which only reads the cache.
1610
+ const resolveUsageScope = (ctx: ExtensionContext): string[] => {
1611
+ usageScope = usageScopeFor(ctx);
1612
+ return usageScope;
1613
+ };
1614
+ // One notification per settled provider — snapshot or failure — so the
1615
+ // shell and the open overlay repaint immediately instead of waiting for
1616
+ // the whole refresh, whose slowest member is the bounded window itself.
1617
+ const notifyUsageSettled = () => {
807
1618
  renderHost?.invalidateSidebar?.();
808
1619
  renderHost?.requestRender();
809
1620
  };
1621
+ // One bounded, self-contained refresh per provider. Providers run
1622
+ // concurrently: a stalled credential lookup or fetch for one can never hold
1623
+ // the others — or the overlay opening on them — past its own window.
1624
+ const refreshProvider = async (ctx: ExtensionContext, provider: string, source: UsageSource | undefined, force: boolean, now: number): Promise<boolean> => {
1625
+ if (!source && provider !== CODEX_PROVIDER && provider !== NAN_PROVIDER) return false;
1626
+ if (!force && now - (usageFetchedAt.get(provider) ?? 0) < USAGE_REFRESH_MS) return false;
1627
+ usageFetchedAt.set(provider, now);
1628
+ const generation = (usageGenerations.get(provider) ?? 0) + 1;
1629
+ usageGenerations.set(provider, generation);
1630
+ // The window covers credential resolution and the fetch together. The
1631
+ // abort signal reaches the underlying fetch through the wrapped fetchFn —
1632
+ // built-ins and registered sources alike — so an expired provider is
1633
+ // actually cancelled when its caller honors the signal. The race is the
1634
+ // only path to state mutation: whatever the work resolves after the
1635
+ // window expired is a value nobody reads, so a late answer — success or
1636
+ // failure — cannot mutate anything.
1637
+ const controller = new AbortController();
1638
+ let expire: (() => void) | undefined;
1639
+ const expired = new Promise<"timeout">((resolve) => { expire = () => resolve("timeout"); });
1640
+ const timer = setTimeout(() => {
1641
+ controller.abort();
1642
+ expire?.();
1643
+ }, usageFetchTimeout);
1644
+ // The window's signal composes with whatever the caller already carries —
1645
+ // init.signal, or a Request input's own signal — instead of replacing it:
1646
+ // either side aborting still aborts, exactly like a plain fetch. With no
1647
+ // caller signal the window's signal passes through unchanged.
1648
+ const boundedFetch: typeof fetch = (input, init) => {
1649
+ const callerSignal = init?.signal ?? (typeof Request !== "undefined" && input instanceof Request ? input.signal : undefined);
1650
+ return deps.fetch(input, {
1651
+ ...init,
1652
+ signal: callerSignal ? AbortSignal.any([callerSignal, controller.signal]) : controller.signal,
1653
+ });
1654
+ };
1655
+ const work = (async () => {
1656
+ const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider).catch(() => undefined);
1657
+ // The window may have expired while the credential was resolving: an
1658
+ // aborted provider must not start its fetch at all.
1659
+ if (controller.signal.aborted) return undefined;
1660
+ return source
1661
+ ? fetchFromSource(source, apiKey, boundedFetch, deps.now())
1662
+ : provider === NAN_PROVIDER
1663
+ ? fetchNanUsage(apiKey, boundedFetch, deps.now())
1664
+ : fetchCodexUsage(apiKey, boundedFetch, deps.now());
1665
+ })();
1666
+ // The race's loser still runs to completion in the background: guard it so
1667
+ // its rejection (a foreign source escaping its own catch) can never
1668
+ // surface as unhandled, and clear the timer so nothing dangles.
1669
+ const guarded = work.catch(() => undefined);
1670
+ const settled = await Promise.race([guarded, expired]);
1671
+ clearTimeout(timer);
1672
+ // Discard before any state mutation: a generation mismatch means a newer
1673
+ // refresh for this provider was dispatched and owns the state, and a
1674
+ // replaced source's answer belongs to neither. A late success must not
1675
+ // overwrite the newer snapshot, and a late failure must not mark the
1676
+ // provider failed after its replacement succeeded.
1677
+ if (usageGenerations.get(provider) !== generation) return false;
1678
+ if (source && usageSources.get(provider) !== source) return false;
1679
+ if (settled === "timeout" || !settled) {
1680
+ usageFailures.add(provider);
1681
+ notifyUsageSettled();
1682
+ return false;
1683
+ }
1684
+ usage.record(settled);
1685
+ usageFailures.delete(provider);
1686
+ notifyUsageSettled();
1687
+ return true;
1688
+ };
1689
+ const refreshUsage = async (ctx: ExtensionContext, force: boolean, only?: string) => {
1690
+ const scope = resolveUsageScope(ctx);
1691
+ const now = deps.now();
1692
+ // Concurrent per provider; each settle notifies the shell (and repaints
1693
+ // the open overlay) on its own, so a fast provider never waits for the
1694
+ // slowest one's window. A discarded late answer notifies nothing: the
1695
+ // refresh that owns the state notifies at its own settle.
1696
+ await Promise.all(
1697
+ scope
1698
+ .filter((provider) => only === undefined || provider === only)
1699
+ .map((provider) => refreshProvider(ctx, provider, usageSources.get(provider), force, now)),
1700
+ );
1701
+ };
810
1702
  // Subscribed once, for the life of the extension: a registration can
811
1703
  // arrive before the first session_start (the owning extension's factory
812
1704
  // runs first) or after it (its own session_start fires later, or it
@@ -817,36 +1709,49 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
817
1709
  const source = parseUsageSource(payload);
818
1710
  if (!source) return;
819
1711
  usageSources.register(source);
820
- if (currentContext?.model?.provider === source.provider) void refreshUsage(currentContext, true);
1712
+ if (!currentContext) return;
1713
+ // A registration is interesting when its provider is targeted at all —
1714
+ // the session's own or a subagent route of the active profile — and then
1715
+ // only that provider is forced, never the whole scope.
1716
+ if (resolveUsageScope(currentContext).includes(source.provider)) void refreshUsage(currentContext, true, source.provider);
821
1717
  });
822
1718
  pi.on("after_provider_response", (event) => {
823
1719
  const parsed = parseUsageHeaders(event.headers, deps.now());
824
1720
  if (!parsed) return;
825
1721
  usage.record(parsed);
1722
+ // A snapshot that arrived with a response is evidence the provider is
1723
+ // answering: a failure some earlier refresh recorded no longer stands.
1724
+ usageFailures.delete(parsed.provider);
826
1725
  renderHost?.invalidateSidebar?.();
827
1726
  renderHost?.requestRender();
828
1727
  });
829
1728
  pi.registerMessageRenderer(REVIEW_PREFLIGHT_TYPE, (message, options, theme) => {
830
- const body = messageText(message.content as string | Array<{ type: string; text?: string }>).split("\n");
1729
+ const lines = messageText(message.content as string | Array<{ type: string; text?: string }>).split("\n");
1730
+ const body = options.expanded ? lines : lines.filter((line) => line.trim() !== "");
831
1731
  const hint = keyHint("app.tools.expand", options.expanded ? "collapse" : "expand");
832
- return cardComponent({ title: "Gentle AI", subtitle: "review preflight", body, tone: CARD_TONE.INFO }, theme, { expanded: options.expanded, hint });
1732
+ return cardComponent({ title: "Gentle AI", subtitle: "review preflight", body, tone: CARD_TONE.INFO }, theme, { expanded: options.expanded, previewRows: 3, hint });
833
1733
  });
834
- const openUsage = async (ctx: ExtensionContext) => {
835
- await refreshUsage(ctx, true);
836
- await ctx.ui.custom<null>(
837
- (tui, theme, _keybindings, done) =>
838
- new UsageView(usage, {
1734
+ const openUsage = (ctx: ExtensionContext) =>
1735
+ ctx.ui.custom<null>(
1736
+ (tui, theme, _keybindings, done) => {
1737
+ const view = new UsageView(usage, {
839
1738
  theme,
840
1739
  now: () => deps.now(),
841
1740
  active: () => (ctx.model ? { provider: ctx.model.provider } : undefined),
842
1741
  registry: () => usageSources,
843
1742
  onRefresh: () => refreshUsage(ctx, true),
844
- onClose: () => done(null),
1743
+ onClose: () => withOverlayRepaint(tui, done)(null),
845
1744
  requestRender: () => tui.requestRender(),
846
- }),
1745
+ scope: () => ({ providers: usageScope, failed: new Set(usageFailures) }),
1746
+ });
1747
+ // Open promptly: the panel draws whatever the store already holds
1748
+ // while the forced refresh runs underneath it, and repaints when
1749
+ // the refresh settles — answers, failures, or the bounded timeout.
1750
+ view.refresh();
1751
+ return view;
1752
+ },
847
1753
  { overlay: true, overlayOptions: { width: "70%", minWidth: 60, anchor: "center" } },
848
1754
  );
849
- };
850
1755
  pi.registerCommand(USAGE_COMMAND_NAME, {
851
1756
  description: "Show subscription usage windows for the connected providers. Press r to refetch.",
852
1757
  handler: async (_args, ctx) => openUsage(ctx),
@@ -875,90 +1780,224 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
875
1780
  const doubleEscCancelConfigHome = gentlePiConfigHome(env);
876
1781
  const animationOptions = { gentlePiConfigHome: doubleEscCancelConfigHome };
877
1782
  let animationPolicy = resolveAnimationPolicy(animationOptions).policy;
1783
+ let vimPolicy = resolveVimPolicy(animationOptions).policy;
1784
+ // Conversation cards read the style from a process-wide slot; the saved
1785
+ // preference fills it at startup and again on every session start.
1786
+ const applyCardStyle = () => setCardStyle(resolveCardStyle(animationOptions).style);
1787
+ applyCardStyle();
1788
+ // The Card content preference rides the same kind of slot: the quiet tool
1789
+ // renderers read it live, so the saved level applies from the first render.
1790
+ const applyCardContent = () => setCardContent(resolveCardContent(animationOptions).content);
1791
+ applyCardContent();
1792
+ const reportVim = (ctx: ExtensionContext, result: ReturnType<typeof resolveVimPolicy>) => {
1793
+ const source = result.source === "default" ? "built-in default" : `global file ${result.globalFile}`;
1794
+ const effective = prompt?.effectiveVimPolicy;
1795
+ const state = effective === undefined ? "No active Gentle prompt; preference applies when one starts" :
1796
+ effective !== result.policy ? `Effective prompt: ${effective}; saved preference and prompt differ; ordinary editing remains active` :
1797
+ result.policy === "off" ? "Prompt applies now; ordinary editing active" : "Prompt applies now";
1798
+ ctx.ui.notify(`vim: ${result.policy} (decided by ${source})${result.malformed ? "; malformed or unreadable file, falling back to off" : ""}. ${state}.`, result.malformed || (effective !== undefined && effective !== result.policy) ? "warning" : "info");
1799
+ };
1800
+ let visualSettings = resolveVisualSettings(animationOptions).settings;
1801
+ let sidebarTui: TUI | undefined;
1802
+ let groupedChangesOwner: (width: number) => boolean = () => false;
1803
+ const refreshVisual = () => {
1804
+ visualSettings = resolveVisualSettings(animationOptions).settings;
1805
+ if (currentContext && changes) showChanges(currentContext, changes.model, visualSettings.visibility.changes, (width) => groupedChangesOwner(width));
1806
+ if (sidebarTui?.terminal) sidebarState(sidebarTui).visibility = { todo: visualSettings.visibility.todo };
1807
+ renderHost?.invalidateSidebar?.();
1808
+ renderHost?.requestRender();
1809
+ };
878
1810
  let doubleEscCancelPolicy: DoubleEscCancelPolicy = resolveDoubleEscCancelPolicy({
879
1811
  env,
880
1812
  gentlePiConfigHome: doubleEscCancelConfigHome,
881
1813
  }).policy;
882
- let changes: SessionChanges | undefined;
1814
+ let changes: WorktreeChangesTracker | undefined;
883
1815
  let registry: SessionWorktreeRegistry | undefined;
884
1816
  let currentContext: ExtensionContext | undefined;
1817
+ let review: ReviewSidebarSnapshot | undefined;
1818
+ let runningJobs: RunningJobDisplay[] | undefined;
1819
+ const redrawReview = () => {
1820
+ renderHost?.invalidateSidebar?.();
1821
+ renderHost?.requestRender();
1822
+ };
1823
+ const unsubscribeReview = pi.events.on(REVIEW_SIDEBAR_EVENT, (value) => {
1824
+ const event = value as { sessionId?: unknown; snapshot?: unknown } | undefined;
1825
+ if (!currentContext || event?.sessionId !== currentContext.sessionManager.getSessionId()) return;
1826
+ if (!isReviewSidebarSnapshot(event.snapshot)) return;
1827
+ review = { state: event.snapshot.state, scope: event.snapshot.scope };
1828
+ redrawReview();
1829
+ });
1830
+ const unsubscribeJobs = pi.events.on(JOBS_SIDEBAR_EVENT, (value) => {
1831
+ const event = value as { sessionId?: unknown; snapshot?: unknown } | undefined;
1832
+ if (!currentContext || event?.sessionId !== currentContext.sessionManager.getSessionId()) return;
1833
+ if (!isJobsSidebarSnapshot(event.snapshot)) return;
1834
+ runningJobs = event.snapshot.jobs.map(({ id, label, startedAt }) => ({ id, label, startedAt }));
1835
+ redrawReview();
1836
+ });
1837
+ pi.on("session_tree", () => {
1838
+ review = undefined;
1839
+ redrawReview();
1840
+ });
885
1841
  let shown = "";
886
1842
  const applyChanges = (ctx: ExtensionContext, model: ChangesModel) => {
887
1843
  const fingerprint = changesFingerprint(model);
888
1844
  if (fingerprint === shown) return;
889
1845
  shown = fingerprint;
890
- showChanges(ctx, model);
1846
+ showChanges(ctx, model, visualSettings.visibility.changes, (width) => groupedChangesOwner(width));
1847
+ };
1848
+ const gitForRoot = (root: string) => deps.gitRunner(root);
1849
+ // Changes is the Git state of the session's roots that still exist: read
1850
+ // at turn end, on panel open and by the panel's poll, never at startup.
1851
+ const changesTracker = (ctx: ExtensionContext) => {
1852
+ const sessionId = ctx.sessionManager.getSessionId();
1853
+ const roots = () => {
1854
+ try { return sessionChangeRoots(sessionId, ctx.sessionManager.getEntries()).filter((root) => deps.rootExists(root) && !isHomeOrBroadUserDirectory(root)); }
1855
+ catch { return []; }
1856
+ };
1857
+ return new WorktreeChangesTracker(gitForRoot(ctx.cwd), gitForRoot, untrackedLines, roots, fileRevision);
891
1858
  };
892
1859
  const refreshChanges = async (ctx: ExtensionContext) => {
893
1860
  const tracker = changes;
894
1861
  if (!tracker || !ctx.hasUI || registry?.sessionId !== ctx.sessionManager.getSessionId()) return;
895
- tracker.restore(ctx.sessionManager.getEntries());
896
1862
  const model = await tracker.refresh();
897
1863
  if (changes === tracker) applyChanges(ctx, model);
898
1864
  };
899
- const unsubscribeWorktrees = pi.events.on(SESSION_CHANGE_EVENT, (data) => {
900
- if (!currentContext || !registry || (data as { sessionId?: string } | undefined)?.sessionId !== registry.sessionId) return;
901
- const notice = (data as { notice?: string } | undefined)?.notice;
902
- if (notice) { if (changes) changes.notice = notice; currentContext.ui.notify(notice, "warning"); }
903
- void refreshChanges(currentContext);
904
- });
905
1865
  pi.registerTool({
906
1866
  name: "session_worktree_register",
907
1867
  renderShell: "self",
908
1868
  label: "Register session worktree",
909
- description: "Register a worktree in the same Git clone for session coordination. Registration does not attribute file changes; Changes shows captured write/edit operations only.",
1869
+ description: "Register a worktree in the same Git clone for session coordination. /gentle:changes then also lists that worktree's uncommitted Git changes.",
910
1870
  parameters: { type: "object", required: ["path"], additionalProperties: false, properties: { path: { type: "string", description: "Worktree path to include in this session." } } } as never,
911
1871
  async execute(_id, params, _signal, _onUpdate, ctx) {
912
1872
  if (!registry || registry.sessionId !== ctx.sessionManager.getSessionId()) throw new Error("No active session worktree registry.");
913
1873
  const root = registry.register((params as { path: string }).path, "explicit");
914
- await refreshChanges(ctx);
1874
+ trackChangeRefresh(refreshChanges(ctx));
915
1875
  return { content: [{ type: "text", text: `Registered session worktree: ${root}` }], details: { root, sessionId: registry.sessionId } };
916
1876
  },
917
1877
  });
918
1878
  pi.on("session_start", async (_event, ctx) => {
1879
+ closeCustomize?.();
1880
+ runningJobs = undefined;
1881
+ adaptiveReasoningNotices.clear();
1882
+ // A restored or default session never emits model_select, so the startup model
1883
+ // announces itself here when it manages its own depth.
1884
+ announceAdaptiveReasoning(ctx, ctx.model);
1885
+ if (review) {
1886
+ review = undefined;
1887
+ redrawReview();
1888
+ }
1889
+ stopProfilePoll();
919
1890
  registry?.close();
920
1891
  currentContext = ctx;
921
1892
  changes = undefined;
922
1893
  registry = new SessionWorktreeRegistry(pi, ctx.sessionManager, ctx.cwd, deps.resolveWorktree);
923
1894
  registry.start();
1895
+ applyCardStyle();
1896
+ applyCardContent();
924
1897
  if (!ctx.hasUI) return;
925
- changes = new SessionChanges(ctx.sessionManager.getSessionId(), ctx.sessionManager.getEntries());
1898
+ visualSettings = resolveVisualSettings(animationOptions).settings;
1899
+ if (!overrides.activeProfile) {
1900
+ profileReader.bind(ctx.cwd, deps.resolveWorktree, ctx.sessionManager.getSessionId());
1901
+ const sessionId = ctx.sessionManager.getSessionId();
1902
+ profilePoll = setInterval(() => {
1903
+ if (currentContext !== ctx || ctx.sessionManager.getSessionId() !== sessionId) return;
1904
+ if (!profileReader.refresh()) return;
1905
+ renderHost?.invalidateSidebar?.();
1906
+ renderHost?.requestRender();
1907
+ }, 2_000);
1908
+ profilePoll.unref?.();
1909
+ }
1910
+ changes = changesTracker(ctx);
926
1911
  const tracker = changes;
927
1912
  ctx.ui.setFooter((tui, theme, footerData) => {
1913
+ sidebarTui = tui;
1914
+ if (tui.terminal) sidebarState(tui).visibility = { todo: visualSettings.visibility.todo };
928
1915
  renderHost = { requestRender: () => tui.requestRender(), invalidateSidebar: () => invalidateSidebar(tui) };
929
- const bottom = createShellBarComponent(pi, ctx, renderHost, theme, footerData, () => tracker.model.files.length, () => usage.get(ctx.model?.provider ?? ""));
1916
+ const bottom = createShellBarComponent(pi, ctx, renderHost, theme, footerData, () => tracker.model.files.length, () => usage.get(ctx.model?.provider ?? ""), () => visualSettings);
930
1917
  // The Status card paints live session state that no event re-registers a
931
1918
  // part for: model, effort, context, cost, session name and extension
932
1919
  // statuses. The digest is what keeps the fullscreen memo honest, and it
933
1920
  // rebuilds the model exactly as the narrow bottom bar does every frame.
934
1921
  const footerModel = (): ShellBarModel => ({
935
- ...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile() }),
936
- changes: { files: tracker.model.files.length, added: tracker.model.added, deleted: tracker.model.deleted, notice: tracker.model.notice },
1922
+ ...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile(), jobs: runningJobs }),
1923
+ changes: { files: tracker.model.files.length, added: tracker.model.added, deleted: tracker.model.deleted },
1924
+ review,
937
1925
  });
938
- const part = sidebarPart(tui, "footer", bottom, {
939
- digest: () => JSON.stringify(footerModel()),
940
- render: (width) => renderShellSidebarBar(footerModel(), theme, width),
1926
+ // At narrow fullscreen widths only one status row paints: a top header
1927
+ // suppresses the bottom bar in the layout, and otherwise the bottom bar
1928
+ // takes over the header's data while the below-input header steps aside.
1929
+ const statusOwner = () => narrowStatusOwner({ mode: (tui as TUI & { mode?: string }).mode, columns: tui.terminal?.columns ?? 0, statusPlacement: visualSettings.statusPlacement, headerPlacement: visualSettings.headerPlacement });
1930
+ const statusCardVisible = () => Boolean(tui.terminal && tui.terminal.columns >= SIDEBAR_BREAKPOINT && sidebarState(tui).ownsHost?.() && visualSettings.statusPlacement !== "hidden");
1931
+ const belowFloat = (width: number, statuses: boolean) => (tui as TUI & { mode?: string }).mode === "fullscreen"
1932
+ ? renderShellBelowInputFloat({ ...footerModel(), ...(statuses ? {} : { statuses: [] }), ...(statusCardVisible() ? { jobs: undefined } : {}) }, theme, width, usageShortcutKey, visualSettings, tracker.model)
1933
+ : undefined;
1934
+ // Match sidebarPart's live paint ownership, including an unavailable rail.
1935
+ const footerSuppressed = () => {
1936
+ if (!tui.terminal) return false;
1937
+ const state = sidebarState(tui);
1938
+ return Boolean(state.statusHidden?.() || state.headerOwnsStatus?.() || (state.active && state.ownsHost?.()));
1939
+ };
1940
+ groupedChangesOwner = (width) => belowFloat(width, !footerSuppressed()) !== undefined;
1941
+ const bottomBar = {
1942
+ ...bottom,
1943
+ render: (width: number) => belowFloat(width, true)?.rows ?? (statusOwner() === STATUS_OWNER.BOTTOM ? renderShellBottomOnlyBar(footerModel(), theme, width, usageShortcutKey, visualSettings) : bottom.render(width)),
1944
+ handleMouse(event: Parameters<NonNullable<SidebarRail["handleMouse"]>>[0]) {
1945
+ if (footerSuppressed() || event.type !== "click" || event.button !== "left") return undefined;
1946
+ const chrome = belowFloat(event.width, true);
1947
+ if (!chrome || !shellHeaderUsageHit(chrome, event.x, event.y)) return undefined;
1948
+ void openUsage(ctx);
1949
+ return { handled: true, render: true };
1950
+ },
1951
+ };
1952
+ const part = sidebarPart(tui, "footer", bottomBar, {
1953
+ digest: () => JSON.stringify([footerModel(), tracker.model, visualSettings, cardStyle()]),
1954
+ render: (width) => renderShellSidebarBar(footerModel(), theme, width, visualSettings),
941
1955
  invalidate() {},
942
1956
  });
943
1957
  // The header row carries everything that ticks every frame (model,
944
- // effort, context, cost, usage) plus session identity; it never sees
945
- // extension statuses or the working/thinking state.
946
- const headerBar = (width: number) => renderShellHeaderBar(buildShellHeaderModel(footerModel()), theme, width, usageShortcutKey);
1958
+ // effort, context, cost, usage) plus session identity and the jobs count
1959
+ // when Status is absent. Opaque statuses and working state stay excluded.
1960
+ const headerBar = (width: number) => renderShellHeaderChrome(buildShellHeaderModel(footerModel(), !statusCardVisible() && (statusOwner() === STATUS_OWNER.HEADER || visualSettings.statusPlacement === "hidden")), theme, width, usageShortcutKey, visualSettings);
947
1961
  const disposeHeader = sidebarHeader(tui, {
948
- digest: () => JSON.stringify(buildShellHeaderModel(footerModel())),
949
- render: (width) => [headerBar(width).text, renderShellHeaderRule(theme, width)],
1962
+ digest: () => JSON.stringify([footerModel(), tracker.model, visualSettings, cardStyle()]),
1963
+ render: (width) => headerBar(width).rows,
950
1964
  invalidate() {},
951
1965
  handleMouse(event) {
952
1966
  if (event.type !== "click" || event.button !== "left") return undefined;
953
- if (event.y !== 0) return undefined; // the rule row under the status line is decorative, never clickable
954
- const { usageSpan } = headerBar(event.width);
955
- if (!usageSpan || event.x < usageSpan.start || event.x >= usageSpan.end) return undefined;
1967
+ if (!shellHeaderUsageHit(headerBar(event.width), event.x, event.y)) return undefined;
956
1968
  void openUsage(ctx);
957
1969
  return { handled: true, render: true };
958
1970
  },
959
1971
  });
960
- const uninstall = installSidebar(tui, theme);
961
- return { ...part, dispose() { disposeHeader(); uninstall(); part.dispose(); } };
1972
+ const uninstall = installSidebar(tui, theme, () => visualSettings.statusPlacement, () => visualSettings.headerPlacement, () => visualSettings.density);
1973
+ // The public widget slot follows the editor even when the rail is absent.
1974
+ const belowHeader = () => visualSettings.headerPlacement === "below-input" && (tui as TUI & { mode?: string }).mode === "fullscreen" && statusOwner() !== STATUS_OWNER.BOTTOM;
1975
+ const widgetChrome = (width: number) => belowFloat(width, false) ?? headerBar(width);
1976
+ ctx.ui.setWidget(HEADER_WIDGET_KEY, () => ({
1977
+ render(width: number) {
1978
+ if (belowFloat(width, false)) return footerSuppressed() ? widgetChrome(width).rows : [];
1979
+ return belowHeader() ? headerBar(width).rows : [];
1980
+ },
1981
+ invalidate() {},
1982
+ handleMouse(event) {
1983
+ const grouped = belowFloat(event.width, false);
1984
+ if ((grouped ? !footerSuppressed() : !belowHeader()) || event.type !== "click" || event.button !== "left") return undefined;
1985
+ if (!shellHeaderUsageHit(widgetChrome(event.width), event.x, event.y)) return undefined;
1986
+ void openUsage(ctx);
1987
+ return { handled: true, render: true };
1988
+ },
1989
+ }), { placement: "belowEditor" });
1990
+ return { ...part,
1991
+ render(width: number) {
1992
+ if (visualSettings.statusPlacement === "hidden" && (tui as TUI & { mode?: string }).mode !== "fullscreen" && runningJobs?.length) {
1993
+ return [truncateToWidth(theme.fg("muted", shellJobsCount(runningJobs.length)), width, "…")];
1994
+ }
1995
+ const rows = part.render(width);
1996
+ // Preserve a shrinkable exterior dock row, outside prompt/completion geometry.
1997
+ return rows.length === 0 && (tui as TUI & { mode?: string }).mode === "fullscreen"
1998
+ && resolvePromptLayout(width, cardStyle(), theme.bg?.bind(theme)).background ? [""] : rows;
1999
+ },
2000
+ dispose() { ctx.ui.setWidget(HEADER_WIDGET_KEY, undefined); disposeHeader(); uninstall(); part.dispose(); if (sidebarTui === tui) { sidebarTui = undefined; groupedChangesOwner = () => false; } } };
962
2001
  });
963
2002
  void refreshUsage(ctx, true);
964
2003
  const ownsPrompt = installPrompt(
@@ -967,17 +2006,19 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
967
2006
  prompt?.dispose();
968
2007
  prompt = created;
969
2008
  prompt.setAnimationPolicy(animationPolicy);
2009
+ prompt.setVimPolicy(vimPolicy);
970
2010
  },
971
- { now: () => deps.now(), doubleEscCancelEnabled: () => doubleEscCancelPolicy === "on", dispatchQueuedText: (text) => { pendingQueuedText = pendingQueuedText === undefined ? text : `${pendingQueuedText}\n\n${text}`; } },
2011
+ { now: () => deps.now(), doubleEscCancelEnabled: () => doubleEscCancelPolicy === "on", dispatchQueuedText: (text) => { pendingQueuedText = pendingQueuedText === undefined ? text : `${pendingQueuedText}\n\n${text}`; }, vimRuntimeVersion: deps.vimRuntimeVersion },
972
2012
  );
973
2013
  // Hide native feedback only when our petal replaces it. Native transcript
974
2014
  // thinking blocks remain Pi-owned; this changes only the supported loader UI.
975
- if (ownsPrompt) ctx.ui.setWorkingVisible(false);
2015
+ // Inside Herdr the row stays: its native Pi detection reads it.
2016
+ if (ownsPrompt && !keepNativeWorkingRow(env)) ctx.ui.setWorkingVisible(false);
976
2017
  const notice = deps.devBinary();
977
2018
  ctx.ui.setWidget(
978
2019
  DEV_BINARY_WIDGET_KEY,
979
2020
  notice
980
- ? (_tui, theme) => spaced(cardComponent(devBinaryCard(notice), theme, { expanded: true }))
2021
+ ? (_tui, theme) => spaced(cardComponent(devBinaryCard(notice), theme, { expanded: true, previewRows: 3 }))
981
2022
  : undefined,
982
2023
  );
983
2024
  if (changes !== tracker) return;
@@ -985,6 +2026,19 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
985
2026
  applyChanges(ctx, tracker.model);
986
2027
  });
987
2028
  pi.on("session_shutdown", (_event, ctx) => {
2029
+ closeCustomize?.();
2030
+ if (review) {
2031
+ review = undefined;
2032
+ redrawReview();
2033
+ }
2034
+ // Pi rebuilds the extension runtime after every shutdown (reload, replacement,
2035
+ // fork, quit), so the factory-level subscription never needs to be restored.
2036
+ unsubscribeReview();
2037
+ unsubscribeJobs();
2038
+ runningJobs = undefined;
2039
+ stopProfilePoll();
2040
+ oddPhaseRegistry.clear(ctx.sessionManager.getSessionId());
2041
+ oddPhaseRegistry.clearRenderRequest(ctx.sessionManager.getSessionId());
988
2042
  pendingQueuedText = undefined;
989
2043
  prompt?.dispose();
990
2044
  prompt = undefined;
@@ -996,26 +2050,33 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
996
2050
  registry = undefined;
997
2051
  changes = undefined;
998
2052
  currentContext = undefined;
999
- unsubscribeWorktrees();
1000
2053
  });
1001
2054
  const openChanges = async (ctx: ExtensionContext) => {
1002
2055
  if (!changes) return;
1003
2056
  const tracker = changes;
1004
2057
  const model = await tracker.refresh();
2058
+ if (changes !== tracker) return;
2059
+ applyChanges(ctx, model);
2060
+ // A failed root scan is unknown, never clean: say so whenever one failed.
2061
+ const failed = tracker.failedRoots.length;
2062
+ const roots = tracker.scannedRootCount;
1005
2063
  if (model.files.length === 0) {
1006
- ctx.ui.notify("No captured agent changes. Only successful write/edit operations from this session and its subagents are shown; shell changes are not attributed.", "info");
2064
+ if (failed > 0 && failed === roots) ctx.ui.notify(`Could not read Git status for ${failed} ${failed === 1 ? "root" : "roots"}; uncommitted changes are unknown.`, "error");
2065
+ else if (failed > 0) ctx.ui.notify(`No uncommitted changes in the roots Git could read; Git status failed for ${failed} of ${roots} roots.`, "warning");
2066
+ else ctx.ui.notify("No uncommitted changes in this session's repository or its subagents' worktrees.", "info");
1007
2067
  return;
1008
2068
  }
1009
- await showChangesOverlay(ctx, { loadDiff: (root, file) => tracker.loadDiff(root, file), worktrees: () => tracker.worktrees, refresh: () => tracker.refresh(), apply: applyChanges, pollMs: changesPollMs(env), gitForRoot: (root) => deps.gitRunner(root) });
2069
+ if (failed > 0) ctx.ui.notify(`Git status failed for ${failed} ${failed === 1 ? "root" : "roots"}; ${failed === 1 ? "its" : "their"} changes are not listed.`, "warning");
2070
+ await showChangesOverlay(ctx, { loadDiff: (root, file) => loadFileDiff(deps.gitRunner(root, DIFF_MAX_BYTES), file), worktrees: () => tracker.worktrees, refresh: () => tracker.refresh(), apply: applyChanges, pollMs: changesPollMs(env) });
1010
2071
  };
1011
2072
  pi.registerCommand(CHANGES_COMMAND_NAME, {
1012
- description: "Browse captured write/edit changes from this agent session and its subagents, excluding preexisting and external edits. Shell changes are not attributed. Press o to open $EDITOR.",
2073
+ description: "Browse uncommitted Git changes (working tree vs HEAD, untracked included) in this session's repository and its subagents' worktrees. Press o to open $EDITOR.",
1013
2074
  handler: async (_args, ctx) => openChanges(ctx),
1014
2075
  });
1015
2076
  const shortcut = changesShortcut(env);
1016
2077
  if (shortcut) {
1017
2078
  pi.registerShortcut(shortcut as Parameters<ExtensionAPI["registerShortcut"]>[0], {
1018
- description: "Open captured agent session changes",
2079
+ description: "Open the session's uncommitted Git changes",
1019
2080
  handler: async (ctx) => openChanges(ctx),
1020
2081
  });
1021
2082
  }
@@ -1030,6 +2091,397 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
1030
2091
  handler: async (ctx) => showCommandPalette(pi, ctx, env),
1031
2092
  });
1032
2093
  }
2094
+ pi.registerCommand("gentle:customize", {
2095
+ description: "Configure appearance, audio notifications, Vim editing, session permissions and global Auto/YOLO defaults, and prompt history capture.",
2096
+ handler: async (_args, ctx) => {
2097
+ if (ctx.mode !== "tui" || !ctx.hasUI) {
2098
+ if (ctx.hasUI) ctx.ui.notify("Customization requires an interactive terminal.", "warning");
2099
+ return;
2100
+ }
2101
+ closeCustomize?.();
2102
+ let closed = false;
2103
+ let adapter: YoloUiAdapter | undefined;
2104
+ let unobserve: (() => void) | undefined;
2105
+ let finish: (() => void) | undefined;
2106
+ let customizeView: VisualCustomizeView | undefined;
2107
+ const close = () => {
2108
+ if (closed) return;
2109
+ closed = true;
2110
+ customizeView?.dispose();
2111
+ unobserve?.(); adapter?.dispose(); finish?.();
2112
+ if (closeCustomize === close) closeCustomize = undefined;
2113
+ };
2114
+ closeCustomize = close;
2115
+ const home = { gentlePiConfigHome: doubleEscCancelConfigHome };
2116
+ const rows: CustomizeRow[] = [];
2117
+ const bannerHome = doubleEscCancelConfigHome;
2118
+ let banner = await readBannerConfig(bannerHome).catch(error => { close(); throw error; });
2119
+ if (closed) return;
2120
+ let activeTheme = ctx.ui.theme.name;
2121
+ let requestCustomizeRender: (() => void) | undefined;
2122
+ let category: CustomizeCategory = "Animations";
2123
+ const add = (label: CustomizeRow["label"], notice: string, action: () => void | false | Promise<void | false>, preview?: CustomizeRow["preview"]) => rows.push({ category, label, preview, action: async () => {
2124
+ if (await action() === false) return;
2125
+ ctx.ui.notify(notice, "info");
2126
+ } });
2127
+ for (const policy of ["quality", "performance", "potato"] as const) add(
2128
+ () => `Animations: ${policy}${resolveAnimationPolicy(animationOptions).policy === policy ? " (current)" : ""}`,
2129
+ "Animation saved. Prompt applies now; startup banner applies at next startup.",
2130
+ () => {
2131
+ writeAnimationPolicy(policy, animationOptions);
2132
+ animationPolicy = resolveAnimationPolicy(animationOptions).policy;
2133
+ prompt?.setAnimationPolicy(animationPolicy);
2134
+ },
2135
+ () => ({ title: `${policy} · static animation sample`, sample: policy === "potato" ? "✿ idle → working (no pulse)" : policy === "performance" ? "✿ short pulse → settle (static)" : "✿ gentle wave → settle (static)" }),
2136
+ );
2137
+ category = "Banner";
2138
+ const bannerColors = { pink: [255, 118, 195], cyan: [95, 210, 255], yellow: [255, 210, 95], green: [110, 220, 145] } as const;
2139
+ const bannerPreview = (next: typeof banner) => {
2140
+ const [r, g, b] = bannerColors[next.color];
2141
+ return { title: `Banner · ${next.color} (static)`, sample: `${next.showRose ? `\x1b[38;2;${r};${g};${b}m🌹\x1b[0m` : "·"} ${next.showTextLogo ? "GENTLE SHELL" : "(logo hidden)"}` };
2142
+ };
2143
+ add(() => `Banner rose: ${banner.showRose ? "on" : "off"}`, "Banner saved; applies at next startup.", async () => {
2144
+ const next = { ...await readBannerConfigForEdit(bannerHome) };
2145
+ next.showRose = !next.showRose;
2146
+ await writeBannerConfig(next, bannerHome);
2147
+ banner = next;
2148
+ }, () => bannerPreview({ ...banner, showRose: !banner.showRose }));
2149
+ add(() => `Banner text logo: ${banner.showTextLogo ? "on" : "off"}`, "Banner saved; applies at next startup.", async () => {
2150
+ const next = { ...await readBannerConfigForEdit(bannerHome) };
2151
+ next.showTextLogo = !next.showTextLogo;
2152
+ await writeBannerConfig(next, bannerHome);
2153
+ banner = next;
2154
+ }, () => bannerPreview({ ...banner, showTextLogo: !banner.showTextLogo }));
2155
+ for (const color of BANNER_COLORS) add(
2156
+ () => `Banner color: ${color}${banner.color === color ? " (current)" : ""}`,
2157
+ "Banner saved; applies at next startup.",
2158
+ async () => { const next = { ...await readBannerConfigForEdit(bannerHome), color }; await writeBannerConfig(next, bannerHome); banner = next; },
2159
+ () => bannerPreview({ ...banner, color }),
2160
+ );
2161
+ category = "Themes";
2162
+ try {
2163
+ if (typeof ctx.ui.getAllThemes !== "function" || typeof ctx.ui.getTheme !== "function" || typeof ctx.ui.setTheme !== "function") throw new Error("theme API unavailable");
2164
+ const themes = ctx.ui.getAllThemes();
2165
+ if (!Array.isArray(themes)) throw new Error("invalid theme list");
2166
+ const names = [...new Set(themes.map((item) => item?.name).filter((name): name is string => typeof name === "string" && !!name && !name.includes("/")))];
2167
+ if (names.length === 0) throw new Error("no installed themes");
2168
+ for (const name of names) rows.push({
2169
+ category,
2170
+ label: () => `Theme: ${name}${activeTheme === name ? " (current)" : ""}`,
2171
+ preview: () => {
2172
+ const selected = ctx.ui.getTheme(name);
2173
+ if (selected?.name !== name) throw new Error("Selected theme is unavailable.");
2174
+ const source = selected.sourcePath ?? ctx.ui.getAllThemes().find((item) => item.name === name)?.path;
2175
+ return sourcePalettePreview(name, source);
2176
+ },
2177
+ action: () => {
2178
+ if (!ctx.ui.getTheme(name)) throw new Error(`Theme ${name} is unavailable or invalid.`);
2179
+ const result = ctx.ui.setTheme(name);
2180
+ if (!result.success) throw new Error(result.error ?? `Could not activate theme ${name}.`);
2181
+ activeTheme = name;
2182
+ ctx.ui.notify("Theme applies now and is saved by Pi.", "info");
2183
+ },
2184
+ });
2185
+ } catch {
2186
+ rows.push({ category, label: "Themes unavailable; use Pi /settings", preview: () => ({ title: "Themes unavailable", sample: "Use Pi /settings to select a theme" }), action: () => ctx.ui.notify("Installed themes are unavailable in this session.", "warning") });
2187
+ }
2188
+ const updateVisual = (change: (settings: ReturnType<typeof resolveVisualSettings>["settings"]) => ReturnType<typeof resolveVisualSettings>["settings"]) => {
2189
+ const current = resolveVisualSettings(home);
2190
+ if (current.malformed || current.readError) throw new Error(`Cannot update unreadable or malformed visual settings: ${current.globalFile}`);
2191
+ writeVisualSettings(change(current.settings), home);
2192
+ refreshVisual();
2193
+ pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: doubleEscCancelConfigHome });
2194
+ };
2195
+ const visual = () => resolveVisualSettings(home).settings;
2196
+ const pending = "Preference saved and applied.";
2197
+ const layoutPreview = (settings: ReturnType<typeof visual>) => ({
2198
+ title: `Layout · ${settings.density} (schematic)`,
2199
+ sample: `${settings.headerPlacement === "top" ? "[Header] → [Input]" : "[Input] → [Header]"} ${settings.statusPlacement === "auto" ? "[responsive status]" : settings.statusPlacement === "right" ? "[Right rail, wide]" : settings.statusPlacement === "hidden" ? "[No status bar or rail]" : "[Bottom status]"}`,
2200
+ });
2201
+ category = "Editor";
2202
+ for (const [label, policy] of [["enable", "on"], ["disable", "off"]] as const) rows.push({
2203
+ category,
2204
+ label: () => `Vim: ${label}${resolveVimPolicy(animationOptions).policy === policy ? " (current preference)" : ""}`,
2205
+ preview: () => {
2206
+ const result = resolveVimPolicy(animationOptions);
2207
+ const effective = prompt?.effectiveVimPolicy ?? "no active prompt";
2208
+ return { title: "Vim · global prompt editing", sample: `preference: ${result.policy} · effective: ${effective}${result.malformed ? " · malformed or unreadable file" : result.policy !== effective && effective !== "no active prompt" ? " · preference and prompt differ" : ""}` };
2209
+ },
2210
+ action: () => {
2211
+ const current = resolveVimPolicy(animationOptions);
2212
+ if (current.malformed) throw new Error(`Cannot update malformed or unreadable Vim policy: ${current.globalFile}`);
2213
+ writeVimPolicy(policy, animationOptions);
2214
+ const result = resolveVimPolicy(animationOptions);
2215
+ vimPolicy = result.policy;
2216
+ prompt?.setVimPolicy(vimPolicy);
2217
+ reportVim(ctx, result);
2218
+ },
2219
+ });
2220
+ let yoloDisplay: YoloDisplay = YOLO_DISPLAY.unavailable;
2221
+ let permissionDisplay = "normal", permissionDefault = "normal";
2222
+ let requestYoloRender = () => {};
2223
+ let refreshing: Promise<void> | undefined;
2224
+ let refreshAgain = false;
2225
+ // One in-flight read and one dirty bit coalesce slash/action/revocation
2226
+ // changes. Labels and previews only read the authority-free snapshot.
2227
+ const refreshYolo = (): Promise<void> => {
2228
+ refreshAgain = true;
2229
+ if (refreshing) return refreshing;
2230
+ refreshing = (async () => {
2231
+ while (refreshAgain && !closed) {
2232
+ refreshAgain = false;
2233
+ const snapshot = await adapter?.readSnapshot?.().catch(() => undefined);
2234
+ const next = snapshot?.yolo ?? await adapter?.read().catch(() => YOLO_DISPLAY.unavailable) ?? YOLO_DISPLAY.unavailable;
2235
+ if (closed) return;
2236
+ const nextMode = snapshot?.mode ?? "normal";
2237
+ const nextDefault = snapshot?.defaultMode ?? "normal";
2238
+ if (closed) return;
2239
+ if (refreshAgain) continue;
2240
+ yoloDisplay = next;
2241
+ permissionDisplay = nextMode;
2242
+ permissionDefault = nextDefault;
2243
+ requestYoloRender();
2244
+ }
2245
+ })().finally(() => { refreshing = undefined; });
2246
+ return refreshing;
2247
+ };
2248
+ rows.push({
2249
+ category,
2250
+ label: () => `YOLO: ${yoloDisplay} · session only`,
2251
+ preview: () => ({ title: "YOLO · session permission", sample: "ordinary scoped commits/push/PR · destructive confirmations remain · review consent unchanged · reset on reload" }),
2252
+ action: async () => {
2253
+ if (closed) return;
2254
+ if (!adapter || yoloDisplay === YOLO_DISPLAY.unavailable) {
2255
+ ctx.ui.notify("YOLO is unavailable in this live primary session.", "warning");
2256
+ return;
2257
+ }
2258
+ await adapter.toggle();
2259
+ await refreshYolo();
2260
+ },
2261
+ });
2262
+ for (const mode of ["normal", "auto", "yolo"] as const) rows.push({
2263
+ category,
2264
+ label: () => `Session permissions: ${mode.toUpperCase()}${permissionDisplay === mode ? " (current)" : ""}`,
2265
+ preview: () => ({ title: "Permissions · current session", sample: mode === "auto"
2266
+ ? "independent session-model action checks · extra inference cost · hard safety and native consent remain"
2267
+ : mode === "yolo" ? "ordinary scoped delivery permission · destructive confirmations remain" : "normal checks · overrides an elevated global default" }),
2268
+ action: async () => {
2269
+ if (closed) return;
2270
+ if (!adapter?.setMode || !await adapter.setMode(mode)) ctx.ui.notify("Session mode unchanged — live primary UI unavailable or changed.", "warning");
2271
+ await refreshYolo();
2272
+ },
2273
+ });
2274
+ for (const mode of ["normal", "auto", "yolo"] as const) rows.push({
2275
+ category,
2276
+ label: () => `Default permissions: ${mode.toUpperCase()}${permissionDefault === mode ? " (saved)" : ""}`,
2277
+ preview: () => ({ title: "Permissions · user-global default", sample: `New primary sessions start in ${mode.toUpperCase()}; current session stays unchanged. ${mode === "auto" ? "Additional classifier inference uses the session model; usage is separate." : "Independent safety and consent remain."}` }),
2278
+ action: async () => {
2279
+ if (closed) return;
2280
+ if (!adapter?.saveDefault || !await adapter.saveDefault(mode)) ctx.ui.notify("Default unchanged — trusted UI unavailable or preference could not be saved.", "warning");
2281
+ await refreshYolo();
2282
+ },
2283
+ });
2284
+ category = "History";
2285
+ // The prompt-history extension re-reads this preference per prompt, so a
2286
+ // change applies without restart. An explicit GENTLE_PI_HISTORY_CAPTURE
2287
+ // value wins; the rows say so instead of silently ignoring the choice.
2288
+ const historyCapture = () => resolveHistoryCapture({ env, gentlePiConfigHome: doubleEscCancelConfigHome });
2289
+ for (const [label, policy] of [["enable", "on"], ["disable", "off"]] as const) rows.push({
2290
+ category,
2291
+ label: () => {
2292
+ const result = historyCapture();
2293
+ return `Prompt history capture: ${label}${result.preference === policy && !result.malformed ? " (current)" : ""}${result.envOverride ? " · env override" : ""}`;
2294
+ },
2295
+ preview: () => {
2296
+ const result = historyCapture();
2297
+ const effective = result.enabled ? "on" : "off";
2298
+ return { title: "Prompt history capture · Customize preference", sample: `preference: ${result.preference} · effective: ${effective}${result.malformed ? " · malformed or unreadable file" : ""}${result.envOverride ? " · GENTLE_PI_HISTORY_CAPTURE overrides this preference" : ""}` };
2299
+ },
2300
+ action: () => {
2301
+ const current = historyCapture();
2302
+ if (current.malformed) throw new Error(`Cannot update malformed or unreadable history capture preference: ${current.globalFile}`);
2303
+ writeHistoryCapturePolicy(policy, { gentlePiConfigHome: doubleEscCancelConfigHome });
2304
+ const result = historyCapture();
2305
+ if (result.envOverride) ctx.ui.notify(`Prompt history capture preference saved: ${result.preference}. GENTLE_PI_HISTORY_CAPTURE=${result.envOverride} overrides it; capture stays ${result.envOverride}.`, "warning");
2306
+ else ctx.ui.notify(result.enabled ? "Prompt history capture: on. Applies from the next prompt; stored history is kept." : "Prompt history capture: off. New prompts are not recorded; stored history is kept.", "info");
2307
+ },
2308
+ });
2309
+ rows.push(...buildNotificationRows(ctx));
2310
+ category = "Layout";
2311
+ for (const value of Object.values(STATUS_PLACEMENT)) add(() => `Status placement: ${value}${visual().statusPlacement === value ? " (current)" : ""}`, pending, () => updateVisual((settings) => ({ ...settings, statusPlacement: value })), () => layoutPreview({ ...visual(), statusPlacement: value }));
2312
+ for (const value of Object.values(HEADER_PLACEMENT)) add(() => `Header placement: ${value}${visual().headerPlacement === value ? " (current)" : ""}`, pending, () => updateVisual((settings) => ({ ...settings, headerPlacement: value })), () => layoutPreview({ ...visual(), headerPlacement: value }));
2313
+ for (const value of Object.values(DENSITY)) add(() => `Density: ${value}${visual().density === value ? " (current)" : ""}`, pending, () => updateVisual((settings) => ({ ...settings, density: value })), () => layoutPreview({ ...visual(), density: value }));
2314
+ category = "Cards";
2315
+ // One live style for conversation cards, panels, header/footer and prompt.
2316
+ const cardStylePreview = { [CARD_STYLE.NEON]: "╭─ ✿ read ─╮ outlined chrome + prompt", [CARD_STYLE.FLOAT]: "▎ ✿ read painted chrome + prompt" };
2317
+ for (const style of Object.values(CARD_STYLE)) add(
2318
+ () => {
2319
+ const current = resolveCardStyle(home);
2320
+ return `Card style: ${style}${current.style === style && !current.malformed ? " (current)" : ""}`;
2321
+ },
2322
+ `Card style: ${style}. Conversation cards and shell chrome redraw now.`,
2323
+ () => {
2324
+ writeCardStyle(style, home);
2325
+ setCardStyle(style);
2326
+ renderHost?.invalidateSidebar?.();
2327
+ renderHost?.requestRender();
2328
+ requestCustomizeRender?.();
2329
+ },
2330
+ () => ({ title: `Cards · ${style}`, sample: `${cardStylePreview[style]}${resolveCardStyle(home).malformed ? " · malformed or unreadable file" : ""}` }),
2331
+ );
2332
+ // The content level picks between the quiet tools' result previews
2333
+ // (default) and command-only cards (minimal).
2334
+ const cardContentPreview = {
2335
+ [CARD_CONTENT.DEFAULT]: "≡ read a.ts · result previews + counts · bash rows keep pi's native card",
2336
+ [CARD_CONTENT.MINIMAL]: "≡ read a.ts · command only · ctrl+o expands · bash draws as a Gentle card",
2337
+ };
2338
+ for (const content of Object.values(CARD_CONTENT)) add(
2339
+ () => {
2340
+ const current = resolveCardContent(home);
2341
+ return `Card content: ${content}${current.content === content && !current.malformed ? " (current)" : ""}`;
2342
+ },
2343
+ `Card content: ${content}. Quiet tool cards redraw now; bash rows follow on new calls.`,
2344
+ () => {
2345
+ writeCardContent(content, home);
2346
+ setCardContent(content);
2347
+ renderHost?.requestRender();
2348
+ requestCustomizeRender?.();
2349
+ },
2350
+ () => ({ title: `Cards · content ${content}`, sample: `${cardContentPreview[content]}${resolveCardContent(home).malformed ? " · malformed or unreadable file" : ""}` }),
2351
+ );
2352
+ category = "Sections";
2353
+ for (const key of VISUAL_SECTION_KEYS) add(
2354
+ () => `Section ${key}: ${visual().visibility[key] ? "shown" : "hidden"}`,
2355
+ pending,
2356
+ () => updateVisual((settings) => ({ ...settings, visibility: { ...settings.visibility, [key]: !settings.visibility[key] } })),
2357
+ () => {
2358
+ const visibility = { ...visual().visibility, [key]: !visual().visibility[key] };
2359
+ return { title: `Sections · ${key} ${visibility[key] ? "shown" : "hidden"}`, sample: `[${Object.entries(visibility).filter(([, shown]) => shown).map(([section]) => section).join("] [") || "no optional sections"}]` };
2360
+ },
2361
+ );
2362
+ const catalog = () => listVisualProfiles(home).map(name => getVisualProfile(name, home)!);
2363
+ category = "Profiles";
2364
+ rows.push({ category, label: "Visual profiles (preview, save, apply, delete)", preview: () => {
2365
+ const saved = catalog()[0];
2366
+ return saved ? { title: `${saved.name} · saved profile`, sample: `${saved.visual.headerPlacement === "top" ? "[Header] → [Input]" : "[Input] → [Header]"} [${saved.visual.statusPlacement} status]` } : { title: "No saved profiles", sample: "Enter to save the current appearance" };
2367
+ }, action: () => {
2368
+ catalog();
2369
+ customizeView?.openProfiles();
2370
+ ctx.ui.notify("Profile preview opened; selections do not change preferences.", "info");
2371
+ } });
2372
+ category = "Reset";
2373
+ add("Reset visual, banner and animation defaults", "Defaults saved. Layout and prompt apply now; banner at next startup.", async (): Promise<void | false> => {
2374
+ // Validate the banner before touching any store. The writes below are
2375
+ // independent, not a transaction: report precisely what succeeded.
2376
+ await readBannerConfigForEdit(bannerHome);
2377
+ const changed: string[] = [];
2378
+ try {
2379
+ writeVisualSettings(structuredClone(DEFAULT_VISUAL_SETTINGS), home);
2380
+ refreshVisual();
2381
+ pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: doubleEscCancelConfigHome });
2382
+ changed.push("visual");
2383
+ await writeBannerConfig({ ...DEFAULT_BANNER_CONFIG }, bannerHome);
2384
+ banner = { ...DEFAULT_BANNER_CONFIG };
2385
+ changed.push("banner");
2386
+ writeAnimationPolicy("quality", animationOptions);
2387
+ animationPolicy = resolveAnimationPolicy(animationOptions).policy;
2388
+ prompt?.setAnimationPolicy(animationPolicy);
2389
+ changed.push("animation");
2390
+ } catch (error) {
2391
+ const state = ["visual", "banner", "animation"].map((store) => `${store} ${changed.includes(store) ? "changed" : "unchanged"}`).join(", ");
2392
+ ctx.ui.notify(`Partial reset: ${state}. ${error instanceof Error ? error.message : String(error)}`, "warning");
2393
+ return false;
2394
+ }
2395
+ }, () => ({ title: "Restore visual defaults", sample: "[Header] → [Input] [responsive status] · quality" }));
2396
+ const profiles: ProfileActions = {
2397
+ list: catalog,
2398
+ save: async (name, replace) => {
2399
+ const visual = resolveVisualSettings(home);
2400
+ if (visual.malformed || visual.readError) throw new Error("Visual settings unavailable for snapshot.");
2401
+ const currentBanner = await readBannerConfigForEdit(bannerHome);
2402
+ const currentAnimation = resolveAnimationPolicy(animationOptions);
2403
+ if (currentAnimation.malformed) throw new Error(`Cannot read animation policy: ${currentAnimation.globalFile}`);
2404
+ const themeName = ctx.ui.theme.name;
2405
+ if (!themeName || !ctx.ui.getAllThemes?.().some((item) => item.name === themeName) || !ctx.ui.getTheme?.(themeName)) throw new Error("Active theme is not installed or valid; profile not saved.");
2406
+ saveVisualProfile(name, { themeName, animationPolicy: currentAnimation.policy, banner: currentBanner, visual: visual.settings }, { ...home, replace });
2407
+ ctx.ui.notify(`Visual profile ${name} saved.`, "info");
2408
+ },
2409
+ apply: async (name) => {
2410
+ const profile = getVisualProfile(name, home);
2411
+ if (!profile) throw new Error(`Visual profile ${name} not found.`);
2412
+ const changed: string[] = [];
2413
+ const failures: string[] = [];
2414
+ const step = async (label: string, action: () => void | Promise<void>) => {
2415
+ try { await action(); changed.push(label); } catch (error) { failures.push(`${label}: ${error instanceof Error ? error.message : String(error)}`); }
2416
+ };
2417
+ await step("theme", () => {
2418
+ if (!ctx.ui.getTheme?.(profile.themeName)) throw new Error("Installed theme unavailable.");
2419
+ const result = ctx.ui.setTheme(profile.themeName);
2420
+ if (!result.success) throw new Error(result.error ?? "Theme activation failed.");
2421
+ activeTheme = profile.themeName;
2422
+ });
2423
+ await step("visual", () => {
2424
+ const current = resolveVisualSettings(home);
2425
+ if (current.malformed || current.readError) throw new Error("Visual settings unreadable.");
2426
+ writeVisualSettings(profile.visual, home);
2427
+ refreshVisual();
2428
+ pi.events.emit(VISUAL_SETTINGS_CHANGED, { configHome: doubleEscCancelConfigHome });
2429
+ });
2430
+ await step("banner", async () => { await readBannerConfigForEdit(bannerHome); await writeBannerConfig(profile.banner, bannerHome); banner = profile.banner; });
2431
+ await step("animation", () => { writeAnimationPolicy(profile.animationPolicy, animationOptions); animationPolicy = profile.animationPolicy; prompt?.setAnimationPolicy(animationPolicy); });
2432
+ ctx.ui.notify(failures.length ? `PARTIAL profile apply: ${changed.join(", ") || "none"} changed; ${failures.join("; ")}` : `Visual profile ${name} applied; banner applies at next startup.`, failures.length ? "warning" : "info");
2433
+ },
2434
+ delete: (name) => { deleteVisualProfile(name, home); ctx.ui.notify(`Visual profile ${name} deleted.`, "info"); },
2435
+ reset: () => { resetVisualProfiles(home); ctx.ui.notify("Visual profile catalog cleared; active settings unchanged.", "info"); },
2436
+ };
2437
+ try {
2438
+ adapter = await discoverYoloUiAdapter(pi, ctx);
2439
+ if (closed) { adapter?.dispose(); return; }
2440
+ unobserve = adapter?.observe(() => { void refreshYolo(); });
2441
+ await refreshYolo();
2442
+ if (closed) return;
2443
+ await ctx.ui.custom<null>((tui, theme, _keys, done) => {
2444
+ finish = () => done(null);
2445
+ if (closed) done(null);
2446
+ requestYoloRender = () => { if (!closed) tui.requestRender(); };
2447
+ requestCustomizeRender = requestYoloRender;
2448
+ customizeView = new VisualCustomizeView({ rows, profiles, theme, requestRender: requestYoloRender, rowsAvailable: () => Math.max(0, Math.floor(tui.terminal.rows * 0.85) - 2), onError: (error) => { if (!closed) ctx.ui.notify(`${customizeView?.title() ?? "Customization"}: ${error.message}`, "error"); }, onClose: close });
2449
+ const view = customizeView;
2450
+ // Own interaction lifetime here, leaving the shared view unchanged.
2451
+ return {
2452
+ render: (width) => closed ? [] : view.render(width),
2453
+ handleInput: (data) => { if (!closed) view.handleInput(data); },
2454
+ invalidate: () => { if (!closed) view.invalidate(); },
2455
+ dispose: close,
2456
+ };
2457
+ }, { overlay: true, overlayOptions: { anchor: "center", width: "70%", minWidth: 60, maxHeight: "85%" } });
2458
+ } finally { close(); }
2459
+ },
2460
+ });
2461
+ pi.registerCommand("gentle:vim", {
2462
+ description: "Show or set global Vim prompt editing (status|enable|disable); no argument opens a menu.",
2463
+ handler: async (args, ctx) => {
2464
+ let action = args.trim() || "status";
2465
+ if (!args.trim() && ctx.hasUI && typeof ctx.ui.select === "function") {
2466
+ const selected = await ctx.ui.select(`Gentle Vim (current: ${vimPolicy})`, ["enable", "disable", "status"]);
2467
+ if (selected === undefined) return;
2468
+ action = selected;
2469
+ }
2470
+ if (action !== "status" && action !== "enable" && action !== "disable") {
2471
+ ctx.ui.notify("Use /gentle:vim status|enable|disable.", "warning");
2472
+ return;
2473
+ }
2474
+ try {
2475
+ if (action !== "status") writeVimPolicy(action === "enable" ? "on" : "off", animationOptions);
2476
+ const result = resolveVimPolicy(animationOptions);
2477
+ vimPolicy = result.policy;
2478
+ prompt?.setVimPolicy(vimPolicy);
2479
+ reportVim(ctx, result);
2480
+ } catch (error) {
2481
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
2482
+ }
2483
+ },
2484
+ });
1033
2485
  pi.registerCommand("gentle:animations", {
1034
2486
  description: "Show or set global animations; no argument opens a selectable menu (quality|performance|potato, plus status).",
1035
2487
  // No argument opens a selectable menu when an interactive UI is present;
@@ -1101,15 +2553,39 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
1101
2553
  // below has delivered the pending text. Nothing is sent from here: Pi
1102
2554
  // is mid-turn, so the text simply waits and goes out, once, when that
1103
2555
  // turn settles. It is never dropped.
2556
+ // A new turn always starts unlabeled: any ODD phase reported for the
2557
+ // previous turn must never leak into this one. The new turn's tool
2558
+ // activity, or an explicit orchestrator report, labels it again.
2559
+ oddPhaseRegistry.clear(ctx.sessionManager.getSessionId());
1104
2560
  prompt?.setWorking(true);
1105
2561
  // The dev-binary card is a startup notice: it leaves with the first prompt.
1106
2562
  if (ctx.hasUI) ctx.ui.setWidget(DEV_BINARY_WIDGET_KEY, undefined);
1107
2563
  });
2564
+ pi.on("tool_execution_start", (event, ctx) => {
2565
+ // The working label follows the primary session's own tool activity so
2566
+ // it never depends on the model remembering gentle_odd_phase, which
2567
+ // stays the explicit refinement (its report is already explicit).
2568
+ // Subagents run as headless `pi --mode rpc` children whose env never
2569
+ // carries the interactive-host signal (lib/agents-runner.ts), so they
2570
+ // never infer; their process-local registry could not reach this
2571
+ // label anyway.
2572
+ if (!ctx.hasUI || !isInteractiveMode(ctx.mode) || event.toolName === "gentle_odd_phase") return;
2573
+ const phase = inferOddPhase(event.toolName, event.args);
2574
+ if (phase) {
2575
+ const delegated = event.toolName === "subagent_run" || /^mcp__.+?__subagent_run$/.test(event.toolName);
2576
+ oddPhaseRegistry.infer(ctx.sessionManager.getSessionId(), phase, delegated ? "delegation" : "tool");
2577
+ }
2578
+ });
1108
2579
  pi.on("agent_settled", (_event, ctx) => {
1109
2580
  // Pi clears its own run-active flag before emitting agent_settled, so
1110
2581
  // this is normally idle; if a run is somehow still in flight the prompt
1111
2582
  // stays working and the pending text waits for the next settle.
1112
2583
  if (!ctx.isIdle()) return;
2584
+ // Covers both a normal finish and an aborted turn settling idle: the
2585
+ // input falls back to the generic "working…" label until the next
2586
+ // turn's own agent_start clears it again (belt-and-suspenders with the
2587
+ // clear above).
2588
+ oddPhaseRegistry.clear(ctx.sessionManager.getSessionId());
1113
2589
  prompt?.setWorking(false);
1114
2590
  if (pendingQueuedText === undefined) return;
1115
2591
  const queued = pendingQueuedText;
@@ -1129,7 +2605,14 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
1129
2605
  }
1130
2606
  });
1131
2607
  pi.on("agent_end", async (_event, ctx) => {
1132
- await refreshChanges(ctx);
2608
+ // A slow or failing Git never holds the turn end.
2609
+ trackChangeRefresh(refreshChanges(ctx));
1133
2610
  void refreshUsage(ctx, false);
1134
2611
  });
2612
+ pi.on("model_select", (event, ctx) => {
2613
+ announceAdaptiveReasoning(ctx, event.model);
2614
+ });
2615
+ pi.on("thinking_level_select", (_event, ctx) => {
2616
+ announceAdaptiveReasoning(ctx, ctx.model);
2617
+ });
1135
2618
  }