pi-vim 0.1.2 → 0.1.4

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 (4) hide show
  1. package/README.md +82 -62
  2. package/index.ts +366 -175
  3. package/motions.ts +23 -14
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -50,34 +50,42 @@ Insert-mode shortcuts (stay in Insert mode):
50
50
 
51
51
  ### Navigation (Normal mode)
52
52
 
53
- | Key | Action |
54
- |-------|-------------------------|
55
- | `h` | Left |
56
- | `l` | Right |
57
- | `j` | Down |
58
- | `k` | Up |
59
- | `0` | Line start |
60
- | `$` | Line end |
61
- | `gg` | Buffer start (line 1) |
62
- | `G` | Buffer end (last line) |
63
- | `w` | Next word start |
64
- | `b` | Previous word start |
65
- | `e` | Word end (inclusive) |
53
+ A `{count}` prefix can be prepended to any navigation key (max: `9999`).
54
+
55
+ | Key | Action |
56
+ |---------------|-------------------------------|
57
+ | `h` | Left |
58
+ | `l` | Right |
59
+ | `j` | Down |
60
+ | `k` | Up |
61
+ | `{count}h/l` | Move left/right `{count}` cols |
62
+ | `{count}j/k` | Move down/up `{count}` lines (clamped to buffer size) |
63
+ | `0` | Line start |
64
+ | `$` | Line end |
65
+ | `gg` | Buffer start (line 1) |
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 |
66
71
 
67
72
  ---
68
73
 
69
74
  ### Character-find motions (Normal mode)
70
75
 
71
- | Key | Action |
72
- |------------|------------------------------------------------|
73
- | `f{char}` | Jump forward to `char` (inclusive) |
74
- | `F{char}` | Jump backward to `char` (inclusive) |
75
- | `t{char}` | Jump forward to one before `char` (exclusive) |
76
- | `T{char}` | Jump backward to one after `char` (exclusive) |
77
- | `;` | Repeat last `f/F/t/T` motion |
78
- | `,` | Repeat last motion in reverse direction |
76
+ A `{count}` prefix finds the Nth occurrence of `{char}` on the line.
79
77
 
80
- Char-find motions compose with operators: `df{char}`, `ct{char}`, etc.
78
+ | Key | Action |
79
+ |------------------|------------------------------------------------|
80
+ | `f{char}` | Jump forward to `char` (inclusive) |
81
+ | `F{char}` | Jump backward to `char` (inclusive) |
82
+ | `t{char}` | Jump forward to one before `char` (exclusive) |
83
+ | `T{char}` | Jump backward to one after `char` (exclusive) |
84
+ | `{count}f{char}` | Jump to Nth occurrence of `char` forward |
85
+ | `;` | Repeat last `f/F/t/T` motion |
86
+ | `,` | Repeat last motion in reverse direction |
87
+
88
+ Char-find motions compose with operators: `df{char}`, `ct{char}`, `d{count}t{char}`, etc.
81
89
 
82
90
  ---
83
91
 
@@ -88,47 +96,57 @@ All operators write to the unnamed register and mirror to the system clipboard
88
96
 
89
97
  #### Delete `d{motion}` / `dd`
90
98
 
91
- | Command | Deletes |
92
- |------------|---------------------------------------------|
93
- | `dw` | Forward to next word start (exclusive, can cross lines) |
94
- | `de` | Forward to word end (inclusive, can cross lines) |
95
- | `db` | Backward to word start (exclusive, can cross lines) |
96
- | `d$` | To end of line |
97
- | `d0` | To start of line |
98
- | `dd` | Current line (linewise) |
99
- | `{count}dd` | `{count}` lines (linewise) |
100
- | `d{count}j` | Current line + `{count}` lines below (linewise) |
101
- | `d{count}k` | Current line + `{count}` lines above (linewise) |
102
- | `dG` | Current line to end of buffer (linewise) |
103
- | `df{char}` | To and including `char` |
104
- | `dt{char}` | Up to (not including) `char` |
105
- | `dF{char}` | Backward to and including `char` |
106
- | `dT{char}` | Backward to one after `char` |
107
- | `diw` | Inner word |
108
- | `daw` | Around word (includes surrounding spaces) |
99
+ A `{count}` or dual-count prefix (`{pfx}d{op}{motion}`) is supported for
100
+ word, char-find, and linewise motions. Maximum total count: `9999`.
101
+
102
+ | Command | Deletes |
103
+ |-------------------|-----------------------------------------------------------|
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 |
108
+ | `d$` | To end of line |
109
+ | `d0` | To start of line |
110
+ | `dd` | Current line (linewise) |
111
+ | `{count}dd` | `{count}` lines (linewise) |
112
+ | `d{count}j` | Current line + `{count}` lines below (linewise) |
113
+ | `d{count}k` | Current line + `{count}` lines above (linewise) |
114
+ | `dG` | Current line to end of buffer (linewise) |
115
+ | `df{char}` | To and including `char` |
116
+ | `d{count}f{char}` | To and including Nth `char` |
117
+ | `dt{char}` | Up to (not including) `char` |
118
+ | `dF{char}` | Backward to and including `char` |
119
+ | `dT{char}` | Backward to one after `char` |
120
+ | `diw` | Inner word |
121
+ | `daw` | Around word (includes surrounding spaces) |
122
+ | `d{count}aw` | Around `{count}` words |
109
123
 
110
124
  #### Change `c{motion}` / `cc`
111
125
 
112
- Same motion set as `d`. Deletes text then enters Insert mode.
126
+ Same motion and count set as `d`. Deletes text then enters Insert mode.
113
127
 
114
- | Command | Alias |
115
- |---------|-------|
116
- | `cw` | delete word + Insert |
117
- | `ciw` | change inner word |
118
- | `caw` | change around word |
119
- | `cc` | delete line + Insert |
120
- | `c$` | delete to EOL + Insert |
121
- | | all `d` motions apply |
128
+ | Command | Action |
129
+ |-----------------|------------------------------------|
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 |
122
137
 
123
138
  #### Single-key edits
124
139
 
125
- | Key | Action |
126
- |-----|----------------------------------------------|
127
- | `x` | Delete char under cursor (no-op at/past EOL) |
128
- | `s` | Delete char under cursor + Insert mode |
129
- | `S` | Delete line content + Insert mode |
130
- | `D` | Delete cursor to EOL (captures `\n` if at EOL with next line) |
131
- | `C` | Delete cursor to EOL + Insert mode |
140
+ A `{count}` prefix is supported for `x`, `p`, `P`. Maximum: `9999`.
141
+
142
+ | Key | Action |
143
+ |--------------|---------------------------------------------------------------|
144
+ | `x` | Delete char under cursor (no-op at/past EOL) |
145
+ | `{count}x` | Delete `{count}` chars |
146
+ | `s` | Delete char under cursor + Insert mode |
147
+ | `S` | Delete line content + Insert mode |
148
+ | `D` | Delete cursor to EOL (captures `\n` if at EOL with next line) |
149
+ | `C` | Delete cursor to EOL + Insert mode |
132
150
 
133
151
  ---
134
152
 
@@ -156,10 +174,12 @@ Same motion set as `d`. Writes to register, **no text mutation**.
156
174
 
157
175
  ### Put / Paste
158
176
 
159
- | Key | Action |
160
- |-----|-------------------------------------------------------------|
161
- | `p` | Put after cursor (char-wise) / new line below (line-wise) |
162
- | `P` | Put before cursor (char-wise) / new line above (line-wise) |
177
+ | Key | Action |
178
+ |--------------|-------------------------------------------------------------|
179
+ | `p` | Put after cursor (char-wise) / new line below (line-wise) |
180
+ | `P` | Put before cursor (char-wise) / new line above (line-wise) |
181
+ | `{count}p` | Put `{count}` times after cursor |
182
+ | `{count}P` | Put `{count}` times before cursor |
163
183
 
164
184
  Put reads from the **unnamed register** (not OS clipboard).
165
185
  Line-wise detection: register content ending in `\n` is treated as line-wise.
@@ -199,7 +219,7 @@ Redo (`<C-r>`) is **not implemented** — see [Out of scope](#out-of-scope).
199
219
  | Redo | Not implemented | `<C-r>` |
200
220
  | Visual mode | Not implemented | `v`, `V`, `<C-v>` |
201
221
  | Text objects | Supports `iw`/`aw` only | Full text-object set |
202
- | Count prefix | Partial: linewise `dd`/`yy` and `d/y{count}j/k` only | Supported |
222
+ | Count prefix | Supported for operators, word/char motions, navigation, and edits (`x`, `p`/`P`); capped at `MAX_COUNT=9999` to prevent abuse | Full support |
203
223
  | Named registers | Not implemented (`"a`, etc.) | Supported |
204
224
  | Macros | Not implemented (`q`, `@`) | Supported |
205
225
  | Search | Not implemented (`/`, `?`, `n`, `N`) | Supported |
@@ -219,7 +239,7 @@ These are **explicitly deferred** and not planned for this feature:
219
239
  - Ex command surface (`:s`, `:g`, `:r`, …)
220
240
  - Search mode (`/`, `?`, `n`, `N`)
221
241
  - Repeat (`.`)
222
- - General count prefixes beyond current linewise scope (`3dw`, `2j`, )
242
+ - Extended count prefix beyond currently supported motions (e.g. counted `gg`, `:`, global operator counts)
223
243
  - Redo (`<C-r>`) — no native redo primitive in the underlying readline editor;
224
244
  deferred until a suitable hook is available.
225
245
  - Window / tab / buffer management
package/index.ts CHANGED
@@ -88,14 +88,15 @@ import {
88
88
  const BRACKETED_PASTE_START = "\x1b[200~";
89
89
  const BRACKETED_PASTE_END = "\x1b[201~";
90
90
  const BRACKETED_PASTE_END_TAIL = BRACKETED_PASTE_END.slice(1);
91
+ const MAX_COUNT = 9999;
91
92
 
92
93
  export class ModalEditor extends CustomEditor {
93
94
  private mode: Mode = "insert";
94
95
  private pendingMotion: PendingMotion = null;
95
96
  private pendingTextObject: "i" | "a" | null = null;
96
97
  private pendingOperator: PendingOperator = null;
97
- private pendingCount: string = "";
98
- private pendingCountKind: "prefix" | "operator" | null = null;
98
+ private prefixCount: string = "";
99
+ private operatorCount: string = "";
99
100
  private pendingG: boolean = false;
100
101
  private lastCharMotion: LastCharMotion | null = null;
101
102
  private discardingBracketedPasteInNormalMode: boolean = false;
@@ -119,8 +120,8 @@ export class ModalEditor extends CustomEditor {
119
120
  this.pendingMotion = null;
120
121
  this.pendingTextObject = null;
121
122
  this.pendingOperator = null;
122
- this.pendingCount = "";
123
- this.pendingCountKind = null;
123
+ this.prefixCount = "";
124
+ this.operatorCount = "";
124
125
  this.pendingG = false;
125
126
  }
126
127
 
@@ -272,7 +273,8 @@ export class ModalEditor extends CustomEditor {
272
273
  this.pendingMotion
273
274
  || this.pendingTextObject
274
275
  || this.pendingOperator
275
- || this.pendingCount
276
+ || this.prefixCount
277
+ || this.operatorCount
276
278
  || this.pendingG
277
279
  ) {
278
280
  this.clearPendingState();
@@ -307,21 +309,38 @@ export class ModalEditor extends CustomEditor {
307
309
  return data.length === 1 && data >= "1" && data <= "9";
308
310
  }
309
311
 
310
- private takePendingCount(defaultValue: number = 1): number {
311
- if (!this.pendingCount) return defaultValue;
312
+ private takeTotalCount(defaultValue: number = 1): number {
313
+ const prefixRaw = this.prefixCount;
314
+ const operatorRaw = this.operatorCount;
315
+ this.prefixCount = "";
316
+ this.operatorCount = "";
312
317
 
313
- const parsed = Number.parseInt(this.pendingCount, 10);
314
- this.pendingCount = "";
315
- this.pendingCountKind = null;
318
+ if (!prefixRaw && !operatorRaw) return defaultValue;
316
319
 
317
- if (!Number.isFinite(parsed) || parsed <= 0) return defaultValue;
318
- return parsed;
320
+ const parse = (raw: string): number | null => {
321
+ if (!raw) return null;
322
+ const parsed = Number.parseInt(raw, 10);
323
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
324
+ return parsed;
325
+ };
326
+
327
+ const prefix = parse(prefixRaw);
328
+ const operator = parse(operatorRaw);
329
+
330
+ if (prefix === null && operator === null) return defaultValue;
331
+
332
+ const total = prefix !== null && operator !== null
333
+ ? prefix * operator
334
+ : prefix ?? operator ?? defaultValue;
335
+
336
+ if (!Number.isFinite(total) || total <= 0) return defaultValue;
337
+ return Math.min(MAX_COUNT, total);
319
338
  }
320
339
 
321
340
  private cancelPendingOperator(data: string): void {
322
341
  this.pendingOperator = null;
323
- this.pendingCount = "";
324
- this.pendingCountKind = null;
342
+ this.prefixCount = "";
343
+ this.operatorCount = "";
325
344
  if (!this.isPrintableChunk(data)) {
326
345
  super.handleInput(data);
327
346
  }
@@ -358,7 +377,8 @@ export class ModalEditor extends CustomEditor {
358
377
  return;
359
378
  }
360
379
 
361
- const range = this.getWordObjectRange(this.pendingTextObject!);
380
+ const count = this.takeTotalCount(1);
381
+ const range = this.getWordObjectRange(this.pendingTextObject!, count);
362
382
  this.pendingTextObject = null;
363
383
  if (!range || !this.pendingOperator) {
364
384
  this.pendingOperator = null;
@@ -390,43 +410,35 @@ export class ModalEditor extends CustomEditor {
390
410
 
391
411
  private handlePendingDelete(data: string): void {
392
412
  if (this.isDigit(data)) {
393
- if (this.pendingCount.length === 0) {
413
+ if (this.operatorCount.length === 0) {
394
414
  if (data !== "0") {
395
- this.pendingCount = data;
396
- this.pendingCountKind = "operator";
415
+ this.operatorCount = data;
397
416
  return;
398
417
  }
399
- } else if (this.pendingCountKind === "operator") {
400
- this.pendingCount += data;
401
- return;
402
418
  } else {
403
- // Dual counts like 2d3j are out of scope; fail closed.
404
- this.cancelPendingOperator(data);
419
+ this.operatorCount += data;
405
420
  return;
406
421
  }
407
422
  }
408
423
 
409
424
  if (data === "d") {
410
- const count = this.takePendingCount(1);
425
+ const count = this.takeTotalCount(1);
411
426
  this.deleteLinewiseByDelta(count - 1);
412
427
  this.pendingOperator = null;
413
428
  return;
414
429
  }
415
430
 
416
431
  if (data === "j" || data === "k") {
417
- if (this.pendingCountKind === "prefix") {
418
- this.cancelPendingOperator(data);
419
- return;
420
- }
421
-
422
- const count = this.takePendingCount(1);
423
- this.deleteLinewiseByDelta(data === "j" ? count : -count);
432
+ const hasDualCount = this.prefixCount.length > 0 && this.operatorCount.length > 0;
433
+ const count = this.takeTotalCount(1);
434
+ const delta = hasDualCount ? Math.max(0, count - 1) : count;
435
+ this.deleteLinewiseByDelta(data === "j" ? delta : -delta);
424
436
  this.pendingOperator = null;
425
437
  return;
426
438
  }
427
439
 
428
440
  if (data === "G") {
429
- if (this.pendingCount.length > 0) {
441
+ if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
430
442
  this.cancelPendingOperator(data);
431
443
  return;
432
444
  }
@@ -436,22 +448,29 @@ export class ModalEditor extends CustomEditor {
436
448
  return;
437
449
  }
438
450
 
439
- if (this.pendingCount.length > 0) {
440
- // Counted forms beyond dd and d{count}j/k are intentionally out of scope.
441
- this.cancelPendingOperator(data);
451
+ if (CHAR_MOTION_KEYS.has(data)) {
452
+ this.pendingMotion = data as PendingMotion;
442
453
  return;
443
454
  }
444
455
 
445
- if (data === "i" || data === "a") {
446
- this.pendingTextObject = data;
456
+ const hasCount = this.prefixCount.length > 0 || this.operatorCount.length > 0;
457
+ const supportsCountedWordMotion = data === "w" || data === "e" || data === "b";
458
+ const supportsCountedTextObject = data === "i" || data === "a";
459
+
460
+ if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) {
461
+ // 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.
463
+ this.cancelPendingOperator(data);
447
464
  return;
448
465
  }
449
- if (CHAR_MOTION_KEYS.has(data)) {
450
- this.pendingMotion = data as PendingMotion;
466
+
467
+ if (supportsCountedTextObject) {
468
+ this.pendingTextObject = data;
451
469
  return;
452
470
  }
453
471
 
454
- if (this.deleteWithMotion(data)) {
472
+ const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1;
473
+ if (this.deleteWithMotion(data, motionCount)) {
455
474
  this.pendingOperator = null;
456
475
  return;
457
476
  }
@@ -461,21 +480,50 @@ export class ModalEditor extends CustomEditor {
461
480
  }
462
481
 
463
482
  private handlePendingChange(data: string): void {
483
+ if (this.isDigit(data)) {
484
+ if (this.operatorCount.length === 0) {
485
+ if (data !== "0") {
486
+ this.operatorCount = data;
487
+ return;
488
+ }
489
+ } else {
490
+ this.operatorCount += data;
491
+ return;
492
+ }
493
+ }
494
+
464
495
  if (data === "c") {
496
+ if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
497
+ this.cancelPendingOperator(data);
498
+ return;
499
+ }
500
+
465
501
  this.cutLine();
466
502
  this.pendingOperator = null;
467
503
  this.mode = "insert";
468
504
  return;
469
505
  }
470
- if (data === "i" || data === "a") {
471
- this.pendingTextObject = data;
472
- return;
473
- }
474
506
  if (CHAR_MOTION_KEYS.has(data)) {
475
507
  this.pendingMotion = data as PendingMotion;
476
508
  return;
477
509
  }
478
- if (this.deleteWithMotion(data)) {
510
+
511
+ const hasCount = this.prefixCount.length > 0 || this.operatorCount.length > 0;
512
+ const supportsCountedWordMotion = data === "w" || data === "e" || data === "b";
513
+ const supportsCountedTextObject = data === "i" || data === "a";
514
+
515
+ if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) {
516
+ this.cancelPendingOperator(data);
517
+ return;
518
+ }
519
+
520
+ if (supportsCountedTextObject) {
521
+ this.pendingTextObject = data;
522
+ return;
523
+ }
524
+
525
+ const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1;
526
+ if (this.deleteWithMotion(data, motionCount)) {
479
527
  this.pendingOperator = null;
480
528
  this.mode = "insert";
481
529
  return;
@@ -495,23 +543,77 @@ export class ModalEditor extends CustomEditor {
495
543
  // Unsupported g-prefix command: discard prefix and keep processing input.
496
544
  }
497
545
 
498
- if (this.pendingCount.length > 0) {
499
- if (this.isDigit(data) && this.pendingCountKind === "prefix") {
500
- this.pendingCount += data;
546
+ if (this.prefixCount.length > 0) {
547
+ if (this.isDigit(data)) {
548
+ this.prefixCount += data;
501
549
  return;
502
550
  }
503
551
 
504
- if ((data === "d" || data === "y") && this.pendingCountKind === "prefix") {
552
+ if (data === "d" || data === "y") {
505
553
  this.pendingOperator = data;
506
554
  return;
507
555
  }
508
556
 
509
- // Count prefixes are currently supported for dd/yy only.
510
- this.pendingCount = "";
511
- this.pendingCountKind = null;
557
+ if (data === "c") {
558
+ this.pendingOperator = "c";
559
+ return;
560
+ }
561
+
562
+ const supportsCountedStandaloneEdit = (
563
+ data === "x"
564
+ || data === "s"
565
+ || data === "S"
566
+ || data === "D"
567
+ || data === "C"
568
+ || data === "p"
569
+ || data === "P"
570
+ );
571
+ const supportsCountedCharMotion = (
572
+ CHAR_MOTION_KEYS.has(data)
573
+ || data === ";"
574
+ || data === ","
575
+ );
576
+ const supportsCountedWordMotion = (
577
+ data === "w"
578
+ || data === "e"
579
+ || data === "b"
580
+ );
581
+ const supportsCountedNav = (
582
+ data === "h"
583
+ || data === "j"
584
+ || data === "k"
585
+ || data === "l"
586
+ );
587
+
588
+ if (supportsCountedNav) {
589
+ const count = this.takeTotalCount(1);
590
+ const clamped = Math.min(count, MAX_COUNT);
591
+ if (data === "h") {
592
+ this.moveCursorBy(-clamped);
593
+ } else if (data === "l") {
594
+ this.moveCursorBy(clamped);
595
+ } else {
596
+ // j/k: clamp vertical nav to buffer size to prevent TUI stalls
597
+ const lines = this.getLines();
598
+ const cursorLine = this.getCursor().line;
599
+ const safeCount = data === "j"
600
+ ? Math.min(clamped, lines.length - 1 - cursorLine)
601
+ : Math.min(clamped, cursorLine);
602
+ const seq = data === "j" ? ESC_DOWN : ESC_UP;
603
+ for (let i = 0; i < safeCount; i++) {
604
+ super.handleInput(seq);
605
+ }
606
+ }
607
+ return;
608
+ }
609
+
610
+ if (!supportsCountedStandaloneEdit && !supportsCountedCharMotion && !supportsCountedWordMotion) {
611
+ // Unsupported prefixed forms: drop count and keep processing this key.
612
+ this.prefixCount = "";
613
+ this.operatorCount = "";
614
+ }
512
615
  } else if (this.isCountStarter(data)) {
513
- this.pendingCount = data;
514
- this.pendingCountKind = "prefix";
616
+ this.prefixCount = data;
515
617
  return;
516
618
  }
517
619
 
@@ -573,9 +675,12 @@ export class ModalEditor extends CustomEditor {
573
675
  return;
574
676
  }
575
677
 
576
- if (data === "w") return this.moveWord("forward", "start");
577
- if (data === "b") return this.moveWord("backward", "start");
578
- if (data === "e") return this.moveWord("forward", "end");
678
+ if (data === "w") {
679
+ const count = this.takeTotalCount(1);
680
+ return this.moveWord("forward", "start", count);
681
+ }
682
+ if (data === "b") return this.moveWord("backward", "start", this.takeTotalCount(1));
683
+ if (data === "e") return this.moveWord("forward", "end", this.takeTotalCount(1));
579
684
 
580
685
  if (Object.hasOwn(NORMAL_KEYS, data)) {
581
686
  return this.handleMappedKey(data);
@@ -618,13 +723,16 @@ export class ModalEditor extends CustomEditor {
618
723
  this.mode = "insert";
619
724
  break;
620
725
  case "D":
726
+ this.takeTotalCount(1);
621
727
  this.cutToEndOfLine();
622
728
  break;
623
729
  case "C":
730
+ this.takeTotalCount(1);
624
731
  this.cutToEndOfLine();
625
732
  this.mode = "insert";
626
733
  break;
627
734
  case "S":
735
+ this.takeTotalCount(1);
628
736
  this.cutCurrentLineContent();
629
737
  this.mode = "insert";
630
738
  break;
@@ -643,7 +751,8 @@ export class ModalEditor extends CustomEditor {
643
751
  private executeCharMotion(motion: CharMotion, targetChar: string, saveMotion: boolean = true): void {
644
752
  const line = this.getLines()[this.getCursor().line] ?? "";
645
753
  const col = this.getCursor().col;
646
- const targetCol = findCharMotionTarget(line, col, motion, targetChar, !saveMotion);
754
+ const count = this.takeTotalCount(1);
755
+ const targetCol = findCharMotionTarget(line, col, motion, targetChar, !saveMotion, count);
647
756
 
648
757
  if (targetCol !== null && saveMotion) {
649
758
  this.lastCharMotion = { motion, char: targetChar };
@@ -755,74 +864,73 @@ export class ModalEditor extends CustomEditor {
755
864
  abs: number,
756
865
  direction: "forward" | "backward",
757
866
  target: "start" | "end",
867
+ count: number = 1,
758
868
  ): number {
759
869
  const len = text.length;
760
870
  if (len === 0) return 0;
761
871
 
872
+ const steps = Math.max(1, Math.min(MAX_COUNT, count));
762
873
  let i = Math.max(0, Math.min(abs, len));
763
874
 
764
- if (direction === "forward") {
765
- if (i >= len) return len;
875
+ for (let step = 0; step < steps; step++) {
876
+ let next = i;
766
877
 
767
- if (target === "start") {
768
- const startType = this.charType(text[i]);
769
- if (startType !== "space") {
770
- while (i < len && this.charType(text[i]) === startType) i++;
878
+ if (direction === "forward") {
879
+ if (next >= len) {
880
+ next = len;
881
+ } else if (target === "start") {
882
+ const startType = this.charType(text[next]);
883
+ if (startType !== "space") {
884
+ while (next < len && this.charType(text[next]) === startType) next++;
885
+ }
886
+ while (next < len && this.charType(text[next]) === "space") next++;
887
+ } else {
888
+ if (next < len - 1) next++;
889
+ while (next < len && this.charType(text[next]) === "space") next++;
890
+ if (next >= len) {
891
+ next = len;
892
+ } else {
893
+ const t = this.charType(text[next]);
894
+ while (next < len - 1 && this.charType(text[next + 1]) === t) next++;
895
+ }
771
896
  }
772
- while (i < len && this.charType(text[i]) === "space") i++;
773
- return i;
897
+ } else {
898
+ if (next >= len) next = len - 1;
899
+ 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--;
774
903
  }
775
904
 
776
- if (i < len - 1) i++;
777
- while (i < len && this.charType(text[i]) === "space") i++;
778
- if (i >= len) return len;
779
- const t = this.charType(text[i]);
780
- while (i < len - 1 && this.charType(text[i + 1]) === t) i++;
781
- return i;
905
+ if (next === i) break;
906
+ i = next;
782
907
  }
783
908
 
784
- if (i >= len) i = len - 1;
785
- if (i > 0) i--;
786
- while (i > 0 && this.charType(text[i]) === "space") i--;
787
- const t = this.charType(text[i]);
788
- while (i > 0 && this.charType(text[i - 1]) === t) i--;
789
909
  return i;
790
910
  }
791
911
 
792
- private tryFindWordTargetLineLocal(
912
+ private tryFindWordTargetInLine(
913
+ line: string,
914
+ col: number,
793
915
  direction: WordMotionDirection,
794
916
  target: WordMotionTarget,
795
917
  allowSameColumn: boolean = false,
796
918
  ): number | null {
797
- const cursor = this.getCursor();
798
- const lineIndex = cursor.line;
799
- const col = cursor.col;
800
- const lineSnapshot = this.getLines()[lineIndex] ?? "";
801
-
802
- if (lineSnapshot.length === 0) return null;
803
- if (col < 0 || col > lineSnapshot.length) return null;
919
+ if (line.length === 0) return null;
920
+ if (col < 0 || col > line.length) return null;
804
921
 
805
922
  if (direction === "forward") {
806
- if (col >= lineSnapshot.length) return null;
923
+ if (col >= line.length) return null;
807
924
  } else {
808
925
  if (col <= 0) return null;
809
- if (!/\S/.test(lineSnapshot.slice(0, col))) return null;
926
+ if (!/\S/.test(line.slice(0, col))) return null;
810
927
  }
811
928
 
812
- const targetCol = this.wordBoundaryCache.tryFindTarget(
813
- lineSnapshot,
814
- col,
815
- direction,
816
- target,
817
- );
929
+ const targetCol = this.wordBoundaryCache.tryFindTarget(line, col, direction, target);
818
930
  if (targetCol === null) return null;
819
931
 
820
- const liveLine = this.getLines()[lineIndex] ?? "";
821
- const liveCol = this.getCursor().col;
822
- if (liveLine !== lineSnapshot || liveCol !== col) return null;
823
-
824
932
  if (direction === "forward") {
825
- if (targetCol >= lineSnapshot.length) return null;
933
+ if (targetCol >= line.length) return null;
826
934
  if (allowSameColumn) {
827
935
  if (targetCol < col) return null;
828
936
  } else if (targetCol <= col) {
@@ -840,6 +948,32 @@ export class ModalEditor extends CustomEditor {
840
948
  return targetCol;
841
949
  }
842
950
 
951
+ private tryFindWordTargetLineLocal(
952
+ direction: WordMotionDirection,
953
+ target: WordMotionTarget,
954
+ allowSameColumn: boolean = false,
955
+ ): number | null {
956
+ const cursor = this.getCursor();
957
+ const lineIndex = cursor.line;
958
+ const col = cursor.col;
959
+ const lineSnapshot = this.getLines()[lineIndex] ?? "";
960
+
961
+ const targetCol = this.tryFindWordTargetInLine(
962
+ lineSnapshot,
963
+ col,
964
+ direction,
965
+ target,
966
+ allowSameColumn,
967
+ );
968
+ if (targetCol === null) return null;
969
+
970
+ const liveLine = this.getLines()[lineIndex] ?? "";
971
+ const liveCol = this.getCursor().col;
972
+ if (liveLine !== lineSnapshot || liveCol !== col) return null;
973
+
974
+ return targetCol;
975
+ }
976
+
843
977
  private tryMoveWordLineLocal(
844
978
  direction: "forward" | "backward",
845
979
  target: "start" | "end",
@@ -854,29 +988,61 @@ export class ModalEditor extends CustomEditor {
854
988
 
855
989
  private tryWordMotionLineLocalRange(
856
990
  motion: "w" | "e" | "b",
991
+ count: number = 1,
857
992
  ): { col: number; targetCol: number; inclusive: boolean } | null {
858
- const col = this.getCursor().col;
993
+ const cursor = this.getCursor();
994
+ const lineIndex = cursor.line;
995
+ const col = cursor.col;
996
+ const lineSnapshot = this.getLines()[lineIndex] ?? "";
859
997
  const direction: WordMotionDirection = motion === "b" ? "backward" : "forward";
860
998
  const target: WordMotionTarget = motion === "e" ? "end" : "start";
861
- const targetCol = this.tryFindWordTargetLineLocal(direction, target, motion === "e");
999
+ const steps = Math.max(1, Math.min(MAX_COUNT, count));
1000
+
1001
+ let currentCol = col;
1002
+ for (let step = 0; step < steps; step++) {
1003
+ const nextCol = this.tryFindWordTargetInLine(
1004
+ lineSnapshot,
1005
+ currentCol,
1006
+ direction,
1007
+ target,
1008
+ motion === "e",
1009
+ );
1010
+ if (nextCol === null) return null;
1011
+ if (nextCol === currentCol && step < steps - 1) return null;
1012
+ currentCol = nextCol;
1013
+ }
862
1014
 
863
- if (targetCol === null) return null;
1015
+ const liveLine = this.getLines()[lineIndex] ?? "";
1016
+ const liveCol = this.getCursor().col;
1017
+ if (liveLine !== lineSnapshot || liveCol !== col) return null;
864
1018
 
865
1019
  return {
866
1020
  col,
867
- targetCol,
1021
+ targetCol: currentCol,
868
1022
  inclusive: motion === "e",
869
1023
  };
870
1024
  }
871
1025
 
872
- private moveWord(direction: "forward" | "backward", target: "start" | "end"): void {
873
- if (this.tryMoveWordLineLocal(direction, target)) return;
1026
+ private moveWord(
1027
+ direction: "forward" | "backward",
1028
+ target: "start" | "end",
1029
+ count: number = 1,
1030
+ ): void {
1031
+ let remaining = Math.max(1, Math.min(MAX_COUNT, count));
1032
+
1033
+ while (remaining > 0) {
1034
+ if (this.tryMoveWordLineLocal(direction, target)) {
1035
+ remaining--;
1036
+ continue;
1037
+ }
874
1038
 
875
- const text = this.getText();
876
- const currentAbs = this.getAbsoluteIndexFromCursor();
877
- const targetAbs = this.findWordTargetInText(text, currentAbs, direction, target);
878
- if (targetAbs !== currentAbs) {
879
- this.moveCursorBy(targetAbs - currentAbs);
1039
+ const text = this.getText();
1040
+ const currentAbs = this.getAbsoluteIndexFromCursor();
1041
+ const targetAbs = this.findWordTargetInText(text, currentAbs, direction, target, remaining);
1042
+ if (targetAbs !== currentAbs) {
1043
+ this.moveCursorBy(targetAbs - currentAbs);
1044
+ }
1045
+ return;
880
1046
  }
881
1047
  }
882
1048
 
@@ -898,13 +1064,13 @@ export class ModalEditor extends CustomEditor {
898
1064
  }
899
1065
 
900
1066
  private cutCharUnderCursor(): void {
1067
+ const count = this.takeTotalCount(1);
901
1068
  const { line, col } = this.getCurrentLineAndCol();
902
1069
  if (line.length === 0) return; // Don't merge empty lines with x
903
1070
  if (col >= line.length) return; // Don't delete past end of line
904
1071
 
905
- const deleted = line.slice(col, col + 1);
906
- this.writeToRegister(deleted);
907
- super.handleInput(ESC_DELETE);
1072
+ const boundedCount = Math.max(1, Math.min(MAX_COUNT, count));
1073
+ this.deleteRange(col, col + boundedCount, false);
908
1074
  }
909
1075
 
910
1076
  private cutToEndOfLine(): void {
@@ -1023,9 +1189,8 @@ export class ModalEditor extends CustomEditor {
1023
1189
  this.yankLineRange(this.getCursor().line, this.getLines().length - 1);
1024
1190
  }
1025
1191
 
1026
- private deleteWithMotion(motion: string): boolean {
1192
+ private deleteWithMotion(motion: string, count: number = 1): boolean {
1027
1193
  const cursor = this.getCursor();
1028
- const line = this.getLines()[cursor.line] ?? "";
1029
1194
  const col = cursor.col;
1030
1195
 
1031
1196
  if (motion === "$") {
@@ -1040,7 +1205,7 @@ export class ModalEditor extends CustomEditor {
1040
1205
  }
1041
1206
 
1042
1207
  if (motion === "w" || motion === "e" || motion === "b") {
1043
- const lineLocalRange = this.tryWordMotionLineLocalRange(motion);
1208
+ const lineLocalRange = this.tryWordMotionLineLocalRange(motion, count);
1044
1209
  if (lineLocalRange) {
1045
1210
  this.deleteRange(
1046
1211
  lineLocalRange.col,
@@ -1057,6 +1222,7 @@ export class ModalEditor extends CustomEditor {
1057
1222
  currentAbs,
1058
1223
  motion === "b" ? "backward" : "forward",
1059
1224
  motion === "e" ? "end" : "start",
1225
+ count,
1060
1226
  );
1061
1227
  this.deleteRangeByAbsolute(currentAbs, targetAbs, motion === "e");
1062
1228
  return true;
@@ -1068,7 +1234,8 @@ export class ModalEditor extends CustomEditor {
1068
1234
  private deleteWithCharMotion(motion: CharMotion, targetChar: string): void {
1069
1235
  const line = this.getLines()[this.getCursor().line] ?? "";
1070
1236
  const col = this.getCursor().col;
1071
- const targetCol = findCharMotionTarget(line, col, motion, targetChar);
1237
+ const count = this.takeTotalCount(1);
1238
+ const targetCol = findCharMotionTarget(line, col, motion, targetChar, false, count);
1072
1239
 
1073
1240
  if (targetCol === null) return;
1074
1241
 
@@ -1078,43 +1245,35 @@ export class ModalEditor extends CustomEditor {
1078
1245
 
1079
1246
  private handlePendingYank(data: string): void {
1080
1247
  if (this.isDigit(data)) {
1081
- if (this.pendingCount.length === 0) {
1248
+ if (this.operatorCount.length === 0) {
1082
1249
  if (data !== "0") {
1083
- this.pendingCount = data;
1084
- this.pendingCountKind = "operator";
1250
+ this.operatorCount = data;
1085
1251
  return;
1086
1252
  }
1087
- } else if (this.pendingCountKind === "operator") {
1088
- this.pendingCount += data;
1089
- return;
1090
1253
  } else {
1091
- // Dual counts like 2y3k are out of scope; fail closed.
1092
- this.cancelPendingOperator(data);
1254
+ this.operatorCount += data;
1093
1255
  return;
1094
1256
  }
1095
1257
  }
1096
1258
 
1097
1259
  if (data === "y") {
1098
- const count = this.takePendingCount(1);
1260
+ const count = this.takeTotalCount(1);
1099
1261
  this.yankLinewiseByDelta(count - 1);
1100
1262
  this.pendingOperator = null;
1101
1263
  return;
1102
1264
  }
1103
1265
 
1104
1266
  if (data === "j" || data === "k") {
1105
- if (this.pendingCountKind === "prefix") {
1106
- this.cancelPendingOperator(data);
1107
- return;
1108
- }
1109
-
1110
- const count = this.takePendingCount(1);
1111
- this.yankLinewiseByDelta(data === "j" ? count : -count);
1267
+ const hasDualCount = this.prefixCount.length > 0 && this.operatorCount.length > 0;
1268
+ const count = this.takeTotalCount(1);
1269
+ const delta = hasDualCount ? Math.max(0, count - 1) : count;
1270
+ this.yankLinewiseByDelta(data === "j" ? delta : -delta);
1112
1271
  this.pendingOperator = null;
1113
1272
  return;
1114
1273
  }
1115
1274
 
1116
1275
  if (data === "G") {
1117
- if (this.pendingCount.length > 0) {
1276
+ if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
1118
1277
  this.cancelPendingOperator(data);
1119
1278
  return;
1120
1279
  }
@@ -1124,8 +1283,13 @@ export class ModalEditor extends CustomEditor {
1124
1283
  return;
1125
1284
  }
1126
1285
 
1127
- if (this.pendingCount.length > 0) {
1128
- // Counted forms beyond yy and y{count}j/k are intentionally out of scope.
1286
+ if (CHAR_MOTION_KEYS.has(data)) {
1287
+ this.pendingMotion = data as PendingMotion;
1288
+ return;
1289
+ }
1290
+
1291
+ if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
1292
+ // Counted forms beyond yy, y{count}j/k, and y{count}{f/F/t/T} are out of scope.
1129
1293
  this.cancelPendingOperator(data);
1130
1294
  return;
1131
1295
  }
@@ -1134,10 +1298,6 @@ export class ModalEditor extends CustomEditor {
1134
1298
  this.pendingTextObject = data;
1135
1299
  return;
1136
1300
  }
1137
- if (CHAR_MOTION_KEYS.has(data)) {
1138
- this.pendingMotion = data as PendingMotion;
1139
- return;
1140
- }
1141
1301
 
1142
1302
  if (this.yankWithMotion(data)) {
1143
1303
  this.pendingOperator = null;
@@ -1190,7 +1350,8 @@ export class ModalEditor extends CustomEditor {
1190
1350
  private yankWithCharMotion(motion: CharMotion, targetChar: string): void {
1191
1351
  const line = this.getLines()[this.getCursor().line] ?? "";
1192
1352
  const col = this.getCursor().col;
1193
- const targetCol = findCharMotionTarget(line, col, motion, targetChar);
1353
+ const count = this.takeTotalCount(1);
1354
+ const targetCol = findCharMotionTarget(line, col, motion, targetChar, false, count);
1194
1355
 
1195
1356
  if (targetCol === null) return;
1196
1357
 
@@ -1241,16 +1402,20 @@ export class ModalEditor extends CustomEditor {
1241
1402
  }
1242
1403
  }
1243
1404
 
1244
- private getWordObjectRange(kind: "i" | "a"): { startAbs: number; endAbs: number } | null {
1405
+ private getWordObjectRange(
1406
+ kind: "i" | "a",
1407
+ count: number = 1,
1408
+ ): { startAbs: number; endAbs: number } | null {
1245
1409
  const lines = this.getLines();
1246
1410
  const cursor = this.getCursor();
1247
1411
  const line = lines[cursor.line] ?? "";
1248
1412
  if (!line) return null;
1249
1413
 
1250
- let col = Math.min(cursor.col, Math.max(0, line.length - 1));
1251
-
1414
+ const steps = Math.max(1, Math.min(MAX_COUNT, count));
1252
1415
  const hasWordChar = (idx: number) => idx >= 0 && idx < line.length && this.isWordChar(line[idx]!);
1253
1416
 
1417
+ let col = Math.min(cursor.col, Math.max(0, line.length - 1));
1418
+
1254
1419
  if (!hasWordChar(col)) {
1255
1420
  let right = col;
1256
1421
  while (right < line.length && !hasWordChar(right)) right++;
@@ -1270,17 +1435,28 @@ export class ModalEditor extends CustomEditor {
1270
1435
  let end = col + 1;
1271
1436
  while (end < line.length && hasWordChar(end)) end++;
1272
1437
 
1438
+ let remaining = steps - 1;
1439
+ while (remaining > 0) {
1440
+ let nextWordStart = end;
1441
+ while (nextWordStart < line.length && !hasWordChar(nextWordStart)) nextWordStart++;
1442
+ if (nextWordStart >= line.length) break;
1443
+
1444
+ let nextWordEnd = nextWordStart + 1;
1445
+ while (nextWordEnd < line.length && hasWordChar(nextWordEnd)) nextWordEnd++;
1446
+
1447
+ end = nextWordEnd;
1448
+ remaining--;
1449
+ }
1450
+
1273
1451
  if (kind === "a") {
1274
- let aroundStart = start;
1275
1452
  let aroundEnd = end;
1276
-
1277
1453
  while (aroundEnd < line.length && /\s/.test(line[aroundEnd]!)) aroundEnd++;
1278
- if (aroundEnd === end) {
1279
- while (aroundStart > 0 && /\s/.test(line[aroundStart - 1]!)) aroundStart--;
1280
- }
1281
1454
 
1282
- start = aroundStart;
1283
- end = aroundEnd;
1455
+ if (aroundEnd > end) {
1456
+ end = aroundEnd;
1457
+ } else {
1458
+ while (start > 0 && /\s/.test(line[start - 1]!)) start--;
1459
+ }
1284
1460
  }
1285
1461
 
1286
1462
  return {
@@ -1289,23 +1465,32 @@ export class ModalEditor extends CustomEditor {
1289
1465
  };
1290
1466
  }
1291
1467
 
1468
+ private static readonly PUT_SIZE_LIMIT = 512 * 1024; // 512 KB safety cap
1469
+
1292
1470
  private putAfter(): void {
1471
+ const count = this.takeTotalCount(1);
1293
1472
  const text = this.unnamedRegister;
1294
1473
  if (!text) return;
1474
+ const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
1295
1475
 
1296
1476
  if (text.endsWith("\n")) {
1297
- // Line-wise: insert new line below and fill it
1298
- super.handleInput(CTRL_E);
1299
- super.handleInput(NEWLINE);
1300
1477
  const content = text.slice(0, -1);
1301
- for (const char of content) {
1302
- super.handleInput(char === "\n" ? NEWLINE : char);
1303
- }
1304
- } else {
1305
- // Character-wise: insert after cursor
1306
- if (!this.isCursorAtOrPastEol()) {
1307
- super.handleInput(ESC_RIGHT);
1478
+ for (let i = 0; i < safeCount; i++) {
1479
+ // Line-wise: insert new line below and fill it
1480
+ super.handleInput(CTRL_E);
1481
+ super.handleInput(NEWLINE);
1482
+ for (const char of content) {
1483
+ super.handleInput(char === "\n" ? NEWLINE : char);
1484
+ }
1308
1485
  }
1486
+ return;
1487
+ }
1488
+
1489
+ // Character-wise: insert after cursor
1490
+ if (!this.isCursorAtOrPastEol()) {
1491
+ super.handleInput(ESC_RIGHT);
1492
+ }
1493
+ for (let i = 0; i < safeCount; i++) {
1309
1494
  for (const char of text) {
1310
1495
  super.handleInput(char === "\n" ? NEWLINE : char);
1311
1496
  }
@@ -1313,20 +1498,27 @@ export class ModalEditor extends CustomEditor {
1313
1498
  }
1314
1499
 
1315
1500
  private putBefore(): void {
1501
+ const count = this.takeTotalCount(1);
1316
1502
  const text = this.unnamedRegister;
1317
1503
  if (!text) return;
1504
+ const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
1318
1505
 
1319
1506
  if (text.endsWith("\n")) {
1320
- // Line-wise: insert new line above and fill it
1321
- super.handleInput(CTRL_A);
1322
- super.handleInput(NEWLINE);
1323
- super.handleInput(ESC_UP);
1324
1507
  const content = text.slice(0, -1);
1325
- for (const char of content) {
1326
- super.handleInput(char === "\n" ? NEWLINE : char);
1508
+ for (let i = 0; i < safeCount; i++) {
1509
+ // Line-wise: insert new line above and fill it
1510
+ super.handleInput(CTRL_A);
1511
+ super.handleInput(NEWLINE);
1512
+ super.handleInput(ESC_UP);
1513
+ for (const char of content) {
1514
+ super.handleInput(char === "\n" ? NEWLINE : char);
1515
+ }
1327
1516
  }
1328
- } else {
1329
- // Character-wise: insert before cursor (just type it)
1517
+ return;
1518
+ }
1519
+
1520
+ // Character-wise: insert before cursor (just type it)
1521
+ for (let i = 0; i < safeCount; i++) {
1330
1522
  for (const char of text) {
1331
1523
  super.handleInput(char === "\n" ? NEWLINE : char);
1332
1524
  }
@@ -1369,20 +1561,19 @@ export class ModalEditor extends CustomEditor {
1369
1561
  private getModeLabel(): string {
1370
1562
  if (this.mode === "insert") return " INSERT ";
1371
1563
 
1372
- const count = this.pendingCount;
1564
+ const prefixCount = this.prefixCount;
1565
+ const operatorCount = this.operatorCount;
1373
1566
 
1374
1567
  if (this.pendingOperator && this.pendingMotion) {
1375
- const prefix = this.pendingCountKind === "prefix" ? count : "";
1376
- const opCount = this.pendingCountKind === "operator" ? count : "";
1377
- return ` NORMAL ${prefix}${this.pendingOperator}${opCount}${this.pendingMotion}_ `;
1568
+ return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}${this.pendingMotion}_ `;
1378
1569
  }
1379
1570
  if (this.pendingOperator) {
1380
- const prefix = this.pendingCountKind === "prefix" ? count : "";
1381
- const opCount = this.pendingCountKind === "operator" ? count : "";
1382
- return ` NORMAL ${prefix}${this.pendingOperator}${opCount}_ `;
1571
+ return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}_ `;
1383
1572
  }
1384
1573
  if (this.pendingMotion) return ` NORMAL ${this.pendingMotion}_ `;
1385
1574
  if (this.pendingG) return " NORMAL g_ ";
1575
+
1576
+ const count = `${prefixCount}${operatorCount}`;
1386
1577
  if (count) return ` NORMAL ${count}_ `;
1387
1578
  return " NORMAL ";
1388
1579
  }
package/motions.ts CHANGED
@@ -40,26 +40,35 @@ export function findCharMotionTarget(
40
40
  motion: CharMotion,
41
41
  targetChar: string,
42
42
  isRepeat: boolean = false,
43
+ count: number = 1,
43
44
  ): number | null {
44
45
  const isForward = motion === "f" || motion === "t";
45
46
  const isTill = motion === "t" || motion === "T";
46
-
47
- // For till repeats (;/,), we need extra offset to skip past the character we stopped before/after
48
- const tillRepeatOffset = isTill && isRepeat ? 1 : 0;
49
-
50
- if (isForward) {
51
- const searchStart = col + 1 + tillRepeatOffset;
52
- const idx = line.indexOf(targetChar, searchStart);
53
- if (idx !== -1) {
54
- return isTill ? idx - 1 : idx;
47
+ const steps = Number.isFinite(count) && count > 0 ? Math.floor(count) : 1;
48
+
49
+ let currentPos = col;
50
+
51
+ for (let i = 0; i < steps; i++) {
52
+ const isFirst = i === 0;
53
+ const isFinal = i === steps - 1;
54
+ const tillRepeatOffset = isFirst && isTill && isRepeat ? 1 : 0;
55
+
56
+ if (isForward) {
57
+ const searchStart = currentPos + 1 + tillRepeatOffset;
58
+ const idx = line.indexOf(targetChar, searchStart);
59
+ if (idx === -1) return null;
60
+ if (isFinal) return isTill ? idx - 1 : idx;
61
+ currentPos = idx;
62
+ continue;
55
63
  }
56
- } else {
57
- const searchStart = col - 1 - tillRepeatOffset;
64
+
65
+ const searchStart = currentPos - 1 - tillRepeatOffset;
58
66
  const idx = line.lastIndexOf(targetChar, searchStart);
59
- if (idx !== -1) {
60
- return isTill ? idx + 1 : idx;
61
- }
67
+ if (idx === -1) return null;
68
+ if (isFinal) return isTill ? idx + 1 : idx;
69
+ currentPos = idx;
62
70
  }
71
+
63
72
  return null;
64
73
  }
65
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vim",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Vim-style modal editing for Pi's TUI editor",
5
5
  "type": "module",
6
6
  "keywords": [