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,416 @@
1
+ import { VimNormalEngine, type VimPosition } from "./vim-normal-engine.ts";
2
+ import type { VisualRange } from "./vim-visual-engine.ts";
3
+
4
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
5
+ const word = /[\p{L}\p{N}_]/u;
6
+ const space = /\s/u;
7
+ const OPERATORS = { d: "delete", c: "change", y: "yank", ">": "indent", "<": "dedent" } as const;
8
+ type Operator = keyof typeof OPERATORS;
9
+ interface Range { start: VimPosition; end: VimPosition }
10
+ export interface OperatorEdit extends Range { text: string; cursor: VimPosition; insert?: boolean }
11
+ interface RepeatChange { keys: string[]; register?: Register; inserted?: string; insertCommand?: string }
12
+ export interface OperatorResult { edit?: OperatorEdit; handled: true; repeat?: RepeatChange }
13
+ interface Register { text: string; linewise: boolean }
14
+
15
+ export class VimOperatorEngine {
16
+ private pending: Operator | undefined;
17
+ private digits = "";
18
+ private operandCount = "";
19
+ private object: "i" | "a" | undefined;
20
+ private find: "f" | "F" | "t" | "T" | undefined;
21
+ private shiftG = false;
22
+ private register: Register | undefined;
23
+ private sequence: string[] = [];
24
+ private lastChange: RepeatChange | undefined;
25
+
26
+ /** Only completed, successfully applied NORMAL edits become repeatable. */
27
+ remember(result: OperatorResult): void {
28
+ if (result.edit) this.lastChange = result.edit.insert ? undefined : result.repeat;
29
+ }
30
+
31
+ forgetRepeat(): void { this.lastChange = undefined; }
32
+
33
+ rememberInsert(result: OperatorResult, inserted: string): void {
34
+ if (result.repeat && inserted) this.lastChange = { ...result.repeat, inserted };
35
+ }
36
+
37
+ rememberLiteralInsert(command: string, inserted: string): void {
38
+ if (["i", "a", "I", "A", "o", "O"].includes(command) && inserted)
39
+ this.lastChange = { keys: [], insertCommand: command, inserted };
40
+ }
41
+
42
+ get repeatInsertion(): string | undefined { return this.lastChange?.inserted; }
43
+ get repeatInsertCommand(): string | undefined { return this.lastChange?.insertCommand; }
44
+
45
+ repeatAt(text: string, cursor: VimPosition, boundaries?: number[][]): OperatorResult | undefined {
46
+ const change = this.lastChange;
47
+ if (!change || change.insertCommand) return undefined;
48
+ const replay = new VimOperatorEngine();
49
+ replay.register = change.register && { ...change.register };
50
+ let result: OperatorResult | null = null;
51
+ for (const key of change.keys) result = replay.input(key, text, cursor, boundaries);
52
+ return result?.edit ? result : undefined;
53
+ }
54
+
55
+ /** A count typed immediately before dot belongs to dot, not a subsequent operator. */
56
+ takeRepeatCount(): number {
57
+ const count = Math.min(100, Number(this.digits) || 1);
58
+ this.cancel();
59
+ return count;
60
+ }
61
+
62
+ get isPending(): boolean { return !!(this.pending || this.object || this.find); }
63
+
64
+ // Caller validates registered paste markers before committing this register.
65
+ visualRegister(text: string, linewise: boolean): void {
66
+ if (text || linewise) this.register = { text, linewise };
67
+ }
68
+
69
+ visualEdit(key: string, text: string, range: VisualRange): OperatorResult {
70
+ const lines = text.split("\n");
71
+ const offset = (p: VimPosition) => lines.slice(0, p.line).reduce((n, line) => n + line.length + 1, 0) + p.col;
72
+ if (key === "y" || (!range.linewise && range.start.line === range.end.line && range.start.col === range.end.col)) return { handled: true };
73
+ if (key === "J") {
74
+ if (range.start.line === range.end.line) return { handled: true };
75
+ const joined = lines.slice(range.start.line, range.end.line + 1).map((line, index) =>
76
+ index === 0 ? line.replace(/\s+$/u, "") : line.replace(/^\s+/u, "").replace(/\s+$/u, "")).join(" ");
77
+ return { handled: true, edit: { start: { line: range.start.line, col: 0 }, end: { line: range.end.line, col: lines[range.end.line]!.length }, text: joined, cursor: { line: range.start.line, col: 0 } } };
78
+ }
79
+ if (key === "~" || key === "u" || key === "U" || key.startsWith("r")) {
80
+ const selected = text.slice(offset(range.start), offset(range.end));
81
+ const replacement = key.startsWith("r") ? [...segmenter.segment(selected)].map((unit) => unit.segment === "\n" ? "\n" : key.slice(1)).join("") :
82
+ key === "u" ? selected.toLowerCase() : key === "U" ? selected.toUpperCase() :
83
+ [...segmenter.segment(selected)].map((unit) => unit.segment === unit.segment.toUpperCase() ? unit.segment.toLowerCase() : unit.segment.toUpperCase()).join("");
84
+ if (replacement === selected) return { handled: true };
85
+ return { handled: true, edit: { start: range.start, end: range.end, text: replacement, cursor: range.start } };
86
+ }
87
+ if (key === "p") {
88
+ const register = this.register;
89
+ if (!register) return { handled: true };
90
+ const start = range.start;
91
+ let end = range.end;
92
+ let replacement = register.text;
93
+ if (range.linewise) {
94
+ if (end.line + 1 < lines.length) {
95
+ end = { line: end.line + 1, col: 0 };
96
+ if (!register.linewise) replacement += "\n";
97
+ } else replacement = replacement.replace(/\n$/, "");
98
+ } else if (register.linewise) replacement = replacement.replace(/\n$/, "");
99
+ return { handled: true, edit: { start, end, text: replacement, cursor: start } };
100
+ }
101
+ if (key === ">" || key === "<") {
102
+ const first = range.start.line;
103
+ const last = range.end.line;
104
+ const replacement = lines.slice(first, last + 1).map((line) =>
105
+ key === ">" ? " " + line : line.replace(/^(?: {1,2}|\t)/, "")).join("\n");
106
+ if (replacement === lines.slice(first, last + 1).join("\n")) return { handled: true };
107
+ return { handled: true, edit: { start: { line: first, col: 0 }, end: { line: last, col: lines[last]!.length },
108
+ text: replacement, cursor: { line: first, col: 0 } } };
109
+ }
110
+ if (key !== "d" && key !== "c") return { handled: true };
111
+ let start = range.start;
112
+ let end = range.end;
113
+ let replacement = "";
114
+ if (range.linewise) {
115
+ if (end.line + 1 < lines.length) {
116
+ end = { line: end.line + 1, col: 0 };
117
+ if (key === "c") replacement = "\n";
118
+ } else if (start.line > 0) {
119
+ start = { line: start.line - 1, col: lines[start.line - 1]!.length };
120
+ if (key === "c") replacement = "\n";
121
+ }
122
+ }
123
+ return { handled: true, edit: { start, end, text: replacement,
124
+ cursor: range.linewise ? { line: Math.min(range.start.line, (text.slice(0, offset(start)) + replacement + text.slice(offset(end))).split("\n").length - 1), col: 0 } : range.start,
125
+ ...(key === "c" ? { insert: true } : {}) } };
126
+ }
127
+
128
+ cancel(): boolean {
129
+ const pending = !!(this.pending || this.digits || this.object || this.find);
130
+ this.pending = undefined; this.digits = ""; this.operandCount = "";
131
+ this.object = undefined; this.find = undefined; this.shiftG = false;
132
+ this.sequence = [];
133
+ return pending;
134
+ }
135
+
136
+ input(key: string, text: string, cursor: VimPosition, boundaries?: number[][]): OperatorResult | null {
137
+ const prior = this.sequence;
138
+ const register = this.register && { ...this.register };
139
+ const result = this.evaluate(key, text, cursor, boundaries);
140
+ if (result?.edit && (prior.length || !this.isPending)) {
141
+ // A finite command grammar, not a recording of arbitrary terminal input.
142
+ const keys = [...prior, key];
143
+ if (keys.length <= 12 && keys.every((part) => [...segmenter.segment(part)].length === 1) &&
144
+ /^(?:[0-9]*[dc<>]?(?:[0-9]*[wbe$0^Gjk]|gg|[ia][wW"'`()\[\]{}<>]|[ftFT].)|[0-9]*[dy<>][0-9]*[dy<>]|[0-9]*[xJsSpP])$/u.test(keys.join(""))) {
145
+ result.repeat = { keys, ...(key === "p" || key === "P" ? { register } : {}) };
146
+ }
147
+ }
148
+ if (this.isPending || (!result?.edit && /^[1-9]$/.test(key) && prior.length < 12)) {
149
+ this.sequence = [...prior, key];
150
+ } else this.sequence = [];
151
+ return result;
152
+ }
153
+
154
+ private evaluate(key: string, text: string, cursor: VimPosition, boundaries?: number[][]): OperatorResult | null {
155
+ if (!this.pending && !this.find && !this.object && /^[0-9]$/.test(key) && (key !== "0" || !!this.digits)) {
156
+ this.digits += key; return { handled: true };
157
+ }
158
+ if (!this.pending && key in OPERATORS) {
159
+ this.pending = key as Operator;
160
+ return { handled: true };
161
+ }
162
+ if (this.pending && !this.find && !this.object && /^[0-9]$/.test(key) && (key !== "0" || !!this.operandCount)) {
163
+ this.operandCount += key; return { handled: true };
164
+ }
165
+ if (this.pending && (key === "i" || key === "a") && !this.object && !this.find) {
166
+ this.object = key; return { handled: true };
167
+ }
168
+ if ((this.pending === ">" || this.pending === "<") && key === "g" && !this.shiftG) {
169
+ this.shiftG = true; return { handled: true };
170
+ }
171
+ if (this.pending && (key === "f" || key === "F" || key === "t" || key === "T") && !this.object && !this.find) {
172
+ this.find = key; return { handled: true };
173
+ }
174
+ let op = this.pending;
175
+ const object = this.object;
176
+ const find = this.find;
177
+ const shiftG = this.shiftG;
178
+ const explicitShift = !!(this.digits || this.operandCount);
179
+ const count = Math.min(10000, (Number(this.digits) || 1) * (Number(this.operandCount) || 1));
180
+ const lines = text.split("\n");
181
+ const stops = boundaries ?? lines.map((line) => [...segmenter.segment(line)].map((part) => part.index).concat(line.length));
182
+ const offset = (p: VimPosition) => lines.slice(0, p.line).reduce((n, line) => n + line.length + 1, 0) + p.col;
183
+ const position = (n: number): VimPosition => {
184
+ let line = 0;
185
+ while (line < lines.length - 1 && n > lines[line]!.length) n -= lines[line++]!.length + 1;
186
+ return { line, col: n };
187
+ };
188
+ const safe = (a: number, b: number): boolean => {
189
+ for (let line = 0, base = 0; line < lines.length; base += lines[line++]!.length + 1) {
190
+ const points = stops[line]!;
191
+ for (let i = 0; i < points.length - 1; i++) {
192
+ const from = base + points[i]!;
193
+ const to = base + points[i + 1]!;
194
+ if (to > from && [...segmenter.segment(text.slice(from, to))].length > 1 && a < to && b > from) return false;
195
+ }
196
+ }
197
+ return true;
198
+ };
199
+ const edit = (a: number, b: number, replacement: string, linewise = false, insert = false): OperatorResult => {
200
+ if (!safe(a, b) || (a === b && !replacement && op !== "y")) return { handled: true };
201
+ if (op) {
202
+ this.register = { text: text.slice(a, b), linewise };
203
+ if (op === "y") return { handled: true };
204
+ }
205
+ return { handled: true, edit: { start: position(a), end: position(b), text: replacement,
206
+ cursor: position(a), ...(insert || op === "c" ? { insert: true } : {}) } };
207
+ };
208
+ if (op) {
209
+ this.cancel();
210
+ if ((op === ">" || op === "<") && key === op && !find && !object) {
211
+ const last = Math.min(lines.length - 1, cursor.line + count - 1);
212
+ const start = offset({ line: cursor.line, col: 0 });
213
+ const end = offset({ line: last, col: lines[last]!.length });
214
+ const replacement = lines.slice(cursor.line, last + 1).map((line) =>
215
+ op === ">" ? " " + line : line.replace(/^(?: {1,2}|\t)/, "")).join("\n");
216
+ if (replacement === lines.slice(cursor.line, last + 1).join("\n")) return { handled: true };
217
+ return { handled: true, edit: { start: { line: cursor.line, col: 0 }, end: { line: last, col: lines[last]!.length },
218
+ text: replacement, cursor: { line: cursor.line, col: 0 } } };
219
+ }
220
+ if (op === ">" || op === "<") {
221
+ let target: VimPosition | undefined;
222
+ if (shiftG && key === "g") target = { line: 0, col: 0 };
223
+ else if (key === "j" || key === "k") target = { line: Math.max(0, Math.min(lines.length - 1, cursor.line + (key === "j" ? count : -count))), col: 0 };
224
+ else if (key === "G") target = { line: explicitShift ? Math.min(lines.length - 1, count - 1) : lines.length - 1, col: 0 };
225
+ else if (!shiftG && !find && ["w", "e", "b", "$", "0", "^"].includes(key)) {
226
+ const motion = new VimNormalEngine().input(`${count === 1 ? "" : count}${key}`, text, cursor, stops);
227
+ if (motion.handled) target = motion.cursor;
228
+ } else if (find && [...segmenter.segment(key)].length === 1 && !key.includes("\n")) {
229
+ const motion = new VimNormalEngine();
230
+ motion.input(`${count === 1 ? "" : count}${find}`, text, cursor, stops);
231
+ target = motion.input(key, text, cursor, stops).cursor;
232
+ }
233
+ if (!target || (target.line === cursor.line && target.col === cursor.col)) return { handled: true };
234
+ const first = Math.min(cursor.line, target.line);
235
+ // Characterwise motions ending at the next line's column zero exclude
236
+ // that line; j/k/G/gg remain inclusive linewise shift motions.
237
+ const exclusiveEnd = !shiftG && !["j", "k", "G"].includes(key) &&
238
+ target.line > cursor.line && target.col === 0;
239
+ const last = Math.max(cursor.line, target.line) - (exclusiveEnd ? 1 : 0);
240
+ const original = lines.slice(first, last + 1).join("\n");
241
+ const replacement = lines.slice(first, last + 1).map((line) =>
242
+ op === ">" ? " " + line : line.replace(/^(?: {1,2}|\t)/, "")).join("\n");
243
+ if (replacement === original) return { handled: true };
244
+ return { handled: true, edit: { start: { line: first, col: 0 }, end: { line: last, col: lines[last]!.length },
245
+ text: replacement, cursor: { line: first, col: 0 } } };
246
+ }
247
+ if (key === op && !find && !object) {
248
+ const last = Math.min(lines.length - 1, cursor.line + count - 1);
249
+ const a = offset({ line: cursor.line, col: 0 });
250
+ const b = last + 1 < lines.length ? offset({ line: last + 1, col: 0 }) : text.length;
251
+ const lineText = lines.slice(cursor.line, last + 1).join("\n") + "\n";
252
+ if (op === "y") {
253
+ if (safe(a, b)) this.register = { text: lineText, linewise: true };
254
+ return { handled: true };
255
+ }
256
+ const from = a === 0 || b < text.length ? a : a - 1;
257
+ const result = edit(from, b, op === "c" && b < text.length ? "\n" :
258
+ op === "c" && from !== a ? "\n" : "", true);
259
+ if (result.edit) {
260
+ this.register = { text: lineText, linewise: true };
261
+ const remaining = text.slice(0, from) + result.edit.text + text.slice(b);
262
+ result.edit.cursor = { line: Math.min(cursor.line, remaining.split("\n").length - 1), col: 0 };
263
+ }
264
+ return result;
265
+ }
266
+ let a = offset(cursor);
267
+ let b: number | undefined;
268
+ if (object) {
269
+ const range = this.textObject(key, object, text, a, count);
270
+ if (range) { a = range[0]; b = range[1]; }
271
+ } else if (find) {
272
+ if ([...segmenter.segment(key)].length === 1 && !key.includes("\n")) {
273
+ const points = stops[cursor.line]!;
274
+ let index = points.indexOf(cursor.col);
275
+ const direction = find === "f" || find === "t" ? 1 : -1;
276
+ for (let n = 0; n < count; n++) {
277
+ let hit = -1;
278
+ for (let i = index + direction; i >= 0 && i < points.length - 1; i += direction) {
279
+ if (lines[cursor.line]!.slice(points[i], points[i + 1]) === key) { hit = i; break; }
280
+ }
281
+ if (hit < 0) return { handled: true };
282
+ index = hit;
283
+ }
284
+ const target = offset({ line: cursor.line, col: points[index]! });
285
+ const after = offset({ line: cursor.line, col: points[index + 1]! });
286
+ b = direction > 0 ? (find === "t" ? target : after) : (find === "T" ? after : target);
287
+ }
288
+ } else if (key === "w" || key === "b" || key === "e" || key === "$" || key === "0") {
289
+ if (key === "$") b = offset({ line: cursor.line, col: lines[cursor.line]!.length });
290
+ else if (key === "0") b = offset({ line: cursor.line, col: 0 });
291
+ else {
292
+ const units = [...segmenter.segment(text)].map((p) => ({ at: p.index, char: p.segment }));
293
+ let index = units.findIndex((p) => p.at === a);
294
+ if (index < 0) return { handled: true };
295
+ const kind = (char: string) => space.test(char) ? 0 : word.test(char) ? 1 : 2;
296
+ for (let n = 0; n < count; n++) {
297
+ if (key === "w") {
298
+ const group = kind(units[index]!.char);
299
+ if (group) while (index < units.length && kind(units[index]!.char) === group) index++;
300
+ while (index < units.length && kind(units[index]!.char) === 0) index++;
301
+ } else if (key === "b") {
302
+ index = Math.max(0, index - 1);
303
+ while (index > 0 && kind(units[index]!.char) === 0) index--;
304
+ const group = kind(units[index]!.char);
305
+ while (index > 0 && kind(units[index - 1]!.char) === group) index--;
306
+ } else {
307
+ if (index < units.length - 1) index++;
308
+ while (index < units.length - 1 && kind(units[index]!.char) === 0) index++;
309
+ const group = kind(units[index]!.char);
310
+ while (index < units.length - 1 && kind(units[index + 1]!.char) === group) index++;
311
+ }
312
+ }
313
+ b = index === units.length ? text.length : units[index]!.at + (key === "e" ? units[index]!.char.length : 0);
314
+ }
315
+ }
316
+ if (b === undefined || b === a) return { handled: true };
317
+ return edit(Math.min(a, b), Math.max(a, b), "");
318
+ }
319
+ if (key === "J") {
320
+ this.cancel();
321
+ const last = Math.min(lines.length - 1, cursor.line + Math.max(2, count) - 1);
322
+ if (last === cursor.line) return { handled: true };
323
+ const joined = lines.slice(cursor.line, last + 1).map((line, index) =>
324
+ index === 0 ? line.replace(/\s+$/u, "") : line.replace(/^\s+/u, "").replace(/\s+$/u, "")).join(" ");
325
+ return { handled: true, edit: { start: { line: cursor.line, col: 0 }, end: { line: last, col: lines[last]!.length },
326
+ text: joined, cursor: { line: cursor.line, col: Math.min(lines[cursor.line]!.length, joined.length) } } };
327
+ }
328
+ if (key === "x" || key === "s" || key === "S") {
329
+ this.cancel();
330
+ if (key === "S") {
331
+ const a = offset({ line: cursor.line, col: 0 });
332
+ const b = offset({ line: cursor.line, col: lines[cursor.line]!.length });
333
+ if (a === b) return { handled: true, edit: { start: cursor, end: cursor, text: "", cursor: { line: cursor.line, col: 0 }, insert: true } };
334
+ op = "c";
335
+ return edit(a, b, "", true, true);
336
+ }
337
+ const points = stops[cursor.line]!;
338
+ const at = points.indexOf(cursor.col);
339
+ if (at < 0 || at === points.length - 1) return { handled: true };
340
+ const b = offset({ line: cursor.line, col: points[Math.min(points.length - 1, at + count)]! });
341
+ op = key === "s" ? "c" : "d";
342
+ const result = edit(offset(cursor), b, "", false, key === "s");
343
+ return result;
344
+ }
345
+ if (key === "p" || key === "P") {
346
+ this.cancel();
347
+ const register = this.register;
348
+ if (!register) return { handled: true };
349
+ const a = register.linewise ? key === "P" ? offset({ line: cursor.line, col: 0 }) :
350
+ cursor.line + 1 < lines.length ? offset({ line: cursor.line + 1, col: 0 }) : text.length :
351
+ key === "P" ? offset(cursor) : offset({ line: cursor.line, col: stops[cursor.line]![Math.min(stops[cursor.line]!.length - 1, stops[cursor.line]!.indexOf(cursor.col) + 1)]! });
352
+ const inserted = register.linewise && a === text.length && a !== 0 ?
353
+ "\n" + register.text.slice(0, -1) : register.text;
354
+ const result = edit(a, a, inserted);
355
+ if (result.edit && register.linewise) result.edit.cursor = { line: cursor.line + (key === "p" ? 1 : 0), col: 0 };
356
+ return result;
357
+ }
358
+ if (this.digits) this.cancel();
359
+ return null;
360
+ }
361
+
362
+ private textObject(key: string, around: "i" | "a", text: string, at: number, count: number): [number, number] | undefined {
363
+ if (key === "w" || key === "W") {
364
+ const units = [...segmenter.segment(text)].map((p) => ({ start: p.index, end: p.index + p.segment.length, char: p.segment }));
365
+ let index = units.findIndex((p) => p.start <= at && p.end > at);
366
+ if (index < 0) return undefined;
367
+ const kind = (char: string) => space.test(char) ? 0 : key === "W" ? 1 : word.test(char) ? 1 : 2;
368
+ if (kind(units[index]!.char) === 0) {
369
+ while (index < units.length - 1 && kind(units[index]!.char) === 0) index++;
370
+ }
371
+ let start = index;
372
+ const group = kind(units[index]!.char);
373
+ while (start > 0 && kind(units[start - 1]!.char) === group) start--;
374
+ let end = index + 1;
375
+ for (let n = 0; n < count; n++) {
376
+ while (end < units.length && kind(units[end]!.char) === kind(units[end - 1]!.char)) end++;
377
+ if (n + 1 < count) {
378
+ while (end < units.length && kind(units[end]!.char) === 0) end++;
379
+ if (end === units.length) return undefined;
380
+ end++;
381
+ }
382
+ }
383
+ if (around === "a") {
384
+ if (end < units.length && kind(units[end]!.char) === 0) while (end < units.length && kind(units[end]!.char) === 0) end++;
385
+ else while (start > 0 && kind(units[start - 1]!.char) === 0) start--;
386
+ }
387
+ return [units[start]!.start, units[end - 1]!.end];
388
+ }
389
+ const pairs: Record<string, [string, string]> = {
390
+ '"': ['"', '"'], "'": ["'", "'"], "`": ["`", "`"], "(": ["(", ")"], ")": ["(", ")"],
391
+ "[": ["[", "]"], "]": ["[", "]"], "{": ["{", "}"], "}": ["{", "}"], "<": ["<", ">"], ">": ["<", ">"],
392
+ };
393
+ const pair = pairs[key];
394
+ if (!pair) return undefined;
395
+ let left = at, right = at;
396
+ if (pair[0] === pair[1]) {
397
+ left = text.lastIndexOf(pair[0], at);
398
+ if (left === at) left = text.lastIndexOf(pair[0], at - 1);
399
+ right = text.indexOf(pair[1], at + (at === left ? 1 : 0));
400
+ } else {
401
+ let depth = 0;
402
+ for (; left >= 0; left--) {
403
+ if (text[left] === pair[1]) depth++;
404
+ else if (text[left] === pair[0] && depth-- === 0) break;
405
+ }
406
+ depth = 0;
407
+ right = left;
408
+ for (; right < text.length; right++) {
409
+ if (text[right] === pair[0]) depth++;
410
+ else if (text[right] === pair[1] && --depth === 0) break;
411
+ }
412
+ }
413
+ if (left < 0 || right < 0 || right >= text.length || left >= right) return undefined;
414
+ return around === "a" ? [left, right + 1] : [left + 1, right];
415
+ }
416
+ }
@@ -0,0 +1,49 @@
1
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import { gentlePiConfigHome } from "./agent-home.ts";
5
+
6
+ export const VIM_POLICY = { ON: "on", OFF: "off" } as const;
7
+ export type VimPolicy = (typeof VIM_POLICY)[keyof typeof VIM_POLICY];
8
+ export const VIM_SCHEMA = "gentle-pi.vim/v1";
9
+ interface VimOptions { gentlePiConfigHome?: string }
10
+ export interface VimResolution {
11
+ policy: VimPolicy;
12
+ source: "global_file" | "default";
13
+ malformed: boolean;
14
+ globalFile: string;
15
+ }
16
+
17
+ export function parseVimPolicyFile(raw: string): VimPolicy | undefined {
18
+ try {
19
+ const value: unknown = JSON.parse(raw);
20
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
21
+ if (Object.keys(value).length !== 2 || !("schema" in value) || value.schema !== VIM_SCHEMA || !("policy" in value)) return undefined;
22
+ return value.policy === "on" || value.policy === "off" ? value.policy : undefined;
23
+ } catch { return undefined; }
24
+ }
25
+
26
+ export function resolveVimPolicy(options: VimOptions = {}): VimResolution {
27
+ const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "vim.json");
28
+ try {
29
+ const policy = parseVimPolicyFile(readFileSync(globalFile, "utf8"));
30
+ return { policy: policy ?? "off", source: "global_file", malformed: policy === undefined, globalFile };
31
+ } catch (error) {
32
+ const missing = typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
33
+ return { policy: "off", source: missing ? "default" : "global_file", malformed: !missing, globalFile };
34
+ }
35
+ }
36
+
37
+ export function writeVimPolicy(policy: VimPolicy, options: VimOptions = {}): string {
38
+ const home = options.gentlePiConfigHome ?? gentlePiConfigHome();
39
+ const path = join(home, "vim.json");
40
+ const temporary = `${path}.${randomUUID()}.tmp`;
41
+ mkdirSync(home, { recursive: true });
42
+ try {
43
+ writeFileSync(temporary, `${JSON.stringify({ schema: VIM_SCHEMA, policy })}\n`, { flag: "wx", mode: 0o600 });
44
+ renameSync(temporary, path);
45
+ } finally {
46
+ try { unlinkSync(temporary); } catch { /* Rename consumed the temporary file. */ }
47
+ }
48
+ return path;
49
+ }
@@ -0,0 +1,107 @@
1
+ import type { VimPosition } from "./vim-normal-engine.ts";
2
+
3
+ const MODES = { char: "char", line: "line" } as const;
4
+ type VisualMode = (typeof MODES)[keyof typeof MODES];
5
+ export interface VisualRange { start: VimPosition; end: VimPosition; linewise: boolean }
6
+
7
+ export class VimVisualEngine {
8
+ private anchor: VimPosition | undefined;
9
+ private mode: VisualMode = MODES.char;
10
+
11
+ get active(): boolean { return this.anchor !== undefined; }
12
+ get kind(): VisualMode { return this.mode; }
13
+ start(mode: VisualMode, cursor: VimPosition): void { this.anchor = { ...cursor }; this.mode = mode; }
14
+ cancel(): void { this.anchor = undefined; }
15
+ /** Resolve without changing the anchor; the caller validates paste markers before selecting. */
16
+ object(text: string, cursor: VimPosition, boundaries: number[][], around: "i" | "a", key: string): VisualRange | undefined {
17
+ if (!this.active || this.mode !== MODES.char) return undefined;
18
+ const lines = text.split("\n");
19
+ const at = lines.slice(0, cursor.line).reduce((n, line) => n + line.length + 1, 0) + cursor.col;
20
+ if (!boundaries[cursor.line]?.includes(cursor.col)) return undefined;
21
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
22
+ let from: number, to: number;
23
+ if (key === "w" || key === "W") {
24
+ const units = [...segmenter.segment(text)].map((unit) => ({ from: unit.index, to: unit.index + unit.segment.length, value: unit.segment }));
25
+ let index = units.findIndex((unit) => unit.from <= at && at < unit.to);
26
+ if (index < 0) return undefined;
27
+ const kind = (value: string) => /\s/u.test(value) ? 0 : key === "W" || /[\p{L}\p{N}_]/u.test(value) ? 1 : 2;
28
+ while (index < units.length - 1 && kind(units[index]!.value) === 0) index++;
29
+ const group = kind(units[index]!.value);
30
+ if (group === 0) return undefined;
31
+ let first = index, last = index + 1;
32
+ while (first > 0 && kind(units[first - 1]!.value) === group) first--;
33
+ while (last < units.length && kind(units[last]!.value) === group) last++;
34
+ if (around === "a") {
35
+ if (last < units.length && kind(units[last]!.value) === 0) {
36
+ while (last < units.length && kind(units[last]!.value) === 0 && units[last]!.value !== "\n") last++;
37
+ } else while (first > 0 && kind(units[first - 1]!.value) === 0 && units[first - 1]!.value !== "\n") first--;
38
+ }
39
+ from = units[first]!.from; to = units[last - 1]!.to;
40
+ } else {
41
+ const pairs: Record<string, readonly [string, string]> = {
42
+ '"': ['"', '"'], "'": ["'", "'"], '`': ['`', '`'], '(': ['(', ')'], ')': ['(', ')'],
43
+ '[': ['[', ']'], ']': ['[', ']'], '{': ['{', '}'], '}': ['{', '}'], '<': ['<', '>'], '>': ['<', '>'],
44
+ };
45
+ const pair = pairs[key];
46
+ if (!pair) return undefined;
47
+ let left = -1, right = -1;
48
+ if (pair[0] === pair[1]) {
49
+ const quotes: number[] = [];
50
+ for (let i = 0; i < text.length; i++) if (text[i] === pair[0] && (i === 0 || text[i - 1] !== "\\")) quotes.push(i);
51
+ for (let i = 0; i + 1 < quotes.length; i += 2) {
52
+ if (quotes[i]! <= at && at <= quotes[i + 1]!) {
53
+ left = quotes[i]!; right = quotes[i + 1]!; break;
54
+ }
55
+ }
56
+ } else {
57
+ let depth = 0;
58
+ for (let i = at; i >= 0; i--) {
59
+ if (text[i] === pair[1]) depth++;
60
+ else if (text[i] === pair[0] && depth-- === 0) { left = i; break; }
61
+ }
62
+ if (left >= 0) {
63
+ depth = 0;
64
+ for (let i = left; i < text.length; i++) {
65
+ if (text[i] === pair[0]) depth++;
66
+ else if (text[i] === pair[1] && --depth === 0) { right = i; break; }
67
+ }
68
+ }
69
+ }
70
+ if (left < 0 || right <= left) return undefined;
71
+ from = left + (around === "i" ? 1 : 0);
72
+ to = right + (around === "a" ? 1 : 0);
73
+ }
74
+ if (from >= to) return undefined;
75
+ const position = (offset: number): VimPosition => {
76
+ let line = 0;
77
+ while (line < lines.length - 1 && offset > lines[line]!.length) offset -= lines[line++]!.length + 1;
78
+ return { line, col: offset };
79
+ };
80
+ const start = position(from), end = position(to);
81
+ if (!boundaries[start.line]?.includes(start.col) || !boundaries[end.line]?.includes(end.col)) return undefined;
82
+ return { start, end, linewise: false };
83
+ }
84
+ swap(cursor: VimPosition): VimPosition | undefined {
85
+ if (!this.anchor) return undefined;
86
+ const old = this.anchor;
87
+ this.anchor = { ...cursor };
88
+ return old;
89
+ }
90
+ range(text: string, cursor: VimPosition, boundaries: number[][]): VisualRange | undefined {
91
+ const anchor = this.anchor;
92
+ if (!anchor) return undefined;
93
+ const lines = text.split("\n");
94
+ const order = (a: VimPosition, b: VimPosition) => a.line - b.line || a.col - b.col;
95
+ const first = order(anchor, cursor) <= 0 ? anchor : cursor;
96
+ const last = order(anchor, cursor) <= 0 ? cursor : anchor;
97
+ if (this.mode === MODES.line) return {
98
+ start: { line: first.line, col: 0 }, end: { line: last.line, col: lines[last.line]!.length }, linewise: true,
99
+ };
100
+ const stops = boundaries[last.line];
101
+ if (!stops) return undefined;
102
+ const index = stops.indexOf(last.col);
103
+ if (index < 0) return undefined;
104
+ return { start: { ...first }, end: index === stops.length - 1 && last.line < lines.length - 1
105
+ ? { line: last.line + 1, col: 0 } : { line: last.line, col: stops[Math.min(stops.length - 1, index + 1)]! }, linewise: false };
106
+ }
107
+ }