gentle-pi 3.7.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (590) hide show
  1. package/README.md +155 -10
  2. package/assets/agents/gentle-ai-explore.md +13 -6
  3. package/assets/agents/gentle-ai-verify.md +27 -6
  4. package/assets/agents/gentle-ai-worker.md +13 -15
  5. package/assets/install-wizard/index.html +47 -0
  6. package/assets/install-wizard/wizard.css +1279 -0
  7. package/assets/install-wizard/wizard.js +951 -0
  8. package/assets/orchestrator-delegation.md +75 -169
  9. package/assets/orchestrator-memory.md +12 -26
  10. package/assets/orchestrator-prompts.md +48 -0
  11. package/assets/orchestrator-skills.md +1 -1
  12. package/assets/orchestrator-tracking.md +53 -0
  13. package/assets/orchestrator-verification.md +28 -0
  14. package/assets/orchestrator-writer.md +38 -0
  15. package/assets/orchestrator.md +30 -38
  16. package/assets/sounds/LICENSE.md +52 -0
  17. package/assets/sounds/attention.wav +0 -0
  18. package/assets/sounds/error.wav +0 -0
  19. package/assets/sounds/success.wav +0 -0
  20. package/assets/support/strict-tdd-verify.md +4 -266
  21. package/assets/support/strict-tdd.md +8 -360
  22. package/bin/engram-guard +9 -0
  23. package/bin/gentle-shell-install.mjs +302 -0
  24. package/bin/gentle-shell.mjs +956 -50
  25. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +7 -7
  26. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/manifest.json +2 -2
  27. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/orchestration/pi.md +16 -4
  28. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-capabilities.baseline.json +2 -2
  29. package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/generated/provider-roles.baseline.json +1 -1
  30. package/docs/bundled-install.md +397 -0
  31. package/docs/delegated-verification.md +27 -4
  32. package/docs/gentle-agents-activity.md +537 -0
  33. package/docs/gentle-shell-uninstall.md +136 -0
  34. package/docs/gentle-shell.md +165 -51
  35. package/docs/install-wizard.md +2040 -0
  36. package/docs/native-authority-architecture.md +3 -3
  37. package/docs/permission-modes.md +82 -0
  38. package/docs/prompt-history.md +280 -0
  39. package/docs/readme-reference.md +364 -231
  40. package/docs/render-cache-performance.md +28 -0
  41. package/docs/sound-notifications-proposal.md +191 -0
  42. package/docs/sound-notifications.md +114 -0
  43. package/docs/telemetry.md +1 -1
  44. package/docs/yolo-mode.md +86 -0
  45. package/extensions/child-context.ts +35 -0
  46. package/extensions/child-safety.ts +25 -0
  47. package/extensions/codegraph-tools.ts +18 -5
  48. package/extensions/gentle-agents.ts +1093 -326
  49. package/extensions/gentle-ai.ts +1636 -890
  50. package/extensions/gentle-herdr-activity.ts +81 -0
  51. package/extensions/gentle-notifications.ts +104 -0
  52. package/extensions/gentle-shell.ts +1619 -136
  53. package/extensions/gentle-stats.ts +101 -0
  54. package/extensions/gentle-todo.ts +126 -20
  55. package/extensions/history/atomic-write.ts +38 -0
  56. package/extensions/history/hide-prompts.ts +183 -0
  57. package/extensions/history/index.ts +1424 -0
  58. package/extensions/history/load-shared-history.ts +39 -0
  59. package/extensions/history/selector-helpers.ts +538 -0
  60. package/extensions/history/session-scan.ts +233 -0
  61. package/extensions/history/store.ts +1119 -0
  62. package/extensions/nan-provider.ts +6 -0
  63. package/extensions/pi-pretty.ts +39 -3
  64. package/extensions/quiet-tools.ts +254 -92
  65. package/extensions/resume-hint.ts +60 -0
  66. package/extensions/skill-registry.ts +191 -58
  67. package/extensions/startup-banner.ts +71 -33
  68. package/lib/agent-assets.ts +604 -0
  69. package/lib/agent-home.ts +10 -1
  70. package/lib/agent-model-resolution.ts +80 -0
  71. package/lib/agent-profile-pin.ts +12 -0
  72. package/lib/agent-profiles.ts +13 -3
  73. package/lib/agents-completion-delivery.ts +11 -3
  74. package/lib/agents-message-delivery.ts +181 -0
  75. package/lib/agents-messaging.ts +1 -1
  76. package/lib/agents-protocol.ts +99 -0
  77. package/lib/agents-runner.ts +180 -107
  78. package/lib/agents-view.ts +26 -3
  79. package/lib/agents-widget.ts +16 -9
  80. package/lib/append-system-prompt.ts +21 -0
  81. package/lib/auto-permission-classifier.ts +280 -0
  82. package/lib/auto-permission-runtime.ts +268 -0
  83. package/lib/background-jobs-tools.ts +202 -0
  84. package/lib/background-jobs.ts +253 -0
  85. package/lib/background-monitor.ts +154 -0
  86. package/lib/bounded-writer-admission.ts +220 -0
  87. package/lib/builtin-codemode-optout.ts +166 -0
  88. package/lib/card-content-policy.ts +85 -0
  89. package/lib/card-style-policy.ts +60 -0
  90. package/lib/child-context-files.ts +166 -0
  91. package/lib/child-package-injection.ts +62 -0
  92. package/lib/codemode-renderer.ts +195 -0
  93. package/lib/command-palette-catalog.ts +5 -10
  94. package/lib/command-palette.ts +25 -14
  95. package/lib/context-threshold.ts +26 -0
  96. package/lib/destructive-command-guard.ts +144 -0
  97. package/lib/gentle-ai-binary.ts +109 -4
  98. package/lib/gentle-ai-elapsed-store.ts +87 -0
  99. package/lib/gentle-ai-renderer.ts +196 -39
  100. package/lib/gentle-shell-bundled.ts +517 -0
  101. package/lib/gentle-shell-launcher.ts +89 -27
  102. package/lib/gentle-shell-resume-hint.ts +176 -0
  103. package/lib/gentle-shell-startup-flow.ts +54 -0
  104. package/lib/gentle-shell-startup-update.ts +92 -0
  105. package/lib/gentle-shell-uninstall.ts +840 -0
  106. package/lib/gentle-shell-update-panel.ts +231 -0
  107. package/lib/herdr-activity.ts +183 -0
  108. package/lib/history-capture-policy.ts +95 -0
  109. package/lib/inprocess-reviewer.ts +3 -0
  110. package/lib/jobs-sidebar-state.ts +20 -0
  111. package/lib/jobs-view.ts +161 -0
  112. package/lib/model-price-ratio.ts +83 -0
  113. package/lib/model-routing-authority.ts +5 -1
  114. package/lib/nan-provider.ts +249 -0
  115. package/lib/native-review-cli.ts +242 -180
  116. package/lib/notification-audio-native.ts +162 -0
  117. package/lib/notification-audio-windows.ts +399 -0
  118. package/lib/notification-audio.ts +332 -0
  119. package/lib/notification-customize.ts +241 -0
  120. package/lib/notification-events.ts +113 -0
  121. package/lib/notification-policy.ts +148 -0
  122. package/lib/notification-pulse-client.ts +367 -0
  123. package/lib/notification-pulse-protocol.ts +305 -0
  124. package/lib/notification-pulse-stream.ts +213 -0
  125. package/lib/notification-pulse-worker.ts +66 -0
  126. package/lib/notification-scheduler.ts +173 -0
  127. package/lib/notification-service.ts +202 -0
  128. package/lib/notification-ui.ts +87 -0
  129. package/lib/odd-phase-inference.ts +231 -0
  130. package/lib/odd-phase.ts +141 -0
  131. package/lib/orchestrator-alias.ts +20 -0
  132. package/lib/orchestrator-catalog.ts +79 -0
  133. package/lib/orchestrator-consultation.ts +61 -0
  134. package/lib/orchestrator-discovery.ts +49 -0
  135. package/lib/orchestrator-helper-consent.ts +122 -0
  136. package/lib/orchestrator-helper.ts +167 -0
  137. package/lib/orchestrator-message-card.ts +105 -0
  138. package/lib/orchestrator-presence.ts +115 -14
  139. package/lib/orchestrator-scope.ts +80 -0
  140. package/lib/orchestrator-state.ts +107 -0
  141. package/lib/orchestrator-tool-card.ts +280 -0
  142. package/lib/orchestrator-work-search.ts +218 -0
  143. package/lib/orchestrator-work.ts +79 -0
  144. package/lib/overlay-repaint.ts +26 -0
  145. package/lib/permission-mode-policy.ts +59 -0
  146. package/lib/pi-tui-keys.ts +53 -0
  147. package/lib/review-candidate-view-owner.ts +73 -21
  148. package/lib/review-candidate-view.ts +115 -27
  149. package/lib/review-compact-contract.ts +2 -2
  150. package/lib/review-host-relay.ts +7 -2
  151. package/lib/review-integration-v2.ts +100 -22
  152. package/lib/review-publication-gate.ts +1 -1
  153. package/lib/review-reminder-receipt.ts +48 -8
  154. package/lib/review-risk-assessment.ts +227 -56
  155. package/lib/review-sidebar-state.ts +224 -0
  156. package/lib/review-transaction.ts +72 -3
  157. package/lib/runtime-metrics-children.ts +1 -1
  158. package/lib/runtime-metrics-native.ts +3 -2
  159. package/lib/selection-engine.ts +527 -0
  160. package/lib/session-change-capture.ts +5 -4
  161. package/lib/session-changes.ts +27 -22
  162. package/lib/session-messaging-grants.ts +135 -0
  163. package/lib/session-profile-binding.ts +83 -0
  164. package/lib/session-profile-freeze.ts +266 -0
  165. package/lib/session-worktree-registry.ts +87 -9
  166. package/lib/shell-bar.ts +215 -30
  167. package/lib/shell-card.ts +287 -18
  168. package/lib/shell-changes-view.ts +42 -14
  169. package/lib/shell-changes.ts +86 -71
  170. package/lib/shell-prompt.ts +102 -6
  171. package/lib/shell-sidebar-layout.ts +83 -15
  172. package/lib/shell-sidebar.ts +15 -1
  173. package/lib/shell-todo.ts +84 -31
  174. package/lib/shell-usage-view.ts +9 -4
  175. package/lib/shell-usage.ts +70 -13
  176. package/lib/stats-collector.ts +381 -0
  177. package/lib/stats-view.ts +431 -0
  178. package/lib/status-timing-diagnostics.ts +201 -0
  179. package/lib/telemetry-trigger.ts +3 -1
  180. package/lib/theme-customization.ts +52 -0
  181. package/lib/vim-editor-adapter.ts +383 -0
  182. package/lib/vim-normal-engine.ts +154 -0
  183. package/lib/vim-operator-engine.ts +416 -0
  184. package/lib/vim-policy.ts +49 -0
  185. package/lib/vim-visual-engine.ts +107 -0
  186. package/lib/visual-customization-policy.ts +119 -0
  187. package/lib/visual-customize-view.ts +481 -0
  188. package/lib/visual-profiles.ts +228 -0
  189. package/lib/writer-surfaces.ts +162 -0
  190. package/lib/yolo-session-policy.ts +317 -0
  191. package/package.json +20 -8
  192. package/runtime/child-package-injection.mjs +63 -0
  193. package/runtime/gentle-ai-binary.mjs +109 -4
  194. package/runtime/gentle-shell-bundled.mjs +518 -0
  195. package/runtime/gentle-shell-launcher.mjs +87 -25
  196. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  197. package/runtime/gentle-shell-startup-flow.mjs +55 -0
  198. package/runtime/gentle-shell-startup-update.mjs +93 -0
  199. package/runtime/gentle-shell-uninstall.mjs +841 -0
  200. package/runtime/gentle-shell-update-panel.mjs +232 -0
  201. package/runtime/native-review-cli.mjs +239 -177
  202. package/runtime/review-integration-v2.mjs +100 -22
  203. package/runtime/review-risk-assessment.mjs +225 -54
  204. package/runtime/status-timing-diagnostics.mjs +202 -0
  205. package/runtime/telemetry-trigger.mjs +3 -1
  206. package/scripts/bootstrap.cmd +255 -0
  207. package/scripts/bootstrap.sh +168 -0
  208. package/scripts/build-distribution.mjs +157 -0
  209. package/scripts/build-installer-bundles.mjs +136 -0
  210. package/scripts/build-runtime-modules.mjs +8 -0
  211. package/scripts/bundled-install.mjs +1308 -0
  212. package/scripts/bundled-wizard.mjs +399 -0
  213. package/scripts/check-types.mjs +4 -6
  214. package/scripts/gentle-ai-installer.mjs +83 -35
  215. package/scripts/install-gentle-ai.mjs +48 -1
  216. package/scripts/install-tui-mode-setting.mjs +10 -1
  217. package/scripts/install-wizard-preview.mjs +154 -0
  218. package/scripts/installer-downloads.mjs +648 -0
  219. package/scripts/installer-preflight.mjs +387 -0
  220. package/scripts/installer-probes.mjs +564 -0
  221. package/scripts/installer-runner.mjs +1102 -0
  222. package/scripts/installer-server.mjs +881 -0
  223. package/scripts/installer-windows-artifacts.json +15 -0
  224. package/scripts/installer-windows.mjs +752 -0
  225. package/scripts/main-channel.mjs +452 -0
  226. package/scripts/mirror-odd-routing.mjs +2 -2
  227. package/scripts/npm-pack-result.mjs +31 -0
  228. package/scripts/render-cache-performance-worker.mjs +205 -0
  229. package/scripts/render-cache-performance.mjs +115 -0
  230. package/scripts/run-test-suite.mjs +90 -0
  231. package/scripts/test-installer-acceptance.sh +161 -0
  232. package/scripts/test-packed-runner.mjs +54 -19
  233. package/scripts/types-baseline.json +2 -92
  234. package/scripts/verify-package-files.mjs +91 -38
  235. package/skills/branch-pr/SKILL.md +24 -52
  236. package/skills/chained-pr/SKILL.md +31 -15
  237. package/skills/chained-pr/references/chaining-details.md +31 -20
  238. package/skills/gentle-ai/SKILL.md +16 -21
  239. package/skills/issue-creation/SKILL.md +8 -2
  240. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  241. package/skills/work-unit-commits/SKILL.md +4 -3
  242. package/tests/agent-home.test.ts +56 -3
  243. package/tests/agent-model-resolution.test.ts +113 -0
  244. package/tests/agent-profiles.test.ts +30 -0
  245. package/tests/agent-risk-escalation.test.ts +123 -0
  246. package/tests/agents-completion-delivery.test.ts +17 -0
  247. package/tests/agents-fake-child.ts +2 -2
  248. package/tests/agents-message-delivery.test.ts +106 -0
  249. package/tests/agents-protocol.test.ts +97 -0
  250. package/tests/agents-queries.test.ts +9 -9
  251. package/tests/agents-runner-process.test.ts +31 -0
  252. package/tests/agents-runner.test.ts +467 -93
  253. package/tests/agents-view-thread-identity.test.ts +169 -0
  254. package/tests/agents-view.test.ts +8 -2
  255. package/tests/agents-widget.test.ts +154 -15
  256. package/tests/append-system-prompt-route.test.ts +160 -0
  257. package/tests/append-system-prompt.test.ts +46 -0
  258. package/tests/artifact-language.test.ts +19 -213
  259. package/tests/ask-user-question.test.ts +44 -1
  260. package/tests/asset-installation-runtime.test.ts +5 -16
  261. package/tests/auto-mode-invariants.test.ts +258 -0
  262. package/tests/auto-mode-sdk.test.ts +57 -0
  263. package/tests/auto-mode.test.ts +49 -0
  264. package/tests/auto-permission-classifier.test.ts +283 -0
  265. package/tests/auto-permission-runtime.test.ts +196 -0
  266. package/tests/autonomous-guard.test.ts +69 -1
  267. package/tests/background-cache-warming.test.ts +2 -1
  268. package/tests/background-jobs.test.ts +227 -0
  269. package/tests/background-monitor.test.ts +195 -0
  270. package/tests/background-subagents-default-mode.test.ts +28 -5
  271. package/tests/bounded-stop-contract.test.ts +85 -0
  272. package/tests/bounded-writer-admission.test.ts +146 -0
  273. package/tests/branch-pr-skill.test.ts +43 -0
  274. package/tests/bridge-wake-lifecycle.test.ts +129 -0
  275. package/tests/bridge-wake-rendering.test.ts +56 -0
  276. package/tests/build-distribution.test.ts +269 -0
  277. package/tests/builtin-codemode-optout.test.ts +242 -0
  278. package/tests/bundled-install.test.ts +1319 -0
  279. package/tests/bundled-wizard.test.ts +505 -0
  280. package/tests/card-content-policy.test.ts +68 -0
  281. package/tests/card-style-policy.test.ts +87 -0
  282. package/tests/chained-pr-skill.test.ts +124 -0
  283. package/tests/child-codegraph-bootstrap.test.ts +74 -0
  284. package/tests/child-context-files.test.ts +255 -0
  285. package/tests/child-package-entrypoints.test.ts +65 -0
  286. package/tests/child-package-injection.test.ts +74 -0
  287. package/tests/child-safety.test.ts +94 -0
  288. package/tests/child-web-package.test.ts +224 -0
  289. package/tests/codegraph-tools.test.ts +35 -0
  290. package/tests/codemode-rendering.test.ts +596 -0
  291. package/tests/command-palette.test.ts +43 -7
  292. package/tests/context-threshold.test.ts +31 -0
  293. package/tests/delegate-for-reason-contract.test.ts +327 -0
  294. package/tests/delegated-key-learnings-contract.test.ts +3 -78
  295. package/tests/destructive-command-guard.test.ts +84 -0
  296. package/tests/devbinary/native-review-parity.devtest.ts +171 -3
  297. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  298. package/tests/devbinary/pi-host-relay.devtest.ts +173 -3
  299. package/tests/engram-safety.test.ts +288 -0
  300. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  301. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  302. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  303. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  304. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  305. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  306. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  307. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  308. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  309. package/tests/generic-agent-tools.test.ts +69 -0
  310. package/tests/gentle-agents.test.ts +2804 -346
  311. package/tests/gentle-ai-binary-isolation.test.ts +135 -0
  312. package/tests/gentle-ai-binary.test.ts +21 -4
  313. package/tests/gentle-ai-child-guards.test.ts +202 -0
  314. package/tests/gentle-ai-dev-binary-surfacing.test.ts +125 -15
  315. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  316. package/tests/gentle-ai-installer.test.ts +566 -62
  317. package/tests/gentle-ai-prompt-model-routing.test.ts +123 -0
  318. package/tests/gentle-ai-renderer.test.ts +493 -14
  319. package/tests/gentle-ai.test.ts +1427 -236
  320. package/tests/gentle-card-text.ts +2 -1
  321. package/tests/gentle-herdr-activity.test.ts +100 -0
  322. package/tests/gentle-notifications.test.ts +427 -0
  323. package/tests/gentle-shell-bin.test.ts +896 -115
  324. package/tests/gentle-shell-bundled.test.ts +1002 -0
  325. package/tests/gentle-shell-launcher.test.ts +333 -65
  326. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  327. package/tests/gentle-shell-startup-flow.test.ts +75 -0
  328. package/tests/gentle-shell-startup-update.test.ts +85 -0
  329. package/tests/gentle-shell-uninstall.test.ts +978 -0
  330. package/tests/gentle-shell-update-panel.test.ts +158 -0
  331. package/tests/gentle-shell.test.ts +4216 -119
  332. package/tests/gentle-stats.test.ts +152 -0
  333. package/tests/gentle-theme.test.ts +4 -1
  334. package/tests/gentle-todo.test.ts +333 -10
  335. package/tests/herdr-activity.test.ts +243 -0
  336. package/tests/history-agent-home.test.ts +81 -0
  337. package/tests/history-atomic-write.test.ts +57 -0
  338. package/tests/history-capture-policy.test.ts +102 -0
  339. package/tests/history-child-guard.test.ts +70 -0
  340. package/tests/history-command-registration.test.ts +164 -0
  341. package/tests/history-dedupe-entries.test.ts +123 -0
  342. package/tests/history-delete-backfill.test.ts +190 -0
  343. package/tests/history-delete-confirm.test.ts +460 -0
  344. package/tests/history-dispatch.test.ts +180 -0
  345. package/tests/history-drain-hidden.test.ts +110 -0
  346. package/tests/history-drain-order.test.ts +98 -0
  347. package/tests/history-expanded-globals.test.ts +62 -0
  348. package/tests/history-gc.test.ts +832 -0
  349. package/tests/history-header-layout.test.ts +265 -0
  350. package/tests/history-hide-prompts.test.ts +275 -0
  351. package/tests/history-lazy-windowing.test.ts +508 -0
  352. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  353. package/tests/history-load-shared-history.test.ts +53 -0
  354. package/tests/history-max-results-cap.test.ts +76 -0
  355. package/tests/history-multi-reader.test.ts +203 -0
  356. package/tests/history-off-path.test.ts +170 -0
  357. package/tests/history-openflow-integration.test.ts +173 -0
  358. package/tests/history-overlay-margin.test.ts +326 -0
  359. package/tests/history-preview-layout.test.ts +93 -0
  360. package/tests/history-registry.test.ts +143 -0
  361. package/tests/history-scope-delete.test.ts +411 -0
  362. package/tests/history-search-caret-keys.test.ts +142 -0
  363. package/tests/history-seed-bootstrap.test.ts +170 -0
  364. package/tests/history-seed-regen.test.ts +129 -0
  365. package/tests/history-selector-windowing.test.ts +94 -0
  366. package/tests/history-session-scan-directory.test.ts +87 -0
  367. package/tests/history-session-scan-extract.test.ts +583 -0
  368. package/tests/history-session-writer.test.ts +351 -0
  369. package/tests/history-store-paths.test.ts +79 -0
  370. package/tests/history-tombstone-exact.test.ts +139 -0
  371. package/tests/history-wheel-mouse.test.ts +242 -0
  372. package/tests/inprocess-reviewer.test.ts +49 -19
  373. package/tests/install-gentle-ai-failure.test.ts +69 -0
  374. package/tests/install-tui-mode-setting.test.ts +15 -0
  375. package/tests/install-wizard.test.ts +1223 -0
  376. package/tests/installer-bundles.test.ts +122 -0
  377. package/tests/installer-posix-bootstrap.test.ts +1201 -0
  378. package/tests/installer-preflight.test.ts +750 -0
  379. package/tests/installer-probes.test.ts +1106 -0
  380. package/tests/installer-runner.test.ts +2158 -0
  381. package/tests/installer-server.test.ts +1567 -0
  382. package/tests/installer-windows-bootstrap.test.ts +2410 -0
  383. package/tests/issue-creation-skill.test.ts +61 -0
  384. package/tests/jobs-view.test.ts +160 -0
  385. package/tests/main-channel-upgrade.test.ts +389 -0
  386. package/tests/main-channel.test.ts +344 -0
  387. package/tests/maintainer/provider-relay.maintest.ts +13 -12
  388. package/tests/model-price-ratio.test.ts +116 -0
  389. package/tests/model-profiles-preservation.test.ts +153 -0
  390. package/tests/model-routing-authority.test.ts +16 -0
  391. package/tests/model-routing-package.test.ts +10 -0
  392. package/tests/nan-provider-runtime.test.ts +156 -0
  393. package/tests/nan-provider.test.ts +481 -0
  394. package/tests/native-binary-gate.test.ts +2 -0
  395. package/tests/native-review-capability-contract.test.ts +13 -1
  396. package/tests/native-review-cli.test.ts +239 -118
  397. package/tests/native-review-consent.test.ts +2 -2
  398. package/tests/native-review-parity-runtime.test.ts +101 -4
  399. package/tests/native-review-parity.test.ts +12 -48
  400. package/tests/native-risk-evidence-parity.test.ts +21 -0
  401. package/tests/no-self-pause-contract.test.ts +87 -0
  402. package/tests/notification-audio-native.test.ts +281 -0
  403. package/tests/notification-audio-windows.test.ts +581 -0
  404. package/tests/notification-audio.test.ts +548 -0
  405. package/tests/notification-customize.test.ts +655 -0
  406. package/tests/notification-events.test.ts +115 -0
  407. package/tests/notification-policy.test.ts +201 -0
  408. package/tests/notification-pulse-client.test.ts +356 -0
  409. package/tests/notification-pulse-protocol.test.ts +213 -0
  410. package/tests/notification-pulse-stream.test.ts +320 -0
  411. package/tests/notification-scheduler.test.ts +258 -0
  412. package/tests/notification-ui.test.ts +157 -0
  413. package/tests/npm-pack-result.test.ts +41 -0
  414. package/tests/odd-integration.test.ts +76 -0
  415. package/tests/odd-phase-inference.test.ts +213 -0
  416. package/tests/odd-phase-loader.test.ts +253 -0
  417. package/tests/odd-phase.test.ts +307 -0
  418. package/tests/odd-routing-canonical-ratchet.test.ts +60 -14
  419. package/tests/odd-routing-contract.test.ts +158 -50
  420. package/tests/orchestrator-budget.test.ts +43 -45
  421. package/tests/orchestrator-catalog.test.ts +146 -0
  422. package/tests/orchestrator-consultation-sdk.test.ts +614 -0
  423. package/tests/orchestrator-consultation.test.ts +111 -0
  424. package/tests/orchestrator-discovery.test.ts +241 -0
  425. package/tests/orchestrator-helper-consent.test.ts +171 -0
  426. package/tests/orchestrator-helper.test.ts +297 -0
  427. package/tests/orchestrator-message-card.test.ts +190 -0
  428. package/tests/orchestrator-presence.test.ts +23 -0
  429. package/tests/orchestrator-rdd-ownership.test.ts +8 -7
  430. package/tests/orchestrator-scope.test.ts +107 -0
  431. package/tests/orchestrator-state.test.ts +114 -0
  432. package/tests/orchestrator-status-publication-contract.test.ts +34 -0
  433. package/tests/orchestrator-tool-card.test.ts +398 -0
  434. package/tests/orchestrator-work-search.test.ts +318 -0
  435. package/tests/orchestrator-work.test.ts +147 -0
  436. package/tests/overlay-repaint.test.ts +74 -0
  437. package/tests/package-manifest.test.ts +344 -123
  438. package/tests/packed-runner-owned-path.test.ts +80 -0
  439. package/tests/parallel-reason-contract.test.ts +21 -0
  440. package/tests/parallel-writer-admission.test.ts +210 -0
  441. package/tests/permission-mode-policy.test.ts +194 -0
  442. package/tests/persona-single-channel.test.ts +10 -18
  443. package/tests/pi-pretty.test.ts +56 -0
  444. package/tests/provider-defect-handoff.test.ts +7 -14
  445. package/tests/quiet-bash-runtime.test.ts +76 -0
  446. package/tests/quiet-tool-rendering.test.ts +569 -187
  447. package/tests/rdd-aware-verification-contract.test.ts +103 -40
  448. package/tests/rdd-status-line.test.ts +9 -4
  449. package/tests/render-cache-performance.test.ts +48 -0
  450. package/tests/resume-hint-extension.test.ts +122 -0
  451. package/tests/review-agent-end-preflight.test.ts +237 -21
  452. package/tests/review-candidate-owner-retry.test.ts +22 -1
  453. package/tests/review-candidate-view.test.ts +399 -12
  454. package/tests/review-contract-prompt.test.ts +138 -44
  455. package/tests/review-controller-lock-status.test.ts +0 -1
  456. package/tests/review-controller-native-recovery.test.ts +327 -17
  457. package/tests/review-controller-native-routing.test.ts +866 -6
  458. package/tests/review-controller-workspace-root.test.ts +206 -6
  459. package/tests/review-controller.test.ts +2 -2
  460. package/tests/review-correction-lifecycle.test.ts +2 -1
  461. package/tests/review-gate.test.ts +7 -4
  462. package/tests/review-graph-schema.test.ts +2 -2
  463. package/tests/review-host-relay-routing.test.ts +342 -4
  464. package/tests/review-host-relay.test.ts +49 -2
  465. package/tests/review-integration-v2-forward.test.ts +221 -1
  466. package/tests/review-json-arguments.test.ts +167 -0
  467. package/tests/review-last-event-closure.test.ts +157 -8
  468. package/tests/review-ledger-contract.test.ts +14 -37
  469. package/tests/review-policy-judgment-day.test.ts +1 -1
  470. package/tests/review-policy-ordinary.test.ts +2 -2
  471. package/tests/review-relay-transport-agent.test.ts +66 -1
  472. package/tests/review-reminder-receipt.test.ts +47 -1
  473. package/tests/review-risk-assessment.test.ts +594 -26
  474. package/tests/review-session-standing-permission-controller.test.ts +16 -17
  475. package/tests/review-session-standing-permission-ipc.test.ts +6 -5
  476. package/tests/review-sidebar-state.test.ts +402 -0
  477. package/tests/review-test-fixtures.ts +7 -1
  478. package/tests/review-transaction.test.ts +18 -1
  479. package/tests/run-test-suite.test.ts +202 -0
  480. package/tests/runtime-harness.mjs +186 -800
  481. package/tests/runtime-metrics-children.test.ts +16 -23
  482. package/tests/runtime-metrics-extension.test.ts +1 -1
  483. package/tests/runtime-metrics-native.test.ts +31 -1
  484. package/tests/selection-engine.test.ts +451 -0
  485. package/tests/session-change-capture.test.ts +10 -10
  486. package/tests/session-changes-shell.test.ts +242 -48
  487. package/tests/session-changes.test.ts +17 -24
  488. package/tests/session-messaging-grants.test.ts +255 -0
  489. package/tests/session-profile-binding.test.ts +163 -0
  490. package/tests/session-profile-freeze.test.ts +381 -0
  491. package/tests/session-subject-routing-contract.test.ts +34 -0
  492. package/tests/session-worktree-registry.test.ts +134 -2
  493. package/tests/shell-bar.test.ts +422 -1
  494. package/tests/shell-card.test.ts +353 -1
  495. package/tests/shell-changes-view.test.ts +135 -32
  496. package/tests/shell-changes.test.ts +61 -62
  497. package/tests/shell-prompt.test.ts +94 -2
  498. package/tests/shell-render-cache-runtime.test.ts +69 -0
  499. package/tests/shell-render-cache.test.ts +129 -0
  500. package/tests/shell-sidebar-layout.test.ts +351 -22
  501. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  502. package/tests/shell-todo.test.ts +207 -3
  503. package/tests/shell-usage-view.test.ts +28 -1
  504. package/tests/shell-usage.test.ts +108 -0
  505. package/tests/short-check-routing-contract.test.ts +49 -0
  506. package/tests/skill-registry-watcher.test.ts +119 -0
  507. package/tests/skill-registry.test.ts +357 -2
  508. package/tests/startup-banner.test.ts +246 -4
  509. package/tests/startup-home-repo.test.ts +170 -0
  510. package/tests/stats-collector.test.ts +195 -0
  511. package/tests/stats-view.test.ts +202 -0
  512. package/tests/status-timing-diagnostics.test.ts +283 -0
  513. package/tests/support/orchestrator-modules.ts +19 -0
  514. package/tests/task-size-routing-contract.test.ts +241 -0
  515. package/tests/telemetry-trigger.test.ts +107 -20
  516. package/tests/test-strength-contract.test.ts +124 -0
  517. package/tests/theme-customization.test.ts +72 -0
  518. package/tests/verify-agent-contract.test.ts +90 -0
  519. package/tests/verify-first-launch-contract.test.ts +139 -0
  520. package/tests/verify-package-files.test.ts +56 -0
  521. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  522. package/tests/vim-editor-adapter.test.ts +804 -0
  523. package/tests/vim-normal-engine.test.ts +101 -0
  524. package/tests/vim-operator-engine.test.ts +215 -0
  525. package/tests/vim-policy.test.ts +19 -0
  526. package/tests/vim-visual-engine.test.ts +52 -0
  527. package/tests/visual-customization-policy.test.ts +139 -0
  528. package/tests/visual-customize-view.test.ts +696 -0
  529. package/tests/visual-profiles.test.ts +87 -0
  530. package/tests/writer-edit-surface-scope.test.ts +59 -2
  531. package/tests/writer-surfaces.test.ts +199 -0
  532. package/tests/yolo-customize.test.ts +271 -0
  533. package/tests/yolo-mode-runtime.test.ts +188 -0
  534. package/tests/yolo-mode.test.ts +289 -0
  535. package/tests/yolo-session-policy.test.ts +59 -0
  536. package/themes/Gentle.json +2 -1
  537. package/themes/Gentleman-Cute.json +2 -1
  538. package/themes/Gentleman-Sexy.json +2 -1
  539. package/assets/agents/sdd-apply.md +0 -159
  540. package/assets/agents/sdd-archive.md +0 -228
  541. package/assets/agents/sdd-design.md +0 -49
  542. package/assets/agents/sdd-explore.md +0 -48
  543. package/assets/agents/sdd-init.md +0 -56
  544. package/assets/agents/sdd-onboard.md +0 -52
  545. package/assets/agents/sdd-proposal.md +0 -64
  546. package/assets/agents/sdd-remediate.md +0 -37
  547. package/assets/agents/sdd-research.md +0 -49
  548. package/assets/agents/sdd-spec.md +0 -192
  549. package/assets/agents/sdd-status.md +0 -54
  550. package/assets/agents/sdd-tasks.md +0 -108
  551. package/assets/agents/sdd-verify.md +0 -124
  552. package/assets/chains/sdd-full.chain.md +0 -83
  553. package/assets/chains/sdd-plan.chain.md +0 -56
  554. package/assets/chains/sdd-verify.chain.md +0 -43
  555. package/assets/sdd-orchestrator-workflow.md +0 -319
  556. package/assets/support/sdd-status-contract.md +0 -77
  557. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  558. package/extensions/sdd-init.ts +0 -816
  559. package/lib/openspec-deltas.ts +0 -156
  560. package/lib/sdd-preflight.ts +0 -1066
  561. package/lib/sdd-research-capabilities.ts +0 -94
  562. package/lib/sdd-status.ts +0 -26
  563. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  564. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  565. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  566. package/tests/openspec-deltas.test.ts +0 -209
  567. package/tests/sdd-agent-tools.test.ts +0 -156
  568. package/tests/sdd-archive-replay.test.ts +0 -82
  569. package/tests/sdd-classical-continuation.test.ts +0 -74
  570. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  571. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  572. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  573. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  574. package/tests/sdd-odd-integration.test.ts +0 -33
  575. package/tests/sdd-optional-research.test.ts +0 -124
  576. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  577. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  578. package/tests/sdd-preflight.test.ts +0 -541
  579. package/tests/sdd-research-capabilities.test.ts +0 -114
  580. package/tests/sdd-research-live.test.ts +0 -241
  581. package/tests/sdd-selection-transport.test.ts +0 -653
  582. package/tests/sdd-status.test.ts +0 -9
  583. package/tests/sdd-task-truth.test.ts +0 -43
  584. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/README.md +0 -0
  585. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/lens.schema.json +0 -0
  586. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/refuter.schema.json +0 -0
  587. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  588. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/lens.json +0 -0
  589. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/refuter.json +0 -0
  590. /package/contracts/review-provider-contract-mirror/{v1.2.0 → v1.3.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -0,0 +1,52 @@
1
+ import { closeSync, constants, fstatSync, openSync, readFileSync, realpathSync } from "node:fs";
2
+ import { isAbsolute } from "node:path";
3
+ import { colorToRgb, parseColor } from "@earendil-works/pi-tui";
4
+
5
+ const MAX_BYTES = 256_000;
6
+ const HEX = /^#[0-9a-fA-F]{6}$/;
7
+ const REFERENCE = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
8
+
9
+ /** Read only the installed source supplied by Pi; never use the active theme as a fallback. */
10
+ export function sourcePalettePreview(name: string, sourcePath: string | undefined): { title: string; sample: string } {
11
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name) || !sourcePath || !isAbsolute(sourcePath)) throw new Error("Installed theme source unavailable.");
12
+ // Pi's installed themes can live behind platform symlinks. Resolve the
13
+ // Pi-supplied path, then open the target without following another symlink.
14
+ const path = realpathSync(sourcePath);
15
+ const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
16
+ let text: string;
17
+ try {
18
+ const stat = fstatSync(fd);
19
+ if (!stat.isFile() || stat.size > MAX_BYTES) throw new Error("Theme source unavailable or too large.");
20
+ const bytes = readFileSync(fd);
21
+ if (bytes.length > MAX_BYTES) throw new Error("Theme source too large.");
22
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
23
+ } finally { closeSync(fd); }
24
+ const document: unknown = JSON.parse(text);
25
+ if (!document || typeof document !== "object" || Array.isArray(document)) throw new Error("Invalid theme source.");
26
+ const { name: sourceName, colors, vars } = document as Record<string, unknown>;
27
+ if (sourceName !== name || !colors || typeof colors !== "object" || Array.isArray(colors)) throw new Error("Theme source does not match installed theme.");
28
+ if (vars !== undefined && (!vars || typeof vars !== "object" || Array.isArray(vars))) throw new Error("Invalid theme variables.");
29
+ const palette = colors as Record<string, unknown>;
30
+ const variables = (vars ?? {}) as Record<string, unknown>;
31
+ const escape = (role: "accent" | "text", background: boolean): string => {
32
+ let value: unknown = palette[role];
33
+ const seen = new Set<string>();
34
+ while (typeof value === "string" && REFERENCE.test(value)) {
35
+ if (seen.has(value) || !Object.hasOwn(variables, value)) throw new Error("Invalid theme color reference.");
36
+ seen.add(value);
37
+ value = variables[value];
38
+ }
39
+ const prefix = background ? 48 : 38;
40
+ if (typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 255) return `\x1b[${prefix};5;${value}m`;
41
+ if (typeof value === "string" && HEX.test(value)) return `\x1b[${prefix};2;${parseInt(value.slice(1, 3), 16)};${parseInt(value.slice(3, 5), 16)};${parseInt(value.slice(5, 7), 16)}m`;
42
+ // Pi 0.99.1's built-in palettes use OKHSL. Delegate the exact color
43
+ // grammar, finite/range checks and conversion to the public host utility;
44
+ // do not broaden this source-only preview to arbitrary CSS colors.
45
+ if (typeof value === "string" && value.startsWith("okhsl(")) {
46
+ const { r, g, b } = colorToRgb(parseColor(value));
47
+ return `\x1b[${prefix};2;${r};${g};${b}m`;
48
+ }
49
+ throw new Error("Invalid theme palette color.");
50
+ };
51
+ return { title: `${name} · source palette`, sample: `${escape("accent", true)} \x1b[0m ${escape("text", false)}Aa sample text\x1b[0m` };
52
+ }
@@ -0,0 +1,383 @@
1
+ import { CURSOR_MARKER, Editor, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { createRequire } from "node:module";
3
+
4
+ // Private shape audited against @earendil-works/pi-tui 0.99.1, 0.99.2, 1.0.0 and 1.1.0:
5
+ // editor.js state/pastes/history, sticky cursor reset, layoutText/render geometry,
6
+ // autocomplete cancellation and { state, pastes, pasteCounter } undo restoration;
7
+ // undo-stack.js clone-on-push/pop snapshots. The two 0.99 releases ship identical
8
+ // editor/undo-stack sources; 1.0.0 is a touched-contract audit, not byte identity.
9
+ // The 1.1.0 touched-contract audit retains clone-on-push snapshots with the
10
+ // paste registry, cursor resets, layoutText/render geometry and history exit.
11
+ // cancelAutocomplete aborts/debounces requests before clearing its UI;
12
+ // the adapter still calls that host-owned method, never mutating its request state.
13
+ // Actual installed bundled/unbundled tests exercise identity, edit/undo, paste,
14
+ // selection, wrapping/scroll and autocomplete; fabricated metadata tests only
15
+ // prove exact-version admission for the older audited releases.
16
+ // Never silently adapt another build: undo also owns the paste registry.
17
+ interface Position { line: number; col: number }
18
+ interface State { lines: string[]; cursorLine: number; cursorCol: number }
19
+ interface Snapshot { state: State; pastes: Map<number, string>; pasteCounter: number }
20
+ interface Stack { stack: Snapshot[]; length: number; push(snapshot: Snapshot): void; pop(): Snapshot | undefined }
21
+ interface InsertSession { prefix: Snapshot[]; before: Snapshot; text: string; anchor: number }
22
+ const insertSessions = new WeakMap<object, InsertSession>();
23
+ interface LayoutLine { text: string; hasCursor: boolean; cursorPos?: number }
24
+ interface PrivateEditor {
25
+ state: State;
26
+ getText(): string;
27
+ getCursor(): Position;
28
+ onChange?: (text: string) => void;
29
+ pastes: Map<number, string>;
30
+ pasteCounter: number;
31
+ undoStack: Stack;
32
+ history: string[];
33
+ historyIndex: number;
34
+ historyDraft: State | null;
35
+ lastAction: string | null;
36
+ preferredVisualCol: number | null;
37
+ snappedFromCursorCol: number | null;
38
+ scrollOffset: number;
39
+ renderedVisibleLineCount: number;
40
+ paddingX: number;
41
+ layoutText(width: number): LayoutLine[];
42
+ render(width: number): string[];
43
+ lastWidth: number;
44
+ pushUndoSnapshot(): void;
45
+ undo(): void;
46
+ setCursorCol(col: number): void;
47
+ autocompleteState: unknown;
48
+ cancelAutocomplete(): void;
49
+ exitHistoryBrowsing(): void;
50
+ }
51
+
52
+ // The single audited-release source for the adapter and Gentle Shell's runtime
53
+ // identity gates. Add a release only after re-auditing the files named above.
54
+ export const AUDITED_PI_EDITOR_VERSIONS = Object.freeze(["0.99.1", "0.99.2", "1.0.0", "1.1.0"] as const);
55
+ export type AuditedPiEditorVersion = (typeof AUDITED_PI_EDITOR_VERSIONS)[number];
56
+
57
+ export function isAuditedPiEditorVersion(version: unknown): version is AuditedPiEditorVersion {
58
+ return typeof version === "string" && (AUDITED_PI_EDITOR_VERSIONS as readonly string[]).includes(version);
59
+ }
60
+ // Pi aliases only ES imports of host packages. This raw require walks
61
+ // node_modules from the extension, which a git install lacks (#1586), so an
62
+ // unresolved host leaves the version unknown and the identity gate closed.
63
+ const importedTuiMetadata: unknown = (() => {
64
+ try { return createRequire(import.meta.url)("@earendil-works/pi-tui/package.json"); } catch { return undefined; }
65
+ })();
66
+ const IMPORTED_TUI_VERSION = typeof importedTuiMetadata === "object" && importedTuiMetadata !== null &&
67
+ "version" in importedTuiMetadata ? importedTuiMetadata.version : undefined;
68
+
69
+ // The caller supplies the Editor constructor from the same Pi/TUI package pair
70
+ // as the version metadata. An arbitrary object with matching fields is not an editor.
71
+ function hasEditorIdentity(value: unknown, version: string, editorClass: typeof Editor, verifiedVersion?: string): boolean {
72
+ if (!isAuditedPiEditorVersion(version) || typeof value !== "object" || value === null ||
73
+ (verifiedVersion !== undefined ? version !== verifiedVersion :
74
+ editorClass === Editor ? version !== IMPORTED_TUI_VERSION : true) || !(value instanceof editorClass)) return false;
75
+ let prototype: unknown = Object.getPrototypeOf(value);
76
+ for (let depth = 0; depth < 3; depth++) {
77
+ if (prototype === editorClass.prototype) return true;
78
+ if (typeof prototype !== "object" || prototype === null || !Object.hasOwn(prototype, "constructor")) return false;
79
+ prototype = Object.getPrototypeOf(prototype);
80
+ }
81
+ return false;
82
+ }
83
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
84
+ const marker = /\[paste #(\d+)(?: (?:\+\d+ lines|\d+ chars))?\]/g;
85
+
86
+ // Only CSI SGR and Pi's own zero-width cursor marker are expected in editor
87
+ // text. Unknown escapes leave the unmodified frame intact (fail closed).
88
+ function highlightRenderedRow(row: string, chunk: LayoutLine, left: number, right: number, padding: number): string | undefined {
89
+ const cursor = chunk.hasCursor ? chunk.cursorPos : undefined;
90
+ const tokens = row.match(/\x1b\[[0-9;]*m|\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b_[^\x07\x1b]*(?:\x07|\x1b\\)|[^\x1b]/g);
91
+ if (!tokens || tokens.join("") !== row) return undefined;
92
+ let plain = "";
93
+ for (const token of tokens) if (!token.startsWith("\x1b")) plain += token;
94
+ const before = " ".repeat(padding);
95
+ if (!plain.startsWith(before + chunk.text)) return undefined;
96
+ // Pi's fake cursor inserts one space only when it sits at the end.
97
+ const expected = before + chunk.text + (cursor === chunk.text.length ? " " : "");
98
+ if (!plain.startsWith(expected)) return undefined;
99
+ let result = "";
100
+ let col = 0;
101
+ let selected = false;
102
+ let inverse = false;
103
+ let nativeInverse = false;
104
+ for (const token of tokens) {
105
+ if (token.startsWith("\x1b[")) {
106
+ const codes = token.slice(2, -1).split(";").map(Number);
107
+ for (let i = 0; i < codes.length; i++) {
108
+ const code = codes[i];
109
+ if ((code === 38 || code === 48 || code === 58) && codes[i + 1] === 2 && i + 4 < codes.length) i += 4;
110
+ else if ((code === 38 || code === 48 || code === 58) && codes[i + 1] === 5 && i + 2 < codes.length) i += 2;
111
+ else if (code === 0 || code === 27) nativeInverse = inverse = false;
112
+ else if (code === 7) nativeInverse = inverse = true;
113
+ }
114
+ result += token;
115
+ if (selected && !inverse) { result += "\x1b[7m"; inverse = true; }
116
+ } else if (token.startsWith("\x1b")) {
117
+ if (token !== CURSOR_MARKER) return undefined;
118
+ result += token;
119
+ } else {
120
+ const local = col - padding;
121
+ const shouldSelect = local >= left && local < right && local < chunk.text.length;
122
+ if (shouldSelect !== selected) {
123
+ // SGR 27 changes only reverse video; keep foreground/background intact.
124
+ if (shouldSelect || !nativeInverse && inverse) result += shouldSelect ? "\x1b[7m" : "\x1b[27m";
125
+ selected = shouldSelect;
126
+ inverse = shouldSelect || nativeInverse;
127
+ }
128
+ result += token;
129
+ col++;
130
+ }
131
+ }
132
+ if (selected && !nativeInverse) result += "\x1b[27m";
133
+ return result;
134
+ }
135
+
136
+ function boundaries(line: string, pastes: Map<number, string>): number[] {
137
+ const units = [...segmenter.segment(line)].map((part) => part.index);
138
+ for (const match of line.matchAll(marker)) {
139
+ if (!pastes.has(Number(match[1]))) continue;
140
+ const start = match.index;
141
+ const end = start + match[0].length;
142
+ for (let i = units.length - 1; i >= 0; i--) {
143
+ if (units[i]! > start && units[i]! < end) units.splice(i, 1);
144
+ }
145
+ }
146
+ return [...units, line.length];
147
+ }
148
+
149
+ function assertUniquePastes(editor: PrivateEditor): void {
150
+ const registered = new Set<number>();
151
+ for (const match of editor.getText().matchAll(marker)) {
152
+ const id = Number(match[1]);
153
+ if (!editor.pastes.has(id)) continue;
154
+ if (registered.has(id)) throw new Error("Duplicate registered paste marker: private Vim editing is unsafe");
155
+ registered.add(id);
156
+ }
157
+ }
158
+
159
+ function assertPosition(editor: PrivateEditor, pos: Position): void {
160
+ assertUniquePastes(editor);
161
+ const line = editor.state.lines[pos.line];
162
+ if (line === undefined || !Number.isInteger(pos.col) || !boundaries(line, editor.pastes).includes(pos.col)) {
163
+ throw new Error("Cursor must be on a valid grapheme or paste boundary");
164
+ }
165
+ }
166
+
167
+ function hasSnapshotShape(value: unknown): value is Snapshot {
168
+ if (typeof value !== "object" || value === null || !("state" in value) ||
169
+ !("pastes" in value) || !("pasteCounter" in value)) return false;
170
+ const state = value.state;
171
+ return typeof state === "object" && state !== null && "lines" in state &&
172
+ Array.isArray(state.lines) && state.lines.length > 0 && state.lines.every((line) => typeof line === "string") &&
173
+ "cursorLine" in state && Number.isInteger(state.cursorLine) &&
174
+ typeof state.cursorLine === "number" && state.cursorLine >= 0 && state.cursorLine < state.lines.length &&
175
+ "cursorCol" in state && typeof state.cursorCol === "number" && Number.isInteger(state.cursorCol) &&
176
+ state.cursorCol >= 0 && state.cursorCol <= state.lines[state.cursorLine].length &&
177
+ value.pastes instanceof Map && [...value.pastes].every(([id, text]) => Number.isInteger(id) && id > 0 && typeof text === "string") &&
178
+ typeof value.pasteCounter === "number" && Number.isInteger(value.pasteCounter) && value.pasteCounter >= 0;
179
+ }
180
+
181
+ export function createVimEditorAdapter(value: unknown, version: string, editorClass: typeof Editor = Editor, verifiedVersion?: string) {
182
+ if (!hasEditorIdentity(value, version, editorClass, verifiedVersion)) throw new Error("Unsupported Pi editor layout/version");
183
+ const editor = value as unknown as PrivateEditor;
184
+ const s = editor.state;
185
+ if (!s || !Array.isArray(s.lines) || s.lines.length === 0 || !s.lines.every((line) => typeof line === "string") ||
186
+ !Number.isInteger(s.cursorLine) || !Number.isInteger(s.cursorCol) || !(editor.pastes instanceof Map) ||
187
+ !Number.isInteger(editor.pasteCounter) || !Array.isArray(editor.history) ||
188
+ typeof editor.undoStack?.push !== "function" || typeof editor.undoStack?.pop !== "function" ||
189
+ !Array.isArray(editor.undoStack.stack) || editor.undoStack.length !== editor.undoStack.stack.length ||
190
+ !hasSnapshotShape(editor) || !editor.undoStack.stack.every(hasSnapshotShape) ||
191
+ typeof editor.getText !== "function" || typeof editor.getCursor !== "function" ||
192
+ !editor.history.every((line) => typeof line === "string") ||
193
+ typeof editor.pushUndoSnapshot !== "function" || typeof editor.undo !== "function" || typeof editor.setCursorCol !== "function" ||
194
+ typeof editor.cancelAutocomplete !== "function" || typeof editor.exitHistoryBrowsing !== "function" ||
195
+ typeof editor.layoutText !== "function" || typeof editor.render !== "function" ||
196
+ !Number.isInteger(editor.paddingX) || editor.paddingX < 0) {
197
+ throw new Error("Unsupported Pi editor layout/version");
198
+ }
199
+ assertPosition(editor, { line: s.cursorLine, col: s.cursorCol });
200
+ return {
201
+ dismissAutocomplete(): void { editor.cancelAutocomplete(); },
202
+ undo(): void { assertUniquePastes(editor); editor.undo(); },
203
+ beginInsertSession(): void {
204
+ assertUniquePastes(editor);
205
+ if (insertSessions.has(editor)) throw new Error("Insert session already active");
206
+ const cursor = editor.getCursor();
207
+ const anchor = editor.state.lines.slice(0, cursor.line).reduce((n, line) => n + line.length + 1, 0) + cursor.col;
208
+ insertSessions.set(editor, { prefix: editor.undoStack.stack.slice(), anchor,
209
+ before: structuredClone({ state: editor.state, pastes: editor.pastes, pasteCounter: editor.pasteCounter }), text: editor.getText() });
210
+ },
211
+ markInsertAnchor(): void {
212
+ assertUniquePastes(editor);
213
+ const session = insertSessions.get(editor);
214
+ if (!session) return;
215
+ const cursor = editor.getCursor();
216
+ session.anchor = editor.state.lines.slice(0, cursor.line).reduce((n, line) => n + line.length + 1, 0) + cursor.col;
217
+ session.text = editor.getText();
218
+ },
219
+ endInsertSession(): string | undefined {
220
+ const session = insertSessions.get(editor);
221
+ insertSessions.delete(editor);
222
+ assertUniquePastes(editor);
223
+ if (!session) return;
224
+ const stack = editor.undoStack;
225
+ // Submission clears Pi's stack. Never resurrect a pre-submit snapshot.
226
+ const depth = session.prefix.length;
227
+ if (stack.length < depth || stack.length !== stack.stack.length ||
228
+ session.prefix.some((snapshot, index) => stack.stack[index] !== snapshot)) return;
229
+ const after = editor.getText();
230
+ const inserted = after.startsWith(session.text.slice(0, session.anchor)) &&
231
+ after.endsWith(session.text.slice(session.anchor)) ? after.slice(session.anchor, after.length - (session.text.length - session.anchor)) : undefined;
232
+ if (after === session.before.state.lines.join("\n") && editor.pasteCounter === session.before.pasteCounter) {
233
+ stack.stack.splice(depth);
234
+ editor.lastAction = null;
235
+ return;
236
+ }
237
+ // A mutation without a Pi snapshot is not an editor-owned insert session.
238
+ if (stack.length === depth) return;
239
+ stack.stack.splice(depth);
240
+ stack.push(session.before);
241
+ editor.lastAction = null;
242
+ // A repeat can only carry an insertion at its original anchor. Reject
243
+ // collapsed paste additions and edits to pre-existing text, even if Pi
244
+ // successfully grouped the original session into one undo snapshot.
245
+ return inserted && editor.pasteCounter === session.before.pasteCounter &&
246
+ ![...inserted.matchAll(marker)].some((match) => editor.pastes.has(Number(match[1]))) ? inserted : undefined;
247
+ },
248
+ motionBoundaries(): number[][] {
249
+ assertUniquePastes(editor);
250
+ return editor.state.lines.map((line) => boundaries(line, editor.pastes));
251
+ },
252
+ move(pos: Position): void {
253
+ assertPosition(editor, pos);
254
+ editor.state.cursorLine = pos.line;
255
+ editor.setCursorCol(pos.col);
256
+ editor.lastAction = null;
257
+ if (editor.autocompleteState) editor.cancelAutocomplete();
258
+ },
259
+ moveByGraphemes(delta: number): void {
260
+ if (!Number.isInteger(delta)) throw new Error("Invalid motion");
261
+ const positions = editor.state.lines.flatMap((line, index) =>
262
+ boundaries(line, editor.pastes).map((col) => ({ line: index, col })));
263
+ const current = editor.getCursor();
264
+ const at = positions.findIndex((p) => p.line === current.line && p.col === current.col);
265
+ if (at < 0) throw new Error("Invalid cursor boundary");
266
+ const target = positions[Math.max(0, Math.min(positions.length - 1, at + delta))]!;
267
+ this.move(target);
268
+ },
269
+ readRange(start: Position, end: Position): string {
270
+ assertPosition(editor, start);
271
+ assertPosition(editor, end);
272
+ const offset = (p: Position) => editor.state.lines.slice(0, p.line).reduce((n, line) => n + line.length + 1, 0) + p.col;
273
+ const a = offset(start), b = offset(end);
274
+ if (b < a) throw new Error("Invalid selection range");
275
+ const source = editor.getText();
276
+ for (const match of source.matchAll(marker)) {
277
+ if (editor.pastes.has(Number(match[1])) && a < match.index + match[0].length && b > match.index)
278
+ throw new Error("Selection cannot edit collapsed paste marker");
279
+ }
280
+ return source.slice(a, b);
281
+ },
282
+ replace(start: Position, end: Position, text: string): void {
283
+ assertPosition(editor, start);
284
+ assertPosition(editor, end);
285
+ const source = editor.getText();
286
+ const offset = (p: Position) => editor.state.lines.slice(0, p.line).reduce((n, line) => n + line.length + 1, 0) + p.col;
287
+ const a = offset(start);
288
+ const b = offset(end);
289
+ if (b < a || (b === a && !text) || text.includes("\r")) throw new Error("Invalid operator range or replacement");
290
+ // No registry surgery: a range intersecting a collapsed paste is unsafe.
291
+ for (const match of source.matchAll(marker)) {
292
+ if (editor.pastes.has(Number(match[1])) && a < match.index + match[0].length && b > match.index) {
293
+ throw new Error("Operator cannot edit collapsed paste marker");
294
+ }
295
+ }
296
+ const next = source.slice(0, a) + text + source.slice(b);
297
+ const registered = new Set<number>();
298
+ for (const match of next.matchAll(marker)) {
299
+ const id = Number(match[1]);
300
+ if (!editor.pastes.has(id)) continue;
301
+ if (registered.has(id)) throw new Error("Duplicate registered paste marker: private Vim editing is unsafe");
302
+ registered.add(id);
303
+ }
304
+ if (next === source) return;
305
+ const lines = next.split("\n");
306
+ const prefix = next.slice(0, a + text.length).split("\n");
307
+ editor.cancelAutocomplete();
308
+ editor.exitHistoryBrowsing();
309
+ editor.pushUndoSnapshot();
310
+ editor.state.lines = lines;
311
+ editor.state.cursorLine = prefix.length - 1;
312
+ editor.setCursorCol(prefix.at(-1)!.length);
313
+ editor.lastAction = null;
314
+ editor.scrollOffset = 0;
315
+ editor.onChange?.(next);
316
+ },
317
+ renderSelection(width: number, start: Position, end: Position, rendered?: string[]): string[] {
318
+ assertPosition(editor, start);
319
+ assertPosition(editor, end);
320
+ if (!Number.isInteger(width) || width < 1) throw new Error("Invalid render width");
321
+ const offset = (pos: Position) => editor.state.lines.slice(0, pos.line)
322
+ .reduce((n, line) => n + line.length + 1, 0) + pos.col;
323
+ const from = offset(start);
324
+ const to = offset(end);
325
+ if (from === to) return rendered ?? editor.render(width);
326
+ const selectionFrom = Math.min(from, to);
327
+ const selectionTo = Math.max(from, to);
328
+ // Observe Pi's completed render; never replace its layout method or
329
+ // reconstruct its cursor, borders, autocomplete, or padded row geometry.
330
+ const rows = rendered ?? editor.render(width);
331
+ const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
332
+ const padding = Math.min(editor.paddingX, maxPadding);
333
+ const layoutWidth = Math.max(1, width - padding * 2 - (padding ? 0 : 1));
334
+ if (editor.lastWidth !== layoutWidth || !Number.isInteger(editor.scrollOffset) || editor.scrollOffset < 0) return rows;
335
+ const layout = editor.layoutText(layoutWidth);
336
+ if (!Number.isInteger(editor.renderedVisibleLineCount) || editor.renderedVisibleLineCount < 1 ||
337
+ editor.renderedVisibleLineCount + 2 > rows.length) return rows;
338
+ const visible = layout.slice(editor.scrollOffset, editor.scrollOffset + editor.renderedVisibleLineCount);
339
+ if (visible.length !== editor.renderedVisibleLineCount) return rows;
340
+ let logicalLine = 0;
341
+ let searchFrom = 0;
342
+ // Advance through hidden wraps before mapping visible chunks.
343
+ for (const hidden of layout.slice(0, editor.scrollOffset)) {
344
+ if (typeof hidden.text !== "string") return rows;
345
+ let index = editor.state.lines[logicalLine]?.indexOf(hidden.text, searchFrom) ?? -1;
346
+ while (index < 0 && logicalLine + 1 < editor.state.lines.length) {
347
+ logicalLine++;
348
+ searchFrom = 0;
349
+ index = editor.state.lines[logicalLine]!.indexOf(hidden.text, searchFrom);
350
+ }
351
+ if (index < 0) return rows;
352
+ searchFrom = index + hidden.text.length;
353
+ }
354
+ const replacements: Array<[number, string]> = [];
355
+ for (let row = 0; row < visible.length; row++) {
356
+ const chunk = visible[row]!;
357
+ if (typeof chunk.text !== "string" || typeof chunk.hasCursor !== "boolean" ||
358
+ (chunk.hasCursor && (!Number.isInteger(chunk.cursorPos) || chunk.cursorPos! < 0 || chunk.cursorPos! > chunk.text.length))) return rows;
359
+ let index = editor.state.lines[logicalLine]?.indexOf(chunk.text, searchFrom) ?? -1;
360
+ while (index < 0 && logicalLine + 1 < editor.state.lines.length) {
361
+ logicalLine++;
362
+ searchFrom = 0;
363
+ index = editor.state.lines[logicalLine]!.indexOf(chunk.text, searchFrom);
364
+ }
365
+ if (index < 0) return rows;
366
+ searchFrom = index + chunk.text.length;
367
+ const chunkStart = offset({ line: logicalLine, col: index });
368
+ const left = Math.max(0, Math.min(chunk.text.length, selectionFrom - chunkStart));
369
+ const right = Math.max(0, Math.min(chunk.text.length, selectionTo - chunkStart));
370
+ if (right <= left) continue;
371
+ const original = rows[row + 1]!;
372
+ const prefix = " ".repeat(padding);
373
+ if (!original.startsWith(prefix)) return rows;
374
+ const result = highlightRenderedRow(original, chunk, left, right, padding);
375
+ if (result === undefined || visibleWidth(result) !== visibleWidth(original)) return rows;
376
+ replacements.push([row + 1, result]);
377
+ }
378
+ const result = rows.slice();
379
+ for (const [index, replacement] of replacements) result[index] = replacement;
380
+ return result;
381
+ },
382
+ };
383
+ }
@@ -0,0 +1,154 @@
1
+ export interface VimPosition { line: number; col: number }
2
+ export interface VimMotion { cursor: VimPosition; insert?: string; handled: boolean }
3
+
4
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
5
+ const word = /[\p{L}\p{N}_]/u;
6
+ const whitespace = /\s/u;
7
+ function kind(char: string): number { return whitespace.test(char) ? 0 : word.test(char) ? 1 : 2; }
8
+
9
+ export class VimNormalEngine {
10
+ private digits = "";
11
+ private awaitingG = false;
12
+ private gLine: number | undefined;
13
+ private preferred: number | undefined;
14
+ private pendingFind: { direction: number; till: boolean; count: number } | undefined;
15
+ private lastFind: { char: string; direction: number; till: boolean; target: VimPosition; landing: VimPosition } | undefined;
16
+
17
+ reset(): void {
18
+ this.digits = ""; this.awaitingG = false; this.gLine = undefined; this.preferred = undefined;
19
+ this.pendingFind = undefined; this.lastFind = undefined;
20
+ }
21
+
22
+ cancelPendingFind(): boolean {
23
+ if (!this.pendingFind) return false;
24
+ this.pendingFind = undefined;
25
+ this.digits = "";
26
+ return true;
27
+ }
28
+
29
+ input(key: string, text: string, cursor: VimPosition, motionBoundaries?: number[][]): VimMotion {
30
+ const lines = text.split("\n");
31
+ const positions = motionBoundaries ?? lines.map((line) => [...segmenter.segment(line)].map((part) => part.index).concat(line.length));
32
+ const firstNonWhitespace = (line: number): number => {
33
+ const stops = positions[line]!;
34
+ return stops.find((col, index) => index === stops.length - 1 || !whitespace.test(lines[line]!.slice(col, stops[index + 1]))) ?? 0;
35
+ };
36
+ const result = (line: number, col: number, insert?: string): VimMotion => ({
37
+ cursor: { line, col }, handled: true, ...(insert === undefined ? {} : { insert }),
38
+ });
39
+ const find = this.pendingFind;
40
+ if (find) {
41
+ this.pendingFind = undefined;
42
+ if ([...segmenter.segment(key)].length !== 1 || key.includes("\n")) return { cursor, handled: false };
43
+ return this.findOnLine(key, find.direction, find.till, find.count, text, cursor, positions);
44
+ }
45
+ if (/^[1-9]$/.test(key) || /^[0-9]+[^0-9]$/.test(key)) {
46
+ if (key.length === 1) { this.digits += key; return result(cursor.line, cursor.col); }
47
+ this.digits = key.slice(0, -1);
48
+ key = key.at(-1)!;
49
+ } else if (key === "0" && this.digits) {
50
+ this.digits += key;
51
+ return result(cursor.line, cursor.col);
52
+ }
53
+ const count = Math.min(10000, Number(this.digits) || 1);
54
+ const explicit = this.digits.length > 0;
55
+ this.digits = "";
56
+ if (this.awaitingG) {
57
+ this.awaitingG = false;
58
+ if (key === "g") {
59
+ this.preferred = undefined;
60
+ const line = this.gLine ?? 0;
61
+ this.gLine = undefined;
62
+ return result(Math.min(lines.length - 1, line), 0);
63
+ }
64
+ this.gLine = undefined;
65
+ }
66
+ if (key === "g") { this.awaitingG = true; this.gLine = explicit ? count - 1 : undefined; return result(cursor.line, cursor.col); }
67
+ if (key === "f" || key === "F" || key === "t" || key === "T") {
68
+ this.pendingFind = { direction: key === "f" || key === "t" ? 1 : -1, till: key === "t" || key === "T", count };
69
+ return result(cursor.line, cursor.col);
70
+ }
71
+ if (key === ";" || key === ",") {
72
+ if (!this.lastFind) return result(cursor.line, cursor.col);
73
+ const { char, direction, till, target, landing } = this.lastFind;
74
+ const motion = this.findOnLine(char, direction * (key === ";" ? 1 : -1), till, count, text, cursor, positions,
75
+ till && cursor.line === landing.line && cursor.col === landing.col ? target : undefined);
76
+ if (this.lastFind) this.lastFind.direction = direction;
77
+ return motion;
78
+ }
79
+ const current = positions[cursor.line] ?? [0];
80
+ const colIndex = Math.max(0, current.indexOf(cursor.col));
81
+ if (key === "h" || key === "l" || key === "Space" || key === " ") {
82
+ this.preferred = undefined;
83
+ return result(cursor.line, current[Math.max(0, Math.min(current.length - 1, colIndex + (key === "h" ? -count : count)))]!);
84
+ }
85
+ if (key === "j" || key === "k") {
86
+ this.preferred ??= colIndex;
87
+ const line = Math.max(0, Math.min(lines.length - 1, cursor.line + (key === "j" ? count : -count)));
88
+ return result(line, positions[line]![Math.min(this.preferred, positions[line]!.length - 1)]!);
89
+ }
90
+ this.preferred = undefined;
91
+ if (key === "0") return result(cursor.line, 0);
92
+ if (key === "^") return result(cursor.line, firstNonWhitespace(cursor.line));
93
+ if (key === "$" || key === "A") return result(cursor.line, lines[cursor.line]!.length, key === "A" ? "" : undefined);
94
+ if (key === "G") return result(Math.min(lines.length - 1, explicit ? count - 1 : lines.length - 1), 0);
95
+ if (key === "i") return result(cursor.line, cursor.col, "");
96
+ if (key === "I") return result(cursor.line, firstNonWhitespace(cursor.line), "");
97
+ if (key === "a") return result(cursor.line, current[Math.min(current.length - 1, colIndex + 1)]!, "");
98
+ if (key === "o") return result(cursor.line, lines[cursor.line]!.length, "\n");
99
+ if (key === "O") return result(cursor.line, 0, "\n");
100
+ if (key === "w" || key === "e" || key === "b") {
101
+ const units = lines.flatMap((line, index) => [...segmenter.segment(line)].map((part) => ({ line: index, col: part.index, char: part.segment })).concat(
102
+ index < lines.length - 1 ? [{ line: index, col: line.length, char: "\n" }] : []));
103
+ if (!units.length) return result(cursor.line, cursor.col);
104
+ let at = units.findIndex((p) => p.line === cursor.line && p.col === cursor.col);
105
+ if (at < 0) at = units.length;
106
+ for (let n = 0; n < count; n++) {
107
+ if (key === "w") {
108
+ const currentKind = kind(units[Math.min(at, units.length - 1)]!.char);
109
+ if (currentKind !== 0) while (at < units.length && kind(units[at]!.char) === currentKind) at++;
110
+ while (at < units.length && kind(units[at]!.char) === 0) at++;
111
+ } else if (key === "e") {
112
+ if (at < units.length - 1) at++;
113
+ while (at < units.length - 1 && kind(units[at]!.char) === 0) at++;
114
+ const group = kind(units[Math.min(at, units.length - 1)]!.char);
115
+ while (at < units.length - 1 && kind(units[at + 1]!.char) === group) at++;
116
+ } else {
117
+ at = Math.max(0, at - 1);
118
+ while (at > 0 && kind(units[at]!.char) === 0) at--;
119
+ const group = kind(units[at]!.char);
120
+ while (at > 0 && kind(units[at - 1]!.char) === group) at--;
121
+ }
122
+ }
123
+ const target = units[Math.min(at, units.length - 1)]!;
124
+ return result(target.line, target.col);
125
+ }
126
+ return { cursor, handled: false };
127
+ }
128
+
129
+ private findOnLine(char: string, direction: number, till: boolean, count: number, text: string,
130
+ cursor: VimPosition, positions: number[][], skip?: VimPosition): VimMotion {
131
+ const line = text.split("\n")[cursor.line];
132
+ const stops = positions[cursor.line];
133
+ if (line === undefined || !stops) return { cursor, handled: true };
134
+ let at = stops.indexOf(cursor.col);
135
+ if (at < 0) return { cursor, handled: true };
136
+ // Search only complete motion units: registered paste placeholders are
137
+ // atomic even if the target occurs inside their displayed marker.
138
+ for (let n = 0; n < count; n++) {
139
+ let found = -1;
140
+ for (let i = at + direction; i >= 0 && i < stops.length - 1; i += direction) {
141
+ if (skip?.line === cursor.line && skip.col === stops[i]) continue;
142
+ if (line.slice(stops[i], stops[i + 1]) === char) { found = i; break; }
143
+ }
144
+ if (found < 0) return { cursor, handled: true };
145
+ at = found;
146
+ skip = undefined;
147
+ }
148
+ const col = stops[Math.max(0, Math.min(stops.length - 1, at - (till ? direction : 0)))]!;
149
+ const landing = { line: cursor.line, col };
150
+ this.preferred = undefined;
151
+ this.lastFind = { char, direction, till, target: { line: cursor.line, col: stops[at]! }, landing };
152
+ return { cursor: landing, handled: true };
153
+ }
154
+ }