pi-vim 0.1.3 → 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/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,
@@ -88,15 +95,17 @@ import {
88
95
  const BRACKETED_PASTE_START = "\x1b[200~";
89
96
  const BRACKETED_PASTE_END = "\x1b[201~";
90
97
  const BRACKETED_PASTE_END_TAIL = BRACKETED_PASTE_END.slice(1);
98
+ const MAX_COUNT = 9999;
91
99
 
92
100
  export class ModalEditor extends CustomEditor {
93
101
  private mode: Mode = "insert";
94
102
  private pendingMotion: PendingMotion = null;
95
103
  private pendingTextObject: "i" | "a" | null = null;
96
104
  private pendingOperator: PendingOperator = null;
97
- private pendingCount: string = "";
98
- private pendingCountKind: "prefix" | "operator" | null = null;
105
+ private prefixCount: string = "";
106
+ private operatorCount: string = "";
99
107
  private pendingG: boolean = false;
108
+ private pendingGCount: string = "";
100
109
  private lastCharMotion: LastCharMotion | null = null;
101
110
  private discardingBracketedPasteInNormalMode: boolean = false;
102
111
  private pendingEscWhileDiscardingBracketedPasteInNormalMode: boolean = false;
@@ -119,9 +128,10 @@ export class ModalEditor extends CustomEditor {
119
128
  this.pendingMotion = null;
120
129
  this.pendingTextObject = null;
121
130
  this.pendingOperator = null;
122
- this.pendingCount = "";
123
- this.pendingCountKind = null;
131
+ this.prefixCount = "";
132
+ this.operatorCount = "";
124
133
  this.pendingG = false;
134
+ this.pendingGCount = "";
125
135
  }
126
136
 
127
137
  private stripBracketedPasteInNormalMode(data: string): { filtered: string | null; stripped: boolean } {
@@ -272,8 +282,10 @@ export class ModalEditor extends CustomEditor {
272
282
  this.pendingMotion
273
283
  || this.pendingTextObject
274
284
  || this.pendingOperator
275
- || this.pendingCount
285
+ || this.prefixCount
286
+ || this.operatorCount
276
287
  || this.pendingG
288
+ || this.pendingGCount
277
289
  ) {
278
290
  this.clearPendingState();
279
291
  return;
@@ -307,21 +319,38 @@ export class ModalEditor extends CustomEditor {
307
319
  return data.length === 1 && data >= "1" && data <= "9";
308
320
  }
309
321
 
310
- private takePendingCount(defaultValue: number = 1): number {
311
- if (!this.pendingCount) return defaultValue;
322
+ private takeTotalCount(defaultValue: number = 1): number {
323
+ const prefixRaw = this.prefixCount;
324
+ const operatorRaw = this.operatorCount;
325
+ this.prefixCount = "";
326
+ this.operatorCount = "";
312
327
 
313
- const parsed = Number.parseInt(this.pendingCount, 10);
314
- this.pendingCount = "";
315
- this.pendingCountKind = null;
328
+ if (!prefixRaw && !operatorRaw) return defaultValue;
316
329
 
317
- if (!Number.isFinite(parsed) || parsed <= 0) return defaultValue;
318
- return parsed;
330
+ const parse = (raw: string): number | null => {
331
+ if (!raw) return null;
332
+ const parsed = Number.parseInt(raw, 10);
333
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
334
+ return parsed;
335
+ };
336
+
337
+ const prefix = parse(prefixRaw);
338
+ const operator = parse(operatorRaw);
339
+
340
+ if (prefix === null && operator === null) return defaultValue;
341
+
342
+ const total = prefix !== null && operator !== null
343
+ ? prefix * operator
344
+ : prefix ?? operator ?? defaultValue;
345
+
346
+ if (!Number.isFinite(total) || total <= 0) return defaultValue;
347
+ return Math.min(MAX_COUNT, total);
319
348
  }
320
349
 
321
350
  private cancelPendingOperator(data: string): void {
322
351
  this.pendingOperator = null;
323
- this.pendingCount = "";
324
- this.pendingCountKind = null;
352
+ this.prefixCount = "";
353
+ this.operatorCount = "";
325
354
  if (!this.isPrintableChunk(data)) {
326
355
  super.handleInput(data);
327
356
  }
@@ -358,7 +387,8 @@ export class ModalEditor extends CustomEditor {
358
387
  return;
359
388
  }
360
389
 
361
- const range = this.getWordObjectRange(this.pendingTextObject!);
390
+ const count = this.takeTotalCount(1);
391
+ const range = this.getWordObjectRange(this.pendingTextObject!, count);
362
392
  this.pendingTextObject = null;
363
393
  if (!range || !this.pendingOperator) {
364
394
  this.pendingOperator = null;
@@ -390,43 +420,35 @@ export class ModalEditor extends CustomEditor {
390
420
 
391
421
  private handlePendingDelete(data: string): void {
392
422
  if (this.isDigit(data)) {
393
- if (this.pendingCount.length === 0) {
423
+ if (this.operatorCount.length === 0) {
394
424
  if (data !== "0") {
395
- this.pendingCount = data;
396
- this.pendingCountKind = "operator";
425
+ this.operatorCount = data;
397
426
  return;
398
427
  }
399
- } else if (this.pendingCountKind === "operator") {
400
- this.pendingCount += data;
401
- return;
402
428
  } else {
403
- // Dual counts like 2d3j are out of scope; fail closed.
404
- this.cancelPendingOperator(data);
429
+ this.operatorCount += data;
405
430
  return;
406
431
  }
407
432
  }
408
433
 
409
434
  if (data === "d") {
410
- const count = this.takePendingCount(1);
435
+ const count = this.takeTotalCount(1);
411
436
  this.deleteLinewiseByDelta(count - 1);
412
437
  this.pendingOperator = null;
413
438
  return;
414
439
  }
415
440
 
416
441
  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);
442
+ const hasDualCount = this.prefixCount.length > 0 && this.operatorCount.length > 0;
443
+ const count = this.takeTotalCount(1);
444
+ const delta = hasDualCount ? Math.max(0, count - 1) : count;
445
+ this.deleteLinewiseByDelta(data === "j" ? delta : -delta);
424
446
  this.pendingOperator = null;
425
447
  return;
426
448
  }
427
449
 
428
450
  if (data === "G") {
429
- if (this.pendingCount.length > 0) {
451
+ if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
430
452
  this.cancelPendingOperator(data);
431
453
  return;
432
454
  }
@@ -436,22 +458,36 @@ export class ModalEditor extends CustomEditor {
436
458
  return;
437
459
  }
438
460
 
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);
461
+ if (CHAR_MOTION_KEYS.has(data)) {
462
+ this.pendingMotion = data as PendingMotion;
442
463
  return;
443
464
  }
444
465
 
445
- if (data === "i" || data === "a") {
446
- this.pendingTextObject = data;
466
+ const hasCount = this.prefixCount.length > 0 || this.operatorCount.length > 0;
467
+ const supportsCountedWordMotion = (
468
+ data === "w"
469
+ || data === "e"
470
+ || data === "b"
471
+ || data === "W"
472
+ || data === "E"
473
+ || data === "B"
474
+ );
475
+ const supportsCountedTextObject = data === "i" || data === "a";
476
+
477
+ if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) {
478
+ // Counted forms beyond dd, d{count}j/k, d{count}{f/F/t/T}, and
479
+ // d{count}{w/e/b/W/E/B}/{i/a}w are out of scope.
480
+ this.cancelPendingOperator(data);
447
481
  return;
448
482
  }
449
- if (CHAR_MOTION_KEYS.has(data)) {
450
- this.pendingMotion = data as PendingMotion;
483
+
484
+ if (supportsCountedTextObject) {
485
+ this.pendingTextObject = data;
451
486
  return;
452
487
  }
453
488
 
454
- if (this.deleteWithMotion(data)) {
489
+ const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1;
490
+ if (this.deleteWithMotion(data, motionCount)) {
455
491
  this.pendingOperator = null;
456
492
  return;
457
493
  }
@@ -461,21 +497,60 @@ export class ModalEditor extends CustomEditor {
461
497
  }
462
498
 
463
499
  private handlePendingChange(data: string): void {
500
+ if (this.isDigit(data)) {
501
+ if (this.operatorCount.length === 0) {
502
+ if (data !== "0") {
503
+ this.operatorCount = data;
504
+ return;
505
+ }
506
+ } else {
507
+ this.operatorCount += data;
508
+ return;
509
+ }
510
+ }
511
+
464
512
  if (data === "c") {
513
+ if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
514
+ this.cancelPendingOperator(data);
515
+ return;
516
+ }
517
+
465
518
  this.cutLine();
466
519
  this.pendingOperator = null;
467
520
  this.mode = "insert";
468
521
  return;
469
522
  }
470
- if (data === "i" || data === "a") {
471
- this.pendingTextObject = data;
472
- return;
473
- }
474
523
  if (CHAR_MOTION_KEYS.has(data)) {
475
524
  this.pendingMotion = data as PendingMotion;
476
525
  return;
477
526
  }
478
- if (this.deleteWithMotion(data)) {
527
+
528
+ const hasCount = this.prefixCount.length > 0 || this.operatorCount.length > 0;
529
+ const supportsCountedWordMotion = (
530
+ data === "w"
531
+ || data === "e"
532
+ || data === "b"
533
+ || data === "W"
534
+ || data === "E"
535
+ || data === "B"
536
+ );
537
+ const supportsCountedTextObject = data === "i" || data === "a";
538
+
539
+ if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) {
540
+ this.cancelPendingOperator(data);
541
+ return;
542
+ }
543
+
544
+ if (supportsCountedTextObject) {
545
+ this.pendingTextObject = data;
546
+ return;
547
+ }
548
+
549
+ const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1;
550
+ const effectiveMotion = data === "W" && this.isCursorOnNonWhitespace()
551
+ ? "E"
552
+ : data;
553
+ if (this.deleteWithMotion(effectiveMotion, motionCount)) {
479
554
  this.pendingOperator = null;
480
555
  this.mode = "insert";
481
556
  return;
@@ -487,35 +562,134 @@ export class ModalEditor extends CustomEditor {
487
562
 
488
563
  private handleNormalMode(data: string): void {
489
564
  if (this.pendingG) {
490
- this.pendingG = false;
491
- if (data === "g") {
492
- this.moveCursorToBufferStart();
565
+ if (this.isDigit(data)) {
566
+ this.pendingGCount += data;
493
567
  return;
494
568
  }
495
- // 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;
496
589
  }
497
590
 
498
- if (this.pendingCount.length > 0) {
499
- if (this.isDigit(data) && this.pendingCountKind === "prefix") {
500
- this.pendingCount += data;
591
+ if (this.prefixCount.length > 0) {
592
+ if (this.isDigit(data)) {
593
+ this.prefixCount += data;
501
594
  return;
502
595
  }
503
596
 
504
- if ((data === "d" || data === "y") && this.pendingCountKind === "prefix") {
597
+ if (data === "d" || data === "y") {
505
598
  this.pendingOperator = data;
506
599
  return;
507
600
  }
508
601
 
509
- // Count prefixes are currently supported for dd/yy only.
510
- this.pendingCount = "";
511
- this.pendingCountKind = null;
602
+ if (data === "c") {
603
+ this.pendingOperator = "c";
604
+ return;
605
+ }
606
+
607
+ if (data === "g") {
608
+ this.pendingGCount = "";
609
+ this.pendingG = true;
610
+ return;
611
+ }
612
+
613
+ const supportsCountedStandaloneEdit = (
614
+ data === "x"
615
+ || data === "s"
616
+ || data === "S"
617
+ || data === "D"
618
+ || data === "C"
619
+ || data === "p"
620
+ || data === "P"
621
+ || data === "J"
622
+ );
623
+ const supportsCountedCharMotion = (
624
+ CHAR_MOTION_KEYS.has(data)
625
+ || data === ";"
626
+ || data === ","
627
+ );
628
+ const supportsCountedWordMotion = (
629
+ data === "w"
630
+ || data === "e"
631
+ || data === "b"
632
+ || data === "W"
633
+ || data === "E"
634
+ || data === "B"
635
+ );
636
+ const supportsCountedParagraphMotion = data === "{" || data === "}";
637
+ const supportsCountedNav = (
638
+ data === "h"
639
+ || data === "j"
640
+ || data === "k"
641
+ || data === "l"
642
+ );
643
+
644
+ if (supportsCountedNav) {
645
+ const count = this.takeTotalCount(1);
646
+ const clamped = Math.min(count, MAX_COUNT);
647
+ if (data === "h") {
648
+ this.moveCursorBy(-clamped);
649
+ } else if (data === "l") {
650
+ this.moveCursorBy(clamped);
651
+ } else {
652
+ // j/k: clamp vertical nav to buffer size to prevent TUI stalls
653
+ const lines = this.getLines();
654
+ const cursorLine = this.getCursor().line;
655
+ const safeCount = data === "j"
656
+ ? Math.min(clamped, lines.length - 1 - cursorLine)
657
+ : Math.min(clamped, cursorLine);
658
+ const seq = data === "j" ? ESC_DOWN : ESC_UP;
659
+ for (let i = 0; i < safeCount; i++) {
660
+ super.handleInput(seq);
661
+ }
662
+ }
663
+ return;
664
+ }
665
+
666
+ if (supportsCountedParagraphMotion) {
667
+ this.executeParagraphMotion(data === "}" ? "forward" : "backward");
668
+ return;
669
+ }
670
+
671
+ if (
672
+ !supportsCountedStandaloneEdit
673
+ && !supportsCountedCharMotion
674
+ && !supportsCountedWordMotion
675
+ && !supportsCountedParagraphMotion
676
+ ) {
677
+ // Unsupported prefixed forms: drop count and keep processing this key.
678
+ this.prefixCount = "";
679
+ this.operatorCount = "";
680
+ }
512
681
  } else if (this.isCountStarter(data)) {
513
- this.pendingCount = data;
514
- this.pendingCountKind = "prefix";
682
+ this.prefixCount = data;
683
+ return;
684
+ }
685
+
686
+ if (data === "J") {
687
+ this.joinLines(true);
515
688
  return;
516
689
  }
517
690
 
518
691
  if (data === "g") {
692
+ this.pendingGCount = "";
519
693
  this.pendingG = true;
520
694
  return;
521
695
  }
@@ -573,9 +747,20 @@ export class ModalEditor extends CustomEditor {
573
747
  return;
574
748
  }
575
749
 
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");
750
+ if (data === "}" || data === "{") {
751
+ this.executeParagraphMotion(data === "}" ? "forward" : "backward");
752
+ return;
753
+ }
754
+
755
+ if (data === "w") {
756
+ const count = this.takeTotalCount(1);
757
+ return this.moveWord("forward", "start", count, "word");
758
+ }
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");
579
764
 
580
765
  if (Object.hasOwn(NORMAL_KEYS, data)) {
581
766
  return this.handleMappedKey(data);
@@ -618,13 +803,16 @@ export class ModalEditor extends CustomEditor {
618
803
  this.mode = "insert";
619
804
  break;
620
805
  case "D":
806
+ this.takeTotalCount(1);
621
807
  this.cutToEndOfLine();
622
808
  break;
623
809
  case "C":
810
+ this.takeTotalCount(1);
624
811
  this.cutToEndOfLine();
625
812
  this.mode = "insert";
626
813
  break;
627
814
  case "S":
815
+ this.takeTotalCount(1);
628
816
  this.cutCurrentLineContent();
629
817
  this.mode = "insert";
630
818
  break;
@@ -643,7 +831,8 @@ export class ModalEditor extends CustomEditor {
643
831
  private executeCharMotion(motion: CharMotion, targetChar: string, saveMotion: boolean = true): void {
644
832
  const line = this.getLines()[this.getCursor().line] ?? "";
645
833
  const col = this.getCursor().col;
646
- const targetCol = findCharMotionTarget(line, col, motion, targetChar, !saveMotion);
834
+ const count = this.takeTotalCount(1);
835
+ const targetCol = findCharMotionTarget(line, col, motion, targetChar, !saveMotion, count);
647
836
 
648
837
  if (targetCol !== null && saveMotion) {
649
838
  this.lastCharMotion = { motion, char: targetChar };
@@ -654,6 +843,14 @@ export class ModalEditor extends CustomEditor {
654
843
  }
655
844
  }
656
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
+
657
854
  private tryMoveCursorByState(delta: number): boolean {
658
855
  if (delta === 0) return true;
659
856
 
@@ -726,16 +923,79 @@ export class ModalEditor extends CustomEditor {
726
923
  this.moveCursorToLineStart(Math.max(0, lines.length - 1));
727
924
  }
728
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
+
729
970
  private isWordChar(ch: string): boolean {
730
971
  return /\w/.test(ch);
731
972
  }
732
973
 
733
- private charType(ch: string | undefined): "space" | "word" | "other" {
974
+ private charType(
975
+ ch: string | undefined,
976
+ semanticClass: WordMotionClass = "word",
977
+ ): "space" | "word" | "other" {
734
978
  if (!ch || /\s/.test(ch)) return "space";
979
+ if (semanticClass === "WORD") return "word";
735
980
  if (this.isWordChar(ch)) return "word";
736
981
  return "other";
737
982
  }
738
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
+
739
999
  private getAbsoluteIndex(line: number, col: number): number {
740
1000
  const lines = this.getLines();
741
1001
  let idx = 0;
@@ -755,74 +1015,81 @@ export class ModalEditor extends CustomEditor {
755
1015
  abs: number,
756
1016
  direction: "forward" | "backward",
757
1017
  target: "start" | "end",
1018
+ count: number = 1,
1019
+ semanticClass: WordMotionClass = "word",
758
1020
  ): number {
759
1021
  const len = text.length;
760
1022
  if (len === 0) return 0;
761
1023
 
1024
+ const steps = Math.max(1, Math.min(MAX_COUNT, count));
762
1025
  let i = Math.max(0, Math.min(abs, len));
763
1026
 
764
- if (direction === "forward") {
765
- if (i >= len) return len;
1027
+ for (let step = 0; step < steps; step++) {
1028
+ let next = i;
766
1029
 
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++;
1030
+ if (direction === "forward") {
1031
+ if (next >= len) {
1032
+ next = len;
1033
+ } else if (target === "start") {
1034
+ const startType = this.charType(text[next], semanticClass);
1035
+ if (startType !== "space") {
1036
+ while (next < len && this.charType(text[next], semanticClass) === startType) next++;
1037
+ }
1038
+ while (next < len && this.charType(text[next], semanticClass) === "space") next++;
1039
+ } else {
1040
+ if (next < len - 1) next++;
1041
+ while (next < len && this.charType(text[next], semanticClass) === "space") next++;
1042
+ if (next >= len) {
1043
+ next = len;
1044
+ } else {
1045
+ const t = this.charType(text[next], semanticClass);
1046
+ while (next < len - 1 && this.charType(text[next + 1], semanticClass) === t) next++;
1047
+ }
771
1048
  }
772
- while (i < len && this.charType(text[i]) === "space") i++;
773
- return i;
1049
+ } else {
1050
+ if (next >= len) next = len - 1;
1051
+ if (next > 0) 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--;
774
1055
  }
775
1056
 
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;
1057
+ if (next === i) break;
1058
+ i = next;
782
1059
  }
783
1060
 
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
1061
  return i;
790
1062
  }
791
1063
 
792
- private tryFindWordTargetLineLocal(
1064
+ private tryFindWordTargetInLine(
1065
+ line: string,
1066
+ col: number,
793
1067
  direction: WordMotionDirection,
794
1068
  target: WordMotionTarget,
795
1069
  allowSameColumn: boolean = false,
1070
+ semanticClass: WordMotionClass = "word",
796
1071
  ): 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;
1072
+ if (line.length === 0) return null;
1073
+ if (col < 0 || col > line.length) return null;
804
1074
 
805
1075
  if (direction === "forward") {
806
- if (col >= lineSnapshot.length) return null;
1076
+ if (col >= line.length) return null;
807
1077
  } else {
808
1078
  if (col <= 0) return null;
809
- if (!/\S/.test(lineSnapshot.slice(0, col))) return null;
1079
+ if (!/\S/.test(line.slice(0, col))) return null;
810
1080
  }
811
1081
 
812
1082
  const targetCol = this.wordBoundaryCache.tryFindTarget(
813
- lineSnapshot,
1083
+ line,
814
1084
  col,
815
1085
  direction,
816
1086
  target,
1087
+ semanticClass,
817
1088
  );
818
1089
  if (targetCol === null) return null;
819
1090
 
820
- const liveLine = this.getLines()[lineIndex] ?? "";
821
- const liveCol = this.getCursor().col;
822
- if (liveLine !== lineSnapshot || liveCol !== col) return null;
823
-
824
1091
  if (direction === "forward") {
825
- if (targetCol >= lineSnapshot.length) return null;
1092
+ if (targetCol >= line.length) return null;
826
1093
  if (allowSameColumn) {
827
1094
  if (targetCol < col) return null;
828
1095
  } else if (targetCol <= col) {
@@ -840,12 +1107,41 @@ export class ModalEditor extends CustomEditor {
840
1107
  return targetCol;
841
1108
  }
842
1109
 
1110
+ private tryFindWordTargetLineLocal(
1111
+ direction: WordMotionDirection,
1112
+ target: WordMotionTarget,
1113
+ allowSameColumn: boolean = false,
1114
+ semanticClass: WordMotionClass = "word",
1115
+ ): number | null {
1116
+ const cursor = this.getCursor();
1117
+ const lineIndex = cursor.line;
1118
+ const col = cursor.col;
1119
+ const lineSnapshot = this.getLines()[lineIndex] ?? "";
1120
+
1121
+ const targetCol = this.tryFindWordTargetInLine(
1122
+ lineSnapshot,
1123
+ col,
1124
+ direction,
1125
+ target,
1126
+ allowSameColumn,
1127
+ semanticClass,
1128
+ );
1129
+ if (targetCol === null) return null;
1130
+
1131
+ const liveLine = this.getLines()[lineIndex] ?? "";
1132
+ const liveCol = this.getCursor().col;
1133
+ if (liveLine !== lineSnapshot || liveCol !== col) return null;
1134
+
1135
+ return targetCol;
1136
+ }
1137
+
843
1138
  private tryMoveWordLineLocal(
844
1139
  direction: "forward" | "backward",
845
1140
  target: "start" | "end",
1141
+ semanticClass: WordMotionClass = "word",
846
1142
  ): boolean {
847
1143
  const col = this.getCursor().col;
848
- const targetCol = this.tryFindWordTargetLineLocal(direction, target);
1144
+ const targetCol = this.tryFindWordTargetLineLocal(direction, target, false, semanticClass);
849
1145
  if (targetCol === null || targetCol === col) return false;
850
1146
 
851
1147
  this.moveCursorBy(targetCol - col);
@@ -854,29 +1150,71 @@ export class ModalEditor extends CustomEditor {
854
1150
 
855
1151
  private tryWordMotionLineLocalRange(
856
1152
  motion: "w" | "e" | "b",
1153
+ count: number = 1,
1154
+ semanticClass: WordMotionClass = "word",
857
1155
  ): { col: number; targetCol: number; inclusive: boolean } | null {
858
- const col = this.getCursor().col;
1156
+ const cursor = this.getCursor();
1157
+ const lineIndex = cursor.line;
1158
+ const col = cursor.col;
1159
+ const lineSnapshot = this.getLines()[lineIndex] ?? "";
859
1160
  const direction: WordMotionDirection = motion === "b" ? "backward" : "forward";
860
1161
  const target: WordMotionTarget = motion === "e" ? "end" : "start";
861
- const targetCol = this.tryFindWordTargetLineLocal(direction, target, motion === "e");
1162
+ const steps = Math.max(1, Math.min(MAX_COUNT, count));
1163
+
1164
+ let currentCol = col;
1165
+ for (let step = 0; step < steps; step++) {
1166
+ const nextCol = this.tryFindWordTargetInLine(
1167
+ lineSnapshot,
1168
+ currentCol,
1169
+ direction,
1170
+ target,
1171
+ motion === "e",
1172
+ semanticClass,
1173
+ );
1174
+ if (nextCol === null) return null;
1175
+ if (nextCol === currentCol && step < steps - 1) return null;
1176
+ currentCol = nextCol;
1177
+ }
862
1178
 
863
- if (targetCol === null) return null;
1179
+ const liveLine = this.getLines()[lineIndex] ?? "";
1180
+ const liveCol = this.getCursor().col;
1181
+ if (liveLine !== lineSnapshot || liveCol !== col) return null;
864
1182
 
865
1183
  return {
866
1184
  col,
867
- targetCol,
1185
+ targetCol: currentCol,
868
1186
  inclusive: motion === "e",
869
1187
  };
870
1188
  }
871
1189
 
872
- private moveWord(direction: "forward" | "backward", target: "start" | "end"): void {
873
- if (this.tryMoveWordLineLocal(direction, target)) return;
1190
+ private moveWord(
1191
+ direction: "forward" | "backward",
1192
+ target: "start" | "end",
1193
+ count: number = 1,
1194
+ semanticClass: WordMotionClass = "word",
1195
+ ): void {
1196
+ let remaining = Math.max(1, Math.min(MAX_COUNT, count));
1197
+
1198
+ while (remaining > 0) {
1199
+ if (this.tryMoveWordLineLocal(direction, target, semanticClass)) {
1200
+ remaining--;
1201
+ continue;
1202
+ }
874
1203
 
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);
1204
+ const text = this.getText();
1205
+ const currentAbs = this.getAbsoluteIndexFromCursor();
1206
+ const targetAbs = this.findWordTargetInText(
1207
+ text,
1208
+ currentAbs,
1209
+ direction,
1210
+ target,
1211
+ remaining,
1212
+ semanticClass,
1213
+ );
1214
+ if (targetAbs !== currentAbs) {
1215
+ this.moveCursorBy(targetAbs - currentAbs);
1216
+ }
1217
+ return;
880
1218
  }
881
1219
  }
882
1220
 
@@ -892,19 +1230,25 @@ export class ModalEditor extends CustomEditor {
892
1230
  return { line, col };
893
1231
  }
894
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
+
895
1239
  private isCursorAtOrPastEol(): boolean {
896
1240
  const { line, col } = this.getCurrentLineAndCol();
897
1241
  return col >= line.length;
898
1242
  }
899
1243
 
900
1244
  private cutCharUnderCursor(): void {
1245
+ const count = this.takeTotalCount(1);
901
1246
  const { line, col } = this.getCurrentLineAndCol();
902
1247
  if (line.length === 0) return; // Don't merge empty lines with x
903
1248
  if (col >= line.length) return; // Don't delete past end of line
904
1249
 
905
- const deleted = line.slice(col, col + 1);
906
- this.writeToRegister(deleted);
907
- super.handleInput(ESC_DELETE);
1250
+ const boundedCount = Math.max(1, Math.min(MAX_COUNT, count));
1251
+ this.deleteRange(col, col + boundedCount, false);
908
1252
  }
909
1253
 
910
1254
  private cutToEndOfLine(): void {
@@ -1023,9 +1367,8 @@ export class ModalEditor extends CustomEditor {
1023
1367
  this.yankLineRange(this.getCursor().line, this.getLines().length - 1);
1024
1368
  }
1025
1369
 
1026
- private deleteWithMotion(motion: string): boolean {
1370
+ private deleteWithMotion(motion: string, count: number = 1): boolean {
1027
1371
  const cursor = this.getCursor();
1028
- const line = this.getLines()[cursor.line] ?? "";
1029
1372
  const col = cursor.col;
1030
1373
 
1031
1374
  if (motion === "$") {
@@ -1039,8 +1382,13 @@ export class ModalEditor extends CustomEditor {
1039
1382
  return true;
1040
1383
  }
1041
1384
 
1042
- if (motion === "w" || motion === "e" || motion === "b") {
1043
- const lineLocalRange = this.tryWordMotionLineLocalRange(motion);
1385
+ const wordMotion = this.resolveWordMotion(motion);
1386
+ if (wordMotion) {
1387
+ const lineLocalRange = this.tryWordMotionLineLocalRange(
1388
+ wordMotion.motion,
1389
+ count,
1390
+ wordMotion.semanticClass,
1391
+ );
1044
1392
  if (lineLocalRange) {
1045
1393
  this.deleteRange(
1046
1394
  lineLocalRange.col,
@@ -1055,10 +1403,12 @@ export class ModalEditor extends CustomEditor {
1055
1403
  const targetAbs = this.findWordTargetInText(
1056
1404
  text,
1057
1405
  currentAbs,
1058
- motion === "b" ? "backward" : "forward",
1059
- motion === "e" ? "end" : "start",
1406
+ wordMotion.motion === "b" ? "backward" : "forward",
1407
+ wordMotion.motion === "e" ? "end" : "start",
1408
+ count,
1409
+ wordMotion.semanticClass,
1060
1410
  );
1061
- this.deleteRangeByAbsolute(currentAbs, targetAbs, motion === "e");
1411
+ this.deleteRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e");
1062
1412
  return true;
1063
1413
  }
1064
1414
 
@@ -1068,7 +1418,8 @@ export class ModalEditor extends CustomEditor {
1068
1418
  private deleteWithCharMotion(motion: CharMotion, targetChar: string): void {
1069
1419
  const line = this.getLines()[this.getCursor().line] ?? "";
1070
1420
  const col = this.getCursor().col;
1071
- const targetCol = findCharMotionTarget(line, col, motion, targetChar);
1421
+ const count = this.takeTotalCount(1);
1422
+ const targetCol = findCharMotionTarget(line, col, motion, targetChar, false, count);
1072
1423
 
1073
1424
  if (targetCol === null) return;
1074
1425
 
@@ -1078,43 +1429,35 @@ export class ModalEditor extends CustomEditor {
1078
1429
 
1079
1430
  private handlePendingYank(data: string): void {
1080
1431
  if (this.isDigit(data)) {
1081
- if (this.pendingCount.length === 0) {
1432
+ if (this.operatorCount.length === 0) {
1082
1433
  if (data !== "0") {
1083
- this.pendingCount = data;
1084
- this.pendingCountKind = "operator";
1434
+ this.operatorCount = data;
1085
1435
  return;
1086
1436
  }
1087
- } else if (this.pendingCountKind === "operator") {
1088
- this.pendingCount += data;
1089
- return;
1090
1437
  } else {
1091
- // Dual counts like 2y3k are out of scope; fail closed.
1092
- this.cancelPendingOperator(data);
1438
+ this.operatorCount += data;
1093
1439
  return;
1094
1440
  }
1095
1441
  }
1096
1442
 
1097
1443
  if (data === "y") {
1098
- const count = this.takePendingCount(1);
1444
+ const count = this.takeTotalCount(1);
1099
1445
  this.yankLinewiseByDelta(count - 1);
1100
1446
  this.pendingOperator = null;
1101
1447
  return;
1102
1448
  }
1103
1449
 
1104
1450
  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);
1451
+ const hasDualCount = this.prefixCount.length > 0 && this.operatorCount.length > 0;
1452
+ const count = this.takeTotalCount(1);
1453
+ const delta = hasDualCount ? Math.max(0, count - 1) : count;
1454
+ this.yankLinewiseByDelta(data === "j" ? delta : -delta);
1112
1455
  this.pendingOperator = null;
1113
1456
  return;
1114
1457
  }
1115
1458
 
1116
1459
  if (data === "G") {
1117
- if (this.pendingCount.length > 0) {
1460
+ if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
1118
1461
  this.cancelPendingOperator(data);
1119
1462
  return;
1120
1463
  }
@@ -1124,8 +1467,13 @@ export class ModalEditor extends CustomEditor {
1124
1467
  return;
1125
1468
  }
1126
1469
 
1127
- if (this.pendingCount.length > 0) {
1128
- // Counted forms beyond yy and y{count}j/k are intentionally out of scope.
1470
+ if (CHAR_MOTION_KEYS.has(data)) {
1471
+ this.pendingMotion = data as PendingMotion;
1472
+ return;
1473
+ }
1474
+
1475
+ if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
1476
+ // Counted forms beyond yy, y{count}j/k, and y{count}{f/F/t/T} are out of scope.
1129
1477
  this.cancelPendingOperator(data);
1130
1478
  return;
1131
1479
  }
@@ -1134,10 +1482,6 @@ export class ModalEditor extends CustomEditor {
1134
1482
  this.pendingTextObject = data;
1135
1483
  return;
1136
1484
  }
1137
- if (CHAR_MOTION_KEYS.has(data)) {
1138
- this.pendingMotion = data as PendingMotion;
1139
- return;
1140
- }
1141
1485
 
1142
1486
  if (this.yankWithMotion(data)) {
1143
1487
  this.pendingOperator = null;
@@ -1161,8 +1505,13 @@ export class ModalEditor extends CustomEditor {
1161
1505
  return true;
1162
1506
  }
1163
1507
 
1164
- if (motion === "w" || motion === "e" || motion === "b") {
1165
- 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
+ );
1166
1515
  if (lineLocalRange) {
1167
1516
  this.yankRange(
1168
1517
  lineLocalRange.col,
@@ -1177,10 +1526,12 @@ export class ModalEditor extends CustomEditor {
1177
1526
  const targetAbs = this.findWordTargetInText(
1178
1527
  text,
1179
1528
  currentAbs,
1180
- motion === "b" ? "backward" : "forward",
1181
- motion === "e" ? "end" : "start",
1529
+ wordMotion.motion === "b" ? "backward" : "forward",
1530
+ wordMotion.motion === "e" ? "end" : "start",
1531
+ 1,
1532
+ wordMotion.semanticClass,
1182
1533
  );
1183
- this.yankRangeByAbsolute(currentAbs, targetAbs, motion === "e");
1534
+ this.yankRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e");
1184
1535
  return true;
1185
1536
  }
1186
1537
 
@@ -1190,7 +1541,8 @@ export class ModalEditor extends CustomEditor {
1190
1541
  private yankWithCharMotion(motion: CharMotion, targetChar: string): void {
1191
1542
  const line = this.getLines()[this.getCursor().line] ?? "";
1192
1543
  const col = this.getCursor().col;
1193
- const targetCol = findCharMotionTarget(line, col, motion, targetChar);
1544
+ const count = this.takeTotalCount(1);
1545
+ const targetCol = findCharMotionTarget(line, col, motion, targetChar, false, count);
1194
1546
 
1195
1547
  if (targetCol === null) return;
1196
1548
 
@@ -1241,16 +1593,20 @@ export class ModalEditor extends CustomEditor {
1241
1593
  }
1242
1594
  }
1243
1595
 
1244
- private getWordObjectRange(kind: "i" | "a"): { startAbs: number; endAbs: number } | null {
1596
+ private getWordObjectRange(
1597
+ kind: "i" | "a",
1598
+ count: number = 1,
1599
+ ): { startAbs: number; endAbs: number } | null {
1245
1600
  const lines = this.getLines();
1246
1601
  const cursor = this.getCursor();
1247
1602
  const line = lines[cursor.line] ?? "";
1248
1603
  if (!line) return null;
1249
1604
 
1250
- let col = Math.min(cursor.col, Math.max(0, line.length - 1));
1251
-
1605
+ const steps = Math.max(1, Math.min(MAX_COUNT, count));
1252
1606
  const hasWordChar = (idx: number) => idx >= 0 && idx < line.length && this.isWordChar(line[idx]!);
1253
1607
 
1608
+ let col = Math.min(cursor.col, Math.max(0, line.length - 1));
1609
+
1254
1610
  if (!hasWordChar(col)) {
1255
1611
  let right = col;
1256
1612
  while (right < line.length && !hasWordChar(right)) right++;
@@ -1270,17 +1626,28 @@ export class ModalEditor extends CustomEditor {
1270
1626
  let end = col + 1;
1271
1627
  while (end < line.length && hasWordChar(end)) end++;
1272
1628
 
1629
+ let remaining = steps - 1;
1630
+ while (remaining > 0) {
1631
+ let nextWordStart = end;
1632
+ while (nextWordStart < line.length && !hasWordChar(nextWordStart)) nextWordStart++;
1633
+ if (nextWordStart >= line.length) break;
1634
+
1635
+ let nextWordEnd = nextWordStart + 1;
1636
+ while (nextWordEnd < line.length && hasWordChar(nextWordEnd)) nextWordEnd++;
1637
+
1638
+ end = nextWordEnd;
1639
+ remaining--;
1640
+ }
1641
+
1273
1642
  if (kind === "a") {
1274
- let aroundStart = start;
1275
1643
  let aroundEnd = end;
1276
-
1277
1644
  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
1645
 
1282
- start = aroundStart;
1283
- end = aroundEnd;
1646
+ if (aroundEnd > end) {
1647
+ end = aroundEnd;
1648
+ } else {
1649
+ while (start > 0 && /\s/.test(line[start - 1]!)) start--;
1650
+ }
1284
1651
  }
1285
1652
 
1286
1653
  return {
@@ -1289,23 +1656,32 @@ export class ModalEditor extends CustomEditor {
1289
1656
  };
1290
1657
  }
1291
1658
 
1659
+ private static readonly PUT_SIZE_LIMIT = 512 * 1024; // 512 KB safety cap
1660
+
1292
1661
  private putAfter(): void {
1662
+ const count = this.takeTotalCount(1);
1293
1663
  const text = this.unnamedRegister;
1294
1664
  if (!text) return;
1665
+ const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
1295
1666
 
1296
1667
  if (text.endsWith("\n")) {
1297
- // Line-wise: insert new line below and fill it
1298
- super.handleInput(CTRL_E);
1299
- super.handleInput(NEWLINE);
1300
1668
  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);
1669
+ for (let i = 0; i < safeCount; i++) {
1670
+ // Line-wise: insert new line below and fill it
1671
+ super.handleInput(CTRL_E);
1672
+ super.handleInput(NEWLINE);
1673
+ for (const char of content) {
1674
+ super.handleInput(char === "\n" ? NEWLINE : char);
1675
+ }
1308
1676
  }
1677
+ return;
1678
+ }
1679
+
1680
+ // Character-wise: insert after cursor
1681
+ if (!this.isCursorAtOrPastEol()) {
1682
+ super.handleInput(ESC_RIGHT);
1683
+ }
1684
+ for (let i = 0; i < safeCount; i++) {
1309
1685
  for (const char of text) {
1310
1686
  super.handleInput(char === "\n" ? NEWLINE : char);
1311
1687
  }
@@ -1313,20 +1689,27 @@ export class ModalEditor extends CustomEditor {
1313
1689
  }
1314
1690
 
1315
1691
  private putBefore(): void {
1692
+ const count = this.takeTotalCount(1);
1316
1693
  const text = this.unnamedRegister;
1317
1694
  if (!text) return;
1695
+ const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
1318
1696
 
1319
1697
  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
1698
  const content = text.slice(0, -1);
1325
- for (const char of content) {
1326
- super.handleInput(char === "\n" ? NEWLINE : char);
1699
+ for (let i = 0; i < safeCount; i++) {
1700
+ // Line-wise: insert new line above and fill it
1701
+ super.handleInput(CTRL_A);
1702
+ super.handleInput(NEWLINE);
1703
+ super.handleInput(ESC_UP);
1704
+ for (const char of content) {
1705
+ super.handleInput(char === "\n" ? NEWLINE : char);
1706
+ }
1327
1707
  }
1328
- } else {
1329
- // Character-wise: insert before cursor (just type it)
1708
+ return;
1709
+ }
1710
+
1711
+ // Character-wise: insert before cursor (just type it)
1712
+ for (let i = 0; i < safeCount; i++) {
1330
1713
  for (const char of text) {
1331
1714
  super.handleInput(char === "\n" ? NEWLINE : char);
1332
1715
  }
@@ -1369,20 +1752,23 @@ export class ModalEditor extends CustomEditor {
1369
1752
  private getModeLabel(): string {
1370
1753
  if (this.mode === "insert") return " INSERT ";
1371
1754
 
1372
- const count = this.pendingCount;
1755
+ const prefixCount = this.prefixCount;
1756
+ const operatorCount = this.operatorCount;
1373
1757
 
1374
1758
  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}_ `;
1759
+ return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}${this.pendingMotion}_ `;
1378
1760
  }
1379
1761
  if (this.pendingOperator) {
1380
- const prefix = this.pendingCountKind === "prefix" ? count : "";
1381
- const opCount = this.pendingCountKind === "operator" ? count : "";
1382
- return ` NORMAL ${prefix}${this.pendingOperator}${opCount}_ `;
1762
+ return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}_ `;
1383
1763
  }
1384
1764
  if (this.pendingMotion) return ` NORMAL ${this.pendingMotion}_ `;
1385
- if (this.pendingG) return " NORMAL g_ ";
1765
+ if (this.pendingG) {
1766
+ return this.pendingGCount
1767
+ ? ` NORMAL g${this.pendingGCount}_ `
1768
+ : " NORMAL g_ ";
1769
+ }
1770
+
1771
+ const count = `${prefixCount}${operatorCount}`;
1386
1772
  if (count) return ` NORMAL ${count}_ `;
1387
1773
  return " NORMAL ";
1388
1774
  }