pi-vim 0.1.4 → 0.1.7

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.
package/README.md CHANGED
@@ -64,10 +64,30 @@ A `{count}` prefix can be prepended to any navigation key (max: `9999`).
64
64
  | `$` | Line end |
65
65
  | `gg` | Buffer start (line 1) |
66
66
  | `G` | Buffer end (last line) |
67
- | `w` | Next word start |
68
- | `b` | Previous word start |
69
- | `e` | Word end (inclusive) |
70
- | `{count}w/b/e`| Move `{count}` words |
67
+ | `w` | Next `word` start (keyword/punctuation aware) |
68
+ | `b` | Previous `word` start |
69
+ | `e` | `word` end (inclusive) |
70
+ | `W` | Next `WORD` start (whitespace-delimited token) |
71
+ | `B` | Previous `WORD` start |
72
+ | `E` | `WORD` end (inclusive) |
73
+ | `{count}w/b/e`| Move `{count}` `word` motions |
74
+ | `{count}W/B/E`| Move `{count}` `WORD` motions |
75
+ | `}` | Move to next paragraph start (line start col `0`) |
76
+ | `{` | Move to previous paragraph start (line start col `0`) |
77
+ | `{count}}` | Repeat `}` `{count}` times |
78
+ | `{count}{` | Repeat `{` `{count}` times |
79
+
80
+ `word` (`w/b/e`) splits punctuation from keyword chars. `WORD` (`W/B/E`)
81
+ treats any non-whitespace run as one token (`foo-bar`, `path/to`, `x.y`).
82
+
83
+ Paragraph boundary definition (this extension wave):
84
+ - blank line: matches `^\s*$`
85
+ - paragraph start: non-blank line at BOF, or non-blank line immediately after a blank line
86
+
87
+ Standalone `{` / `}` motions are navigation-only (no text/register mutation).
88
+ Counted forms (`{count}{`, `{count}}`) step paragraph-by-paragraph.
89
+ If no further paragraph boundary exists, motions clamp at BOF/EOF.
90
+ Operator forms with braces (`d{`, `d}`, `c{`, `c}`, `y{`, `y}`) are out of scope for this wave.
71
91
 
72
92
  ---
73
93
 
@@ -101,10 +121,14 @@ word, char-find, and linewise motions. Maximum total count: `9999`.
101
121
 
102
122
  | Command | Deletes |
103
123
  |-------------------|-----------------------------------------------------------|
104
- | `dw` | Forward to next word start (exclusive, can cross lines) |
105
- | `de` | Forward to word end (inclusive, can cross lines) |
106
- | `db` | Backward to word start (exclusive, can cross lines) |
107
- | `d{count}w/e/b` | Forward/backward `{count}` words |
124
+ | `dw` | Forward to next `word` start (exclusive, can cross lines) |
125
+ | `de` | Forward to `word` end (inclusive, can cross lines) |
126
+ | `db` | Backward to `word` start (exclusive, can cross lines) |
127
+ | `dW` | Forward to next `WORD` start (exclusive, can cross lines) |
128
+ | `dE` | Forward to `WORD` end (inclusive, can cross lines) |
129
+ | `dB` | Backward to `WORD` start (exclusive, can cross lines) |
130
+ | `d{count}w/e/b` | Forward/backward `{count}` `word` motions |
131
+ | `d{count}W/E/B` | Forward/backward `{count}` `WORD` motions |
108
132
  | `d$` | To end of line |
109
133
  | `d0` | To start of line |
110
134
  | `dd` | Current line (linewise) |
@@ -127,13 +151,17 @@ Same motion and count set as `d`. Deletes text then enters Insert mode.
127
151
 
128
152
  | Command | Action |
129
153
  |-----------------|------------------------------------|
130
- | `cw` | Delete word + Insert |
131
- | `c{count}w/e/b` | Delete `{count}` words + Insert |
132
- | `ciw` | Change inner word |
133
- | `caw` | Change around word |
134
- | `cc` | Delete line content + Insert |
135
- | `c$` | Delete to EOL + Insert |
136
- | | All `d` motions apply |
154
+ | `cw` | Change `word` + Insert |
155
+ | `ce` / `cb` | Change to `word` end / previous `word` start |
156
+ | `cW` | Change `WORD` + Insert (`cW` on non-space behaves like `cE`) |
157
+ | `cE` / `cB` | Change to `WORD` end / previous `WORD` start |
158
+ | `c{count}w/e/b` | Change `{count}` `word` motions + Insert |
159
+ | `c{count}W/E/B` | Change `{count}` `WORD` motions + Insert |
160
+ | `ciw` | Change inner word |
161
+ | `caw` | Change around word |
162
+ | `cc` | Delete line content + Insert |
163
+ | `c$` | Delete to EOL + Insert |
164
+ | … | All `d` motions apply |
137
165
 
138
166
  #### Single-key edits
139
167
 
@@ -161,15 +189,22 @@ Same motion set as `d`. Writes to register, **no text mutation**.
161
189
  | `y{count}j` | Current line + `{count}` lines below (linewise) |
162
190
  | `y{count}k` | Current line + `{count}` lines above (linewise) |
163
191
  | `yG` | Current line to end of buffer (linewise) |
164
- | `yw` | Forward to next word start |
165
- | `ye` | To word end (inclusive) |
166
- | `yb` | Backward to word start |
192
+ | `yw` | Forward to next `word` start |
193
+ | `ye` | To `word` end (inclusive) |
194
+ | `yb` | Backward to `word` start |
195
+ | `yW` | Forward to next `WORD` start |
196
+ | `yE` | To `WORD` end (inclusive) |
197
+ | `yB` | Backward to `WORD` start |
167
198
  | `y$` | To end of line |
168
199
  | `y0` | To start of line |
169
200
  | `yf{c}` | To and including `char` |
170
201
  | `yiw` | Inner word |
171
202
  | `yaw` | Around word (includes spaces) |
172
203
 
204
+ Counted yank caveat: counted `word`/`WORD` yank motions are intentionally not
205
+ implemented (`y2w`, `2yw`, `y2W`, `2yW`, etc. cancel the pending operator).
206
+ Linewise counted yank (`{count}yy`, `y{count}j/k`) remains supported.
207
+
173
208
  ---
174
209
 
175
210
  ### Put / Paste
@@ -213,7 +248,7 @@ Redo (`<C-r>`) is **not implemented** — see [Out of scope](#out-of-scope).
213
248
  | Area | This extension | Full Vim |
214
249
  |-----------------------|----------------------------------------|-------------------------------|
215
250
  | `$` motion | Moves past last char (readline CTRL+E) | Moves to last char |
216
- | `w` / `e` / `b` | Cross-line for word motions | Cross-line |
251
+ | `w` / `e` / `b` + `W` / `E` / `B` | Cross-line for `word` + `WORD` motions | Cross-line |
217
252
  | `0` / `$` operators | Exclusive of anchor col | `0` inclusive of col 0 |
218
253
  | Undo depth | Delegates to underlying readline undo | Full per-change undo tree |
219
254
  | Redo | Not implemented | `<C-r>` |
@@ -224,7 +259,7 @@ Redo (`<C-r>`) is **not implemented** — see [Out of scope](#out-of-scope).
224
259
  | Macros | Not implemented (`q`, `@`) | Supported |
225
260
  | Search | Not implemented (`/`, `?`, `n`, `N`) | Supported |
226
261
  | Ex commands | Not implemented (`:s`, `:g`, etc.) | Supported |
227
- | Multi-line operators | Supports `d/y` with `w/e/b`, `j/k` counts, and `G`; not full Vim motion matrix | Rich cross-line semantics |
262
+ | Multi-line operators | Supports `d/c/y` with `w/e/b` and `W/E/B`, plus `j/k` counts and `G`; not full Vim motion matrix | Rich cross-line semantics |
228
263
 
229
264
  ---
230
265
 
package/index.ts CHANGED
@@ -16,16 +16,21 @@
16
16
  * - D: delete to end of line
17
17
  * - S: substitute line (delete line content + insert mode)
18
18
  * - s: substitute char (delete char + insert mode)
19
- * - d{motion}: delete with motion (dw, db, de, d$, d0, dd, df/dt/dF/dT{char})
19
+ * - d{motion}: delete with motion (`w/b/e` + `W/B/E`, `$`, `0`, `dd`, `f/t/F/T{char}`)
20
+ * - c{motion}: change with same motion set as `d` (then enter insert mode)
21
+ * - y{motion}: yank with same motion set as `d` (no text mutation)
20
22
  * - f{char}: jump to next {char} on line
21
23
  * - F{char}: jump to previous {char} on line
22
24
  * - t{char}: jump to just before next {char} on line
23
25
  * - T{char}: jump to just after previous {char} on line
24
26
  * - ;: repeat last f/F/t/T motion (same direction)
25
27
  * - ,: repeat last f/F/t/T motion (reverse direction)
26
- * - w: move to start of next word
27
- * - b: move to start of previous word
28
- * - e: move to end of word
28
+ * - w/b/e: `word` motions (keyword/punctuation aware)
29
+ * - W/B/E: `WORD` motions (whitespace-delimited non-space runs)
30
+ * - {/}: paragraph motions to previous/next paragraph start (line start col 0)
31
+ * - `{count}` prefixes supported for navigation, paragraph motions, and `d/c` word/WORD motions
32
+ * - operator forms with braces (`d{`, `d}`, `c{`, `c}`, `y{`, `y}`) are out of scope
33
+ * - counted yank caveat: `y2w`, `2yw`, `y2W`, `2yW` cancel (linewise counts still supported)
29
34
  * - Shift+Alt+A: go to end of line (insert mode shortcut)
30
35
  * - Shift+Alt+I: go to start of line (insert mode shortcut)
31
36
  * - Alt+o: open new line below (insert mode shortcut)
@@ -78,6 +83,8 @@ import {
78
83
  import {
79
84
  reverseCharMotion,
80
85
  findCharMotionTarget,
86
+ findParagraphMotionTarget,
87
+ type WordMotionClass,
81
88
  } from "./motions.js";
82
89
  import {
83
90
  WordBoundaryCache,
@@ -98,6 +105,7 @@ export class ModalEditor extends CustomEditor {
98
105
  private prefixCount: string = "";
99
106
  private operatorCount: string = "";
100
107
  private pendingG: boolean = false;
108
+ private pendingGCount: string = "";
101
109
  private lastCharMotion: LastCharMotion | null = null;
102
110
  private discardingBracketedPasteInNormalMode: boolean = false;
103
111
  private pendingEscWhileDiscardingBracketedPasteInNormalMode: boolean = false;
@@ -123,6 +131,7 @@ export class ModalEditor extends CustomEditor {
123
131
  this.prefixCount = "";
124
132
  this.operatorCount = "";
125
133
  this.pendingG = false;
134
+ this.pendingGCount = "";
126
135
  }
127
136
 
128
137
  private stripBracketedPasteInNormalMode(data: string): { filtered: string | null; stripped: boolean } {
@@ -276,6 +285,7 @@ export class ModalEditor extends CustomEditor {
276
285
  || this.prefixCount
277
286
  || this.operatorCount
278
287
  || this.pendingG
288
+ || this.pendingGCount
279
289
  ) {
280
290
  this.clearPendingState();
281
291
  return;
@@ -454,12 +464,19 @@ export class ModalEditor extends CustomEditor {
454
464
  }
455
465
 
456
466
  const hasCount = this.prefixCount.length > 0 || this.operatorCount.length > 0;
457
- const supportsCountedWordMotion = data === "w" || data === "e" || data === "b";
467
+ const supportsCountedWordMotion = (
468
+ data === "w"
469
+ || data === "e"
470
+ || data === "b"
471
+ || data === "W"
472
+ || data === "E"
473
+ || data === "B"
474
+ );
458
475
  const supportsCountedTextObject = data === "i" || data === "a";
459
476
 
460
477
  if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) {
461
478
  // Counted forms beyond dd, d{count}j/k, d{count}{f/F/t/T}, and
462
- // d{count}{w/e/b}/{i/a}w are out of scope.
479
+ // d{count}{w/e/b/W/E/B}/{i/a}w are out of scope.
463
480
  this.cancelPendingOperator(data);
464
481
  return;
465
482
  }
@@ -509,7 +526,14 @@ export class ModalEditor extends CustomEditor {
509
526
  }
510
527
 
511
528
  const hasCount = this.prefixCount.length > 0 || this.operatorCount.length > 0;
512
- const supportsCountedWordMotion = data === "w" || data === "e" || data === "b";
529
+ const supportsCountedWordMotion = (
530
+ data === "w"
531
+ || data === "e"
532
+ || data === "b"
533
+ || data === "W"
534
+ || data === "E"
535
+ || data === "B"
536
+ );
513
537
  const supportsCountedTextObject = data === "i" || data === "a";
514
538
 
515
539
  if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) {
@@ -523,7 +547,10 @@ export class ModalEditor extends CustomEditor {
523
547
  }
524
548
 
525
549
  const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1;
526
- if (this.deleteWithMotion(data, motionCount)) {
550
+ const effectiveMotion = data === "W" && this.isCursorOnNonWhitespace()
551
+ ? "E"
552
+ : data;
553
+ if (this.deleteWithMotion(effectiveMotion, motionCount)) {
527
554
  this.pendingOperator = null;
528
555
  this.mode = "insert";
529
556
  return;
@@ -535,12 +562,30 @@ export class ModalEditor extends CustomEditor {
535
562
 
536
563
  private handleNormalMode(data: string): void {
537
564
  if (this.pendingG) {
538
- this.pendingG = false;
539
- if (data === "g") {
540
- this.moveCursorToBufferStart();
565
+ if (this.isDigit(data)) {
566
+ this.pendingGCount += data;
541
567
  return;
542
568
  }
543
- // Unsupported g-prefix command: discard prefix and keep processing input.
569
+
570
+ this.pendingG = false;
571
+ const hadGCount = this.pendingGCount.length > 0;
572
+ this.pendingGCount = "";
573
+
574
+ if (!hadGCount) {
575
+ if (data === "g") {
576
+ this.takeTotalCount(1);
577
+ this.moveCursorToBufferStart();
578
+ return;
579
+ }
580
+
581
+ if (data === "J") {
582
+ this.joinLines(false);
583
+ return;
584
+ }
585
+ }
586
+
587
+ this.clearPendingState();
588
+ return;
544
589
  }
545
590
 
546
591
  if (this.prefixCount.length > 0) {
@@ -559,6 +604,12 @@ export class ModalEditor extends CustomEditor {
559
604
  return;
560
605
  }
561
606
 
607
+ if (data === "g") {
608
+ this.pendingGCount = "";
609
+ this.pendingG = true;
610
+ return;
611
+ }
612
+
562
613
  const supportsCountedStandaloneEdit = (
563
614
  data === "x"
564
615
  || data === "s"
@@ -567,6 +618,7 @@ export class ModalEditor extends CustomEditor {
567
618
  || data === "C"
568
619
  || data === "p"
569
620
  || data === "P"
621
+ || data === "J"
570
622
  );
571
623
  const supportsCountedCharMotion = (
572
624
  CHAR_MOTION_KEYS.has(data)
@@ -577,7 +629,11 @@ export class ModalEditor extends CustomEditor {
577
629
  data === "w"
578
630
  || data === "e"
579
631
  || data === "b"
632
+ || data === "W"
633
+ || data === "E"
634
+ || data === "B"
580
635
  );
636
+ const supportsCountedParagraphMotion = data === "{" || data === "}";
581
637
  const supportsCountedNav = (
582
638
  data === "h"
583
639
  || data === "j"
@@ -607,7 +663,17 @@ export class ModalEditor extends CustomEditor {
607
663
  return;
608
664
  }
609
665
 
610
- if (!supportsCountedStandaloneEdit && !supportsCountedCharMotion && !supportsCountedWordMotion) {
666
+ if (supportsCountedParagraphMotion) {
667
+ this.executeParagraphMotion(data === "}" ? "forward" : "backward");
668
+ return;
669
+ }
670
+
671
+ if (
672
+ !supportsCountedStandaloneEdit
673
+ && !supportsCountedCharMotion
674
+ && !supportsCountedWordMotion
675
+ && !supportsCountedParagraphMotion
676
+ ) {
611
677
  // Unsupported prefixed forms: drop count and keep processing this key.
612
678
  this.prefixCount = "";
613
679
  this.operatorCount = "";
@@ -617,7 +683,13 @@ export class ModalEditor extends CustomEditor {
617
683
  return;
618
684
  }
619
685
 
686
+ if (data === "J") {
687
+ this.joinLines(true);
688
+ return;
689
+ }
690
+
620
691
  if (data === "g") {
692
+ this.pendingGCount = "";
621
693
  this.pendingG = true;
622
694
  return;
623
695
  }
@@ -675,12 +747,20 @@ export class ModalEditor extends CustomEditor {
675
747
  return;
676
748
  }
677
749
 
750
+ if (data === "}" || data === "{") {
751
+ this.executeParagraphMotion(data === "}" ? "forward" : "backward");
752
+ return;
753
+ }
754
+
678
755
  if (data === "w") {
679
756
  const count = this.takeTotalCount(1);
680
- return this.moveWord("forward", "start", count);
757
+ return this.moveWord("forward", "start", count, "word");
681
758
  }
682
- if (data === "b") return this.moveWord("backward", "start", this.takeTotalCount(1));
683
- if (data === "e") return this.moveWord("forward", "end", this.takeTotalCount(1));
759
+ if (data === "b") return this.moveWord("backward", "start", this.takeTotalCount(1), "word");
760
+ if (data === "e") return this.moveWord("forward", "end", this.takeTotalCount(1), "word");
761
+ if (data === "W") return this.moveWord("forward", "start", this.takeTotalCount(1), "WORD");
762
+ if (data === "B") return this.moveWord("backward", "start", this.takeTotalCount(1), "WORD");
763
+ if (data === "E") return this.moveWord("forward", "end", this.takeTotalCount(1), "WORD");
684
764
 
685
765
  if (Object.hasOwn(NORMAL_KEYS, data)) {
686
766
  return this.handleMappedKey(data);
@@ -763,6 +843,14 @@ export class ModalEditor extends CustomEditor {
763
843
  }
764
844
  }
765
845
 
846
+ private executeParagraphMotion(direction: "forward" | "backward"): void {
847
+ const lines = this.getLines();
848
+ const fromLine = this.getCursor().line;
849
+ const count = this.takeTotalCount(1);
850
+ const targetLine = findParagraphMotionTarget(lines, fromLine, direction, count);
851
+ this.moveCursorToLineStart(targetLine);
852
+ }
853
+
766
854
  private tryMoveCursorByState(delta: number): boolean {
767
855
  if (delta === 0) return true;
768
856
 
@@ -835,16 +923,79 @@ export class ModalEditor extends CustomEditor {
835
923
  this.moveCursorToLineStart(Math.max(0, lines.length - 1));
836
924
  }
837
925
 
926
+ private joinLines(normalize: boolean): void {
927
+ const count = this.takeTotalCount(2);
928
+ const steps = Math.max(0, count - 1);
929
+ if (steps === 0) return;
930
+
931
+ const editor = this as unknown as {
932
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
933
+ preferredVisualCol?: number;
934
+ tui?: { requestRender?: () => void };
935
+ };
936
+
937
+ const state = editor.state;
938
+ if (!state || !Array.isArray(state.lines)) return;
939
+
940
+ const currentLine = state.cursorLine ?? 0;
941
+ let joinPoint = state.cursorCol ?? 0;
942
+
943
+ for (let i = 0; i < steps; i++) {
944
+ if (currentLine >= state.lines.length - 1) break;
945
+
946
+ const left = state.lines[currentLine]!;
947
+ const right = state.lines[currentLine + 1]!;
948
+ let joined: string;
949
+
950
+ if (normalize) {
951
+ const trimmedRight = right.trimStart();
952
+ const leftEndsWithSpace = left.length > 0 && /\s/.test(left[left.length - 1]!);
953
+ const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
954
+ joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
955
+ joinPoint = left.length;
956
+ } else {
957
+ joined = left + right;
958
+ joinPoint = left.length;
959
+ }
960
+
961
+ state.lines.splice(currentLine, 2, joined);
962
+ }
963
+
964
+ state.cursorLine = currentLine;
965
+ state.cursorCol = joinPoint;
966
+ editor.preferredVisualCol = joinPoint;
967
+ editor.tui?.requestRender?.();
968
+ }
969
+
838
970
  private isWordChar(ch: string): boolean {
839
971
  return /\w/.test(ch);
840
972
  }
841
973
 
842
- private charType(ch: string | undefined): "space" | "word" | "other" {
974
+ private charType(
975
+ ch: string | undefined,
976
+ semanticClass: WordMotionClass = "word",
977
+ ): "space" | "word" | "other" {
843
978
  if (!ch || /\s/.test(ch)) return "space";
979
+ if (semanticClass === "WORD") return "word";
844
980
  if (this.isWordChar(ch)) return "word";
845
981
  return "other";
846
982
  }
847
983
 
984
+ private resolveWordMotion(
985
+ motion: string,
986
+ ): { motion: "w" | "e" | "b"; semanticClass: WordMotionClass } | null {
987
+ if (motion === "w" || motion === "e" || motion === "b") {
988
+ return { motion, semanticClass: "word" };
989
+ }
990
+
991
+ if (motion === "W" || motion === "E" || motion === "B") {
992
+ const normalizedMotion = motion.toLowerCase() as "w" | "e" | "b";
993
+ return { motion: normalizedMotion, semanticClass: "WORD" };
994
+ }
995
+
996
+ return null;
997
+ }
998
+
848
999
  private getAbsoluteIndex(line: number, col: number): number {
849
1000
  const lines = this.getLines();
850
1001
  let idx = 0;
@@ -865,6 +1016,7 @@ export class ModalEditor extends CustomEditor {
865
1016
  direction: "forward" | "backward",
866
1017
  target: "start" | "end",
867
1018
  count: number = 1,
1019
+ semanticClass: WordMotionClass = "word",
868
1020
  ): number {
869
1021
  const len = text.length;
870
1022
  if (len === 0) return 0;
@@ -879,27 +1031,27 @@ export class ModalEditor extends CustomEditor {
879
1031
  if (next >= len) {
880
1032
  next = len;
881
1033
  } else if (target === "start") {
882
- const startType = this.charType(text[next]);
1034
+ const startType = this.charType(text[next], semanticClass);
883
1035
  if (startType !== "space") {
884
- while (next < len && this.charType(text[next]) === startType) next++;
1036
+ while (next < len && this.charType(text[next], semanticClass) === startType) next++;
885
1037
  }
886
- while (next < len && this.charType(text[next]) === "space") next++;
1038
+ while (next < len && this.charType(text[next], semanticClass) === "space") next++;
887
1039
  } else {
888
1040
  if (next < len - 1) next++;
889
- while (next < len && this.charType(text[next]) === "space") next++;
1041
+ while (next < len && this.charType(text[next], semanticClass) === "space") next++;
890
1042
  if (next >= len) {
891
1043
  next = len;
892
1044
  } else {
893
- const t = this.charType(text[next]);
894
- while (next < len - 1 && this.charType(text[next + 1]) === t) next++;
1045
+ const t = this.charType(text[next], semanticClass);
1046
+ while (next < len - 1 && this.charType(text[next + 1], semanticClass) === t) next++;
895
1047
  }
896
1048
  }
897
1049
  } else {
898
1050
  if (next >= len) next = len - 1;
899
1051
  if (next > 0) next--;
900
- while (next > 0 && this.charType(text[next]) === "space") next--;
901
- const t = this.charType(text[next]);
902
- while (next > 0 && this.charType(text[next - 1]) === t) next--;
1052
+ while (next > 0 && this.charType(text[next], semanticClass) === "space") next--;
1053
+ const t = this.charType(text[next], semanticClass);
1054
+ while (next > 0 && this.charType(text[next - 1], semanticClass) === t) next--;
903
1055
  }
904
1056
 
905
1057
  if (next === i) break;
@@ -915,6 +1067,7 @@ export class ModalEditor extends CustomEditor {
915
1067
  direction: WordMotionDirection,
916
1068
  target: WordMotionTarget,
917
1069
  allowSameColumn: boolean = false,
1070
+ semanticClass: WordMotionClass = "word",
918
1071
  ): number | null {
919
1072
  if (line.length === 0) return null;
920
1073
  if (col < 0 || col > line.length) return null;
@@ -926,7 +1079,13 @@ export class ModalEditor extends CustomEditor {
926
1079
  if (!/\S/.test(line.slice(0, col))) return null;
927
1080
  }
928
1081
 
929
- const targetCol = this.wordBoundaryCache.tryFindTarget(line, col, direction, target);
1082
+ const targetCol = this.wordBoundaryCache.tryFindTarget(
1083
+ line,
1084
+ col,
1085
+ direction,
1086
+ target,
1087
+ semanticClass,
1088
+ );
930
1089
  if (targetCol === null) return null;
931
1090
 
932
1091
  if (direction === "forward") {
@@ -952,6 +1111,7 @@ export class ModalEditor extends CustomEditor {
952
1111
  direction: WordMotionDirection,
953
1112
  target: WordMotionTarget,
954
1113
  allowSameColumn: boolean = false,
1114
+ semanticClass: WordMotionClass = "word",
955
1115
  ): number | null {
956
1116
  const cursor = this.getCursor();
957
1117
  const lineIndex = cursor.line;
@@ -964,6 +1124,7 @@ export class ModalEditor extends CustomEditor {
964
1124
  direction,
965
1125
  target,
966
1126
  allowSameColumn,
1127
+ semanticClass,
967
1128
  );
968
1129
  if (targetCol === null) return null;
969
1130
 
@@ -977,9 +1138,10 @@ export class ModalEditor extends CustomEditor {
977
1138
  private tryMoveWordLineLocal(
978
1139
  direction: "forward" | "backward",
979
1140
  target: "start" | "end",
1141
+ semanticClass: WordMotionClass = "word",
980
1142
  ): boolean {
981
1143
  const col = this.getCursor().col;
982
- const targetCol = this.tryFindWordTargetLineLocal(direction, target);
1144
+ const targetCol = this.tryFindWordTargetLineLocal(direction, target, false, semanticClass);
983
1145
  if (targetCol === null || targetCol === col) return false;
984
1146
 
985
1147
  this.moveCursorBy(targetCol - col);
@@ -989,6 +1151,7 @@ export class ModalEditor extends CustomEditor {
989
1151
  private tryWordMotionLineLocalRange(
990
1152
  motion: "w" | "e" | "b",
991
1153
  count: number = 1,
1154
+ semanticClass: WordMotionClass = "word",
992
1155
  ): { col: number; targetCol: number; inclusive: boolean } | null {
993
1156
  const cursor = this.getCursor();
994
1157
  const lineIndex = cursor.line;
@@ -1006,6 +1169,7 @@ export class ModalEditor extends CustomEditor {
1006
1169
  direction,
1007
1170
  target,
1008
1171
  motion === "e",
1172
+ semanticClass,
1009
1173
  );
1010
1174
  if (nextCol === null) return null;
1011
1175
  if (nextCol === currentCol && step < steps - 1) return null;
@@ -1027,18 +1191,26 @@ export class ModalEditor extends CustomEditor {
1027
1191
  direction: "forward" | "backward",
1028
1192
  target: "start" | "end",
1029
1193
  count: number = 1,
1194
+ semanticClass: WordMotionClass = "word",
1030
1195
  ): void {
1031
1196
  let remaining = Math.max(1, Math.min(MAX_COUNT, count));
1032
1197
 
1033
1198
  while (remaining > 0) {
1034
- if (this.tryMoveWordLineLocal(direction, target)) {
1199
+ if (this.tryMoveWordLineLocal(direction, target, semanticClass)) {
1035
1200
  remaining--;
1036
1201
  continue;
1037
1202
  }
1038
1203
 
1039
1204
  const text = this.getText();
1040
1205
  const currentAbs = this.getAbsoluteIndexFromCursor();
1041
- const targetAbs = this.findWordTargetInText(text, currentAbs, direction, target, remaining);
1206
+ const targetAbs = this.findWordTargetInText(
1207
+ text,
1208
+ currentAbs,
1209
+ direction,
1210
+ target,
1211
+ remaining,
1212
+ semanticClass,
1213
+ );
1042
1214
  if (targetAbs !== currentAbs) {
1043
1215
  this.moveCursorBy(targetAbs - currentAbs);
1044
1216
  }
@@ -1058,6 +1230,12 @@ export class ModalEditor extends CustomEditor {
1058
1230
  return { line, col };
1059
1231
  }
1060
1232
 
1233
+ private isCursorOnNonWhitespace(): boolean {
1234
+ const { line, col } = this.getCurrentLineAndCol();
1235
+ const ch = line[col];
1236
+ return ch !== undefined && !/\s/.test(ch);
1237
+ }
1238
+
1061
1239
  private isCursorAtOrPastEol(): boolean {
1062
1240
  const { line, col } = this.getCurrentLineAndCol();
1063
1241
  return col >= line.length;
@@ -1204,8 +1382,13 @@ export class ModalEditor extends CustomEditor {
1204
1382
  return true;
1205
1383
  }
1206
1384
 
1207
- if (motion === "w" || motion === "e" || motion === "b") {
1208
- const lineLocalRange = this.tryWordMotionLineLocalRange(motion, count);
1385
+ const wordMotion = this.resolveWordMotion(motion);
1386
+ if (wordMotion) {
1387
+ const lineLocalRange = this.tryWordMotionLineLocalRange(
1388
+ wordMotion.motion,
1389
+ count,
1390
+ wordMotion.semanticClass,
1391
+ );
1209
1392
  if (lineLocalRange) {
1210
1393
  this.deleteRange(
1211
1394
  lineLocalRange.col,
@@ -1220,11 +1403,12 @@ export class ModalEditor extends CustomEditor {
1220
1403
  const targetAbs = this.findWordTargetInText(
1221
1404
  text,
1222
1405
  currentAbs,
1223
- motion === "b" ? "backward" : "forward",
1224
- motion === "e" ? "end" : "start",
1406
+ wordMotion.motion === "b" ? "backward" : "forward",
1407
+ wordMotion.motion === "e" ? "end" : "start",
1225
1408
  count,
1409
+ wordMotion.semanticClass,
1226
1410
  );
1227
- this.deleteRangeByAbsolute(currentAbs, targetAbs, motion === "e");
1411
+ this.deleteRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e");
1228
1412
  return true;
1229
1413
  }
1230
1414
 
@@ -1321,8 +1505,13 @@ export class ModalEditor extends CustomEditor {
1321
1505
  return true;
1322
1506
  }
1323
1507
 
1324
- if (motion === "w" || motion === "e" || motion === "b") {
1325
- const lineLocalRange = this.tryWordMotionLineLocalRange(motion);
1508
+ const wordMotion = this.resolveWordMotion(motion);
1509
+ if (wordMotion) {
1510
+ const lineLocalRange = this.tryWordMotionLineLocalRange(
1511
+ wordMotion.motion,
1512
+ 1,
1513
+ wordMotion.semanticClass,
1514
+ );
1326
1515
  if (lineLocalRange) {
1327
1516
  this.yankRange(
1328
1517
  lineLocalRange.col,
@@ -1337,10 +1526,12 @@ export class ModalEditor extends CustomEditor {
1337
1526
  const targetAbs = this.findWordTargetInText(
1338
1527
  text,
1339
1528
  currentAbs,
1340
- motion === "b" ? "backward" : "forward",
1341
- motion === "e" ? "end" : "start",
1529
+ wordMotion.motion === "b" ? "backward" : "forward",
1530
+ wordMotion.motion === "e" ? "end" : "start",
1531
+ 1,
1532
+ wordMotion.semanticClass,
1342
1533
  );
1343
- this.yankRangeByAbsolute(currentAbs, targetAbs, motion === "e");
1534
+ this.yankRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e");
1344
1535
  return true;
1345
1536
  }
1346
1537
 
@@ -1571,7 +1762,11 @@ export class ModalEditor extends CustomEditor {
1571
1762
  return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}_ `;
1572
1763
  }
1573
1764
  if (this.pendingMotion) return ` NORMAL ${this.pendingMotion}_ `;
1574
- if (this.pendingG) return " NORMAL g_ ";
1765
+ if (this.pendingG) {
1766
+ return this.pendingGCount
1767
+ ? ` NORMAL g${this.pendingGCount}_ `
1768
+ : " NORMAL g_ ";
1769
+ }
1575
1770
 
1576
1771
  const count = `${prefixCount}${operatorCount}`;
1577
1772
  if (count) return ` NORMAL ${count}_ `;
package/motions.ts CHANGED
@@ -5,18 +5,105 @@
5
5
  import type { CharMotion } from "./types.js";
6
6
 
7
7
  // Character types for word boundary detection
8
+ export type WordMotionClass = "word" | "WORD";
9
+
8
10
  enum CharType {
9
11
  Space = 0,
10
- Keyword = 1, // alphanumeric + underscore
12
+ Keyword = 1, // alphanumeric + underscore (or all non-space in WORD mode)
11
13
  Other = 2, // punctuation/symbols
12
14
  }
13
15
 
14
- function getCharType(c: string | undefined): CharType {
16
+ function getCharType(
17
+ c: string | undefined,
18
+ semanticClass: WordMotionClass = "word",
19
+ ): CharType {
15
20
  if (!c || /\s/.test(c)) return CharType.Space;
21
+ if (semanticClass === "WORD") return CharType.Keyword;
16
22
  if (/\w/.test(c)) return CharType.Keyword;
17
23
  return CharType.Other;
18
24
  }
19
25
 
26
+ function clampLineIndex(lines: readonly string[], lineIndex: number): number {
27
+ if (lines.length === 0) return 0;
28
+ if (!Number.isFinite(lineIndex)) return 0;
29
+ const normalized = Math.trunc(lineIndex);
30
+ return Math.max(0, Math.min(normalized, lines.length - 1));
31
+ }
32
+
33
+ /**
34
+ * True when line matches ^\s*$.
35
+ */
36
+ export function isBlankLine(line: string | undefined): boolean {
37
+ if (line === undefined) return true;
38
+ return /^\s*$/.test(line);
39
+ }
40
+
41
+ /**
42
+ * Paragraph start: non-blank line at BOF or after a blank line.
43
+ */
44
+ export function isParagraphStart(lines: readonly string[], lineIndex: number): boolean {
45
+ if (!Number.isInteger(lineIndex)) return false;
46
+ if (lineIndex < 0 || lineIndex >= lines.length) return false;
47
+ if (isBlankLine(lines[lineIndex])) return false;
48
+ if (lineIndex === 0) return true;
49
+ return isBlankLine(lines[lineIndex - 1]);
50
+ }
51
+
52
+ /**
53
+ * One step of } motion from current line index.
54
+ */
55
+ export function findNextParagraphStart(lines: readonly string[], fromLine: number): number {
56
+ if (lines.length === 0) return 0;
57
+
58
+ const start = clampLineIndex(lines, fromLine) + 1;
59
+ for (let i = start; i < lines.length; i++) {
60
+ if (isParagraphStart(lines, i)) return i;
61
+ }
62
+
63
+ return lines.length - 1;
64
+ }
65
+
66
+ /**
67
+ * One step of { motion from current line index.
68
+ */
69
+ export function findPrevParagraphStart(lines: readonly string[], fromLine: number): number {
70
+ if (lines.length === 0) return 0;
71
+
72
+ const start = clampLineIndex(lines, fromLine) - 1;
73
+ for (let i = start; i >= 0; i--) {
74
+ if (isParagraphStart(lines, i)) return i;
75
+ }
76
+
77
+ return 0;
78
+ }
79
+
80
+ /**
81
+ * Paragraph motion target for counted { / } semantics.
82
+ */
83
+ export function findParagraphMotionTarget(
84
+ lines: readonly string[],
85
+ fromLine: number,
86
+ direction: "forward" | "backward",
87
+ count: number = 1,
88
+ ): number {
89
+ if (lines.length === 0) return 0;
90
+
91
+ const steps = Number.isFinite(count) && count > 0 ? Math.floor(count) : 1;
92
+ let currentLine = clampLineIndex(lines, fromLine);
93
+
94
+ for (let i = 0; i < steps; i++) {
95
+ const nextLine =
96
+ direction === "forward"
97
+ ? findNextParagraphStart(lines, currentLine)
98
+ : findPrevParagraphStart(lines, currentLine);
99
+
100
+ if (nextLine === currentLine) break;
101
+ currentLine = nextLine;
102
+ }
103
+
104
+ return currentLine;
105
+ }
106
+
20
107
  /**
21
108
  * Reverse a character motion direction (f ↔ F, t ↔ T).
22
109
  */
@@ -80,6 +167,7 @@ export function findWordMotionTarget(
80
167
  col: number,
81
168
  direction: "forward" | "backward",
82
169
  target: "start" | "end",
170
+ semanticClass: WordMotionClass = "word",
83
171
  ): number {
84
172
  const len = line.length;
85
173
  if (len === 0) return 0;
@@ -91,15 +179,15 @@ export function findWordMotionTarget(
91
179
 
92
180
  if (target === "start") {
93
181
  // w: move to start of next word
94
- const startType = getCharType(line[i]);
182
+ const startType = getCharType(line[i], semanticClass);
95
183
 
96
184
  // Skip current word/punct block
97
185
  if (startType !== CharType.Space) {
98
- while (i < len && getCharType(line[i]) === startType) i++;
186
+ while (i < len && getCharType(line[i], semanticClass) === startType) i++;
99
187
  }
100
188
 
101
189
  // Skip whitespace
102
- while (i < len && getCharType(line[i]) === CharType.Space) i++;
190
+ while (i < len && getCharType(line[i], semanticClass) === CharType.Space) i++;
103
191
 
104
192
  return i;
105
193
  }
@@ -108,13 +196,13 @@ export function findWordMotionTarget(
108
196
  if (i < len - 1) i++;
109
197
 
110
198
  // Skip whitespace forward
111
- while (i < len && getCharType(line[i]) === CharType.Space) i++;
199
+ while (i < len && getCharType(line[i], semanticClass) === CharType.Space) i++;
112
200
 
113
201
  // Now at start of next word (or end of line). Find end.
114
202
  if (i >= len) return len;
115
203
 
116
- const type = getCharType(line[i]);
117
- while (i < len - 1 && getCharType(line[i + 1]) === type) i++;
204
+ const type = getCharType(line[i], semanticClass);
205
+ while (i < len - 1 && getCharType(line[i + 1], semanticClass) === type) i++;
118
206
 
119
207
  return i;
120
208
  }
@@ -124,11 +212,11 @@ export function findWordMotionTarget(
124
212
  if (i > 0) i--;
125
213
 
126
214
  // Skip whitespace backward
127
- while (i > 0 && getCharType(line[i]) === CharType.Space) i--;
215
+ while (i > 0 && getCharType(line[i], semanticClass) === CharType.Space) i--;
128
216
 
129
217
  // Now at end of prev word (or start of line). Find start.
130
- const type = getCharType(line[i]);
131
- while (i > 0 && getCharType(line[i - 1]) === type) i--;
218
+ const type = getCharType(line[i], semanticClass);
219
+ while (i > 0 && getCharType(line[i - 1], semanticClass) === type) i--;
132
220
 
133
221
  return i;
134
222
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vim",
3
- "version": "0.1.4",
3
+ "version": "0.1.7",
4
4
  "description": "Vim-style modal editing for Pi's TUI editor",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -23,7 +23,9 @@
23
23
  "scripts": {
24
24
  "build": "echo 'nothing to build'",
25
25
  "test": "node --import tsx/esm --test 'test/**/*.test.ts'",
26
- "check": "npm run test"
26
+ "check": "npm run test",
27
+ "pack:check": "node --import tsx/esm script/pack-check.ts",
28
+ "prepublishOnly": "npm run pack:check && npm test"
27
29
  },
28
30
  "pi": {
29
31
  "extensions": [
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * Line-local cache for Vim word motion boundaries.
3
3
  *
4
- * Keyed by exact line content to avoid stale boundary reuse.
4
+ * Keyed by semantic class + exact line content to avoid stale boundary reuse.
5
5
  */
6
6
 
7
+ import type { WordMotionClass } from "./motions.js";
8
+
7
9
  export type WordMotionDirection = "forward" | "backward";
8
10
  export type WordMotionTarget = "start" | "end";
9
11
 
@@ -22,13 +24,20 @@ export interface WordBoundaryData {
22
24
  readonly prevNonSpaceAtOrBefore: Int32Array;
23
25
  }
24
26
 
25
- function getCharType(ch: string | undefined): CharType {
27
+ function getCharType(
28
+ ch: string | undefined,
29
+ semanticClass: WordMotionClass = "word",
30
+ ): CharType {
26
31
  if (!ch || /\s/.test(ch)) return CharType.Space;
32
+ if (semanticClass === "WORD") return CharType.Word;
27
33
  if (/\w/.test(ch)) return CharType.Word;
28
34
  return CharType.Other;
29
35
  }
30
36
 
31
- function buildWordBoundaryData(line: string): WordBoundaryData {
37
+ function buildWordBoundaryData(
38
+ line: string,
39
+ semanticClass: WordMotionClass = "word",
40
+ ): WordBoundaryData {
32
41
  const len = line.length;
33
42
  const charTypes = new Uint8Array(len);
34
43
  const runStartByIndex = new Int32Array(len);
@@ -40,7 +49,7 @@ function buildWordBoundaryData(line: string): WordBoundaryData {
40
49
  prevNonSpaceAtOrBefore.fill(-1);
41
50
 
42
51
  for (let i = 0; i < len; i++) {
43
- charTypes[i] = getCharType(line[i]);
52
+ charTypes[i] = getCharType(line[i], semanticClass);
44
53
  }
45
54
 
46
55
  for (let runStart = 0; runStart < len;) {
@@ -149,11 +158,16 @@ export class WordBoundaryCache {
149
158
  : DEFAULT_MAX_CACHE_ENTRIES;
150
159
  }
151
160
 
152
- get(line: string): WordBoundaryData {
153
- const cached = this.entries.get(line);
161
+ private makeCacheKey(line: string, semanticClass: WordMotionClass): string {
162
+ return `${semanticClass}\u0000${line}`;
163
+ }
164
+
165
+ get(line: string, semanticClass: WordMotionClass = "word"): WordBoundaryData {
166
+ const key = this.makeCacheKey(line, semanticClass);
167
+ const cached = this.entries.get(key);
154
168
  if (cached) return cached;
155
169
 
156
- const built = buildWordBoundaryData(line);
170
+ const built = buildWordBoundaryData(line, semanticClass);
157
171
 
158
172
  if (this.entries.size >= this.maxEntries) {
159
173
  const oldestKey = this.entries.keys().next().value;
@@ -162,7 +176,7 @@ export class WordBoundaryCache {
162
176
  }
163
177
  }
164
178
 
165
- this.entries.set(line, built);
179
+ this.entries.set(key, built);
166
180
  return built;
167
181
  }
168
182
 
@@ -171,10 +185,11 @@ export class WordBoundaryCache {
171
185
  col: number,
172
186
  direction: WordMotionDirection,
173
187
  target: WordMotionTarget,
188
+ semanticClass: WordMotionClass = "word",
174
189
  ): number | null {
175
190
  if (!Number.isInteger(col) || col < 0) return null;
176
191
 
177
- const boundaries = this.get(line);
192
+ const boundaries = this.get(line, semanticClass);
178
193
  return findTargetInLine(boundaries, col, direction, target);
179
194
  }
180
195
  }