pi-vim 0.1.4 → 0.1.8

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
@@ -63,11 +63,33 @@ A `{count}` prefix can be prepended to any navigation key (max: `9999`).
63
63
  | `0` | Line start |
64
64
  | `$` | Line end |
65
65
  | `gg` | Buffer start (line 1) |
66
+ | `{count}gg` | Go to line `{count}` (1-indexed, clamped) |
66
67
  | `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 |
68
+ | `{count}G` | Go to line `{count}` (1-indexed, clamped) |
69
+ | `w` | Next `word` start (keyword/punctuation aware) |
70
+ | `b` | Previous `word` start |
71
+ | `e` | `word` end (inclusive) |
72
+ | `W` | Next `WORD` start (whitespace-delimited token) |
73
+ | `B` | Previous `WORD` start |
74
+ | `E` | `WORD` end (inclusive) |
75
+ | `{count}w/b/e`| Move `{count}` `word` motions |
76
+ | `{count}W/B/E`| Move `{count}` `WORD` motions |
77
+ | `}` | Move to next paragraph start (line start col `0`) |
78
+ | `{` | Move to previous paragraph start (line start col `0`) |
79
+ | `{count}}` | Repeat `}` `{count}` times |
80
+ | `{count}{` | Repeat `{` `{count}` times |
81
+
82
+ `word` (`w/b/e`) splits punctuation from keyword chars. `WORD` (`W/B/E`)
83
+ treats any non-whitespace run as one token (`foo-bar`, `path/to`, `x.y`).
84
+
85
+ Paragraph boundary definition (this extension wave):
86
+ - blank line: matches `^\s*$`
87
+ - paragraph start: non-blank line at BOF, or non-blank line immediately after a blank line
88
+
89
+ Standalone `{` / `}` motions are navigation-only (no text/register mutation).
90
+ Counted forms (`{count}{`, `{count}}`) step paragraph-by-paragraph.
91
+ If no further paragraph boundary exists, motions clamp at BOF/EOF.
92
+ Operator forms with braces (`d{`, `d}`, `c{`, `c}`, `y{`, `y}`) are out of scope for this wave.
71
93
 
72
94
  ---
73
95
 
@@ -101,10 +123,14 @@ word, char-find, and linewise motions. Maximum total count: `9999`.
101
123
 
102
124
  | Command | Deletes |
103
125
  |-------------------|-----------------------------------------------------------|
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 |
126
+ | `dw` | Forward to next `word` start (exclusive, can cross lines) |
127
+ | `de` | Forward to `word` end (inclusive, can cross lines) |
128
+ | `db` | Backward to `word` start (exclusive, can cross lines) |
129
+ | `dW` | Forward to next `WORD` start (exclusive, can cross lines) |
130
+ | `dE` | Forward to `WORD` end (inclusive, can cross lines) |
131
+ | `dB` | Backward to `WORD` start (exclusive, can cross lines) |
132
+ | `d{count}w/e/b` | Forward/backward `{count}` `word` motions |
133
+ | `d{count}W/E/B` | Forward/backward `{count}` `WORD` motions |
108
134
  | `d$` | To end of line |
109
135
  | `d0` | To start of line |
110
136
  | `dd` | Current line (linewise) |
@@ -127,13 +153,17 @@ Same motion and count set as `d`. Deletes text then enters Insert mode.
127
153
 
128
154
  | Command | Action |
129
155
  |-----------------|------------------------------------|
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 |
156
+ | `cw` | Change `word` + Insert |
157
+ | `ce` / `cb` | Change to `word` end / previous `word` start |
158
+ | `cW` | Change `WORD` + Insert (`cW` on non-space behaves like `cE`) |
159
+ | `cE` / `cB` | Change to `WORD` end / previous `WORD` start |
160
+ | `c{count}w/e/b` | Change `{count}` `word` motions + Insert |
161
+ | `c{count}W/E/B` | Change `{count}` `WORD` motions + Insert |
162
+ | `ciw` | Change inner word |
163
+ | `caw` | Change around word |
164
+ | `cc` | Delete line content + Insert |
165
+ | `c$` | Delete to EOL + Insert |
166
+ | … | All `d` motions apply |
137
167
 
138
168
  #### Single-key edits
139
169
 
@@ -161,15 +191,22 @@ Same motion set as `d`. Writes to register, **no text mutation**.
161
191
  | `y{count}j` | Current line + `{count}` lines below (linewise) |
162
192
  | `y{count}k` | Current line + `{count}` lines above (linewise) |
163
193
  | `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 |
194
+ | `yw` | Forward to next `word` start |
195
+ | `ye` | To `word` end (inclusive) |
196
+ | `yb` | Backward to `word` start |
197
+ | `yW` | Forward to next `WORD` start |
198
+ | `yE` | To `WORD` end (inclusive) |
199
+ | `yB` | Backward to `WORD` start |
167
200
  | `y$` | To end of line |
168
201
  | `y0` | To start of line |
169
202
  | `yf{c}` | To and including `char` |
170
203
  | `yiw` | Inner word |
171
204
  | `yaw` | Around word (includes spaces) |
172
205
 
206
+ Counted yank caveat: counted `word`/`WORD` yank motions are intentionally not
207
+ implemented (`y2w`, `2yw`, `y2W`, `2yW`, etc. cancel the pending operator).
208
+ Linewise counted yank (`{count}yy`, `y{count}j/k`) remains supported.
209
+
173
210
  ---
174
211
 
175
212
  ### Put / Paste
@@ -213,7 +250,7 @@ Redo (`<C-r>`) is **not implemented** — see [Out of scope](#out-of-scope).
213
250
  | Area | This extension | Full Vim |
214
251
  |-----------------------|----------------------------------------|-------------------------------|
215
252
  | `$` motion | Moves past last char (readline CTRL+E) | Moves to last char |
216
- | `w` / `e` / `b` | Cross-line for word motions | Cross-line |
253
+ | `w` / `e` / `b` + `W` / `E` / `B` | Cross-line for `word` + `WORD` motions | Cross-line |
217
254
  | `0` / `$` operators | Exclusive of anchor col | `0` inclusive of col 0 |
218
255
  | Undo depth | Delegates to underlying readline undo | Full per-change undo tree |
219
256
  | Redo | Not implemented | `<C-r>` |
@@ -224,7 +261,7 @@ Redo (`<C-r>`) is **not implemented** — see [Out of scope](#out-of-scope).
224
261
  | Macros | Not implemented (`q`, `@`) | Supported |
225
262
  | Search | Not implemented (`/`, `?`, `n`, `N`) | Supported |
226
263
  | 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 |
264
+ | 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
265
 
229
266
  ---
230
267
 
@@ -239,7 +276,7 @@ These are **explicitly deferred** and not planned for this feature:
239
276
  - Ex command surface (`:s`, `:g`, `:r`, …)
240
277
  - Search mode (`/`, `?`, `n`, `N`)
241
278
  - Repeat (`.`)
242
- - Extended count prefix beyond currently supported motions (e.g. counted `gg`, `:`, global operator counts)
279
+ - Extended count prefix beyond currently supported motions (e.g. `:`, global operator counts)
243
280
  - Redo (`<C-r>`) — no native redo primitive in the underlying readline editor;
244
281
  deferred until a suitable hook is available.
245
282
  - Window / tab / buffer management
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
+ const count = this.takeTotalCount(1);
577
+ this.moveCursorToLineStart(count - 1);
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,18 @@ 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
+
613
+ if (data === "G") {
614
+ const count = this.takeTotalCount(1);
615
+ this.moveCursorToLineStart(count - 1);
616
+ return;
617
+ }
618
+
562
619
  const supportsCountedStandaloneEdit = (
563
620
  data === "x"
564
621
  || data === "s"
@@ -567,6 +624,7 @@ export class ModalEditor extends CustomEditor {
567
624
  || data === "C"
568
625
  || data === "p"
569
626
  || data === "P"
627
+ || data === "J"
570
628
  );
571
629
  const supportsCountedCharMotion = (
572
630
  CHAR_MOTION_KEYS.has(data)
@@ -577,7 +635,11 @@ export class ModalEditor extends CustomEditor {
577
635
  data === "w"
578
636
  || data === "e"
579
637
  || data === "b"
638
+ || data === "W"
639
+ || data === "E"
640
+ || data === "B"
580
641
  );
642
+ const supportsCountedParagraphMotion = data === "{" || data === "}";
581
643
  const supportsCountedNav = (
582
644
  data === "h"
583
645
  || data === "j"
@@ -607,7 +669,17 @@ export class ModalEditor extends CustomEditor {
607
669
  return;
608
670
  }
609
671
 
610
- if (!supportsCountedStandaloneEdit && !supportsCountedCharMotion && !supportsCountedWordMotion) {
672
+ if (supportsCountedParagraphMotion) {
673
+ this.executeParagraphMotion(data === "}" ? "forward" : "backward");
674
+ return;
675
+ }
676
+
677
+ if (
678
+ !supportsCountedStandaloneEdit
679
+ && !supportsCountedCharMotion
680
+ && !supportsCountedWordMotion
681
+ && !supportsCountedParagraphMotion
682
+ ) {
611
683
  // Unsupported prefixed forms: drop count and keep processing this key.
612
684
  this.prefixCount = "";
613
685
  this.operatorCount = "";
@@ -617,7 +689,13 @@ export class ModalEditor extends CustomEditor {
617
689
  return;
618
690
  }
619
691
 
692
+ if (data === "J") {
693
+ this.joinLines(true);
694
+ return;
695
+ }
696
+
620
697
  if (data === "g") {
698
+ this.pendingGCount = "";
621
699
  this.pendingG = true;
622
700
  return;
623
701
  }
@@ -675,12 +753,20 @@ export class ModalEditor extends CustomEditor {
675
753
  return;
676
754
  }
677
755
 
756
+ if (data === "}" || data === "{") {
757
+ this.executeParagraphMotion(data === "}" ? "forward" : "backward");
758
+ return;
759
+ }
760
+
678
761
  if (data === "w") {
679
762
  const count = this.takeTotalCount(1);
680
- return this.moveWord("forward", "start", count);
763
+ return this.moveWord("forward", "start", count, "word");
681
764
  }
682
- if (data === "b") return this.moveWord("backward", "start", this.takeTotalCount(1));
683
- if (data === "e") return this.moveWord("forward", "end", this.takeTotalCount(1));
765
+ if (data === "b") return this.moveWord("backward", "start", this.takeTotalCount(1), "word");
766
+ if (data === "e") return this.moveWord("forward", "end", this.takeTotalCount(1), "word");
767
+ if (data === "W") return this.moveWord("forward", "start", this.takeTotalCount(1), "WORD");
768
+ if (data === "B") return this.moveWord("backward", "start", this.takeTotalCount(1), "WORD");
769
+ if (data === "E") return this.moveWord("forward", "end", this.takeTotalCount(1), "WORD");
684
770
 
685
771
  if (Object.hasOwn(NORMAL_KEYS, data)) {
686
772
  return this.handleMappedKey(data);
@@ -763,6 +849,14 @@ export class ModalEditor extends CustomEditor {
763
849
  }
764
850
  }
765
851
 
852
+ private executeParagraphMotion(direction: "forward" | "backward"): void {
853
+ const lines = this.getLines();
854
+ const fromLine = this.getCursor().line;
855
+ const count = this.takeTotalCount(1);
856
+ const targetLine = findParagraphMotionTarget(lines, fromLine, direction, count);
857
+ this.moveCursorToLineStart(targetLine);
858
+ }
859
+
766
860
  private tryMoveCursorByState(delta: number): boolean {
767
861
  if (delta === 0) return true;
768
862
 
@@ -835,16 +929,79 @@ export class ModalEditor extends CustomEditor {
835
929
  this.moveCursorToLineStart(Math.max(0, lines.length - 1));
836
930
  }
837
931
 
932
+ private joinLines(normalize: boolean): void {
933
+ const count = this.takeTotalCount(2);
934
+ const steps = Math.max(0, count - 1);
935
+ if (steps === 0) return;
936
+
937
+ const editor = this as unknown as {
938
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
939
+ preferredVisualCol?: number;
940
+ tui?: { requestRender?: () => void };
941
+ };
942
+
943
+ const state = editor.state;
944
+ if (!state || !Array.isArray(state.lines)) return;
945
+
946
+ const currentLine = state.cursorLine ?? 0;
947
+ let joinPoint = state.cursorCol ?? 0;
948
+
949
+ for (let i = 0; i < steps; i++) {
950
+ if (currentLine >= state.lines.length - 1) break;
951
+
952
+ const left = state.lines[currentLine]!;
953
+ const right = state.lines[currentLine + 1]!;
954
+ let joined: string;
955
+
956
+ if (normalize) {
957
+ const trimmedRight = right.trimStart();
958
+ const leftEndsWithSpace = left.length > 0 && /\s/.test(left[left.length - 1]!);
959
+ const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
960
+ joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
961
+ joinPoint = left.length;
962
+ } else {
963
+ joined = left + right;
964
+ joinPoint = left.length;
965
+ }
966
+
967
+ state.lines.splice(currentLine, 2, joined);
968
+ }
969
+
970
+ state.cursorLine = currentLine;
971
+ state.cursorCol = joinPoint;
972
+ editor.preferredVisualCol = joinPoint;
973
+ editor.tui?.requestRender?.();
974
+ }
975
+
838
976
  private isWordChar(ch: string): boolean {
839
977
  return /\w/.test(ch);
840
978
  }
841
979
 
842
- private charType(ch: string | undefined): "space" | "word" | "other" {
980
+ private charType(
981
+ ch: string | undefined,
982
+ semanticClass: WordMotionClass = "word",
983
+ ): "space" | "word" | "other" {
843
984
  if (!ch || /\s/.test(ch)) return "space";
985
+ if (semanticClass === "WORD") return "word";
844
986
  if (this.isWordChar(ch)) return "word";
845
987
  return "other";
846
988
  }
847
989
 
990
+ private resolveWordMotion(
991
+ motion: string,
992
+ ): { motion: "w" | "e" | "b"; semanticClass: WordMotionClass } | null {
993
+ if (motion === "w" || motion === "e" || motion === "b") {
994
+ return { motion, semanticClass: "word" };
995
+ }
996
+
997
+ if (motion === "W" || motion === "E" || motion === "B") {
998
+ const normalizedMotion = motion.toLowerCase() as "w" | "e" | "b";
999
+ return { motion: normalizedMotion, semanticClass: "WORD" };
1000
+ }
1001
+
1002
+ return null;
1003
+ }
1004
+
848
1005
  private getAbsoluteIndex(line: number, col: number): number {
849
1006
  const lines = this.getLines();
850
1007
  let idx = 0;
@@ -865,6 +1022,7 @@ export class ModalEditor extends CustomEditor {
865
1022
  direction: "forward" | "backward",
866
1023
  target: "start" | "end",
867
1024
  count: number = 1,
1025
+ semanticClass: WordMotionClass = "word",
868
1026
  ): number {
869
1027
  const len = text.length;
870
1028
  if (len === 0) return 0;
@@ -879,27 +1037,27 @@ export class ModalEditor extends CustomEditor {
879
1037
  if (next >= len) {
880
1038
  next = len;
881
1039
  } else if (target === "start") {
882
- const startType = this.charType(text[next]);
1040
+ const startType = this.charType(text[next], semanticClass);
883
1041
  if (startType !== "space") {
884
- while (next < len && this.charType(text[next]) === startType) next++;
1042
+ while (next < len && this.charType(text[next], semanticClass) === startType) next++;
885
1043
  }
886
- while (next < len && this.charType(text[next]) === "space") next++;
1044
+ while (next < len && this.charType(text[next], semanticClass) === "space") next++;
887
1045
  } else {
888
1046
  if (next < len - 1) next++;
889
- while (next < len && this.charType(text[next]) === "space") next++;
1047
+ while (next < len && this.charType(text[next], semanticClass) === "space") next++;
890
1048
  if (next >= len) {
891
1049
  next = len;
892
1050
  } else {
893
- const t = this.charType(text[next]);
894
- while (next < len - 1 && this.charType(text[next + 1]) === t) next++;
1051
+ const t = this.charType(text[next], semanticClass);
1052
+ while (next < len - 1 && this.charType(text[next + 1], semanticClass) === t) next++;
895
1053
  }
896
1054
  }
897
1055
  } else {
898
1056
  if (next >= len) next = len - 1;
899
1057
  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--;
1058
+ while (next > 0 && this.charType(text[next], semanticClass) === "space") next--;
1059
+ const t = this.charType(text[next], semanticClass);
1060
+ while (next > 0 && this.charType(text[next - 1], semanticClass) === t) next--;
903
1061
  }
904
1062
 
905
1063
  if (next === i) break;
@@ -915,6 +1073,7 @@ export class ModalEditor extends CustomEditor {
915
1073
  direction: WordMotionDirection,
916
1074
  target: WordMotionTarget,
917
1075
  allowSameColumn: boolean = false,
1076
+ semanticClass: WordMotionClass = "word",
918
1077
  ): number | null {
919
1078
  if (line.length === 0) return null;
920
1079
  if (col < 0 || col > line.length) return null;
@@ -926,7 +1085,13 @@ export class ModalEditor extends CustomEditor {
926
1085
  if (!/\S/.test(line.slice(0, col))) return null;
927
1086
  }
928
1087
 
929
- const targetCol = this.wordBoundaryCache.tryFindTarget(line, col, direction, target);
1088
+ const targetCol = this.wordBoundaryCache.tryFindTarget(
1089
+ line,
1090
+ col,
1091
+ direction,
1092
+ target,
1093
+ semanticClass,
1094
+ );
930
1095
  if (targetCol === null) return null;
931
1096
 
932
1097
  if (direction === "forward") {
@@ -952,6 +1117,7 @@ export class ModalEditor extends CustomEditor {
952
1117
  direction: WordMotionDirection,
953
1118
  target: WordMotionTarget,
954
1119
  allowSameColumn: boolean = false,
1120
+ semanticClass: WordMotionClass = "word",
955
1121
  ): number | null {
956
1122
  const cursor = this.getCursor();
957
1123
  const lineIndex = cursor.line;
@@ -964,6 +1130,7 @@ export class ModalEditor extends CustomEditor {
964
1130
  direction,
965
1131
  target,
966
1132
  allowSameColumn,
1133
+ semanticClass,
967
1134
  );
968
1135
  if (targetCol === null) return null;
969
1136
 
@@ -977,9 +1144,10 @@ export class ModalEditor extends CustomEditor {
977
1144
  private tryMoveWordLineLocal(
978
1145
  direction: "forward" | "backward",
979
1146
  target: "start" | "end",
1147
+ semanticClass: WordMotionClass = "word",
980
1148
  ): boolean {
981
1149
  const col = this.getCursor().col;
982
- const targetCol = this.tryFindWordTargetLineLocal(direction, target);
1150
+ const targetCol = this.tryFindWordTargetLineLocal(direction, target, false, semanticClass);
983
1151
  if (targetCol === null || targetCol === col) return false;
984
1152
 
985
1153
  this.moveCursorBy(targetCol - col);
@@ -989,6 +1157,7 @@ export class ModalEditor extends CustomEditor {
989
1157
  private tryWordMotionLineLocalRange(
990
1158
  motion: "w" | "e" | "b",
991
1159
  count: number = 1,
1160
+ semanticClass: WordMotionClass = "word",
992
1161
  ): { col: number; targetCol: number; inclusive: boolean } | null {
993
1162
  const cursor = this.getCursor();
994
1163
  const lineIndex = cursor.line;
@@ -1006,6 +1175,7 @@ export class ModalEditor extends CustomEditor {
1006
1175
  direction,
1007
1176
  target,
1008
1177
  motion === "e",
1178
+ semanticClass,
1009
1179
  );
1010
1180
  if (nextCol === null) return null;
1011
1181
  if (nextCol === currentCol && step < steps - 1) return null;
@@ -1027,18 +1197,26 @@ export class ModalEditor extends CustomEditor {
1027
1197
  direction: "forward" | "backward",
1028
1198
  target: "start" | "end",
1029
1199
  count: number = 1,
1200
+ semanticClass: WordMotionClass = "word",
1030
1201
  ): void {
1031
1202
  let remaining = Math.max(1, Math.min(MAX_COUNT, count));
1032
1203
 
1033
1204
  while (remaining > 0) {
1034
- if (this.tryMoveWordLineLocal(direction, target)) {
1205
+ if (this.tryMoveWordLineLocal(direction, target, semanticClass)) {
1035
1206
  remaining--;
1036
1207
  continue;
1037
1208
  }
1038
1209
 
1039
1210
  const text = this.getText();
1040
1211
  const currentAbs = this.getAbsoluteIndexFromCursor();
1041
- const targetAbs = this.findWordTargetInText(text, currentAbs, direction, target, remaining);
1212
+ const targetAbs = this.findWordTargetInText(
1213
+ text,
1214
+ currentAbs,
1215
+ direction,
1216
+ target,
1217
+ remaining,
1218
+ semanticClass,
1219
+ );
1042
1220
  if (targetAbs !== currentAbs) {
1043
1221
  this.moveCursorBy(targetAbs - currentAbs);
1044
1222
  }
@@ -1058,6 +1236,12 @@ export class ModalEditor extends CustomEditor {
1058
1236
  return { line, col };
1059
1237
  }
1060
1238
 
1239
+ private isCursorOnNonWhitespace(): boolean {
1240
+ const { line, col } = this.getCurrentLineAndCol();
1241
+ const ch = line[col];
1242
+ return ch !== undefined && !/\s/.test(ch);
1243
+ }
1244
+
1061
1245
  private isCursorAtOrPastEol(): boolean {
1062
1246
  const { line, col } = this.getCurrentLineAndCol();
1063
1247
  return col >= line.length;
@@ -1204,8 +1388,13 @@ export class ModalEditor extends CustomEditor {
1204
1388
  return true;
1205
1389
  }
1206
1390
 
1207
- if (motion === "w" || motion === "e" || motion === "b") {
1208
- const lineLocalRange = this.tryWordMotionLineLocalRange(motion, count);
1391
+ const wordMotion = this.resolveWordMotion(motion);
1392
+ if (wordMotion) {
1393
+ const lineLocalRange = this.tryWordMotionLineLocalRange(
1394
+ wordMotion.motion,
1395
+ count,
1396
+ wordMotion.semanticClass,
1397
+ );
1209
1398
  if (lineLocalRange) {
1210
1399
  this.deleteRange(
1211
1400
  lineLocalRange.col,
@@ -1220,11 +1409,12 @@ export class ModalEditor extends CustomEditor {
1220
1409
  const targetAbs = this.findWordTargetInText(
1221
1410
  text,
1222
1411
  currentAbs,
1223
- motion === "b" ? "backward" : "forward",
1224
- motion === "e" ? "end" : "start",
1412
+ wordMotion.motion === "b" ? "backward" : "forward",
1413
+ wordMotion.motion === "e" ? "end" : "start",
1225
1414
  count,
1415
+ wordMotion.semanticClass,
1226
1416
  );
1227
- this.deleteRangeByAbsolute(currentAbs, targetAbs, motion === "e");
1417
+ this.deleteRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e");
1228
1418
  return true;
1229
1419
  }
1230
1420
 
@@ -1321,8 +1511,13 @@ export class ModalEditor extends CustomEditor {
1321
1511
  return true;
1322
1512
  }
1323
1513
 
1324
- if (motion === "w" || motion === "e" || motion === "b") {
1325
- const lineLocalRange = this.tryWordMotionLineLocalRange(motion);
1514
+ const wordMotion = this.resolveWordMotion(motion);
1515
+ if (wordMotion) {
1516
+ const lineLocalRange = this.tryWordMotionLineLocalRange(
1517
+ wordMotion.motion,
1518
+ 1,
1519
+ wordMotion.semanticClass,
1520
+ );
1326
1521
  if (lineLocalRange) {
1327
1522
  this.yankRange(
1328
1523
  lineLocalRange.col,
@@ -1337,10 +1532,12 @@ export class ModalEditor extends CustomEditor {
1337
1532
  const targetAbs = this.findWordTargetInText(
1338
1533
  text,
1339
1534
  currentAbs,
1340
- motion === "b" ? "backward" : "forward",
1341
- motion === "e" ? "end" : "start",
1535
+ wordMotion.motion === "b" ? "backward" : "forward",
1536
+ wordMotion.motion === "e" ? "end" : "start",
1537
+ 1,
1538
+ wordMotion.semanticClass,
1342
1539
  );
1343
- this.yankRangeByAbsolute(currentAbs, targetAbs, motion === "e");
1540
+ this.yankRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e");
1344
1541
  return true;
1345
1542
  }
1346
1543
 
@@ -1571,7 +1768,11 @@ export class ModalEditor extends CustomEditor {
1571
1768
  return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}_ `;
1572
1769
  }
1573
1770
  if (this.pendingMotion) return ` NORMAL ${this.pendingMotion}_ `;
1574
- if (this.pendingG) return " NORMAL g_ ";
1771
+ if (this.pendingG) {
1772
+ return this.pendingGCount
1773
+ ? ` NORMAL g${this.pendingGCount}_ `
1774
+ : " NORMAL g_ ";
1775
+ }
1575
1776
 
1576
1777
  const count = `${prefixCount}${operatorCount}`;
1577
1778
  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.8",
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
  }