gentle-pi 3.7.0 → 4.0.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 (316) hide show
  1. package/README.md +37 -6
  2. package/assets/agents/gentle-ai-explore.md +4 -4
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -9
  5. package/assets/orchestrator-delegation.md +40 -41
  6. package/assets/orchestrator-memory.md +1 -22
  7. package/assets/orchestrator-skills.md +1 -1
  8. package/assets/orchestrator.md +12 -24
  9. package/assets/support/strict-tdd-verify.md +4 -266
  10. package/assets/support/strict-tdd.md +8 -360
  11. package/bin/gentle-shell.mjs +254 -29
  12. package/docs/delegated-verification.md +26 -1
  13. package/docs/gentle-agents-activity.md +24 -0
  14. package/docs/gentle-shell.md +92 -28
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +112 -206
  18. package/docs/telemetry.md +1 -1
  19. package/docs/yolo-mode.md +86 -0
  20. package/extensions/child-context.ts +26 -0
  21. package/extensions/child-safety.ts +23 -0
  22. package/extensions/gentle-agents.ts +429 -262
  23. package/extensions/gentle-ai.ts +781 -683
  24. package/extensions/gentle-shell.ts +1375 -88
  25. package/extensions/gentle-stats.ts +101 -0
  26. package/extensions/gentle-todo.ts +18 -7
  27. package/extensions/history/atomic-write.ts +38 -0
  28. package/extensions/history/hide-prompts.ts +183 -0
  29. package/extensions/history/index.ts +1419 -0
  30. package/extensions/history/load-shared-history.ts +39 -0
  31. package/extensions/history/selector-helpers.ts +538 -0
  32. package/extensions/history/session-scan.ts +233 -0
  33. package/extensions/history/store.ts +1119 -0
  34. package/extensions/nan-provider.ts +6 -0
  35. package/extensions/quiet-tools.ts +179 -87
  36. package/extensions/resume-hint.ts +60 -0
  37. package/extensions/skill-registry.ts +16 -12
  38. package/extensions/startup-banner.ts +60 -31
  39. package/lib/agent-assets.ts +604 -0
  40. package/lib/agent-profile-pin.ts +12 -0
  41. package/lib/agents-message-delivery.ts +181 -0
  42. package/lib/agents-protocol.ts +60 -0
  43. package/lib/agents-runner.ts +96 -98
  44. package/lib/agents-view.ts +26 -3
  45. package/lib/agents-widget.ts +16 -9
  46. package/lib/append-system-prompt.ts +21 -0
  47. package/lib/bounded-writer-admission.ts +147 -0
  48. package/lib/card-style-policy.ts +60 -0
  49. package/lib/child-context-files.ts +166 -0
  50. package/lib/codemode-renderer.ts +185 -0
  51. package/lib/command-palette-catalog.ts +3 -9
  52. package/lib/command-palette.ts +25 -14
  53. package/lib/destructive-command-guard.ts +144 -0
  54. package/lib/gentle-ai-elapsed-store.ts +87 -0
  55. package/lib/gentle-ai-renderer.ts +196 -39
  56. package/lib/gentle-shell-launcher.ts +24 -13
  57. package/lib/gentle-shell-resume-hint.ts +176 -0
  58. package/lib/history-capture-policy.ts +95 -0
  59. package/lib/model-routing-authority.ts +5 -1
  60. package/lib/nan-provider.ts +227 -0
  61. package/lib/native-review-cli.ts +49 -108
  62. package/lib/odd-phase-inference.ts +231 -0
  63. package/lib/odd-phase.ts +141 -0
  64. package/lib/overlay-repaint.ts +26 -0
  65. package/lib/pi-tui-keys.ts +53 -0
  66. package/lib/review-candidate-view-owner.ts +67 -17
  67. package/lib/review-candidate-view.ts +112 -25
  68. package/lib/review-reminder-receipt.ts +48 -8
  69. package/lib/review-risk-assessment.ts +156 -11
  70. package/lib/review-sidebar-state.ts +223 -0
  71. package/lib/selection-engine.ts +515 -0
  72. package/lib/session-messaging-grants.ts +135 -0
  73. package/lib/session-worktree-registry.ts +14 -2
  74. package/lib/shell-bar.ts +179 -24
  75. package/lib/shell-card.ts +287 -18
  76. package/lib/shell-changes-view.ts +2 -1
  77. package/lib/shell-prompt.ts +102 -6
  78. package/lib/shell-sidebar-layout.ts +78 -14
  79. package/lib/shell-sidebar.ts +15 -1
  80. package/lib/shell-todo.ts +23 -13
  81. package/lib/shell-usage-view.ts +9 -4
  82. package/lib/shell-usage.ts +66 -10
  83. package/lib/stats-collector.ts +381 -0
  84. package/lib/stats-view.ts +431 -0
  85. package/lib/theme-customization.ts +52 -0
  86. package/lib/vim-editor-adapter.ts +379 -0
  87. package/lib/vim-normal-engine.ts +154 -0
  88. package/lib/vim-operator-engine.ts +416 -0
  89. package/lib/vim-policy.ts +49 -0
  90. package/lib/vim-visual-engine.ts +107 -0
  91. package/lib/visual-customization-policy.ts +108 -0
  92. package/lib/visual-customize-view.ts +330 -0
  93. package/lib/visual-profiles.ts +228 -0
  94. package/lib/yolo-session-policy.ts +240 -0
  95. package/package.json +20 -8
  96. package/runtime/gentle-shell-launcher.mjs +23 -12
  97. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  98. package/runtime/native-review-cli.mjs +49 -108
  99. package/runtime/review-risk-assessment.mjs +154 -9
  100. package/scripts/build-runtime-modules.mjs +1 -0
  101. package/scripts/gentle-ai-installer.mjs +14 -13
  102. package/scripts/mirror-odd-routing.mjs +2 -2
  103. package/scripts/run-test-suite.mjs +76 -0
  104. package/scripts/test-packed-runner.mjs +31 -14
  105. package/scripts/verify-package-files.mjs +11 -22
  106. package/skills/branch-pr/SKILL.md +24 -52
  107. package/skills/chained-pr/SKILL.md +31 -15
  108. package/skills/chained-pr/references/chaining-details.md +31 -20
  109. package/skills/gentle-ai/SKILL.md +9 -15
  110. package/skills/issue-creation/SKILL.md +8 -2
  111. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  112. package/skills/work-unit-commits/SKILL.md +4 -3
  113. package/tests/agent-profiles.test.ts +18 -0
  114. package/tests/agents-fake-child.ts +2 -2
  115. package/tests/agents-message-delivery.test.ts +106 -0
  116. package/tests/agents-protocol.test.ts +40 -0
  117. package/tests/agents-runner.test.ts +378 -89
  118. package/tests/agents-view-thread-identity.test.ts +169 -0
  119. package/tests/agents-view.test.ts +8 -2
  120. package/tests/agents-widget.test.ts +154 -15
  121. package/tests/append-system-prompt-route.test.ts +160 -0
  122. package/tests/append-system-prompt.test.ts +46 -0
  123. package/tests/artifact-language.test.ts +19 -213
  124. package/tests/ask-user-question.test.ts +44 -1
  125. package/tests/asset-installation-runtime.test.ts +5 -16
  126. package/tests/autonomous-guard.test.ts +69 -1
  127. package/tests/bounded-writer-admission.test.ts +95 -0
  128. package/tests/branch-pr-skill.test.ts +43 -0
  129. package/tests/card-style-policy.test.ts +55 -0
  130. package/tests/chained-pr-skill.test.ts +124 -0
  131. package/tests/child-context-files.test.ts +255 -0
  132. package/tests/child-safety.test.ts +82 -0
  133. package/tests/codemode-rendering.test.ts +491 -0
  134. package/tests/command-palette.test.ts +39 -3
  135. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  136. package/tests/destructive-command-guard.test.ts +84 -0
  137. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  138. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  139. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  140. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  145. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  146. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  148. package/tests/generic-agent-tools.test.ts +54 -0
  149. package/tests/gentle-agents.test.ts +1047 -252
  150. package/tests/gentle-ai-binary.test.ts +3 -3
  151. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  152. package/tests/gentle-ai-installer.test.ts +68 -54
  153. package/tests/gentle-ai-renderer.test.ts +487 -8
  154. package/tests/gentle-ai.test.ts +288 -65
  155. package/tests/gentle-card-text.ts +2 -1
  156. package/tests/gentle-shell-bin.test.ts +651 -114
  157. package/tests/gentle-shell-launcher.test.ts +99 -52
  158. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  159. package/tests/gentle-shell.test.ts +3839 -187
  160. package/tests/gentle-stats.test.ts +152 -0
  161. package/tests/gentle-theme.test.ts +4 -1
  162. package/tests/gentle-todo.test.ts +80 -8
  163. package/tests/history-atomic-write.test.ts +57 -0
  164. package/tests/history-capture-policy.test.ts +102 -0
  165. package/tests/history-command-registration.test.ts +164 -0
  166. package/tests/history-dedupe-entries.test.ts +123 -0
  167. package/tests/history-delete-backfill.test.ts +190 -0
  168. package/tests/history-delete-confirm.test.ts +460 -0
  169. package/tests/history-dispatch.test.ts +180 -0
  170. package/tests/history-drain-hidden.test.ts +110 -0
  171. package/tests/history-drain-order.test.ts +98 -0
  172. package/tests/history-expanded-globals.test.ts +62 -0
  173. package/tests/history-gc.test.ts +832 -0
  174. package/tests/history-header-layout.test.ts +265 -0
  175. package/tests/history-hide-prompts.test.ts +275 -0
  176. package/tests/history-lazy-windowing.test.ts +508 -0
  177. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  178. package/tests/history-load-shared-history.test.ts +53 -0
  179. package/tests/history-max-results-cap.test.ts +76 -0
  180. package/tests/history-multi-reader.test.ts +203 -0
  181. package/tests/history-off-path.test.ts +170 -0
  182. package/tests/history-openflow-integration.test.ts +173 -0
  183. package/tests/history-overlay-margin.test.ts +326 -0
  184. package/tests/history-preview-layout.test.ts +93 -0
  185. package/tests/history-registry.test.ts +143 -0
  186. package/tests/history-scope-delete.test.ts +411 -0
  187. package/tests/history-search-caret-keys.test.ts +142 -0
  188. package/tests/history-seed-bootstrap.test.ts +170 -0
  189. package/tests/history-seed-regen.test.ts +129 -0
  190. package/tests/history-selector-windowing.test.ts +94 -0
  191. package/tests/history-session-scan-directory.test.ts +87 -0
  192. package/tests/history-session-scan-extract.test.ts +583 -0
  193. package/tests/history-session-writer.test.ts +351 -0
  194. package/tests/history-store-paths.test.ts +79 -0
  195. package/tests/history-tombstone-exact.test.ts +139 -0
  196. package/tests/history-wheel-mouse.test.ts +242 -0
  197. package/tests/inprocess-reviewer.test.ts +29 -19
  198. package/tests/issue-creation-skill.test.ts +61 -0
  199. package/tests/model-routing-authority.test.ts +16 -0
  200. package/tests/nan-provider.test.ts +471 -0
  201. package/tests/native-review-capability-contract.test.ts +7 -1
  202. package/tests/native-review-cli.test.ts +6 -120
  203. package/tests/native-review-parity-runtime.test.ts +100 -3
  204. package/tests/odd-integration.test.ts +67 -0
  205. package/tests/odd-phase-inference.test.ts +213 -0
  206. package/tests/odd-phase-loader.test.ts +253 -0
  207. package/tests/odd-phase.test.ts +307 -0
  208. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  209. package/tests/odd-routing-contract.test.ts +86 -35
  210. package/tests/orchestrator-budget.test.ts +14 -39
  211. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  212. package/tests/overlay-repaint.test.ts +74 -0
  213. package/tests/package-manifest.test.ts +252 -115
  214. package/tests/packed-runner-owned-path.test.ts +46 -0
  215. package/tests/persona-single-channel.test.ts +6 -6
  216. package/tests/provider-defect-handoff.test.ts +3 -11
  217. package/tests/quiet-bash-runtime.test.ts +76 -0
  218. package/tests/quiet-tool-rendering.test.ts +409 -184
  219. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  220. package/tests/rdd-status-line.test.ts +9 -4
  221. package/tests/resume-hint-extension.test.ts +122 -0
  222. package/tests/review-agent-end-preflight.test.ts +176 -12
  223. package/tests/review-candidate-owner-retry.test.ts +22 -1
  224. package/tests/review-candidate-view.test.ts +298 -0
  225. package/tests/review-contract-prompt.test.ts +108 -43
  226. package/tests/review-controller-lock-status.test.ts +0 -1
  227. package/tests/review-controller-native-routing.test.ts +611 -5
  228. package/tests/review-controller-workspace-root.test.ts +163 -4
  229. package/tests/review-host-relay-routing.test.ts +338 -2
  230. package/tests/review-integration-v2-forward.test.ts +200 -0
  231. package/tests/review-ledger-contract.test.ts +10 -34
  232. package/tests/review-reminder-receipt.test.ts +47 -1
  233. package/tests/review-risk-assessment.test.ts +498 -6
  234. package/tests/review-sidebar-state.test.ts +402 -0
  235. package/tests/run-test-suite.test.ts +124 -0
  236. package/tests/runtime-harness.mjs +145 -786
  237. package/tests/runtime-metrics-children.test.ts +16 -23
  238. package/tests/selection-engine.test.ts +421 -0
  239. package/tests/session-messaging-grants.test.ts +255 -0
  240. package/tests/session-worktree-registry.test.ts +77 -0
  241. package/tests/shell-bar.test.ts +382 -1
  242. package/tests/shell-card.test.ts +353 -1
  243. package/tests/shell-changes-view.test.ts +52 -0
  244. package/tests/shell-prompt.test.ts +94 -2
  245. package/tests/shell-sidebar-layout.test.ts +325 -21
  246. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  247. package/tests/shell-todo.test.ts +87 -1
  248. package/tests/shell-usage-view.test.ts +27 -0
  249. package/tests/shell-usage.test.ts +73 -0
  250. package/tests/skill-registry.test.ts +50 -1
  251. package/tests/startup-banner.test.ts +130 -2
  252. package/tests/stats-collector.test.ts +195 -0
  253. package/tests/stats-view.test.ts +202 -0
  254. package/tests/telemetry-trigger.test.ts +81 -20
  255. package/tests/theme-customization.test.ts +72 -0
  256. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  257. package/tests/vim-editor-adapter.test.ts +804 -0
  258. package/tests/vim-normal-engine.test.ts +101 -0
  259. package/tests/vim-operator-engine.test.ts +215 -0
  260. package/tests/vim-policy.test.ts +19 -0
  261. package/tests/vim-visual-engine.test.ts +52 -0
  262. package/tests/visual-customization-policy.test.ts +110 -0
  263. package/tests/visual-customize-view.test.ts +418 -0
  264. package/tests/visual-profiles.test.ts +87 -0
  265. package/tests/yolo-customize.test.ts +256 -0
  266. package/tests/yolo-mode-runtime.test.ts +161 -0
  267. package/tests/yolo-mode.test.ts +261 -0
  268. package/tests/yolo-session-policy.test.ts +59 -0
  269. package/themes/Gentle.json +2 -1
  270. package/themes/Gentleman-Cute.json +2 -1
  271. package/themes/Gentleman-Sexy.json +2 -1
  272. package/assets/agents/sdd-apply.md +0 -159
  273. package/assets/agents/sdd-archive.md +0 -228
  274. package/assets/agents/sdd-design.md +0 -49
  275. package/assets/agents/sdd-explore.md +0 -48
  276. package/assets/agents/sdd-init.md +0 -56
  277. package/assets/agents/sdd-onboard.md +0 -52
  278. package/assets/agents/sdd-proposal.md +0 -64
  279. package/assets/agents/sdd-remediate.md +0 -37
  280. package/assets/agents/sdd-research.md +0 -49
  281. package/assets/agents/sdd-spec.md +0 -192
  282. package/assets/agents/sdd-status.md +0 -54
  283. package/assets/agents/sdd-tasks.md +0 -108
  284. package/assets/agents/sdd-verify.md +0 -124
  285. package/assets/chains/sdd-full.chain.md +0 -83
  286. package/assets/chains/sdd-plan.chain.md +0 -56
  287. package/assets/chains/sdd-verify.chain.md +0 -43
  288. package/assets/sdd-orchestrator-workflow.md +0 -319
  289. package/assets/support/sdd-status-contract.md +0 -77
  290. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  291. package/extensions/sdd-init.ts +0 -816
  292. package/lib/openspec-deltas.ts +0 -156
  293. package/lib/sdd-preflight.ts +0 -1066
  294. package/lib/sdd-research-capabilities.ts +0 -94
  295. package/lib/sdd-status.ts +0 -26
  296. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  297. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  298. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  299. package/tests/openspec-deltas.test.ts +0 -209
  300. package/tests/sdd-agent-tools.test.ts +0 -156
  301. package/tests/sdd-archive-replay.test.ts +0 -82
  302. package/tests/sdd-classical-continuation.test.ts +0 -74
  303. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  304. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  305. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  306. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  307. package/tests/sdd-odd-integration.test.ts +0 -33
  308. package/tests/sdd-optional-research.test.ts +0 -124
  309. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  310. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  311. package/tests/sdd-preflight.test.ts +0 -541
  312. package/tests/sdd-research-capabilities.test.ts +0 -114
  313. package/tests/sdd-research-live.test.ts +0 -241
  314. package/tests/sdd-selection-transport.test.ts +0 -653
  315. package/tests/sdd-status.test.ts +0 -9
  316. package/tests/sdd-task-truth.test.ts +0 -43
@@ -0,0 +1,379 @@
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 and 1.0.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
+ // Actual 1.0.0 bundled and unbundled tests exercise identity, edit/undo, paste,
10
+ // selection, wrapping/scroll and autocomplete; fabricated metadata tests only
11
+ // prove exact-version admission for the older audited releases.
12
+ // Never silently adapt another build: undo also owns the paste registry.
13
+ interface Position { line: number; col: number }
14
+ interface State { lines: string[]; cursorLine: number; cursorCol: number }
15
+ interface Snapshot { state: State; pastes: Map<number, string>; pasteCounter: number }
16
+ interface Stack { stack: Snapshot[]; length: number; push(snapshot: Snapshot): void; pop(): Snapshot | undefined }
17
+ interface InsertSession { prefix: Snapshot[]; before: Snapshot; text: string; anchor: number }
18
+ const insertSessions = new WeakMap<object, InsertSession>();
19
+ interface LayoutLine { text: string; hasCursor: boolean; cursorPos?: number }
20
+ interface PrivateEditor {
21
+ state: State;
22
+ getText(): string;
23
+ getCursor(): Position;
24
+ onChange?: (text: string) => void;
25
+ pastes: Map<number, string>;
26
+ pasteCounter: number;
27
+ undoStack: Stack;
28
+ history: string[];
29
+ historyIndex: number;
30
+ historyDraft: State | null;
31
+ lastAction: string | null;
32
+ preferredVisualCol: number | null;
33
+ snappedFromCursorCol: number | null;
34
+ scrollOffset: number;
35
+ renderedVisibleLineCount: number;
36
+ paddingX: number;
37
+ layoutText(width: number): LayoutLine[];
38
+ render(width: number): string[];
39
+ lastWidth: number;
40
+ pushUndoSnapshot(): void;
41
+ undo(): void;
42
+ setCursorCol(col: number): void;
43
+ autocompleteState: unknown;
44
+ cancelAutocomplete(): void;
45
+ exitHistoryBrowsing(): void;
46
+ }
47
+
48
+ // The single audited-release source for the adapter and Gentle Shell's runtime
49
+ // identity gates. Add a release only after re-auditing the files named above.
50
+ export const AUDITED_PI_EDITOR_VERSIONS = Object.freeze(["0.99.1", "0.99.2", "1.0.0"] as const);
51
+ export type AuditedPiEditorVersion = (typeof AUDITED_PI_EDITOR_VERSIONS)[number];
52
+
53
+ export function isAuditedPiEditorVersion(version: unknown): version is AuditedPiEditorVersion {
54
+ return typeof version === "string" && (AUDITED_PI_EDITOR_VERSIONS as readonly string[]).includes(version);
55
+ }
56
+ // Pi aliases only ES imports of host packages. This raw require walks
57
+ // node_modules from the extension, which a git install lacks (#1586), so an
58
+ // unresolved host leaves the version unknown and the identity gate closed.
59
+ const importedTuiMetadata: unknown = (() => {
60
+ try { return createRequire(import.meta.url)("@earendil-works/pi-tui/package.json"); } catch { return undefined; }
61
+ })();
62
+ const IMPORTED_TUI_VERSION = typeof importedTuiMetadata === "object" && importedTuiMetadata !== null &&
63
+ "version" in importedTuiMetadata ? importedTuiMetadata.version : undefined;
64
+
65
+ // The caller supplies the Editor constructor from the same Pi/TUI package pair
66
+ // as the version metadata. An arbitrary object with matching fields is not an editor.
67
+ function hasEditorIdentity(value: unknown, version: string, editorClass: typeof Editor, verifiedVersion?: string): boolean {
68
+ if (!isAuditedPiEditorVersion(version) || typeof value !== "object" || value === null ||
69
+ (verifiedVersion !== undefined ? version !== verifiedVersion :
70
+ editorClass === Editor ? version !== IMPORTED_TUI_VERSION : true) || !(value instanceof editorClass)) return false;
71
+ let prototype: unknown = Object.getPrototypeOf(value);
72
+ for (let depth = 0; depth < 3; depth++) {
73
+ if (prototype === editorClass.prototype) return true;
74
+ if (typeof prototype !== "object" || prototype === null || !Object.hasOwn(prototype, "constructor")) return false;
75
+ prototype = Object.getPrototypeOf(prototype);
76
+ }
77
+ return false;
78
+ }
79
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
80
+ const marker = /\[paste #(\d+)(?: (?:\+\d+ lines|\d+ chars))?\]/g;
81
+
82
+ // Only CSI SGR and Pi's own zero-width cursor marker are expected in editor
83
+ // text. Unknown escapes leave the unmodified frame intact (fail closed).
84
+ function highlightRenderedRow(row: string, chunk: LayoutLine, left: number, right: number, padding: number): string | undefined {
85
+ const cursor = chunk.hasCursor ? chunk.cursorPos : undefined;
86
+ const tokens = row.match(/\x1b\[[0-9;]*m|\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b_[^\x07\x1b]*(?:\x07|\x1b\\)|[^\x1b]/g);
87
+ if (!tokens || tokens.join("") !== row) return undefined;
88
+ let plain = "";
89
+ for (const token of tokens) if (!token.startsWith("\x1b")) plain += token;
90
+ const before = " ".repeat(padding);
91
+ if (!plain.startsWith(before + chunk.text)) return undefined;
92
+ // Pi's fake cursor inserts one space only when it sits at the end.
93
+ const expected = before + chunk.text + (cursor === chunk.text.length ? " " : "");
94
+ if (!plain.startsWith(expected)) return undefined;
95
+ let result = "";
96
+ let col = 0;
97
+ let selected = false;
98
+ let inverse = false;
99
+ let nativeInverse = false;
100
+ for (const token of tokens) {
101
+ if (token.startsWith("\x1b[")) {
102
+ const codes = token.slice(2, -1).split(";").map(Number);
103
+ for (let i = 0; i < codes.length; i++) {
104
+ const code = codes[i];
105
+ if ((code === 38 || code === 48 || code === 58) && codes[i + 1] === 2 && i + 4 < codes.length) i += 4;
106
+ else if ((code === 38 || code === 48 || code === 58) && codes[i + 1] === 5 && i + 2 < codes.length) i += 2;
107
+ else if (code === 0 || code === 27) nativeInverse = inverse = false;
108
+ else if (code === 7) nativeInverse = inverse = true;
109
+ }
110
+ result += token;
111
+ if (selected && !inverse) { result += "\x1b[7m"; inverse = true; }
112
+ } else if (token.startsWith("\x1b")) {
113
+ if (token !== CURSOR_MARKER) return undefined;
114
+ result += token;
115
+ } else {
116
+ const local = col - padding;
117
+ const shouldSelect = local >= left && local < right && local < chunk.text.length;
118
+ if (shouldSelect !== selected) {
119
+ // SGR 27 changes only reverse video; keep foreground/background intact.
120
+ if (shouldSelect || !nativeInverse && inverse) result += shouldSelect ? "\x1b[7m" : "\x1b[27m";
121
+ selected = shouldSelect;
122
+ inverse = shouldSelect || nativeInverse;
123
+ }
124
+ result += token;
125
+ col++;
126
+ }
127
+ }
128
+ if (selected && !nativeInverse) result += "\x1b[27m";
129
+ return result;
130
+ }
131
+
132
+ function boundaries(line: string, pastes: Map<number, string>): number[] {
133
+ const units = [...segmenter.segment(line)].map((part) => part.index);
134
+ for (const match of line.matchAll(marker)) {
135
+ if (!pastes.has(Number(match[1]))) continue;
136
+ const start = match.index;
137
+ const end = start + match[0].length;
138
+ for (let i = units.length - 1; i >= 0; i--) {
139
+ if (units[i]! > start && units[i]! < end) units.splice(i, 1);
140
+ }
141
+ }
142
+ return [...units, line.length];
143
+ }
144
+
145
+ function assertUniquePastes(editor: PrivateEditor): void {
146
+ const registered = new Set<number>();
147
+ for (const match of editor.getText().matchAll(marker)) {
148
+ const id = Number(match[1]);
149
+ if (!editor.pastes.has(id)) continue;
150
+ if (registered.has(id)) throw new Error("Duplicate registered paste marker: private Vim editing is unsafe");
151
+ registered.add(id);
152
+ }
153
+ }
154
+
155
+ function assertPosition(editor: PrivateEditor, pos: Position): void {
156
+ assertUniquePastes(editor);
157
+ const line = editor.state.lines[pos.line];
158
+ if (line === undefined || !Number.isInteger(pos.col) || !boundaries(line, editor.pastes).includes(pos.col)) {
159
+ throw new Error("Cursor must be on a valid grapheme or paste boundary");
160
+ }
161
+ }
162
+
163
+ function hasSnapshotShape(value: unknown): value is Snapshot {
164
+ if (typeof value !== "object" || value === null || !("state" in value) ||
165
+ !("pastes" in value) || !("pasteCounter" in value)) return false;
166
+ const state = value.state;
167
+ return typeof state === "object" && state !== null && "lines" in state &&
168
+ Array.isArray(state.lines) && state.lines.length > 0 && state.lines.every((line) => typeof line === "string") &&
169
+ "cursorLine" in state && Number.isInteger(state.cursorLine) &&
170
+ typeof state.cursorLine === "number" && state.cursorLine >= 0 && state.cursorLine < state.lines.length &&
171
+ "cursorCol" in state && typeof state.cursorCol === "number" && Number.isInteger(state.cursorCol) &&
172
+ state.cursorCol >= 0 && state.cursorCol <= state.lines[state.cursorLine].length &&
173
+ value.pastes instanceof Map && [...value.pastes].every(([id, text]) => Number.isInteger(id) && id > 0 && typeof text === "string") &&
174
+ typeof value.pasteCounter === "number" && Number.isInteger(value.pasteCounter) && value.pasteCounter >= 0;
175
+ }
176
+
177
+ export function createVimEditorAdapter(value: unknown, version: string, editorClass: typeof Editor = Editor, verifiedVersion?: string) {
178
+ if (!hasEditorIdentity(value, version, editorClass, verifiedVersion)) throw new Error("Unsupported Pi editor layout/version");
179
+ const editor = value as unknown as PrivateEditor;
180
+ const s = editor.state;
181
+ if (!s || !Array.isArray(s.lines) || s.lines.length === 0 || !s.lines.every((line) => typeof line === "string") ||
182
+ !Number.isInteger(s.cursorLine) || !Number.isInteger(s.cursorCol) || !(editor.pastes instanceof Map) ||
183
+ !Number.isInteger(editor.pasteCounter) || !Array.isArray(editor.history) ||
184
+ typeof editor.undoStack?.push !== "function" || typeof editor.undoStack?.pop !== "function" ||
185
+ !Array.isArray(editor.undoStack.stack) || editor.undoStack.length !== editor.undoStack.stack.length ||
186
+ !hasSnapshotShape(editor) || !editor.undoStack.stack.every(hasSnapshotShape) ||
187
+ typeof editor.getText !== "function" || typeof editor.getCursor !== "function" ||
188
+ !editor.history.every((line) => typeof line === "string") ||
189
+ typeof editor.pushUndoSnapshot !== "function" || typeof editor.undo !== "function" || typeof editor.setCursorCol !== "function" ||
190
+ typeof editor.cancelAutocomplete !== "function" || typeof editor.exitHistoryBrowsing !== "function" ||
191
+ typeof editor.layoutText !== "function" || typeof editor.render !== "function" ||
192
+ !Number.isInteger(editor.paddingX) || editor.paddingX < 0) {
193
+ throw new Error("Unsupported Pi editor layout/version");
194
+ }
195
+ assertPosition(editor, { line: s.cursorLine, col: s.cursorCol });
196
+ return {
197
+ dismissAutocomplete(): void { editor.cancelAutocomplete(); },
198
+ undo(): void { assertUniquePastes(editor); editor.undo(); },
199
+ beginInsertSession(): void {
200
+ assertUniquePastes(editor);
201
+ if (insertSessions.has(editor)) throw new Error("Insert session already active");
202
+ const cursor = editor.getCursor();
203
+ const anchor = editor.state.lines.slice(0, cursor.line).reduce((n, line) => n + line.length + 1, 0) + cursor.col;
204
+ insertSessions.set(editor, { prefix: editor.undoStack.stack.slice(), anchor,
205
+ before: structuredClone({ state: editor.state, pastes: editor.pastes, pasteCounter: editor.pasteCounter }), text: editor.getText() });
206
+ },
207
+ markInsertAnchor(): void {
208
+ assertUniquePastes(editor);
209
+ const session = insertSessions.get(editor);
210
+ if (!session) return;
211
+ const cursor = editor.getCursor();
212
+ session.anchor = editor.state.lines.slice(0, cursor.line).reduce((n, line) => n + line.length + 1, 0) + cursor.col;
213
+ session.text = editor.getText();
214
+ },
215
+ endInsertSession(): string | undefined {
216
+ const session = insertSessions.get(editor);
217
+ insertSessions.delete(editor);
218
+ assertUniquePastes(editor);
219
+ if (!session) return;
220
+ const stack = editor.undoStack;
221
+ // Submission clears Pi's stack. Never resurrect a pre-submit snapshot.
222
+ const depth = session.prefix.length;
223
+ if (stack.length < depth || stack.length !== stack.stack.length ||
224
+ session.prefix.some((snapshot, index) => stack.stack[index] !== snapshot)) return;
225
+ const after = editor.getText();
226
+ const inserted = after.startsWith(session.text.slice(0, session.anchor)) &&
227
+ after.endsWith(session.text.slice(session.anchor)) ? after.slice(session.anchor, after.length - (session.text.length - session.anchor)) : undefined;
228
+ if (after === session.before.state.lines.join("\n") && editor.pasteCounter === session.before.pasteCounter) {
229
+ stack.stack.splice(depth);
230
+ editor.lastAction = null;
231
+ return;
232
+ }
233
+ // A mutation without a Pi snapshot is not an editor-owned insert session.
234
+ if (stack.length === depth) return;
235
+ stack.stack.splice(depth);
236
+ stack.push(session.before);
237
+ editor.lastAction = null;
238
+ // A repeat can only carry an insertion at its original anchor. Reject
239
+ // collapsed paste additions and edits to pre-existing text, even if Pi
240
+ // successfully grouped the original session into one undo snapshot.
241
+ return inserted && editor.pasteCounter === session.before.pasteCounter &&
242
+ ![...inserted.matchAll(marker)].some((match) => editor.pastes.has(Number(match[1]))) ? inserted : undefined;
243
+ },
244
+ motionBoundaries(): number[][] {
245
+ assertUniquePastes(editor);
246
+ return editor.state.lines.map((line) => boundaries(line, editor.pastes));
247
+ },
248
+ move(pos: Position): void {
249
+ assertPosition(editor, pos);
250
+ editor.state.cursorLine = pos.line;
251
+ editor.setCursorCol(pos.col);
252
+ editor.lastAction = null;
253
+ if (editor.autocompleteState) editor.cancelAutocomplete();
254
+ },
255
+ moveByGraphemes(delta: number): void {
256
+ if (!Number.isInteger(delta)) throw new Error("Invalid motion");
257
+ const positions = editor.state.lines.flatMap((line, index) =>
258
+ boundaries(line, editor.pastes).map((col) => ({ line: index, col })));
259
+ const current = editor.getCursor();
260
+ const at = positions.findIndex((p) => p.line === current.line && p.col === current.col);
261
+ if (at < 0) throw new Error("Invalid cursor boundary");
262
+ const target = positions[Math.max(0, Math.min(positions.length - 1, at + delta))]!;
263
+ this.move(target);
264
+ },
265
+ readRange(start: Position, end: Position): string {
266
+ assertPosition(editor, start);
267
+ assertPosition(editor, end);
268
+ const offset = (p: Position) => editor.state.lines.slice(0, p.line).reduce((n, line) => n + line.length + 1, 0) + p.col;
269
+ const a = offset(start), b = offset(end);
270
+ if (b < a) throw new Error("Invalid selection range");
271
+ const source = editor.getText();
272
+ for (const match of source.matchAll(marker)) {
273
+ if (editor.pastes.has(Number(match[1])) && a < match.index + match[0].length && b > match.index)
274
+ throw new Error("Selection cannot edit collapsed paste marker");
275
+ }
276
+ return source.slice(a, b);
277
+ },
278
+ replace(start: Position, end: Position, text: string): void {
279
+ assertPosition(editor, start);
280
+ assertPosition(editor, end);
281
+ const source = editor.getText();
282
+ const offset = (p: Position) => editor.state.lines.slice(0, p.line).reduce((n, line) => n + line.length + 1, 0) + p.col;
283
+ const a = offset(start);
284
+ const b = offset(end);
285
+ if (b < a || (b === a && !text) || text.includes("\r")) throw new Error("Invalid operator range or replacement");
286
+ // No registry surgery: a range intersecting a collapsed paste is unsafe.
287
+ for (const match of source.matchAll(marker)) {
288
+ if (editor.pastes.has(Number(match[1])) && a < match.index + match[0].length && b > match.index) {
289
+ throw new Error("Operator cannot edit collapsed paste marker");
290
+ }
291
+ }
292
+ const next = source.slice(0, a) + text + source.slice(b);
293
+ const registered = new Set<number>();
294
+ for (const match of next.matchAll(marker)) {
295
+ const id = Number(match[1]);
296
+ if (!editor.pastes.has(id)) continue;
297
+ if (registered.has(id)) throw new Error("Duplicate registered paste marker: private Vim editing is unsafe");
298
+ registered.add(id);
299
+ }
300
+ if (next === source) return;
301
+ const lines = next.split("\n");
302
+ const prefix = next.slice(0, a + text.length).split("\n");
303
+ editor.cancelAutocomplete();
304
+ editor.exitHistoryBrowsing();
305
+ editor.pushUndoSnapshot();
306
+ editor.state.lines = lines;
307
+ editor.state.cursorLine = prefix.length - 1;
308
+ editor.setCursorCol(prefix.at(-1)!.length);
309
+ editor.lastAction = null;
310
+ editor.scrollOffset = 0;
311
+ editor.onChange?.(next);
312
+ },
313
+ renderSelection(width: number, start: Position, end: Position, rendered?: string[]): string[] {
314
+ assertPosition(editor, start);
315
+ assertPosition(editor, end);
316
+ if (!Number.isInteger(width) || width < 1) throw new Error("Invalid render width");
317
+ const offset = (pos: Position) => editor.state.lines.slice(0, pos.line)
318
+ .reduce((n, line) => n + line.length + 1, 0) + pos.col;
319
+ const from = offset(start);
320
+ const to = offset(end);
321
+ if (from === to) return rendered ?? editor.render(width);
322
+ const selectionFrom = Math.min(from, to);
323
+ const selectionTo = Math.max(from, to);
324
+ // Observe Pi's completed render; never replace its layout method or
325
+ // reconstruct its cursor, borders, autocomplete, or padded row geometry.
326
+ const rows = rendered ?? editor.render(width);
327
+ const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
328
+ const padding = Math.min(editor.paddingX, maxPadding);
329
+ const layoutWidth = Math.max(1, width - padding * 2 - (padding ? 0 : 1));
330
+ if (editor.lastWidth !== layoutWidth || !Number.isInteger(editor.scrollOffset) || editor.scrollOffset < 0) return rows;
331
+ const layout = editor.layoutText(layoutWidth);
332
+ if (!Number.isInteger(editor.renderedVisibleLineCount) || editor.renderedVisibleLineCount < 1 ||
333
+ editor.renderedVisibleLineCount + 2 > rows.length) return rows;
334
+ const visible = layout.slice(editor.scrollOffset, editor.scrollOffset + editor.renderedVisibleLineCount);
335
+ if (visible.length !== editor.renderedVisibleLineCount) return rows;
336
+ let logicalLine = 0;
337
+ let searchFrom = 0;
338
+ // Advance through hidden wraps before mapping visible chunks.
339
+ for (const hidden of layout.slice(0, editor.scrollOffset)) {
340
+ if (typeof hidden.text !== "string") return rows;
341
+ let index = editor.state.lines[logicalLine]?.indexOf(hidden.text, searchFrom) ?? -1;
342
+ while (index < 0 && logicalLine + 1 < editor.state.lines.length) {
343
+ logicalLine++;
344
+ searchFrom = 0;
345
+ index = editor.state.lines[logicalLine]!.indexOf(hidden.text, searchFrom);
346
+ }
347
+ if (index < 0) return rows;
348
+ searchFrom = index + hidden.text.length;
349
+ }
350
+ const replacements: Array<[number, string]> = [];
351
+ for (let row = 0; row < visible.length; row++) {
352
+ const chunk = visible[row]!;
353
+ if (typeof chunk.text !== "string" || typeof chunk.hasCursor !== "boolean" ||
354
+ (chunk.hasCursor && (!Number.isInteger(chunk.cursorPos) || chunk.cursorPos! < 0 || chunk.cursorPos! > chunk.text.length))) return rows;
355
+ let index = editor.state.lines[logicalLine]?.indexOf(chunk.text, searchFrom) ?? -1;
356
+ while (index < 0 && logicalLine + 1 < editor.state.lines.length) {
357
+ logicalLine++;
358
+ searchFrom = 0;
359
+ index = editor.state.lines[logicalLine]!.indexOf(chunk.text, searchFrom);
360
+ }
361
+ if (index < 0) return rows;
362
+ searchFrom = index + chunk.text.length;
363
+ const chunkStart = offset({ line: logicalLine, col: index });
364
+ const left = Math.max(0, Math.min(chunk.text.length, selectionFrom - chunkStart));
365
+ const right = Math.max(0, Math.min(chunk.text.length, selectionTo - chunkStart));
366
+ if (right <= left) continue;
367
+ const original = rows[row + 1]!;
368
+ const prefix = " ".repeat(padding);
369
+ if (!original.startsWith(prefix)) return rows;
370
+ const result = highlightRenderedRow(original, chunk, left, right, padding);
371
+ if (result === undefined || visibleWidth(result) !== visibleWidth(original)) return rows;
372
+ replacements.push([row + 1, result]);
373
+ }
374
+ const result = rows.slice();
375
+ for (const [index, replacement] of replacements) result[index] = replacement;
376
+ return result;
377
+ },
378
+ };
379
+ }
@@ -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
+ }