gentle-pi 4.0.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 (398) hide show
  1. package/README.md +122 -8
  2. package/assets/agents/gentle-ai-explore.md +11 -4
  3. package/assets/agents/gentle-ai-verify.md +23 -4
  4. package/assets/agents/gentle-ai-worker.md +6 -6
  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 +70 -163
  9. package/assets/orchestrator-memory.md +11 -4
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-tracking.md +53 -0
  12. package/assets/orchestrator-verification.md +28 -0
  13. package/assets/orchestrator-writer.md +38 -0
  14. package/assets/orchestrator.md +29 -25
  15. package/assets/sounds/LICENSE.md +52 -0
  16. package/assets/sounds/attention.wav +0 -0
  17. package/assets/sounds/error.wav +0 -0
  18. package/assets/sounds/success.wav +0 -0
  19. package/bin/engram-guard +9 -0
  20. package/bin/gentle-shell-install.mjs +302 -0
  21. package/bin/gentle-shell.mjs +703 -22
  22. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  23. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  24. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  25. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  27. package/docs/bundled-install.md +397 -0
  28. package/docs/delegated-verification.md +3 -5
  29. package/docs/gentle-agents-activity.md +513 -0
  30. package/docs/gentle-shell-uninstall.md +136 -0
  31. package/docs/gentle-shell.md +81 -31
  32. package/docs/install-wizard.md +2040 -0
  33. package/docs/native-authority-architecture.md +1 -1
  34. package/docs/permission-modes.md +82 -0
  35. package/docs/prompt-history.md +1 -1
  36. package/docs/readme-reference.md +267 -40
  37. package/docs/render-cache-performance.md +28 -0
  38. package/docs/sound-notifications-proposal.md +191 -0
  39. package/docs/sound-notifications.md +114 -0
  40. package/docs/yolo-mode.md +9 -9
  41. package/extensions/child-context.ts +11 -2
  42. package/extensions/child-safety.ts +4 -2
  43. package/extensions/codegraph-tools.ts +18 -5
  44. package/extensions/gentle-agents.ts +688 -88
  45. package/extensions/gentle-ai.ts +886 -238
  46. package/extensions/gentle-herdr-activity.ts +81 -0
  47. package/extensions/gentle-notifications.ts +104 -0
  48. package/extensions/gentle-shell.ts +265 -69
  49. package/extensions/gentle-todo.ts +108 -13
  50. package/extensions/history/index.ts +11 -6
  51. package/extensions/pi-pretty.ts +39 -3
  52. package/extensions/quiet-tools.ts +91 -21
  53. package/extensions/skill-registry.ts +175 -46
  54. package/extensions/startup-banner.ts +11 -2
  55. package/lib/agent-home.ts +10 -1
  56. package/lib/agent-model-resolution.ts +80 -0
  57. package/lib/agent-profiles.ts +13 -3
  58. package/lib/agents-completion-delivery.ts +11 -3
  59. package/lib/agents-messaging.ts +1 -1
  60. package/lib/agents-protocol.ts +40 -1
  61. package/lib/agents-runner.ts +90 -15
  62. package/lib/auto-permission-classifier.ts +280 -0
  63. package/lib/auto-permission-runtime.ts +268 -0
  64. package/lib/background-jobs-tools.ts +202 -0
  65. package/lib/background-jobs.ts +253 -0
  66. package/lib/background-monitor.ts +154 -0
  67. package/lib/bounded-writer-admission.ts +83 -10
  68. package/lib/builtin-codemode-optout.ts +166 -0
  69. package/lib/card-content-policy.ts +85 -0
  70. package/lib/child-package-injection.ts +62 -0
  71. package/lib/codemode-renderer.ts +14 -4
  72. package/lib/command-palette-catalog.ts +3 -2
  73. package/lib/context-threshold.ts +26 -0
  74. package/lib/gentle-ai-binary.ts +109 -4
  75. package/lib/gentle-shell-bundled.ts +517 -0
  76. package/lib/gentle-shell-launcher.ts +74 -23
  77. package/lib/gentle-shell-startup-flow.ts +54 -0
  78. package/lib/gentle-shell-startup-update.ts +92 -0
  79. package/lib/gentle-shell-uninstall.ts +840 -0
  80. package/lib/gentle-shell-update-panel.ts +231 -0
  81. package/lib/herdr-activity.ts +183 -0
  82. package/lib/inprocess-reviewer.ts +3 -0
  83. package/lib/jobs-sidebar-state.ts +20 -0
  84. package/lib/jobs-view.ts +161 -0
  85. package/lib/model-price-ratio.ts +83 -0
  86. package/lib/nan-provider.ts +33 -11
  87. package/lib/native-review-cli.ts +194 -73
  88. package/lib/notification-audio-native.ts +162 -0
  89. package/lib/notification-audio-windows.ts +399 -0
  90. package/lib/notification-audio.ts +332 -0
  91. package/lib/notification-customize.ts +241 -0
  92. package/lib/notification-events.ts +113 -0
  93. package/lib/notification-policy.ts +148 -0
  94. package/lib/notification-pulse-client.ts +367 -0
  95. package/lib/notification-pulse-protocol.ts +305 -0
  96. package/lib/notification-pulse-stream.ts +213 -0
  97. package/lib/notification-pulse-worker.ts +66 -0
  98. package/lib/notification-scheduler.ts +173 -0
  99. package/lib/notification-service.ts +202 -0
  100. package/lib/notification-ui.ts +87 -0
  101. package/lib/odd-phase.ts +1 -1
  102. package/lib/orchestrator-alias.ts +20 -0
  103. package/lib/orchestrator-catalog.ts +79 -0
  104. package/lib/orchestrator-consultation.ts +61 -0
  105. package/lib/orchestrator-discovery.ts +49 -0
  106. package/lib/orchestrator-helper-consent.ts +122 -0
  107. package/lib/orchestrator-helper.ts +167 -0
  108. package/lib/orchestrator-message-card.ts +105 -0
  109. package/lib/orchestrator-presence.ts +115 -14
  110. package/lib/orchestrator-scope.ts +80 -0
  111. package/lib/orchestrator-state.ts +107 -0
  112. package/lib/orchestrator-tool-card.ts +280 -0
  113. package/lib/orchestrator-work-search.ts +218 -0
  114. package/lib/orchestrator-work.ts +79 -0
  115. package/lib/permission-mode-policy.ts +59 -0
  116. package/lib/review-candidate-view-owner.ts +8 -6
  117. package/lib/review-candidate-view.ts +3 -2
  118. package/lib/review-compact-contract.ts +2 -2
  119. package/lib/review-host-relay.ts +7 -2
  120. package/lib/review-integration-v2.ts +100 -22
  121. package/lib/review-publication-gate.ts +1 -1
  122. package/lib/review-risk-assessment.ts +77 -51
  123. package/lib/review-sidebar-state.ts +2 -1
  124. package/lib/review-transaction.ts +72 -3
  125. package/lib/runtime-metrics-children.ts +1 -1
  126. package/lib/runtime-metrics-native.ts +3 -2
  127. package/lib/selection-engine.ts +18 -6
  128. package/lib/session-change-capture.ts +5 -4
  129. package/lib/session-changes.ts +27 -22
  130. package/lib/session-profile-binding.ts +83 -0
  131. package/lib/session-profile-freeze.ts +266 -0
  132. package/lib/session-worktree-registry.ts +74 -8
  133. package/lib/shell-bar.ts +41 -11
  134. package/lib/shell-changes-view.ts +40 -13
  135. package/lib/shell-changes.ts +86 -71
  136. package/lib/shell-sidebar-layout.ts +5 -1
  137. package/lib/shell-todo.ts +62 -19
  138. package/lib/shell-usage.ts +4 -3
  139. package/lib/status-timing-diagnostics.ts +201 -0
  140. package/lib/telemetry-trigger.ts +3 -1
  141. package/lib/vim-editor-adapter.ts +7 -3
  142. package/lib/visual-customization-policy.ts +20 -9
  143. package/lib/visual-customize-view.ts +171 -20
  144. package/lib/writer-surfaces.ts +162 -0
  145. package/lib/yolo-session-policy.ts +198 -121
  146. package/package.json +1 -1
  147. package/runtime/child-package-injection.mjs +63 -0
  148. package/runtime/gentle-ai-binary.mjs +109 -4
  149. package/runtime/gentle-shell-bundled.mjs +518 -0
  150. package/runtime/gentle-shell-launcher.mjs +73 -22
  151. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  152. package/runtime/gentle-shell-startup-update.mjs +93 -0
  153. package/runtime/gentle-shell-uninstall.mjs +841 -0
  154. package/runtime/gentle-shell-update-panel.mjs +232 -0
  155. package/runtime/native-review-cli.mjs +191 -70
  156. package/runtime/review-integration-v2.mjs +100 -22
  157. package/runtime/review-risk-assessment.mjs +77 -51
  158. package/runtime/status-timing-diagnostics.mjs +202 -0
  159. package/runtime/telemetry-trigger.mjs +3 -1
  160. package/scripts/bootstrap.cmd +255 -0
  161. package/scripts/bootstrap.sh +168 -0
  162. package/scripts/build-distribution.mjs +157 -0
  163. package/scripts/build-installer-bundles.mjs +136 -0
  164. package/scripts/build-runtime-modules.mjs +7 -0
  165. package/scripts/bundled-install.mjs +1308 -0
  166. package/scripts/bundled-wizard.mjs +399 -0
  167. package/scripts/check-types.mjs +4 -6
  168. package/scripts/gentle-ai-installer.mjs +79 -32
  169. package/scripts/install-gentle-ai.mjs +48 -1
  170. package/scripts/install-tui-mode-setting.mjs +10 -1
  171. package/scripts/install-wizard-preview.mjs +154 -0
  172. package/scripts/installer-downloads.mjs +648 -0
  173. package/scripts/installer-preflight.mjs +387 -0
  174. package/scripts/installer-probes.mjs +564 -0
  175. package/scripts/installer-runner.mjs +1102 -0
  176. package/scripts/installer-server.mjs +881 -0
  177. package/scripts/installer-windows-artifacts.json +15 -0
  178. package/scripts/installer-windows.mjs +752 -0
  179. package/scripts/main-channel.mjs +452 -0
  180. package/scripts/npm-pack-result.mjs +31 -0
  181. package/scripts/render-cache-performance-worker.mjs +205 -0
  182. package/scripts/render-cache-performance.mjs +115 -0
  183. package/scripts/run-test-suite.mjs +22 -8
  184. package/scripts/test-installer-acceptance.sh +161 -0
  185. package/scripts/test-packed-runner.mjs +23 -5
  186. package/scripts/types-baseline.json +2 -92
  187. package/scripts/verify-package-files.mjs +82 -18
  188. package/skills/gentle-ai/SKILL.md +12 -11
  189. package/tests/agent-home.test.ts +56 -3
  190. package/tests/agent-model-resolution.test.ts +113 -0
  191. package/tests/agent-profiles.test.ts +12 -0
  192. package/tests/agent-risk-escalation.test.ts +123 -0
  193. package/tests/agents-completion-delivery.test.ts +17 -0
  194. package/tests/agents-protocol.test.ts +57 -0
  195. package/tests/agents-queries.test.ts +9 -9
  196. package/tests/agents-runner-process.test.ts +31 -0
  197. package/tests/agents-runner.test.ts +89 -4
  198. package/tests/auto-mode-invariants.test.ts +258 -0
  199. package/tests/auto-mode-sdk.test.ts +57 -0
  200. package/tests/auto-mode.test.ts +49 -0
  201. package/tests/auto-permission-classifier.test.ts +283 -0
  202. package/tests/auto-permission-runtime.test.ts +196 -0
  203. package/tests/background-cache-warming.test.ts +2 -1
  204. package/tests/background-jobs.test.ts +227 -0
  205. package/tests/background-monitor.test.ts +195 -0
  206. package/tests/background-subagents-default-mode.test.ts +28 -5
  207. package/tests/bounded-stop-contract.test.ts +85 -0
  208. package/tests/bounded-writer-admission.test.ts +52 -1
  209. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  210. package/tests/bridge-wake-rendering.test.ts +56 -0
  211. package/tests/build-distribution.test.ts +269 -0
  212. package/tests/builtin-codemode-optout.test.ts +242 -0
  213. package/tests/bundled-install.test.ts +1319 -0
  214. package/tests/bundled-wizard.test.ts +505 -0
  215. package/tests/card-content-policy.test.ts +68 -0
  216. package/tests/card-style-policy.test.ts +34 -2
  217. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  218. package/tests/child-package-entrypoints.test.ts +65 -0
  219. package/tests/child-package-injection.test.ts +74 -0
  220. package/tests/child-safety.test.ts +12 -0
  221. package/tests/child-web-package.test.ts +224 -0
  222. package/tests/codegraph-tools.test.ts +35 -0
  223. package/tests/codemode-rendering.test.ts +107 -2
  224. package/tests/command-palette.test.ts +5 -5
  225. package/tests/context-threshold.test.ts +31 -0
  226. package/tests/delegate-for-reason-contract.test.ts +327 -0
  227. package/tests/delegated-key-learnings-contract.test.ts +3 -2
  228. package/tests/devbinary/native-review-parity.devtest.ts +1 -1
  229. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  230. package/tests/engram-safety.test.ts +288 -0
  231. package/tests/generic-agent-tools.test.ts +16 -1
  232. package/tests/gentle-agents.test.ts +1722 -59
  233. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  234. package/tests/gentle-ai-binary.test.ts +19 -2
  235. package/tests/gentle-ai-child-guards.test.ts +202 -0
  236. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  237. package/tests/gentle-ai-installer.test.ts +548 -58
  238. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  239. package/tests/gentle-ai-renderer.test.ts +6 -6
  240. package/tests/gentle-ai.test.ts +999 -31
  241. package/tests/gentle-herdr-activity.test.ts +100 -0
  242. package/tests/gentle-notifications.test.ts +427 -0
  243. package/tests/gentle-shell-bin.test.ts +249 -5
  244. package/tests/gentle-shell-bundled.test.ts +1002 -0
  245. package/tests/gentle-shell-launcher.test.ts +234 -13
  246. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  247. package/tests/gentle-shell-startup-update.test.ts +85 -0
  248. package/tests/gentle-shell-uninstall.test.ts +978 -0
  249. package/tests/gentle-shell-update-panel.test.ts +158 -0
  250. package/tests/gentle-shell.test.ts +541 -96
  251. package/tests/gentle-todo.test.ts +253 -2
  252. package/tests/herdr-activity.test.ts +243 -0
  253. package/tests/history-agent-home.test.ts +81 -0
  254. package/tests/history-child-guard.test.ts +70 -0
  255. package/tests/inprocess-reviewer.test.ts +20 -0
  256. package/tests/install-gentle-ai-failure.test.ts +69 -0
  257. package/tests/install-tui-mode-setting.test.ts +15 -0
  258. package/tests/install-wizard.test.ts +1223 -0
  259. package/tests/installer-bundles.test.ts +122 -0
  260. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  261. package/tests/installer-preflight.test.ts +750 -0
  262. package/tests/installer-probes.test.ts +1106 -0
  263. package/tests/installer-runner.test.ts +2158 -0
  264. package/tests/installer-server.test.ts +1567 -0
  265. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  266. package/tests/jobs-view.test.ts +160 -0
  267. package/tests/main-channel-upgrade.test.ts +389 -0
  268. package/tests/main-channel.test.ts +344 -0
  269. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  270. package/tests/model-price-ratio.test.ts +116 -0
  271. package/tests/model-profiles-preservation.test.ts +153 -0
  272. package/tests/model-routing-package.test.ts +10 -0
  273. package/tests/nan-provider-runtime.test.ts +156 -0
  274. package/tests/nan-provider.test.ts +28 -18
  275. package/tests/native-binary-gate.test.ts +2 -0
  276. package/tests/native-review-capability-contract.test.ts +7 -1
  277. package/tests/native-review-cli.test.ts +236 -1
  278. package/tests/native-review-consent.test.ts +2 -2
  279. package/tests/native-review-parity-runtime.test.ts +1 -1
  280. package/tests/native-review-parity.test.ts +12 -48
  281. package/tests/native-risk-evidence-parity.test.ts +21 -0
  282. package/tests/no-self-pause-contract.test.ts +87 -0
  283. package/tests/notification-audio-native.test.ts +281 -0
  284. package/tests/notification-audio-windows.test.ts +581 -0
  285. package/tests/notification-audio.test.ts +548 -0
  286. package/tests/notification-customize.test.ts +655 -0
  287. package/tests/notification-events.test.ts +115 -0
  288. package/tests/notification-policy.test.ts +201 -0
  289. package/tests/notification-pulse-client.test.ts +356 -0
  290. package/tests/notification-pulse-protocol.test.ts +213 -0
  291. package/tests/notification-pulse-stream.test.ts +320 -0
  292. package/tests/notification-scheduler.test.ts +258 -0
  293. package/tests/notification-ui.test.ts +157 -0
  294. package/tests/npm-pack-result.test.ts +41 -0
  295. package/tests/odd-integration.test.ts +14 -5
  296. package/tests/odd-routing-canonical-ratchet.test.ts +53 -12
  297. package/tests/odd-routing-contract.test.ts +80 -23
  298. package/tests/orchestrator-budget.test.ts +29 -6
  299. package/tests/orchestrator-catalog.test.ts +146 -0
  300. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  301. package/tests/orchestrator-consultation.test.ts +111 -0
  302. package/tests/orchestrator-discovery.test.ts +241 -0
  303. package/tests/orchestrator-helper-consent.test.ts +171 -0
  304. package/tests/orchestrator-helper.test.ts +297 -0
  305. package/tests/orchestrator-message-card.test.ts +190 -0
  306. package/tests/orchestrator-presence.test.ts +23 -0
  307. package/tests/orchestrator-rdd-ownership.test.ts +6 -5
  308. package/tests/orchestrator-scope.test.ts +107 -0
  309. package/tests/orchestrator-state.test.ts +114 -0
  310. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  311. package/tests/orchestrator-tool-card.test.ts +398 -0
  312. package/tests/orchestrator-work-search.test.ts +318 -0
  313. package/tests/orchestrator-work.test.ts +147 -0
  314. package/tests/package-manifest.test.ts +101 -17
  315. package/tests/packed-runner-owned-path.test.ts +35 -1
  316. package/tests/parallel-reason-contract.test.ts +21 -0
  317. package/tests/parallel-writer-admission.test.ts +210 -0
  318. package/tests/permission-mode-policy.test.ts +194 -0
  319. package/tests/persona-single-channel.test.ts +4 -12
  320. package/tests/pi-pretty.test.ts +56 -0
  321. package/tests/provider-defect-handoff.test.ts +4 -3
  322. package/tests/quiet-tool-rendering.test.ts +160 -3
  323. package/tests/rdd-aware-verification-contract.test.ts +29 -41
  324. package/tests/render-cache-performance.test.ts +48 -0
  325. package/tests/review-agent-end-preflight.test.ts +62 -10
  326. package/tests/review-candidate-view.test.ts +101 -12
  327. package/tests/review-contract-prompt.test.ts +33 -4
  328. package/tests/review-controller-native-recovery.test.ts +327 -17
  329. package/tests/review-controller-native-routing.test.ts +255 -1
  330. package/tests/review-controller-workspace-root.test.ts +43 -2
  331. package/tests/review-controller.test.ts +2 -2
  332. package/tests/review-correction-lifecycle.test.ts +2 -1
  333. package/tests/review-gate.test.ts +7 -4
  334. package/tests/review-graph-schema.test.ts +2 -2
  335. package/tests/review-host-relay-routing.test.ts +5 -3
  336. package/tests/review-host-relay.test.ts +49 -2
  337. package/tests/review-integration-v2-forward.test.ts +21 -1
  338. package/tests/review-json-arguments.test.ts +167 -0
  339. package/tests/review-last-event-closure.test.ts +157 -8
  340. package/tests/review-ledger-contract.test.ts +5 -4
  341. package/tests/review-policy-judgment-day.test.ts +1 -1
  342. package/tests/review-policy-ordinary.test.ts +2 -2
  343. package/tests/review-relay-transport-agent.test.ts +66 -1
  344. package/tests/review-risk-assessment.test.ts +109 -33
  345. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  346. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  347. package/tests/review-test-fixtures.ts +7 -1
  348. package/tests/review-transaction.test.ts +18 -1
  349. package/tests/run-test-suite.test.ts +83 -5
  350. package/tests/runtime-harness.mjs +42 -15
  351. package/tests/runtime-metrics-extension.test.ts +1 -1
  352. package/tests/runtime-metrics-native.test.ts +31 -1
  353. package/tests/selection-engine.test.ts +30 -0
  354. package/tests/session-change-capture.test.ts +10 -10
  355. package/tests/session-changes-shell.test.ts +242 -48
  356. package/tests/session-changes.test.ts +17 -24
  357. package/tests/session-profile-binding.test.ts +163 -0
  358. package/tests/session-profile-freeze.test.ts +381 -0
  359. package/tests/session-subject-routing-contract.test.ts +34 -0
  360. package/tests/session-worktree-registry.test.ts +57 -2
  361. package/tests/shell-bar.test.ts +40 -0
  362. package/tests/shell-changes-view.test.ts +85 -34
  363. package/tests/shell-changes.test.ts +61 -62
  364. package/tests/shell-render-cache-runtime.test.ts +69 -0
  365. package/tests/shell-render-cache.test.ts +129 -0
  366. package/tests/shell-sidebar-layout.test.ts +27 -2
  367. package/tests/shell-todo.test.ts +120 -2
  368. package/tests/shell-usage-view.test.ts +1 -1
  369. package/tests/shell-usage.test.ts +35 -0
  370. package/tests/short-check-routing-contract.test.ts +49 -0
  371. package/tests/skill-registry-watcher.test.ts +119 -0
  372. package/tests/skill-registry.test.ts +311 -5
  373. package/tests/startup-banner.test.ts +117 -3
  374. package/tests/startup-home-repo.test.ts +170 -0
  375. package/tests/status-timing-diagnostics.test.ts +283 -0
  376. package/tests/support/orchestrator-modules.ts +19 -0
  377. package/tests/task-size-routing-contract.test.ts +241 -0
  378. package/tests/telemetry-trigger.test.ts +27 -1
  379. package/tests/test-strength-contract.test.ts +124 -0
  380. package/tests/verify-agent-contract.test.ts +90 -0
  381. package/tests/verify-first-launch-contract.test.ts +139 -0
  382. package/tests/verify-package-files.test.ts +56 -0
  383. package/tests/vim-editor-adapter.test.ts +7 -7
  384. package/tests/visual-customization-policy.test.ts +32 -3
  385. package/tests/visual-customize-view.test.ts +288 -10
  386. package/tests/writer-edit-surface-scope.test.ts +59 -2
  387. package/tests/writer-surfaces.test.ts +199 -0
  388. package/tests/yolo-customize.test.ts +18 -3
  389. package/tests/yolo-mode-runtime.test.ts +37 -10
  390. package/tests/yolo-mode.test.ts +32 -4
  391. package/tests/yolo-session-policy.test.ts +1 -1
  392. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  393. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  394. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  395. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  396. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  397. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  398. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
package/lib/shell-bar.ts CHANGED
@@ -6,6 +6,8 @@ import { CARD_TONE, floatRows, panelInnerWidth, renderCard } from "./shell-card.
6
6
  import { REVIEW_SCOPE_UNAVAILABLE, REVIEW_SIDEBAR_LABELS, type ReviewSidebarSnapshot } from "./review-sidebar-state.ts";
7
7
  import type { VisualSettings } from "./visual-customization-policy.ts";
8
8
  import { renderChangesWidget, type ChangesModel } from "./shell-changes.ts";
9
+ import type { RunningJobDisplay } from "./jobs-sidebar-state.ts";
10
+ import { JOB_GLYPH } from "./background-jobs-tools.ts";
9
11
 
10
12
  type Presentation = Pick<VisualSettings, "density" | "visibility">;
11
13
  type HeaderPresentation = Presentation & Partial<Pick<VisualSettings, "headerPlacement" | "statusPlacement">>;
@@ -18,6 +20,7 @@ export { gaugeTone, renderGauge, type GaugeTone };
18
20
 
19
21
  export interface ShellBarModel {
20
22
  review?: ReviewSidebarSnapshot;
23
+ jobs?: RunningJobDisplay[];
21
24
  profile?: string;
22
25
  changes?: { files: number; added: number; deleted: number; notice?: string };
23
26
  cwd: string;
@@ -35,11 +38,10 @@ export interface ShellBarModel {
35
38
  }
36
39
 
37
40
  // The live header row above the fullscreen rail: session identity plus the
38
- // two counters that tick every frame (context, cost). Deliberately narrower
39
- // than ShellBarModel — extension statuses and the working/thinking state
40
- // never reach the header, so there is nothing on this type for them to leak
41
- // through.
41
+ // live counters. Only the structured jobs count may join context/cost when
42
+ // Status is absent; opaque extension statuses and working state stay excluded.
42
43
  export interface ShellHeaderModel {
44
+ jobs?: number;
43
45
  cwd: string;
44
46
  branch: string | null;
45
47
  dirty: number | undefined;
@@ -55,9 +57,14 @@ export interface ShellHeaderModel {
55
57
  usage: ProviderUsage | undefined;
56
58
  }
57
59
 
58
- export function buildShellHeaderModel(model: ShellBarModel): ShellHeaderModel {
60
+ export function buildShellHeaderModel(model: ShellBarModel, showJobs = false): ShellHeaderModel {
59
61
  const { cwd, branch, dirty, modelId, effort, profile, contextPercent, costTotal, subscription, usage } = model;
60
- return { cwd, branch, dirty, modelId, effort, profile, contextPercent, costTotal, subscription, usage };
62
+ return { cwd, branch, dirty, modelId, effort, profile, contextPercent, costTotal, subscription, usage,
63
+ ...(showJobs && model.jobs?.length ? { jobs: model.jobs.length } : {}) };
64
+ }
65
+
66
+ export function shellJobsCount(count: number): string {
67
+ return `${JOB_GLYPH} ${count} job${count === 1 ? "" : "s"}`;
61
68
  }
62
69
 
63
70
  /** A column span (`[start, end)`, in the rendered line's visible columns) a click must land in to hit the usage segment. */
@@ -110,6 +117,13 @@ export function shellEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
110
117
  return !(value === "0" || value === "false" || value === "off");
111
118
  }
112
119
 
120
+ // Herdr's native Pi detection matches Pi's standard loader row ("⠋ Working...").
121
+ // Inside a Herdr pane keep that row, which Pi shows only during real activity,
122
+ // so detection works without a local manifest override.
123
+ export function keepNativeWorkingRow(env: NodeJS.ProcessEnv = process.env): boolean {
124
+ return env.HERDR_ENV === "1" && env.GENTLE_PI_AGENTS_CHILD !== "1";
125
+ }
126
+
113
127
  export function formatTokens(count: number): string {
114
128
  if (count < 1000) return count.toString();
115
129
  if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
@@ -166,6 +180,7 @@ function buildSegments(model: ShellBarModel, theme: ShellBarTheme, presentation?
166
180
  location,
167
181
  ...(presentation?.visibility.modelDetails === false ? [] : [modelSegment]),
168
182
  ...(presentation?.visibility.usageCost === false ? [] : [context, cost, ...(usage ? [usage] : [])]),
183
+ ...(model.jobs?.length ? [theme.fg(ROLE.STATUS, shellJobsCount(model.jobs.length))] : []),
169
184
  ...statuses,
170
185
  ];
171
186
  }
@@ -237,6 +252,10 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
237
252
  ...(model.review.scope !== REVIEW_SCOPE_UNAVAILABLE ? [label(sanitizeStatus(model.review.scope))] : []),
238
253
  ],
239
254
  }] : []),
255
+ ...(model.jobs?.length ? [{
256
+ title: "Jobs",
257
+ lines: [...model.jobs].sort((a, b) => b.startedAt - a.startedAt).map((job) => value(sanitizeStatus(job.label))),
258
+ }] : []),
240
259
  { title: "Integrations", lines: model.statuses.length
241
260
  ? model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)))
242
261
  : [label("No status reported")] },
@@ -360,7 +379,11 @@ export function shellHeaderUsageHit(chrome: ShellHeaderChrome, x: number, y: num
360
379
 
361
380
  function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: Presentation): ShellHeaderResult {
362
381
  const targetWidth = Math.max(0, Math.floor(width));
363
- const ctxCost = joinSegments([contextSegment(model.contextPercent, theme), costSegment(model.costTotal, model.subscription, theme)], theme);
382
+ const jobs = model.jobs ? theme.fg(ROLE.STATUS, shellJobsCount(model.jobs)) : undefined;
383
+ const ctxCost = joinSegments([
384
+ ...(jobs ? [jobs] : []),
385
+ ...(presentation?.visibility.usageCost === false ? [] : [contextSegment(model.contextPercent, theme), costSegment(model.costTotal, model.subscription, theme)]),
386
+ ], theme);
364
387
  const windows = model.usage ? (selectUsageLimit(model.usage, model.modelId)?.windows ?? []) : [];
365
388
  const leftStages = headerLeftStages(model, theme, presentation?.visibility.modelDetails !== false)
366
389
  .map((stage) => presentation?.density === "minimal" ? stage.slice(1) : stage);
@@ -381,7 +404,7 @@ function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: num
381
404
  ];
382
405
  for (const { leftIndex, usageStage } of attempts) {
383
406
  const usageText = usageStage ? usageSegmentText(windows, theme, usageStage, usageHint) : undefined;
384
- const right = presentation?.visibility.usageCost === false ? "" : usageText ? joinSegments([ctxCost, usageText], theme) : ctxCost;
407
+ const right = usageText ? joinSegments([ctxCost, usageText], theme) : ctxCost;
385
408
  const left = joinSegments(leftStages[leftIndex]!, theme);
386
409
  if (visibleWidth(left) + (right ? RIGHT_PADDING : 0) + visibleWidth(right) > targetWidth) continue;
387
410
  const text = left + " ".repeat(targetWidth - visibleWidth(left) - visibleWidth(right)) + right;
@@ -390,6 +413,10 @@ function headerContent(model: ShellHeaderModel, theme: ShellBarTheme, width: num
390
413
  return { text, usageSpan: { start: usageStart, end: usageStart + visibleWidth(usageText) } };
391
414
  }
392
415
  const brand = presentation?.density === "minimal" ? "" : theme.fg(ROLE.BRAND, theme.bold(HEADER_BRAND));
416
+ if (jobs && visibleWidth(jobs) <= targetWidth) {
417
+ const left = visibleWidth(brand) + RIGHT_PADDING + visibleWidth(jobs) <= targetWidth ? brand : "";
418
+ return { text: left + " ".repeat(targetWidth - visibleWidth(left) - visibleWidth(jobs)) + jobs };
419
+ }
393
420
  return { text: visibleWidth(brand) <= targetWidth ? brand : "" };
394
421
  }
395
422
 
@@ -399,11 +426,14 @@ export function renderShellBelowInputFloat(model: ShellBarModel, theme: ShellBar
399
426
  if (presentation?.headerPlacement !== "below-input") return undefined;
400
427
  const targetWidth = Math.max(0, Math.floor(width));
401
428
  if (floatHeaderPaint(theme, targetWidth, () => "") === undefined) return undefined;
402
- const chrome = renderShellHeaderChrome(buildShellHeaderModel(model), theme, targetWidth, usageHint, presentation);
429
+ const chrome = renderShellHeaderChrome(buildShellHeaderModel(model, presentation.statusPlacement === "hidden"), theme, targetWidth, usageHint, presentation);
403
430
  const changesRow = changes?.files.length && presentation.visibility.changes !== false
404
431
  ? floatHeaderRow(theme, targetWidth, (inner) => renderChangesWidget(changes, theme, inner)[0] ?? "")
405
432
  : undefined;
406
- const statuses = presentation.statusPlacement === "hidden" ? [] : model.statuses.map(sanitizeStatus).filter(Boolean);
433
+ const statuses = presentation.statusPlacement === "hidden" ? [] : [
434
+ ...(model.jobs?.length ? [shellJobsCount(model.jobs.length)] : []),
435
+ ...model.statuses.map(sanitizeStatus).filter(Boolean),
436
+ ];
407
437
  const statusRow = statuses.length
408
438
  ? floatHeaderRow(theme, targetWidth, () => joinSegments(statuses.map((status) => theme.fg(ROLE.STATUS, status)), theme))
409
439
  : undefined;
@@ -429,7 +459,7 @@ export function renderShellBelowInputFloat(model: ShellBarModel, theme: ShellBar
429
459
  export function renderShellBottomOnlyBar(model: ShellBarModel, theme: ShellBarTheme, width: number, usageHint?: string, presentation?: HeaderPresentation, changes?: ChangesModel): string[] {
430
460
  const grouped = renderShellBelowInputFloat(model, theme, width, usageHint, presentation, changes);
431
461
  if (grouped) return grouped.rows;
432
- const headerModel = buildShellHeaderModel(model);
462
+ const headerModel = buildShellHeaderModel(model, true);
433
463
  // Neon, missing backgrounds and sub-minimum widths keep the old row count.
434
464
  const rows = [headerContent(headerModel, theme, width, usageHint, presentation).text];
435
465
  const statuses = model.statuses.map(sanitizeStatus).filter((status) => status.length > 0).map((status) => theme.fg(ROLE.STATUS, status));
@@ -1,7 +1,7 @@
1
1
  import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
2
2
  import { sanitizeTerminalText } from "./terminal-theme.ts";
3
- import { basename } from "node:path";
4
- import { CHANGE_STATUS, changesSummary, type ChangedFile, type ChangesModel, type WorktreeChanges } from "./shell-changes.ts";
3
+ import { basename, posix } from "node:path";
4
+ import { CHANGE_STATUS, DIFF_TOO_LARGE, changesSummary, type ChangedFile, type ChangesModel, type WorktreeChanges } from "./shell-changes.ts";
5
5
  import { paintHoverable } from "./shell-hover.ts";
6
6
 
7
7
  // Gentle Shell changes overlay: a framed two-pane view with the working
@@ -37,7 +37,7 @@ const ROLE = {
37
37
  } as const;
38
38
 
39
39
  const HEADER_PREFIXES = ["diff --git", "index ", "--- ", "+++ ", "new file mode", "deleted file mode", "similarity index", "rename from", "rename to", "Binary files"];
40
- const LIST_MAX_WIDTH = 36;
40
+ const LIST_MAX_WIDTH = 48;
41
41
  const LIST_RATIO = 0.35;
42
42
  const CHROME_ROWS = 3;
43
43
  const MIN_BODY_ROWS = 1;
@@ -80,8 +80,32 @@ function fileCounts(file: ChangedFile, theme: ChangesViewTheme): string {
80
80
  return `${theme.fg(ROLE.ADDED, `+${file.added}`)} ${theme.fg(ROLE.REMOVED, `-${file.deleted}`)}`;
81
81
  }
82
82
 
83
+ // The name leads and the directory trails, so a narrow list clips the
84
+ // directory instead of the part that identifies the file.
83
85
  function fileLabel(file: ChangedFile, theme: ChangesViewTheme, hovered: boolean, idleRole: string): string {
84
- return `${paintHoverable(theme, `${FILE_STATUS[file.status]} ${displayText(file.path)}`, hovered, idleRole)} ${fileCounts(file, theme)}`;
86
+ const directory = posix.dirname(file.path.replace(/\\/g, "/"));
87
+ const label = `${paintHoverable(theme, `${FILE_STATUS[file.status]} ${displayText(posix.basename(file.path.replace(/\\/g, "/")))}`, hovered, idleRole)} ${fileCounts(file, theme)}`;
88
+ return directory === "." ? label : `${label} ${theme.fg(ROLE.EMPTY, displayText(directory))}`;
89
+ }
90
+
91
+ // The diff pane's first row: the selected file's full path, clipped from the
92
+ // left so the file name always stays visible.
93
+ const PATH_HEADER_ROWS = 1;
94
+
95
+ function pathHeader(file: ChangedFile, theme: ChangesViewTheme, width: number): string {
96
+ const status = `${FILE_STATUS[file.status]} `;
97
+ let path = displayText(file.path);
98
+ const room = Math.max(1, width - visibleWidth(status));
99
+ if (visibleWidth(path) > room) {
100
+ const characters = [...path];
101
+ while (characters.length > 0 && visibleWidth(`…${characters.join("")}`) > room) characters.shift();
102
+ path = `…${characters.join("")}`;
103
+ }
104
+ return theme.fg(ROLE.SELECTED, status + path);
105
+ }
106
+
107
+ function diffRows(bodyRows: number): number {
108
+ return Math.max(MIN_BODY_ROWS, bodyRows - PATH_HEADER_ROWS);
85
109
  }
86
110
 
87
111
  function fit(text: string, width: number): string {
@@ -90,10 +114,11 @@ function fit(text: string, width: number): string {
90
114
  }
91
115
 
92
116
  // Both standalone files and the worktree accordion use the original frame.
93
- function renderPanes(width: number, rows: number, theme: ChangesViewTheme, title: string, leftLine: (row: number) => string, rightLines: string[], keys: string): string[] {
117
+ function renderPanes(width: number, rows: number, theme: ChangesViewTheme, title: string, leftLine: (row: number) => string, diffLines: string[], keys: string, headerFile?: ChangedFile): string[] {
94
118
  const inner = Math.max(8, width - 2);
95
119
  const listWidth = Math.min(LIST_MAX_WIDTH, Math.floor(inner * LIST_RATIO));
96
120
  const diffWidth = inner - listWidth - 4;
121
+ const rightLines = headerFile ? [pathHeader(headerFile, theme, diffWidth), ...diffLines] : diffLines;
97
122
  const titleText = truncateToWidth(title, inner - 4, "…");
98
123
  const top = theme.fg(ROLE.FRAME, "╭─ ") + theme.fg(ROLE.TITLE, titleText) + theme.fg(ROLE.FRAME, ` ${rule(inner - visibleWidth(titleText) - 3)}╮`);
99
124
  const body: string[] = [];
@@ -292,7 +317,7 @@ export class WorktreeChangesView {
292
317
  };
293
318
  const keys = theme.fg(ROLE.KEY_TEXT, "j/k select enter toggle/open ← parent/fold ctrl+j/k scroll r refresh esc close");
294
319
  this.pointerLayout = pointerLayout(width, height);
295
- return renderPanes(width, height, theme, `✎ Changes · ${this.trees.length} worktrees`, leftLine, preview, keys);
320
+ return renderPanes(width, height, theme, `✎ Changes · ${this.trees.length} worktrees`, leftLine, preview, keys, selected?.file);
296
321
  }
297
322
 
298
323
  invalidate(): void {
@@ -406,8 +431,8 @@ export class ChangesView {
406
431
  // the scroll keys are checked before the open key; a real Enter is CR.
407
432
  if (data === "j" || matchesKey(data, Key.down)) this.select(this.selected + 1);
408
433
  else if (data === "k" || matchesKey(data, Key.up)) this.select(this.selected - 1);
409
- else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("j"))) this.scrollBy(this.bodyRows());
410
- else if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("k"))) this.scrollBy(-this.bodyRows());
434
+ else if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("j"))) this.scrollBy(diffRows(this.bodyRows()));
435
+ else if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("k"))) this.scrollBy(-diffRows(this.bodyRows()));
411
436
  else if (data === "o" || matchesKey(data, Key.enter)) {
412
437
  const file = this.model.files[this.selected];
413
438
  if (file) this.deps.onOpen(file);
@@ -464,7 +489,7 @@ export class ChangesView {
464
489
  this.fileScroll = this.clampFileScroll(this.fileScroll, rows);
465
490
  const keys = KEYS.map(([key, label]) => `${theme.fg(ROLE.KEY, key)} ${theme.fg(ROLE.KEY_TEXT, key === "esc" ? (this.deps.backLabel ?? label) : label)}`).join(" ");
466
491
  this.pointerLayout = pointerLayout(width, rows);
467
- return renderPanes(width, rows, theme, `✎ Changes · ${changesSummary(this.model)}`, (row) => this.fileLine(row), this.visibleDiff(rows), keys);
492
+ return renderPanes(width, rows, theme, `✎ Changes · ${changesSummary(this.model)}`, (row) => this.fileLine(row), this.visibleDiff(rows), keys, this.model.files[this.selected]);
468
493
  }
469
494
 
470
495
  invalidate(): void {
@@ -500,8 +525,9 @@ export class ChangesView {
500
525
  const lines = this.diffs.get(file.path);
501
526
  if (!lines) return [];
502
527
  if (lines.length === 0) return [this.deps.theme.fg(ROLE.EMPTY, EMPTY_DIFF)];
503
- this.diffScroll = Math.min(this.diffScroll, Math.max(0, lines.length - rows));
504
- return lines.slice(this.diffScroll, this.diffScroll + rows);
528
+ const visible = diffRows(rows);
529
+ this.diffScroll = Math.min(this.diffScroll, Math.max(0, lines.length - visible));
530
+ return lines.slice(this.diffScroll, this.diffScroll + visible);
505
531
  }
506
532
 
507
533
  private select(index: number): void {
@@ -534,7 +560,7 @@ export class ChangesView {
534
560
  const file = this.model.files[this.selected];
535
561
  const lines = file ? this.diffs.get(file.path) : undefined;
536
562
  if (!lines) return false;
537
- const next = Math.max(0, Math.min(Math.max(0, lines.length - rows), this.diffScroll + Math.trunc(delta)));
563
+ const next = Math.max(0, Math.min(Math.max(0, lines.length - diffRows(rows)), this.diffScroll + Math.trunc(delta)));
538
564
  if (next === this.diffScroll) return false;
539
565
  this.diffScroll = next;
540
566
  return true;
@@ -557,7 +583,8 @@ export class ChangesView {
557
583
  void this.deps.loadDiff(file).then(
558
584
  (text) => {
559
585
  if (this.disposed) return;
560
- this.diffs.set(file.path, colorDiff(text, this.deps.theme));
586
+ // An oversized diff arrives as one plain line pointing at the editor.
587
+ this.diffs.set(file.path, text === DIFF_TOO_LARGE ? [this.deps.theme.fg(ROLE.EMPTY, text)] : colorDiff(text, this.deps.theme));
561
588
  this.deps.requestRender();
562
589
  },
563
590
  () => {
@@ -27,7 +27,6 @@ export interface ChangesModel {
27
27
  files: ChangedFile[];
28
28
  added: number;
29
29
  deleted: number;
30
- notice?: string;
31
30
  }
32
31
 
33
32
  export interface ChangesSnapshot {
@@ -46,6 +45,11 @@ interface NumstatEntry {
46
45
  }
47
46
 
48
47
  export const CHANGES_COMMAND = "/gentle:changes";
48
+ // One selected file's diff is the only bounded thing in Changes: past either
49
+ // limit the panel shows DIFF_TOO_LARGE instead of buffering and coloring it.
50
+ export const DIFF_MAX_BYTES = 1024 * 1024;
51
+ export const DIFF_MAX_LINES = 20_000;
52
+ export const DIFF_TOO_LARGE = "Diff too large — press o to open in editor.";
49
53
  const WIDGET_GLYPH = "✎";
50
54
  const STATUS_BY_CODE: Record<string, ChangeStatus> = {
51
55
  A: CHANGE_STATUS.ADDED,
@@ -123,7 +127,7 @@ export function changesModel(changed: ChangedFile[]): ChangesModel {
123
127
 
124
128
  export function changesSummary(model: ChangesModel): string {
125
129
  const noun = model.files.length === 1 ? "file" : "files";
126
- return `${model.files.length} ${noun} · +${model.added} −${model.deleted}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}${model.notice ? " · capture limit reached" : ""}`;
130
+ return `${model.files.length} ${noun} · +${model.added} −${model.deleted}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}`;
127
131
  }
128
132
 
129
133
  // One line: summary, the files joined by dots, and the command pushed to
@@ -132,7 +136,7 @@ export function renderChangesWidget(model: ChangesModel, theme: ChangesTheme, wi
132
136
  if (model.files.length === 0) return [];
133
137
  const noun = model.files.length === 1 ? "file" : "files";
134
138
  const dot = theme.fg("muted", "·");
135
- const head = `${theme.fg("accent", WIDGET_GLYPH)} ${theme.fg("text", `${model.files.length} ${noun}`)} ${dot} ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}${model.notice ? ` ${dot} ${theme.fg("warning", "capture limit reached")}` : ""}`;
139
+ const head = `${theme.fg("accent", WIDGET_GLYPH)} ${theme.fg("text", `${model.files.length} ${noun}`)} ${dot} ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}${model.files.some(file => file.countsUnavailable) ? " · partial counts" : ""}`;
136
140
  const hint = theme.fg("dim", CHANGES_COMMAND);
137
141
  const list = model.files.map((file) => theme.fg("muted", file.path)).join(` ${dot} `);
138
142
  const left = `${head} ${dot} ${list}`;
@@ -146,10 +150,14 @@ export function renderChangesWidget(model: ChangesModel, theme: ChangesTheme, wi
146
150
  export interface GitResult {
147
151
  stdout: string;
148
152
  code: number;
153
+ /** The runner stopped reading at its output bound; stdout is partial. */
154
+ truncated?: boolean;
149
155
  }
150
156
 
151
157
  export type GitRunner = (args: string[]) => Promise<GitResult>;
152
158
  export type LineCounter = (path: string) => Promise<number>;
159
+ /** A cheap content identity (e.g. size and mtime) so same-count edits refresh their diff. */
160
+ export type FileRevision = (path: string) => Promise<string | undefined>;
153
161
 
154
162
  export interface WorktreeChanges {
155
163
  root: string;
@@ -189,76 +197,41 @@ export async function foreignRootBranch(git: GitRunner): Promise<string | undefi
189
197
  return verified.code === 0 ? branch : "no commits yet";
190
198
  }
191
199
 
192
- // The session-evidence tracker behind /gentle:changes records roots, never
193
- // branches, so the overlay would label every tree "detached". This resolves
194
- // each root's own HEAD state once (branch, "no commits yet", undefined for a
195
- // real detached HEAD, or UNKNOWN_BRANCH when the root could not even be
196
- // asked), decorates the trees from the cache, and tells the caller when a
197
- // fresh answer arrived so the view can repaint. Git is touched only while the
198
- // overlay is open, never at startup or on evidence refresh.
199
- export class RootBranchLabels {
200
- private readonly labels = new Map<string, string | undefined>();
201
- private readonly pending = new Map<string, Promise<void>>();
202
- private readonly gitForRoot: (root: string) => GitRunner;
203
- private readonly onChange: () => void;
204
- constructor(gitForRoot: (root: string) => GitRunner, onChange: () => void = () => {}) {
205
- this.gitForRoot = gitForRoot;
206
- this.onChange = onChange;
207
- }
208
-
209
- decorate(trees: readonly WorktreeChanges[]): WorktreeChanges[] {
210
- return trees.map((tree) => {
211
- if (tree.branch !== undefined) return tree;
212
- if (!this.labels.has(tree.root)) this.resolve(tree.root);
213
- const branch = this.labels.get(tree.root);
214
- return branch === undefined ? tree : { ...tree, branch };
215
- });
216
- }
217
-
218
- /** Every in-flight resolution has finished. */
219
- async settled(): Promise<void> {
220
- while (this.pending.size) await Promise.all(this.pending.values());
221
- }
222
-
223
- private resolve(root: string): void {
224
- if (this.pending.has(root)) return;
225
- // A rejection here means the root could not even be asked -- distinct
226
- // from foreignRootBranch resolving to undefined, which means git
227
- // answered and there really is no branch (a real detached HEAD).
228
- const task = foreignRootBranch(this.gitForRoot(root))
229
- .then(
230
- (branch) => branch,
231
- () => UNKNOWN_BRANCH,
232
- )
233
- .then((branch) => {
234
- this.labels.set(root, branch);
235
- this.pending.delete(root);
236
- this.onChange();
237
- });
238
- this.pending.set(root, task);
239
- }
240
- }
241
-
200
+ // /gentle:changes: one group per registered root holding its working tree
201
+ // against HEAD. A root whose Git fails (timed out, rejected) has no group but
202
+ // is listed in failedRoots: a failed scan is never reported as a clean root.
242
203
  export class WorktreeChangesTracker {
243
204
  worktrees: WorktreeChanges[] = [];
205
+ failedRoots: string[] = [];
206
+ scannedRootCount = 0;
244
207
  private inFlight: Promise<ChangesModel> | undefined;
245
208
  private readonly discover: GitRunner;
246
209
  private readonly gitForRoot: (root: string) => GitRunner;
247
210
  private readonly linesForRoot: (root: string) => LineCounter;
248
211
  private readonly registeredRoots: () => readonly string[];
212
+ private readonly revisionForRoot: (root: string) => FileRevision | undefined;
249
213
 
250
- constructor(discover: GitRunner, gitForRoot: (root: string) => GitRunner, linesForRoot: (root: string) => LineCounter = () => noLines, registeredRoots: () => readonly string[] = () => []) {
214
+ constructor(discover: GitRunner, gitForRoot: (root: string) => GitRunner, linesForRoot: (root: string) => LineCounter = () => noLines, registeredRoots: () => readonly string[] = () => [], revisionForRoot: (root: string) => FileRevision | undefined = () => undefined) {
251
215
  this.discover = discover;
252
216
  this.gitForRoot = gitForRoot;
253
217
  this.linesForRoot = linesForRoot;
254
218
  this.registeredRoots = registeredRoots;
219
+ this.revisionForRoot = revisionForRoot;
255
220
  }
256
221
 
222
+ private modelOf: WorktreeChanges[] | undefined;
223
+ private combined: ChangesModel = emptyChanges();
224
+
225
+ // Footers read this every frame; rebuild only when a scan replaced the trees.
257
226
  get model(): ChangesModel {
258
- return changesModel(this.worktrees.flatMap((tree) => tree.model.files.map((file) => ({
259
- ...file,
260
- path: this.worktrees.length === 1 ? file.path : `${tree.root}/${file.path}`,
261
- }))));
227
+ if (this.modelOf !== this.worktrees) {
228
+ this.modelOf = this.worktrees;
229
+ this.combined = changesModel(this.worktrees.flatMap((tree) => tree.model.files.map((file) => ({
230
+ ...file,
231
+ path: this.worktrees.length === 1 ? file.path : `${tree.root}/${file.path}`,
232
+ }))));
233
+ }
234
+ return this.combined;
262
235
  }
263
236
 
264
237
  async start(): Promise<void> {
@@ -278,17 +251,29 @@ export class WorktreeChangesTracker {
278
251
  }
279
252
 
280
253
  private async capture(): Promise<ChangesModel> {
281
- const result = await this.discover(["worktree", "list", "--porcelain", "-z"]).catch(() => ({ code: 128, stdout: "" }));
282
- const trees: WorktreeChanges[] = [];
283
- const metadata = new Map((result.code === 0 ? parseWorktrees(result.stdout) : []).map((tree) => [tree.root, tree]));
284
254
  // Discovery labels registered roots; it never grants visibility to siblings.
285
255
  const roots = new Set(this.registeredRoots());
286
- for (const root of roots) {
256
+ // No root to scan means no Git at all, not even branch discovery.
257
+ if (roots.size === 0) {
258
+ this.worktrees = [];
259
+ this.failedRoots = [];
260
+ this.scannedRootCount = 0;
261
+ return this.model;
262
+ }
263
+ const result = await this.discover(["worktree", "list", "--porcelain", "-z"]).catch(() => ({ code: 128, stdout: "" }));
264
+ const metadata = new Map((result.code === 0 ? parseWorktrees(result.stdout) : []).map((tree) => [tree.root, tree]));
265
+ // Roots scan in parallel so one slow root does not delay the others.
266
+ const failed: string[] = [];
267
+ const scanned = await Promise.all([...roots].map(async (root): Promise<WorktreeChanges | undefined> => {
287
268
  const known = metadata.get(root);
288
269
  try {
289
- const tracker = new ChangesTracker(this.gitForRoot(root), this.linesForRoot(root));
270
+ const tracker = new ChangesTracker(this.gitForRoot(root), this.linesForRoot(root), this.revisionForRoot(root));
290
271
  await tracker.start();
291
- if (!tracker.model.files.length) continue;
272
+ if (!tracker.statusRead) {
273
+ failed.push(root);
274
+ return undefined;
275
+ }
276
+ if (!tracker.model.files.length) return undefined;
292
277
  // A root missing from this discovery scan belongs to a different Git
293
278
  // clone entirely (e.g. an inner repository nested inside an outer
294
279
  // one) -- worktree list can never describe a foreign clone, so ask
@@ -299,22 +284,40 @@ export class WorktreeChangesTracker {
299
284
  // change scan above failing -- that must label the root
300
285
  // UNKNOWN_BRANCH, not drop real, already-captured changes.
301
286
  const branch = known ? known.branch : await foreignRootBranch(this.gitForRoot(root)).catch(() => UNKNOWN_BRANCH);
302
- trees.push({ root, ...(branch ? { branch } : {}), model: tracker.model });
287
+ return { root, ...(branch ? { branch } : {}), model: tracker.model };
303
288
  } catch {
304
- // Linked roots can disappear between discovery and status.
289
+ // A root the caller stops registering mid-scan (e.g. a removed
290
+ // worktree) triggers the rescan below without it, not a failure.
291
+ failed.push(root);
292
+ return undefined;
305
293
  }
306
- }
294
+ }));
295
+ const trees = scanned.filter((tree): tree is WorktreeChanges => tree !== undefined);
307
296
  const latest = new Set(this.registeredRoots());
308
297
  // A root admitted during status must not wait for a later poll (which may
309
298
  // be disabled). Ordinary overlapping polls still share exactly one scan.
310
299
  if (latest.size !== roots.size || [...latest].some((root) => !roots.has(root))) return this.capture();
311
300
  this.worktrees = trees;
301
+ this.failedRoots = failed;
302
+ this.scannedRootCount = roots.size;
312
303
  return this.model;
313
304
  }
314
305
  }
315
306
 
316
307
  const noLines: LineCounter = async () => 0;
317
308
 
309
+ // Per-file line counts and revision lookups are file IO: a large change set
310
+ // runs this many files at a time instead of one by one or all at once.
311
+ export const FILE_WORK_CONCURRENCY = 8;
312
+
313
+ async function eachLimited<T>(items: readonly T[], limit: number, work: (item: T) => Promise<void>): Promise<void> {
314
+ let next = 0;
315
+ const worker = async () => {
316
+ while (next < items.length) await work(items[next++]);
317
+ };
318
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
319
+ }
320
+
318
321
  const NUMSTAT_ARGS = ["diff", "--numstat", "HEAD"];
319
322
  const PORCELAIN_ARGS = ["status", "--porcelain=v1", "--untracked-files=all", "-z"];
320
323
 
@@ -324,20 +327,27 @@ const PORCELAIN_ARGS = ["status", "--porcelain=v1", "--untracked-files=all", "-z
324
327
  export class ChangesTracker {
325
328
  private readonly git: GitRunner;
326
329
  private readonly countLines: LineCounter;
330
+ private readonly revision: FileRevision | undefined;
327
331
  private current: ChangesModel = emptyChanges();
328
332
  private available = false;
329
333
  private inFlight: Promise<ChangesModel> | undefined;
330
334
  private queued = false;
331
335
 
332
- constructor(git: GitRunner, countLines: LineCounter = noLines) {
336
+ constructor(git: GitRunner, countLines: LineCounter = noLines, revision?: FileRevision) {
333
337
  this.git = git;
334
338
  this.countLines = countLines;
339
+ this.revision = revision;
335
340
  }
336
341
 
337
342
  get model(): ChangesModel {
338
343
  return this.current;
339
344
  }
340
345
 
346
+ /** False when the last start could not read `git status`. */
347
+ get statusRead(): boolean {
348
+ return this.available;
349
+ }
350
+
341
351
  async start(): Promise<void> {
342
352
  const files = await this.capture();
343
353
  this.available = files !== undefined;
@@ -372,9 +382,14 @@ export class ChangesTracker {
372
382
  if (porcelain.code !== 0) return undefined;
373
383
  const files = snapshotChanges({ numstat: numstat.stdout, porcelain: porcelain.stdout });
374
384
  // Untracked files never appear in numstat; count their lines directly.
375
- for (const file of files) {
376
- if (file.status === CHANGE_STATUS.UNTRACKED) file.added = await this.countLines(file.path).catch(() => 0);
377
- }
385
+ await eachLimited(files, FILE_WORK_CONCURRENCY, async (file) => {
386
+ const [added, revision] = await Promise.all([
387
+ file.status === CHANGE_STATUS.UNTRACKED ? this.countLines(file.path).catch(() => 0) : undefined,
388
+ this.revision?.(file.path).catch(() => undefined),
389
+ ]);
390
+ if (added !== undefined) file.added = added;
391
+ if (revision) file.diffRevision = revision;
392
+ });
378
393
  return files;
379
394
  }
380
395
  }
@@ -179,6 +179,10 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme, placement: () =>
179
179
  };
180
180
  scroll.handleMouse = (event) => {
181
181
  if (event.type === "wheel") {
182
+ // A bounded section (Todo) owns its inner viewport. Only wheel
183
+ // events it declines should move the surrounding rail.
184
+ const sectionResult = dispatchPartMouse(event);
185
+ if (sectionResult?.handled) return sectionResult;
182
186
  // Consume even at the boundary or over blank rail space: Pi 0.85.1
183
187
  // can send unconsumed delta to the primary transcript despite containment.
184
188
  scroll.scrollBy(event.wheelDelta ?? 0);
@@ -375,7 +379,7 @@ export function installSidebar(tui: TUI, theme: ShellBarTheme, placement: () =>
375
379
  stopped = true;
376
380
  state.active = false;
377
381
  clearInterval(timer);
378
- scroll.hideTransientScrollbar();
382
+ scroll.setScrollbar("hidden");
379
383
  for (const cleanup of cleanups.reverse()) cleanup();
380
384
  tui.requestRender();
381
385
  };