pi-vim 0.1.9 → 0.2.1

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 (5) hide show
  1. package/README.md +62 -60
  2. package/index.ts +545 -129
  3. package/motions.ts +55 -13
  4. package/package.json +1 -1
  5. package/types.ts +1 -1
package/index.ts CHANGED
@@ -13,11 +13,12 @@
13
13
  * - hjkl: navigation in normal mode
14
14
  * - 0/$: line start/end
15
15
  * - ^: first non-whitespace char of line
16
+ * - _: first non-whitespace (with count: down count-1 lines first); linewise with d/c/y
16
17
  * - x: delete char under cursor
17
18
  * - D: delete to end of line
18
19
  * - S: substitute line (delete line content + insert mode)
19
20
  * - s: substitute char (delete char + insert mode)
20
- * - d{motion}: delete with motion (`w/b/e` + `W/B/E`, `$`, `0`, `^`, `dd`, `f/t/F/T{char}`)
21
+ * - d{motion}: delete with motion (`w/b/e` + `W/B/E`, `$`, `0`, `^`, `dd`/`d_`, `f/t/F/T{char}`)
21
22
  * - c{motion}: change with same motion set as `d` (then enter insert mode)
22
23
  * - y{motion}: yank with same motion set as `d` (no text mutation)
23
24
  * - f{char}: jump to next {char} on line
@@ -72,11 +73,11 @@ import {
72
73
  CHAR_MOTION_KEYS,
73
74
  ESC_LEFT,
74
75
  ESC_RIGHT,
75
- ESC_DELETE,
76
76
  ESC_UP,
77
77
  CTRL_A,
78
78
  CTRL_E,
79
79
  CTRL_K,
80
+ CTRL_R,
80
81
  CTRL_UNDERSCORE,
81
82
  NEWLINE,
82
83
  ESC_DOWN,
@@ -86,6 +87,7 @@ import {
86
87
  findCharMotionTarget,
87
88
  findParagraphMotionTarget,
88
89
  findFirstNonWhitespaceColumn,
90
+ getLineGraphemes,
89
91
  type WordMotionClass,
90
92
  } from "./motions.js";
91
93
  import {
@@ -99,6 +101,24 @@ const BRACKETED_PASTE_END = "\x1b[201~";
99
101
  const BRACKETED_PASTE_END_TAIL = BRACKETED_PASTE_END.slice(1);
100
102
  const MAX_COUNT = 9999;
101
103
 
104
+ type EditorSnapshot = {
105
+ text: string;
106
+ cursor: { line: number; col: number };
107
+ };
108
+
109
+ type TransitionState = "none" | "undo" | "redo";
110
+
111
+ type ModalEditorInternals = {
112
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
113
+ preferredVisualCol?: number | null;
114
+ lastAction?: string | null;
115
+ historyIndex?: number;
116
+ onChange?: (text: string) => void;
117
+ tui?: { requestRender?: () => void };
118
+ pushUndoSnapshot?: () => void;
119
+ setCursorCol?: (col: number) => void;
120
+ };
121
+
102
122
  export class ModalEditor extends CustomEditor {
103
123
  private mode: Mode = "insert";
104
124
  private pendingMotion: PendingMotion = null;
@@ -108,10 +128,14 @@ export class ModalEditor extends CustomEditor {
108
128
  private operatorCount: string = "";
109
129
  private pendingG: boolean = false;
110
130
  private pendingGCount: string = "";
131
+ private pendingReplace: boolean = false;
111
132
  private lastCharMotion: LastCharMotion | null = null;
112
133
  private discardingBracketedPasteInNormalMode: boolean = false;
113
134
  private pendingEscWhileDiscardingBracketedPasteInNormalMode: boolean = false;
114
- private readonly wordBoundaryCache = new WordBoundaryCache();
135
+ private wordBoundaryCache = new WordBoundaryCache();
136
+ private readonly redoStack: EditorSnapshot[] = [];
137
+ private currentTransition: TransitionState = "none";
138
+ private onChangeHooked: boolean = false;
115
139
 
116
140
  // Unnamed register
117
141
  private unnamedRegister: string = "";
@@ -126,6 +150,183 @@ export class ModalEditor extends CustomEditor {
126
150
  getMode(): Mode { return this.mode; }
127
151
  getText(): string { return this.getLines().join("\n"); }
128
152
 
153
+ override setText(text: string): void {
154
+ this.clearRedoStack();
155
+ super.setText(text);
156
+ }
157
+
158
+ private captureSnapshot(): EditorSnapshot {
159
+ const cursor = this.getCursor();
160
+ return {
161
+ text: this.getText(),
162
+ cursor: { line: cursor.line, col: cursor.col },
163
+ };
164
+ }
165
+
166
+ private requireRedoRestoreState(
167
+ editor: ModalEditorInternals,
168
+ ): { lines: string[]; cursorLine?: number; cursorCol?: number } {
169
+ const state = editor.state;
170
+ if (!state || !Array.isArray(state.lines)) {
171
+ throw new Error("Redo restore prerequisite: editor state unavailable");
172
+ }
173
+ return state;
174
+ }
175
+
176
+ private restoreSnapshot(snapshot: EditorSnapshot): void {
177
+ const editor = this as unknown as ModalEditorInternals;
178
+ const state = this.requireRedoRestoreState(editor);
179
+
180
+ const lines = snapshot.text.split("\n");
181
+ state.lines = lines.length > 0 ? lines : [""];
182
+
183
+ const maxLine = Math.max(0, state.lines.length - 1);
184
+ const cursorLine = Math.max(0, Math.min(snapshot.cursor.line, maxLine));
185
+ const line = state.lines[cursorLine] ?? "";
186
+ const cursorCol = Math.max(0, Math.min(snapshot.cursor.col, line.length));
187
+
188
+ state.cursorLine = cursorLine;
189
+ if (typeof editor.setCursorCol === "function") {
190
+ editor.setCursorCol(cursorCol);
191
+ } else {
192
+ state.cursorCol = cursorCol;
193
+ editor.preferredVisualCol = null;
194
+ }
195
+
196
+ this.invalidateWordBoundaryCache();
197
+
198
+ editor.historyIndex = -1;
199
+ editor.lastAction = null;
200
+ editor.onChange?.(this.getText());
201
+ editor.tui?.requestRender?.();
202
+ }
203
+
204
+ private snapshotChanged(a: EditorSnapshot, b: EditorSnapshot): boolean {
205
+ return a.text !== b.text
206
+ || a.cursor.line !== b.cursor.line
207
+ || a.cursor.col !== b.cursor.col;
208
+ }
209
+
210
+ private withTransition<T>(
211
+ transition: Exclude<TransitionState, "none">,
212
+ action: () => T,
213
+ ): T {
214
+ const previousTransition = this.currentTransition;
215
+ this.currentTransition = transition;
216
+ try {
217
+ return action();
218
+ } finally {
219
+ this.currentTransition = previousTransition;
220
+ }
221
+ }
222
+
223
+ private performUndo(): void {
224
+ this.withTransition("undo", () => {
225
+ const beforeUndo = this.captureSnapshot();
226
+ super.handleInput(CTRL_UNDERSCORE);
227
+ const afterUndo = this.captureSnapshot();
228
+
229
+ if (this.snapshotChanged(beforeUndo, afterUndo)) {
230
+ this.redoStack.push(beforeUndo);
231
+ }
232
+ });
233
+ }
234
+
235
+ private performRedo(count: number = this.takeTotalCount(1)): void {
236
+ const maxSteps = Math.max(1, Math.min(MAX_COUNT, count));
237
+ const editor = this as unknown as ModalEditorInternals;
238
+
239
+ for (let i = 0; i < maxSteps; i++) {
240
+ const snapshot = this.redoStack[this.redoStack.length - 1];
241
+ if (!snapshot) break;
242
+
243
+ this.withTransition("redo", () => {
244
+ this.requireRedoRestoreState(editor);
245
+ if (typeof editor.pushUndoSnapshot !== "function") {
246
+ throw new Error(
247
+ "Redo restore prerequisite: pushUndoSnapshot unavailable",
248
+ );
249
+ }
250
+ editor.pushUndoSnapshot();
251
+ this.restoreSnapshot(snapshot);
252
+ this.redoStack.pop();
253
+ });
254
+ }
255
+ }
256
+
257
+ private clearRedoStack(): void {
258
+ this.redoStack.length = 0;
259
+ }
260
+
261
+ private invalidateWordBoundaryCache(): void {
262
+ this.wordBoundaryCache = new WordBoundaryCache();
263
+ }
264
+
265
+ private ensureOnChangeHook(): void {
266
+ if (this.onChangeHooked) return;
267
+
268
+ const editor = this as unknown as ModalEditorInternals;
269
+ const originalOnChange = editor.onChange;
270
+
271
+ editor.onChange = (text: string) => {
272
+ originalOnChange?.(text);
273
+ this.centralInvalidationCheck();
274
+ };
275
+
276
+ this.onChangeHooked = true;
277
+ }
278
+
279
+ private centralInvalidationCheck(): void {
280
+ if (this.redoStack.length === 0) return;
281
+ if (this.currentTransition !== "none") return;
282
+ this.clearRedoStack();
283
+ }
284
+
285
+ private applySyntheticEdit(mutation: () => void): void {
286
+ const editor = this as unknown as ModalEditorInternals;
287
+ if (!editor.state || !Array.isArray(editor.state.lines)) {
288
+ throw new Error(
289
+ "Synthetic edit prerequisite: editor state unavailable",
290
+ );
291
+ }
292
+
293
+ if (typeof editor.pushUndoSnapshot !== "function") {
294
+ throw new Error(
295
+ "Synthetic edit prerequisite: pushUndoSnapshot unavailable",
296
+ );
297
+ }
298
+
299
+ const textBefore = this.getText();
300
+ const preCursorLine = editor.state.cursorLine;
301
+ const preCursorCol = editor.state.cursorCol;
302
+
303
+ mutation();
304
+
305
+ if (this.getText() === textBefore) return;
306
+
307
+ // Text changed — push undo boundary for pre-mutation state.
308
+ // Briefly swap pre-mutation state in for the snapshot, then
309
+ // restore the post-mutation result.
310
+ const postLines = editor.state.lines.slice();
311
+ const postCursorLine = editor.state.cursorLine;
312
+ const postCursorCol = editor.state.cursorCol;
313
+ const postPreferredCol = editor.preferredVisualCol;
314
+
315
+ const preLines = textBefore.split("\n");
316
+ editor.state.lines = preLines.length > 0 ? preLines : [""];
317
+ editor.state.cursorLine = preCursorLine;
318
+ editor.state.cursorCol = preCursorCol;
319
+ editor.pushUndoSnapshot();
320
+
321
+ editor.state.lines = postLines;
322
+ editor.state.cursorLine = postCursorLine;
323
+ editor.state.cursorCol = postCursorCol;
324
+ editor.preferredVisualCol = postPreferredCol;
325
+
326
+ editor.onChange?.(this.getText());
327
+ editor.tui?.requestRender?.();
328
+ }
329
+
129
330
  private clearPendingState(): void {
130
331
  this.pendingMotion = null;
131
332
  this.pendingTextObject = null;
@@ -134,6 +335,7 @@ export class ModalEditor extends CustomEditor {
134
335
  this.operatorCount = "";
135
336
  this.pendingG = false;
136
337
  this.pendingGCount = "";
338
+ this.pendingReplace = false;
137
339
  }
138
340
 
139
341
  private isEscapeLikeInput(data: string): boolean {
@@ -176,6 +378,8 @@ export class ModalEditor extends CustomEditor {
176
378
  }
177
379
 
178
380
  handleInput(data: string): void {
381
+ this.ensureOnChangeHook();
382
+
179
383
  if (this.mode !== "insert") {
180
384
  if (this.discardingBracketedPasteInNormalMode) {
181
385
  if (this.isEscapeLikeInput(data)) {
@@ -227,19 +431,43 @@ export class ModalEditor extends CustomEditor {
227
431
  }
228
432
  // Alt+o: open new line below (stay in insert mode)
229
433
  if (matchesKey(data, Key.alt("o")) || data === "\x1bo") {
230
- super.handleInput(CTRL_E);
231
- super.handleInput(NEWLINE);
434
+ this.openLineBelow();
232
435
  return;
233
436
  }
234
437
  // Alt+Shift+o: open new line above (stay in insert mode)
235
438
  // \x1bO is the legacy sequence for Alt+Shift+O (VT100 SS3 prefix in non-Kitty terminals)
236
439
  if (matchesKey(data, Key.shiftAlt("o")) || data === "\x1bO") {
237
- super.handleInput(CTRL_A);
238
- super.handleInput(NEWLINE);
239
- super.handleInput(ESC_UP);
440
+ this.openLineAbove();
441
+ return;
442
+ }
443
+ super.handleInput(data);
444
+ return;
445
+ }
446
+
447
+ if (this.pendingReplace) {
448
+ this.pendingReplace = false;
449
+ if (!this.isPrintableInput(data)) {
450
+ this.prefixCount = "";
451
+ this.operatorCount = "";
240
452
  return;
241
453
  }
242
- return super.handleInput(data);
454
+
455
+ const count = this.takeTotalCount(1);
456
+ const cursor = this.getCursor();
457
+ const line = this.getLines()[cursor.line] ?? "";
458
+ const range = this.getGraphemeRangeAtCol(line, cursor.col, count);
459
+ if (!range) return;
460
+
461
+ const before = line.slice(0, range.start);
462
+ const after = line.slice(range.end);
463
+ const replacement = data.repeat(count);
464
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
465
+ const text = this.getText();
466
+ const newText = text.slice(0, lineStartAbs) + before + replacement + after
467
+ + text.slice(lineStartAbs + line.length);
468
+ const newCursorAbs = lineStartAbs + before.length + data.length * (count - 1);
469
+ this.replaceTextInBuffer(newText, newCursorAbs);
470
+ return;
243
471
  }
244
472
 
245
473
  if (this.pendingTextObject) {
@@ -292,6 +520,7 @@ export class ModalEditor extends CustomEditor {
292
520
  || this.operatorCount
293
521
  || this.pendingG
294
522
  || this.pendingGCount
523
+ || this.pendingReplace
295
524
  ) {
296
525
  this.clearPendingState();
297
526
  return;
@@ -314,7 +543,7 @@ export class ModalEditor extends CustomEditor {
314
543
  }
315
544
 
316
545
  private isPrintableInput(data: string): boolean {
317
- return this.isPrintableChunk(data) && Array.from(data).length === 1;
546
+ return this.isPrintableChunk(data) && getLineGraphemes(data).length === 1;
318
547
  }
319
548
 
320
549
  private isDigit(data: string): boolean {
@@ -464,6 +693,13 @@ export class ModalEditor extends CustomEditor {
464
693
  return;
465
694
  }
466
695
 
696
+ if (data === "_") {
697
+ const count = this.takeTotalCount(1);
698
+ this.deleteLinewiseByDelta(count - 1);
699
+ this.pendingOperator = null;
700
+ return;
701
+ }
702
+
467
703
  if (CHAR_MOTION_KEYS.has(data)) {
468
704
  this.pendingMotion = data as PendingMotion;
469
705
  return;
@@ -526,6 +762,28 @@ export class ModalEditor extends CustomEditor {
526
762
  this.mode = "insert";
527
763
  return;
528
764
  }
765
+
766
+ if (data === "_") {
767
+ const count = this.takeTotalCount(1);
768
+ if (count <= 1) {
769
+ this.cutLine();
770
+ } else {
771
+ const currentLine = this.getCursor().line;
772
+ const lines = this.getLines();
773
+ const clampedEnd = Math.min(currentLine + count - 1, lines.length - 1);
774
+ this.writeToRegister(this.getLinewisePayload(currentLine, clampedEnd));
775
+ const before = lines.slice(0, currentLine);
776
+ const after = lines.slice(clampedEnd + 1);
777
+ const newLines = [...before, "", ...after];
778
+ const newText = newLines.join("\n");
779
+ const cursorAbs = before.reduce((acc, l) => acc + l.length + 1, 0);
780
+ this.replaceTextInBuffer(newText, cursorAbs);
781
+ }
782
+ this.pendingOperator = null;
783
+ this.mode = "insert";
784
+ return;
785
+ }
786
+
529
787
  if (CHAR_MOTION_KEYS.has(data)) {
530
788
  this.pendingMotion = data as PendingMotion;
531
789
  return;
@@ -624,6 +882,7 @@ export class ModalEditor extends CustomEditor {
624
882
 
625
883
  const supportsCountedStandaloneEdit = (
626
884
  data === "x"
885
+ || data === "r"
627
886
  || data === "s"
628
887
  || data === "S"
629
888
  || data === "D"
@@ -631,6 +890,8 @@ export class ModalEditor extends CustomEditor {
631
890
  || data === "p"
632
891
  || data === "P"
633
892
  || data === "J"
893
+ || data === CTRL_R
894
+ || matchesKey(data, "ctrl+r")
634
895
  );
635
896
  const supportsCountedCharMotion = (
636
897
  CHAR_MOTION_KEYS.has(data)
@@ -652,6 +913,7 @@ export class ModalEditor extends CustomEditor {
652
913
  || data === "k"
653
914
  || data === "l"
654
915
  );
916
+ const supportsCountedUnderscore = data === "_";
655
917
 
656
918
  if (supportsCountedNav) {
657
919
  const count = this.takeTotalCount(1);
@@ -661,16 +923,8 @@ export class ModalEditor extends CustomEditor {
661
923
  } else if (data === "l") {
662
924
  this.moveCursorBy(clamped);
663
925
  } else {
664
- // j/k: clamp vertical nav to buffer size to prevent TUI stalls
665
- const lines = this.getLines();
666
- const cursorLine = this.getCursor().line;
667
- const safeCount = data === "j"
668
- ? Math.min(clamped, lines.length - 1 - cursorLine)
669
- : Math.min(clamped, cursorLine);
670
- const seq = data === "j" ? ESC_DOWN : ESC_UP;
671
- for (let i = 0; i < safeCount; i++) {
672
- super.handleInput(seq);
673
- }
926
+ const delta = data === "j" ? clamped : -clamped;
927
+ this.moveCursorVertically(delta);
674
928
  }
675
929
  return;
676
930
  }
@@ -685,6 +939,7 @@ export class ModalEditor extends CustomEditor {
685
939
  && !supportsCountedCharMotion
686
940
  && !supportsCountedWordMotion
687
941
  && !supportsCountedParagraphMotion
942
+ && !supportsCountedUnderscore
688
943
  ) {
689
944
  // Unsupported prefixed forms: drop count and keep processing this key.
690
945
  this.prefixCount = "";
@@ -711,6 +966,11 @@ export class ModalEditor extends CustomEditor {
711
966
  return;
712
967
  }
713
968
 
969
+ if (data === "r") {
970
+ this.pendingReplace = true;
971
+ return;
972
+ }
973
+
714
974
  if (data === "d") {
715
975
  this.pendingOperator = "d";
716
976
  return;
@@ -754,8 +1014,13 @@ export class ModalEditor extends CustomEditor {
754
1014
  return;
755
1015
  }
756
1016
 
757
- if (data === "u") {
758
- super.handleInput(CTRL_UNDERSCORE); // ctrl+_ — readline undo
1017
+ if (data === "u" || data === CTRL_UNDERSCORE || matchesKey(data, "ctrl+_")) {
1018
+ this.performUndo();
1019
+ return;
1020
+ }
1021
+
1022
+ if (data === CTRL_R || matchesKey(data, "ctrl+r")) {
1023
+ this.performRedo();
759
1024
  return;
760
1025
  }
761
1026
 
@@ -769,6 +1034,15 @@ export class ModalEditor extends CustomEditor {
769
1034
  return;
770
1035
  }
771
1036
 
1037
+ if (data === "_") {
1038
+ const count = this.takeTotalCount(1);
1039
+ if (count > 1) {
1040
+ this.moveCursorVertically(count - 1);
1041
+ }
1042
+ this.moveCursorToFirstNonWhitespace();
1043
+ return;
1044
+ }
1045
+
772
1046
  if (data === "w") {
773
1047
  const count = this.takeTotalCount(1);
774
1048
  return this.moveWord("forward", "start", count, "word");
@@ -788,6 +1062,17 @@ export class ModalEditor extends CustomEditor {
788
1062
  super.handleInput(data);
789
1063
  }
790
1064
 
1065
+ private openLineBelow(): void {
1066
+ super.handleInput(CTRL_E);
1067
+ super.handleInput(NEWLINE);
1068
+ }
1069
+
1070
+ private openLineAbove(): void {
1071
+ super.handleInput(CTRL_A);
1072
+ super.handleInput(NEWLINE);
1073
+ super.handleInput(ESC_UP);
1074
+ }
1075
+
791
1076
  private handleMappedKey(key: string): void {
792
1077
  const seq = NORMAL_KEYS[key];
793
1078
  switch (key) {
@@ -809,14 +1094,11 @@ export class ModalEditor extends CustomEditor {
809
1094
  super.handleInput(CTRL_A);
810
1095
  break;
811
1096
  case "o":
812
- super.handleInput(CTRL_E);
813
- super.handleInput(NEWLINE);
1097
+ this.openLineBelow();
814
1098
  this.mode = "insert";
815
1099
  break;
816
1100
  case "O":
817
- super.handleInput(CTRL_A);
818
- super.handleInput(NEWLINE);
819
- super.handleInput(ESC_UP);
1101
+ this.openLineAbove();
820
1102
  this.mode = "insert";
821
1103
  break;
822
1104
  case "D":
@@ -840,6 +1122,12 @@ export class ModalEditor extends CustomEditor {
840
1122
  case "x":
841
1123
  this.cutCharUnderCursor();
842
1124
  break;
1125
+ case "j":
1126
+ this.moveCursorVertically(1);
1127
+ break;
1128
+ case "k":
1129
+ this.moveCursorVertically(-1);
1130
+ break;
843
1131
  default:
844
1132
  if (seq) super.handleInput(seq);
845
1133
  }
@@ -856,7 +1144,7 @@ export class ModalEditor extends CustomEditor {
856
1144
  }
857
1145
 
858
1146
  if (targetCol !== null && targetCol !== col) {
859
- this.moveCursorBy(targetCol - col);
1147
+ this.moveCursorToCol(targetCol);
860
1148
  }
861
1149
  }
862
1150
 
@@ -884,10 +1172,12 @@ export class ModalEditor extends CustomEditor {
884
1172
  const cursorLine = state.cursorLine as number;
885
1173
  const cursorCol = state.cursorCol as number;
886
1174
  const line = state.lines[cursorLine] ?? "";
1175
+ if (this.hasMultiCodeUnitGraphemes(line)) return false;
1176
+
887
1177
  const target = cursorCol + delta;
888
1178
 
889
- // Only short-circuit line-local movement; preserve canonical key replay for
890
- // any potential cross-line traversal semantics.
1179
+ // Only short-circuit line-local movement when each grapheme is one code
1180
+ // unit; otherwise let the base editor keep cursor boundaries valid.
891
1181
  if (target < 0 || target > line.length) return false;
892
1182
 
893
1183
  state.cursorCol = target;
@@ -907,38 +1197,100 @@ export class ModalEditor extends CustomEditor {
907
1197
  }
908
1198
  }
909
1199
 
910
- private moveCursorToLineStart(lineIndex: number): void {
911
- const lines = this.getLines();
912
- if (lines.length === 0) {
913
- super.handleInput(CTRL_A);
914
- return;
915
- }
1200
+ private moveCursorVertically(delta: number): void {
1201
+ if (delta === 0) return;
916
1202
 
917
- const targetLine = Math.max(0, Math.min(lineIndex, lines.length - 1));
918
- const currentLine = this.getCursor().line;
919
- const delta = targetLine - currentLine;
1203
+ const editor = this as unknown as {
1204
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
1205
+ preferredVisualCol?: number | null;
1206
+ lastAction?: string | null;
1207
+ tui?: { requestRender?: () => void };
1208
+ };
920
1209
 
921
- if (delta > 0) {
922
- for (let i = 0; i < delta; i++) {
923
- super.handleInput(ESC_DOWN);
924
- }
925
- } else if (delta < 0) {
1210
+ const state = editor.state;
1211
+ if (!state || !Array.isArray(state.lines) || state.lines.length === 0) {
1212
+ const seq = delta > 0 ? ESC_DOWN : ESC_UP;
926
1213
  for (let i = 0; i < Math.abs(delta); i++) {
927
- super.handleInput(ESC_UP);
1214
+ super.handleInput(seq);
928
1215
  }
1216
+ return;
929
1217
  }
930
1218
 
931
- super.handleInput(CTRL_A);
1219
+ const currentLine = state.cursorLine ?? 0;
1220
+ const targetLine = Math.max(0, Math.min(currentLine + delta, state.lines.length - 1));
1221
+ if (targetLine === currentLine) return;
1222
+
1223
+ const preferredCol = editor.preferredVisualCol ?? state.cursorCol ?? 0;
1224
+ const targetLineText = state.lines[targetLine] ?? "";
1225
+ editor.lastAction = null;
1226
+ state.cursorLine = targetLine;
1227
+ state.cursorCol = Math.min(preferredCol, targetLineText.length);
1228
+ editor.preferredVisualCol = preferredCol;
1229
+ editor.tui?.requestRender?.();
1230
+ }
1231
+
1232
+ private moveCursorToCol(col: number): void {
1233
+ const editor = this as unknown as {
1234
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
1235
+ preferredVisualCol?: number | null;
1236
+ lastAction?: string | null;
1237
+ tui?: { requestRender?: () => void };
1238
+ };
1239
+
1240
+ const state = editor.state;
1241
+ if (!state || !Array.isArray(state.lines)) return;
1242
+
1243
+ editor.lastAction = null;
1244
+ state.cursorCol = col;
1245
+ editor.preferredVisualCol = col;
1246
+ editor.tui?.requestRender?.();
1247
+ }
1248
+
1249
+ private moveCursorToAbsoluteIndex(abs: number): void {
1250
+ const editor = this as unknown as {
1251
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
1252
+ preferredVisualCol?: number | null;
1253
+ lastAction?: string | null;
1254
+ tui?: { requestRender?: () => void };
1255
+ };
1256
+
1257
+ const state = editor.state;
1258
+ if (!state || !Array.isArray(state.lines)) return;
1259
+
1260
+ const { line, col } = this.getCursorFromAbsoluteIndex(this.getText(), abs);
1261
+ editor.lastAction = null;
1262
+ state.cursorLine = line;
1263
+ state.cursorCol = col;
1264
+ editor.preferredVisualCol = col;
1265
+ editor.tui?.requestRender?.();
1266
+ }
1267
+
1268
+ private moveCursorToLineStart(lineIndex: number): void {
1269
+ const editor = this as unknown as {
1270
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
1271
+ preferredVisualCol?: number | null;
1272
+ lastAction?: string | null;
1273
+ tui?: { requestRender?: () => void };
1274
+ };
1275
+
1276
+ const state = editor.state;
1277
+ if (!state || !Array.isArray(state.lines) || state.lines.length === 0) {
1278
+ super.handleInput(CTRL_A);
1279
+ return;
1280
+ }
1281
+
1282
+ const targetLine = Math.max(0, Math.min(lineIndex, state.lines.length - 1));
1283
+ editor.lastAction = null;
1284
+ state.cursorLine = targetLine;
1285
+ state.cursorCol = 0;
1286
+ editor.preferredVisualCol = null;
1287
+ editor.tui?.requestRender?.();
932
1288
  }
933
1289
 
934
1290
  private moveCursorToFirstNonWhitespace(): void {
935
1291
  const { line, col } = this.getCurrentLineAndCol();
936
1292
  const targetCol = findFirstNonWhitespaceColumn(line);
937
- this.moveCursorBy(targetCol - col);
938
- }
939
-
940
- private moveCursorToBufferStart(): void {
941
- this.moveCursorToLineStart(0);
1293
+ this.moveCursorToCol(targetCol);
942
1294
  }
943
1295
 
944
1296
  private moveCursorToBufferEnd(): void {
@@ -951,43 +1303,39 @@ export class ModalEditor extends CustomEditor {
951
1303
  const steps = Math.max(0, count - 1);
952
1304
  if (steps === 0) return;
953
1305
 
954
- const editor = this as unknown as {
955
- state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
956
- preferredVisualCol?: number;
957
- tui?: { requestRender?: () => void };
958
- };
1306
+ this.applySyntheticEdit(() => {
1307
+ const editor = this as unknown as ModalEditorInternals;
1308
+ const state = editor.state;
1309
+ if (!state || !Array.isArray(state.lines)) return;
959
1310
 
960
- const state = editor.state;
961
- if (!state || !Array.isArray(state.lines)) return;
1311
+ const currentLine = state.cursorLine ?? 0;
1312
+ let joinPoint = state.cursorCol ?? 0;
962
1313
 
963
- const currentLine = state.cursorLine ?? 0;
964
- let joinPoint = state.cursorCol ?? 0;
1314
+ for (let i = 0; i < steps; i++) {
1315
+ if (currentLine >= state.lines.length - 1) break;
965
1316
 
966
- for (let i = 0; i < steps; i++) {
967
- if (currentLine >= state.lines.length - 1) break;
1317
+ const left = state.lines[currentLine]!;
1318
+ const right = state.lines[currentLine + 1]!;
1319
+ let joined: string;
968
1320
 
969
- const left = state.lines[currentLine]!;
970
- const right = state.lines[currentLine + 1]!;
971
- let joined: string;
1321
+ if (normalize) {
1322
+ const trimmedRight = right.trimStart();
1323
+ const leftEndsWithSpace = left.length > 0 && /\s/.test(left[left.length - 1]!);
1324
+ const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
1325
+ joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
1326
+ joinPoint = left.length;
1327
+ } else {
1328
+ joined = left + right;
1329
+ joinPoint = left.length;
1330
+ }
972
1331
 
973
- if (normalize) {
974
- const trimmedRight = right.trimStart();
975
- const leftEndsWithSpace = left.length > 0 && /\s/.test(left[left.length - 1]!);
976
- const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
977
- joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
978
- joinPoint = left.length;
979
- } else {
980
- joined = left + right;
981
- joinPoint = left.length;
1332
+ state.lines.splice(currentLine, 2, joined);
982
1333
  }
983
1334
 
984
- state.lines.splice(currentLine, 2, joined);
985
- }
986
-
987
- state.cursorLine = currentLine;
988
- state.cursorCol = joinPoint;
989
- editor.preferredVisualCol = joinPoint;
990
- editor.tui?.requestRender?.();
1335
+ state.cursorLine = currentLine;
1336
+ state.cursorCol = joinPoint;
1337
+ editor.preferredVisualCol = joinPoint;
1338
+ });
991
1339
  }
992
1340
 
993
1341
  private isWordChar(ch: string): boolean {
@@ -1133,7 +1481,6 @@ export class ModalEditor extends CustomEditor {
1133
1481
  private tryFindWordTargetLineLocal(
1134
1482
  direction: WordMotionDirection,
1135
1483
  target: WordMotionTarget,
1136
- allowSameColumn: boolean = false,
1137
1484
  semanticClass: WordMotionClass = "word",
1138
1485
  ): number | null {
1139
1486
  const cursor = this.getCursor();
@@ -1146,7 +1493,7 @@ export class ModalEditor extends CustomEditor {
1146
1493
  col,
1147
1494
  direction,
1148
1495
  target,
1149
- allowSameColumn,
1496
+ false,
1150
1497
  semanticClass,
1151
1498
  );
1152
1499
  if (targetCol === null) return null;
@@ -1164,10 +1511,10 @@ export class ModalEditor extends CustomEditor {
1164
1511
  semanticClass: WordMotionClass = "word",
1165
1512
  ): boolean {
1166
1513
  const col = this.getCursor().col;
1167
- const targetCol = this.tryFindWordTargetLineLocal(direction, target, false, semanticClass);
1514
+ const targetCol = this.tryFindWordTargetLineLocal(direction, target, semanticClass);
1168
1515
  if (targetCol === null || targetCol === col) return false;
1169
1516
 
1170
- this.moveCursorBy(targetCol - col);
1517
+ this.moveCursorToCol(targetCol);
1171
1518
  return true;
1172
1519
  }
1173
1520
 
@@ -1235,7 +1582,7 @@ export class ModalEditor extends CustomEditor {
1235
1582
  semanticClass,
1236
1583
  );
1237
1584
  if (targetAbs !== currentAbs) {
1238
- this.moveCursorBy(targetAbs - currentAbs);
1585
+ this.moveCursorToAbsoluteIndex(targetAbs);
1239
1586
  }
1240
1587
  return;
1241
1588
  }
@@ -1253,6 +1600,33 @@ export class ModalEditor extends CustomEditor {
1253
1600
  return { line, col };
1254
1601
  }
1255
1602
 
1603
+ private hasMultiCodeUnitGraphemes(line: string): boolean {
1604
+ return getLineGraphemes(line).some((segment) => segment.end - segment.start > 1);
1605
+ }
1606
+
1607
+ private getGraphemeRangeAtCol(
1608
+ line: string,
1609
+ col: number,
1610
+ count: number,
1611
+ clampToLine: boolean = false,
1612
+ ): { start: number; end: number } | null {
1613
+ const clampedCol = Math.max(0, Math.min(col, line.length));
1614
+ const segments = getLineGraphemes(line);
1615
+ const startIndex = segments.findIndex((segment) => clampedCol < segment.end);
1616
+ if (startIndex === -1) return null;
1617
+
1618
+ let endIndex = startIndex + Math.max(1, count) - 1;
1619
+ if (endIndex >= segments.length) {
1620
+ if (!clampToLine) return null;
1621
+ endIndex = segments.length - 1;
1622
+ }
1623
+
1624
+ return {
1625
+ start: segments[startIndex]!.start,
1626
+ end: segments[endIndex]!.end,
1627
+ };
1628
+ }
1629
+
1256
1630
  private isCursorOnNonWhitespace(): boolean {
1257
1631
  const { line, col } = this.getCurrentLineAndCol();
1258
1632
  const ch = line[col];
@@ -1265,13 +1639,19 @@ export class ModalEditor extends CustomEditor {
1265
1639
  }
1266
1640
 
1267
1641
  private cutCharUnderCursor(): void {
1268
- const count = this.takeTotalCount(1);
1269
- const { line, col } = this.getCurrentLineAndCol();
1270
- if (line.length === 0) return; // Don't merge empty lines with x
1271
- if (col >= line.length) return; // Don't delete past end of line
1642
+ const count = Math.max(1, Math.min(MAX_COUNT, this.takeTotalCount(1)));
1643
+ const cursor = this.getCursor();
1644
+ const line = this.getLines()[cursor.line] ?? "";
1645
+ const range = this.getGraphemeRangeAtCol(line, cursor.col, count, true);
1646
+ if (!range) return;
1272
1647
 
1273
- const boundedCount = Math.max(1, Math.min(MAX_COUNT, count));
1274
- this.deleteRange(col, col + boundedCount, false);
1648
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
1649
+ const text = this.getText();
1650
+ this.writeToRegister(line.slice(range.start, range.end));
1651
+ this.replaceTextInBuffer(
1652
+ text.slice(0, lineStartAbs + range.start) + text.slice(lineStartAbs + range.end),
1653
+ lineStartAbs + range.start,
1654
+ );
1275
1655
  }
1276
1656
 
1277
1657
  private cutToEndOfLine(): void {
@@ -1352,19 +1732,13 @@ export class ModalEditor extends CustomEditor {
1352
1732
  this.writeToRegister(payload);
1353
1733
 
1354
1734
  if (endAbs > startAbs) {
1355
- const cursor = this.getCursor();
1356
- const cursorAbs = this.getAbsoluteIndex(cursor.line, cursor.col);
1357
- if (cursorAbs !== startAbs) {
1358
- this.moveCursorBy(startAbs - cursorAbs);
1359
- }
1735
+ const text = this.getText();
1736
+ const newText = text.slice(0, startAbs) + text.slice(endAbs);
1737
+ this.replaceTextInBuffer(newText, startAbs);
1360
1738
 
1361
- const count = endAbs - startAbs;
1362
- for (let i = 0; i < count; i++) {
1363
- super.handleInput(ESC_DELETE);
1364
- }
1739
+ // Ensure cursor is at column 0 of the landing line
1740
+ super.handleInput(CTRL_A);
1365
1741
  }
1366
-
1367
- super.handleInput(CTRL_A);
1368
1742
  }
1369
1743
 
1370
1744
  private yankLineRange(startLine: number, endLine: number): void {
@@ -1427,7 +1801,7 @@ export class ModalEditor extends CustomEditor {
1427
1801
  }
1428
1802
 
1429
1803
  const text = this.getText();
1430
- const currentAbs = this.getAbsoluteIndex(cursor.line, col);
1804
+ const currentAbs = this.getAbsoluteIndexFromCursor();
1431
1805
  const targetAbs = this.findWordTargetInText(
1432
1806
  text,
1433
1807
  currentAbs,
@@ -1495,6 +1869,13 @@ export class ModalEditor extends CustomEditor {
1495
1869
  return;
1496
1870
  }
1497
1871
 
1872
+ if (data === "_") {
1873
+ const count = this.takeTotalCount(1);
1874
+ this.yankLinewiseByDelta(count - 1);
1875
+ this.pendingOperator = null;
1876
+ return;
1877
+ }
1878
+
1498
1879
  if (CHAR_MOTION_KEYS.has(data)) {
1499
1880
  this.pendingMotion = data as PendingMotion;
1500
1881
  return;
@@ -1555,7 +1936,7 @@ export class ModalEditor extends CustomEditor {
1555
1936
  }
1556
1937
 
1557
1938
  const text = this.getText();
1558
- const currentAbs = this.getAbsoluteIndex(cursor.line, col);
1939
+ const currentAbs = this.getAbsoluteIndexFromCursor();
1559
1940
  const targetAbs = this.findWordTargetInText(
1560
1941
  text,
1561
1942
  currentAbs,
@@ -1587,7 +1968,12 @@ export class ModalEditor extends CustomEditor {
1587
1968
  const line = this.getLines()[this.getCursor().line] ?? "";
1588
1969
  const start = Math.min(col, targetCol);
1589
1970
  const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
1590
- const end = Math.min(rawEnd, line.length);
1971
+ let end = Math.min(rawEnd, line.length);
1972
+
1973
+ if (inclusive) {
1974
+ const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1);
1975
+ end = targetRange?.end ?? end;
1976
+ }
1591
1977
 
1592
1978
  if (end <= start) return;
1593
1979
 
@@ -1604,6 +1990,47 @@ export class ModalEditor extends CustomEditor {
1604
1990
  this.writeToRegister(text.slice(start, end));
1605
1991
  }
1606
1992
 
1993
+ private getCursorFromAbsoluteIndex(text: string, abs: number): { line: number; col: number } {
1994
+ const lines = text.length === 0 ? [""] : text.split("\n");
1995
+ let remaining = Math.max(0, Math.min(abs, text.length));
1996
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
1997
+ const line = lines[lineIndex] ?? "";
1998
+ if (remaining <= line.length) return { line: lineIndex, col: remaining };
1999
+ remaining -= line.length + 1;
2000
+ }
2001
+ const lastLine = Math.max(0, lines.length - 1);
2002
+ return { line: lastLine, col: (lines[lastLine] ?? "").length };
2003
+ }
2004
+
2005
+ private replaceTextInBuffer(text: string, cursorAbs: number): void {
2006
+ const editor = this as unknown as {
2007
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
2008
+ preferredVisualCol?: number | null;
2009
+ historyIndex?: number;
2010
+ lastAction?: string | null;
2011
+ onChange?: (text: string) => void;
2012
+ tui?: { requestRender?: () => void };
2013
+ pushUndoSnapshot?: () => void;
2014
+ autocompleteState?: unknown;
2015
+ updateAutocomplete?: () => void;
2016
+ };
2017
+ const state = editor.state;
2018
+ if (!state) return;
2019
+ const currentText = this.getText();
2020
+ if (currentText !== text) editor.pushUndoSnapshot?.();
2021
+ const nextLines = text.length === 0 ? [""] : text.split("\n");
2022
+ const { line, col } = this.getCursorFromAbsoluteIndex(text, cursorAbs);
2023
+ editor.historyIndex = -1;
2024
+ editor.lastAction = null;
2025
+ state.lines = nextLines;
2026
+ state.cursorLine = line;
2027
+ state.cursorCol = col;
2028
+ editor.preferredVisualCol = null;
2029
+ editor.onChange?.(text);
2030
+ if (editor.autocompleteState) editor.updateAutocomplete?.();
2031
+ editor.tui?.requestRender?.();
2032
+ }
2033
+
1607
2034
  private deleteRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean = false): void {
1608
2035
  const text = this.getText();
1609
2036
  const start = Math.min(currentAbs, targetAbs);
@@ -1614,16 +2041,7 @@ export class ModalEditor extends CustomEditor {
1614
2041
 
1615
2042
  this.writeToRegister(text.slice(start, end));
1616
2043
 
1617
- const cursor = this.getCursor();
1618
- const cursorAbs = this.getAbsoluteIndex(cursor.line, cursor.col);
1619
- if (cursorAbs !== start) {
1620
- this.moveCursorBy(start - cursorAbs);
1621
- }
1622
-
1623
- const count = end - start;
1624
- for (let i = 0; i < count; i++) {
1625
- super.handleInput(ESC_DELETE);
1626
- }
2044
+ this.replaceTextInBuffer(text.slice(0, start) + text.slice(end), start);
1627
2045
  }
1628
2046
 
1629
2047
  private getWordObjectRange(
@@ -1750,24 +2168,19 @@ export class ModalEditor extends CustomEditor {
1750
2168
  }
1751
2169
 
1752
2170
  private deleteRange(col: number, targetCol: number, inclusive: boolean): void {
1753
- const line = this.getLines()[this.getCursor().line] ?? "";
1754
-
2171
+ const cursor = this.getCursor();
2172
+ const line = this.getLines()[cursor.line] ?? "";
2173
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
1755
2174
  const start = Math.min(col, targetCol);
1756
2175
  const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
1757
- const end = Math.min(rawEnd, line.length);
1758
-
1759
- if (end <= start) return;
2176
+ let end = Math.min(rawEnd, line.length);
1760
2177
 
1761
- this.writeToRegister(line.slice(start, end));
1762
-
1763
- if (start !== col) {
1764
- this.moveCursorBy(start - col);
2178
+ if (inclusive) {
2179
+ const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1);
2180
+ end = targetRange?.end ?? end;
1765
2181
  }
1766
2182
 
1767
- const count = end - start;
1768
- for (let i = 0; i < count; i++) {
1769
- super.handleInput(ESC_DELETE);
1770
- }
2183
+ this.deleteRangeByAbsolute(lineStartAbs + start, lineStartAbs + end);
1771
2184
  }
1772
2185
 
1773
2186
  render(width: number): string[] {
@@ -1788,6 +2201,9 @@ export class ModalEditor extends CustomEditor {
1788
2201
  const prefixCount = this.prefixCount;
1789
2202
  const operatorCount = this.operatorCount;
1790
2203
 
2204
+ if (this.pendingReplace) {
2205
+ return prefixCount ? ` NORMAL ${prefixCount}r_ ` : " NORMAL r_ ";
2206
+ }
1791
2207
  if (this.pendingOperator && this.pendingMotion) {
1792
2208
  return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}${this.pendingMotion}_ `;
1793
2209
  }