pi-vim 0.1.0

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 ADDED
@@ -0,0 +1,1395 @@
1
+ /**
2
+ * Modal Editor - vim-like modal editing extension
3
+ *
4
+ * Usage: pi --extension ./index.ts
5
+ *
6
+ * - Escape: insert → normal mode (in normal mode, aborts agent)
7
+ * - i: normal → insert mode (at cursor)
8
+ * - a: insert after cursor
9
+ * - A: insert at end of line
10
+ * - I: insert at start of line
11
+ * - o: open new line below (insert mode)
12
+ * - O: open new line above (insert mode)
13
+ * - hjkl: navigation in normal mode
14
+ * - 0/$: line start/end
15
+ * - x: delete char under cursor
16
+ * - D: delete to end of line
17
+ * - S: substitute line (delete line content + insert mode)
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})
20
+ * - f{char}: jump to next {char} on line
21
+ * - F{char}: jump to previous {char} on line
22
+ * - t{char}: jump to just before next {char} on line
23
+ * - T{char}: jump to just after previous {char} on line
24
+ * - ;: repeat last f/F/t/T motion (same direction)
25
+ * - ,: 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
29
+ * - Shift+Alt+A: go to end of line (insert mode shortcut)
30
+ * - Shift+Alt+I: go to start of line (insert mode shortcut)
31
+ * - Alt+o: open new line below (insert mode shortcut)
32
+ * - Alt+Shift+o: open new line above (insert mode shortcut)
33
+ * - u: undo (normal mode, sends ctrl+_ to underlying readline editor)
34
+ * - ctrl+c, ctrl+d, etc. work in both modes
35
+ *
36
+ * Inspired by original repo:
37
+ * - https://github.com/badlogic/pi-mono
38
+ * (packages/coding-agent/examples/extensions/modal-editor.ts)
39
+ *
40
+ * Additional ideas adapted from:
41
+ * - https://github.com/l-lin/dotfiles
42
+ * (home-manager/modules/share/ai/pi/.pi/agent/extensions/vim-mode)
43
+ */
44
+
45
+ import {
46
+ copyToClipboard,
47
+ CustomEditor,
48
+ type ExtensionAPI,
49
+ } from "@mariozechner/pi-coding-agent";
50
+ import {
51
+ Key,
52
+ matchesKey,
53
+ truncateToWidth,
54
+ visibleWidth,
55
+ } from "@mariozechner/pi-tui";
56
+
57
+ import type {
58
+ Mode,
59
+ CharMotion,
60
+ PendingMotion,
61
+ PendingOperator,
62
+ LastCharMotion,
63
+ } from "./types.js";
64
+ import {
65
+ NORMAL_KEYS,
66
+ CHAR_MOTION_KEYS,
67
+ ESC_LEFT,
68
+ ESC_RIGHT,
69
+ ESC_DELETE,
70
+ ESC_UP,
71
+ CTRL_A,
72
+ CTRL_E,
73
+ CTRL_K,
74
+ CTRL_UNDERSCORE,
75
+ NEWLINE,
76
+ ESC_DOWN,
77
+ } from "./types.js";
78
+ import {
79
+ reverseCharMotion,
80
+ findCharMotionTarget,
81
+ } from "./motions.js";
82
+ import {
83
+ WordBoundaryCache,
84
+ type WordMotionDirection,
85
+ type WordMotionTarget,
86
+ } from "./word-boundary-cache.js";
87
+
88
+ const BRACKETED_PASTE_START = "\x1b[200~";
89
+ const BRACKETED_PASTE_END = "\x1b[201~";
90
+ const BRACKETED_PASTE_END_TAIL = BRACKETED_PASTE_END.slice(1);
91
+
92
+ export class ModalEditor extends CustomEditor {
93
+ private mode: Mode = "insert";
94
+ private pendingMotion: PendingMotion = null;
95
+ private pendingTextObject: "i" | "a" | null = null;
96
+ private pendingOperator: PendingOperator = null;
97
+ private pendingCount: string = "";
98
+ private pendingCountKind: "prefix" | "operator" | null = null;
99
+ private pendingG: boolean = false;
100
+ private lastCharMotion: LastCharMotion | null = null;
101
+ private discardingBracketedPasteInNormalMode: boolean = false;
102
+ private pendingEscWhileDiscardingBracketedPasteInNormalMode: boolean = false;
103
+ private readonly wordBoundaryCache = new WordBoundaryCache();
104
+
105
+ // Unnamed register
106
+ private unnamedRegister: string = "";
107
+ private clipboardFn: (text: string) => void = (text: string) => {
108
+ try { copyToClipboard(text); } catch { /* best effort */ }
109
+ };
110
+
111
+ // Test seams
112
+ setClipboardFn(fn: (text: string) => void): void { this.clipboardFn = fn; }
113
+ getRegister(): string { return this.unnamedRegister; }
114
+ setRegister(text: string): void { this.unnamedRegister = text; }
115
+ getMode(): Mode { return this.mode; }
116
+ getText(): string { return this.getLines().join("\n"); }
117
+
118
+ private clearPendingState(): void {
119
+ this.pendingMotion = null;
120
+ this.pendingTextObject = null;
121
+ this.pendingOperator = null;
122
+ this.pendingCount = "";
123
+ this.pendingCountKind = null;
124
+ this.pendingG = false;
125
+ }
126
+
127
+ private stripBracketedPasteInNormalMode(data: string): { filtered: string | null; stripped: boolean } {
128
+ let chunk = data;
129
+ let stripped = false;
130
+
131
+ while (true) {
132
+ if (this.discardingBracketedPasteInNormalMode) {
133
+ stripped = true;
134
+ const end = chunk.indexOf(BRACKETED_PASTE_END);
135
+ if (end === -1) {
136
+ return { filtered: null, stripped };
137
+ }
138
+ this.discardingBracketedPasteInNormalMode = false;
139
+ this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
140
+ chunk = chunk.slice(end + BRACKETED_PASTE_END.length);
141
+ if (!chunk) return { filtered: null, stripped };
142
+ }
143
+
144
+ const start = chunk.indexOf(BRACKETED_PASTE_START);
145
+ if (start === -1) {
146
+ return { filtered: chunk, stripped };
147
+ }
148
+
149
+ stripped = true;
150
+ const end = chunk.indexOf(BRACKETED_PASTE_END, start + BRACKETED_PASTE_START.length);
151
+ if (end === -1) {
152
+ this.discardingBracketedPasteInNormalMode = true;
153
+ const leading = chunk.slice(0, start);
154
+ return { filtered: leading.length > 0 ? leading : null, stripped };
155
+ }
156
+
157
+ chunk = chunk.slice(0, start) + chunk.slice(end + BRACKETED_PASTE_END.length);
158
+ if (!chunk) return { filtered: null, stripped };
159
+ }
160
+ }
161
+
162
+ handleInput(data: string): void {
163
+ if (this.mode !== "insert") {
164
+ if (this.discardingBracketedPasteInNormalMode) {
165
+ if (data === "\x1b") {
166
+ if (this.pendingEscWhileDiscardingBracketedPasteInNormalMode) {
167
+ this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
168
+ this.discardingBracketedPasteInNormalMode = false;
169
+ this.clearPendingState();
170
+ return;
171
+ } else {
172
+ this.pendingEscWhileDiscardingBracketedPasteInNormalMode = true;
173
+ this.clearPendingState();
174
+ return;
175
+ }
176
+ } else if (this.pendingEscWhileDiscardingBracketedPasteInNormalMode) {
177
+ if (data.startsWith(BRACKETED_PASTE_END_TAIL)) {
178
+ this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
179
+ this.discardingBracketedPasteInNormalMode = false;
180
+ data = data.slice(BRACKETED_PASTE_END_TAIL.length);
181
+ if (data.length === 0) {
182
+ this.clearPendingState();
183
+ return;
184
+ }
185
+ } else {
186
+ this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
187
+ }
188
+ }
189
+ }
190
+
191
+ const { filtered, stripped } = this.stripBracketedPasteInNormalMode(data);
192
+ if (stripped) {
193
+ this.clearPendingState();
194
+ }
195
+ if (filtered === null) return;
196
+ data = filtered;
197
+ }
198
+
199
+ if (matchesKey(data, "escape")) {
200
+ return this.handleEscape();
201
+ }
202
+
203
+ if (this.mode === "insert") {
204
+ // Shift+Alt+A: go to end of line (like Esc -> A but stay in insert)
205
+ if (matchesKey(data, Key.shiftAlt("a")) || data === "\x1bA") {
206
+ return super.handleInput(CTRL_E);
207
+ }
208
+ // Shift+Alt+I: go to start of line (like Esc -> I but stay in insert)
209
+ if (matchesKey(data, Key.shiftAlt("i")) || data === "\x1bI") {
210
+ return super.handleInput(CTRL_A);
211
+ }
212
+ // Alt+o: open new line below (stay in insert mode)
213
+ if (matchesKey(data, Key.alt("o")) || data === "\x1bo") {
214
+ super.handleInput(CTRL_E);
215
+ super.handleInput(NEWLINE);
216
+ return;
217
+ }
218
+ // Alt+Shift+o: open new line above (stay in insert mode)
219
+ // \x1bO is the legacy sequence for Alt+Shift+O (VT100 SS3 prefix in non-Kitty terminals)
220
+ if (matchesKey(data, Key.shiftAlt("o")) || data === "\x1bO") {
221
+ super.handleInput(CTRL_A);
222
+ super.handleInput(NEWLINE);
223
+ super.handleInput(ESC_UP);
224
+ return;
225
+ }
226
+ return super.handleInput(data);
227
+ }
228
+
229
+ if (this.pendingTextObject) {
230
+ return this.handlePendingTextObject(data);
231
+ }
232
+
233
+ if (this.pendingMotion) {
234
+ return this.handlePendingMotion(data);
235
+ }
236
+
237
+ if (this.pendingOperator === "d") {
238
+ return this.handlePendingDelete(data);
239
+ }
240
+
241
+ if (this.pendingOperator === "c") {
242
+ return this.handlePendingChange(data);
243
+ }
244
+
245
+ if (this.pendingOperator === "y") {
246
+ return this.handlePendingYank(data);
247
+ }
248
+
249
+ this.handleNormalMode(data);
250
+ }
251
+
252
+ private clearUnderlyingPasteStateIfActive(): void {
253
+ const editor = this as unknown as {
254
+ isInPaste?: boolean;
255
+ pasteBuffer?: string;
256
+ pasteCounter?: number;
257
+ };
258
+
259
+ if (!editor.isInPaste) return;
260
+
261
+ editor.isInPaste = false;
262
+ if (typeof editor.pasteBuffer === "string") {
263
+ editor.pasteBuffer = "";
264
+ }
265
+ if (typeof editor.pasteCounter === "number") {
266
+ editor.pasteCounter = 0;
267
+ }
268
+ }
269
+
270
+ private handleEscape(): void {
271
+ if (
272
+ this.pendingMotion
273
+ || this.pendingTextObject
274
+ || this.pendingOperator
275
+ || this.pendingCount
276
+ || this.pendingG
277
+ ) {
278
+ this.clearPendingState();
279
+ return;
280
+ }
281
+ if (this.mode === "insert") {
282
+ this.clearUnderlyingPasteStateIfActive();
283
+ this.mode = "normal";
284
+ } else {
285
+ super.handleInput("\x1b"); // pass escape to abort agent
286
+ }
287
+ }
288
+
289
+ private isPrintableChunk(data: string): boolean {
290
+ if (data.length === 0) return false;
291
+ for (const char of data) {
292
+ const codePoint = char.codePointAt(0)!;
293
+ if (codePoint < 32 || codePoint === 127) return false;
294
+ }
295
+ return true;
296
+ }
297
+
298
+ private isPrintableInput(data: string): boolean {
299
+ return this.isPrintableChunk(data) && Array.from(data).length === 1;
300
+ }
301
+
302
+ private isDigit(data: string): boolean {
303
+ return data.length === 1 && data >= "0" && data <= "9";
304
+ }
305
+
306
+ private isCountStarter(data: string): boolean {
307
+ return data.length === 1 && data >= "1" && data <= "9";
308
+ }
309
+
310
+ private takePendingCount(defaultValue: number = 1): number {
311
+ if (!this.pendingCount) return defaultValue;
312
+
313
+ const parsed = Number.parseInt(this.pendingCount, 10);
314
+ this.pendingCount = "";
315
+ this.pendingCountKind = null;
316
+
317
+ if (!Number.isFinite(parsed) || parsed <= 0) return defaultValue;
318
+ return parsed;
319
+ }
320
+
321
+ private cancelPendingOperator(data: string): void {
322
+ this.pendingOperator = null;
323
+ this.pendingCount = "";
324
+ this.pendingCountKind = null;
325
+ if (!this.isPrintableChunk(data)) {
326
+ super.handleInput(data);
327
+ }
328
+ }
329
+
330
+ private handlePendingMotion(data: string): void {
331
+ if (!this.isPrintableInput(data)) {
332
+ this.pendingMotion = null;
333
+ this.cancelPendingOperator(data);
334
+ return;
335
+ }
336
+
337
+ if (this.pendingOperator === "d") {
338
+ this.deleteWithCharMotion(this.pendingMotion!, data);
339
+ this.pendingOperator = null;
340
+ } else if (this.pendingOperator === "c") {
341
+ this.deleteWithCharMotion(this.pendingMotion!, data);
342
+ this.pendingOperator = null;
343
+ this.mode = "insert";
344
+ } else if (this.pendingOperator === "y") {
345
+ this.yankWithCharMotion(this.pendingMotion!, data);
346
+ this.pendingOperator = null;
347
+ } else {
348
+ this.executeCharMotion(this.pendingMotion!, data);
349
+ }
350
+
351
+ this.pendingMotion = null;
352
+ }
353
+
354
+ private handlePendingTextObject(data: string): void {
355
+ if (data !== "w") {
356
+ this.pendingTextObject = null;
357
+ this.cancelPendingOperator(data);
358
+ return;
359
+ }
360
+
361
+ const range = this.getWordObjectRange(this.pendingTextObject!);
362
+ this.pendingTextObject = null;
363
+ if (!range || !this.pendingOperator) {
364
+ this.pendingOperator = null;
365
+ return;
366
+ }
367
+
368
+ const { startAbs, endAbs } = range;
369
+ if (this.pendingOperator === "d") {
370
+ this.deleteRangeByAbsolute(startAbs, endAbs);
371
+ this.pendingOperator = null;
372
+ return;
373
+ }
374
+
375
+ if (this.pendingOperator === "c") {
376
+ this.deleteRangeByAbsolute(startAbs, endAbs);
377
+ this.pendingOperator = null;
378
+ this.mode = "insert";
379
+ return;
380
+ }
381
+
382
+ if (this.pendingOperator === "y") {
383
+ this.yankRangeByAbsolute(startAbs, endAbs);
384
+ this.pendingOperator = null;
385
+ return;
386
+ }
387
+
388
+ this.pendingOperator = null;
389
+ }
390
+
391
+ private handlePendingDelete(data: string): void {
392
+ if (this.isDigit(data)) {
393
+ if (this.pendingCount.length === 0) {
394
+ if (data !== "0") {
395
+ this.pendingCount = data;
396
+ this.pendingCountKind = "operator";
397
+ return;
398
+ }
399
+ } else if (this.pendingCountKind === "operator") {
400
+ this.pendingCount += data;
401
+ return;
402
+ } else {
403
+ // Dual counts like 2d3j are out of scope; fail closed.
404
+ this.cancelPendingOperator(data);
405
+ return;
406
+ }
407
+ }
408
+
409
+ if (data === "d") {
410
+ const count = this.takePendingCount(1);
411
+ this.deleteLinewiseByDelta(count - 1);
412
+ this.pendingOperator = null;
413
+ return;
414
+ }
415
+
416
+ 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);
424
+ this.pendingOperator = null;
425
+ return;
426
+ }
427
+
428
+ if (data === "G") {
429
+ if (this.pendingCount.length > 0) {
430
+ this.cancelPendingOperator(data);
431
+ return;
432
+ }
433
+
434
+ this.deleteToBufferEndLinewise();
435
+ this.pendingOperator = null;
436
+ return;
437
+ }
438
+
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);
442
+ return;
443
+ }
444
+
445
+ if (data === "i" || data === "a") {
446
+ this.pendingTextObject = data;
447
+ return;
448
+ }
449
+ if (CHAR_MOTION_KEYS.has(data)) {
450
+ this.pendingMotion = data as PendingMotion;
451
+ return;
452
+ }
453
+
454
+ if (this.deleteWithMotion(data)) {
455
+ this.pendingOperator = null;
456
+ return;
457
+ }
458
+
459
+ // Invalid motion: cancel operator to avoid sticky surprising deletes.
460
+ this.cancelPendingOperator(data);
461
+ }
462
+
463
+ private handlePendingChange(data: string): void {
464
+ if (data === "c") {
465
+ this.cutLine();
466
+ this.pendingOperator = null;
467
+ this.mode = "insert";
468
+ return;
469
+ }
470
+ if (data === "i" || data === "a") {
471
+ this.pendingTextObject = data;
472
+ return;
473
+ }
474
+ if (CHAR_MOTION_KEYS.has(data)) {
475
+ this.pendingMotion = data as PendingMotion;
476
+ return;
477
+ }
478
+ if (this.deleteWithMotion(data)) {
479
+ this.pendingOperator = null;
480
+ this.mode = "insert";
481
+ return;
482
+ }
483
+
484
+ // Invalid motion: cancel operator to avoid sticky surprising changes.
485
+ this.cancelPendingOperator(data);
486
+ }
487
+
488
+ private handleNormalMode(data: string): void {
489
+ if (this.pendingG) {
490
+ this.pendingG = false;
491
+ if (data === "g") {
492
+ this.moveCursorToBufferStart();
493
+ return;
494
+ }
495
+ // Unsupported g-prefix command: discard prefix and keep processing input.
496
+ }
497
+
498
+ if (this.pendingCount.length > 0) {
499
+ if (this.isDigit(data) && this.pendingCountKind === "prefix") {
500
+ this.pendingCount += data;
501
+ return;
502
+ }
503
+
504
+ if ((data === "d" || data === "y") && this.pendingCountKind === "prefix") {
505
+ this.pendingOperator = data;
506
+ return;
507
+ }
508
+
509
+ // Count prefixes are currently supported for dd/yy only.
510
+ this.pendingCount = "";
511
+ this.pendingCountKind = null;
512
+ } else if (this.isCountStarter(data)) {
513
+ this.pendingCount = data;
514
+ this.pendingCountKind = "prefix";
515
+ return;
516
+ }
517
+
518
+ if (data === "g") {
519
+ this.pendingG = true;
520
+ return;
521
+ }
522
+
523
+ if (data === "G") {
524
+ this.moveCursorToBufferEnd();
525
+ return;
526
+ }
527
+
528
+ if (data === "d") {
529
+ this.pendingOperator = "d";
530
+ return;
531
+ }
532
+
533
+ if (data === "c") {
534
+ this.pendingOperator = "c";
535
+ return;
536
+ }
537
+
538
+ if (data === "y") {
539
+ this.pendingOperator = "y";
540
+ return;
541
+ }
542
+
543
+ if (data === "p") {
544
+ this.putAfter();
545
+ return;
546
+ }
547
+
548
+ if (data === "P") {
549
+ this.putBefore();
550
+ return;
551
+ }
552
+
553
+ if (CHAR_MOTION_KEYS.has(data)) {
554
+ this.pendingMotion = data as PendingMotion;
555
+ return;
556
+ }
557
+
558
+ if (data === ";" && this.lastCharMotion) {
559
+ this.executeCharMotion(this.lastCharMotion.motion, this.lastCharMotion.char, false);
560
+ return;
561
+ }
562
+ if (data === "," && this.lastCharMotion) {
563
+ this.executeCharMotion(
564
+ reverseCharMotion(this.lastCharMotion.motion),
565
+ this.lastCharMotion.char,
566
+ false,
567
+ );
568
+ return;
569
+ }
570
+
571
+ if (data === "u") {
572
+ super.handleInput(CTRL_UNDERSCORE); // ctrl+_ — readline undo
573
+ return;
574
+ }
575
+
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");
579
+
580
+ if (Object.hasOwn(NORMAL_KEYS, data)) {
581
+ return this.handleMappedKey(data);
582
+ }
583
+
584
+ // Pass control sequences (ctrl+c, etc.) to super, ignore printable chars
585
+ if (this.isPrintableChunk(data)) return;
586
+ super.handleInput(data);
587
+ }
588
+
589
+ private handleMappedKey(key: string): void {
590
+ const seq = NORMAL_KEYS[key];
591
+ switch (key) {
592
+ case "i":
593
+ this.mode = "insert";
594
+ break;
595
+ case "a":
596
+ this.mode = "insert";
597
+ if (!this.isCursorAtOrPastEol()) {
598
+ super.handleInput(ESC_RIGHT);
599
+ }
600
+ break;
601
+ case "A":
602
+ this.mode = "insert";
603
+ super.handleInput(CTRL_E);
604
+ break;
605
+ case "I":
606
+ this.mode = "insert";
607
+ super.handleInput(CTRL_A);
608
+ break;
609
+ case "o":
610
+ super.handleInput(CTRL_E);
611
+ super.handleInput(NEWLINE);
612
+ this.mode = "insert";
613
+ break;
614
+ case "O":
615
+ super.handleInput(CTRL_A);
616
+ super.handleInput(NEWLINE);
617
+ super.handleInput(ESC_UP);
618
+ this.mode = "insert";
619
+ break;
620
+ case "D":
621
+ this.cutToEndOfLine();
622
+ break;
623
+ case "C":
624
+ this.cutToEndOfLine();
625
+ this.mode = "insert";
626
+ break;
627
+ case "S":
628
+ this.cutCurrentLineContent();
629
+ this.mode = "insert";
630
+ break;
631
+ case "s":
632
+ this.cutCharUnderCursor();
633
+ this.mode = "insert";
634
+ break;
635
+ case "x":
636
+ this.cutCharUnderCursor();
637
+ break;
638
+ default:
639
+ if (seq) super.handleInput(seq);
640
+ }
641
+ }
642
+
643
+ private executeCharMotion(motion: CharMotion, targetChar: string, saveMotion: boolean = true): void {
644
+ const line = this.getLines()[this.getCursor().line] ?? "";
645
+ const col = this.getCursor().col;
646
+ const targetCol = findCharMotionTarget(line, col, motion, targetChar, !saveMotion);
647
+
648
+ if (targetCol !== null && saveMotion) {
649
+ this.lastCharMotion = { motion, char: targetChar };
650
+ }
651
+
652
+ if (targetCol !== null && targetCol !== col) {
653
+ this.moveCursorBy(targetCol - col);
654
+ }
655
+ }
656
+
657
+ private tryMoveCursorByState(delta: number): boolean {
658
+ if (delta === 0) return true;
659
+
660
+ const editor = this as unknown as {
661
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
662
+ preferredVisualCol?: number;
663
+ tui?: { requestRender?: () => void };
664
+ };
665
+
666
+ const state = editor.state;
667
+ if (!state || !Array.isArray(state.lines)) return false;
668
+ if (!Number.isInteger(state.cursorLine) || !Number.isInteger(state.cursorCol)) return false;
669
+
670
+ const cursorLine = state.cursorLine as number;
671
+ const cursorCol = state.cursorCol as number;
672
+ const line = state.lines[cursorLine] ?? "";
673
+ const target = cursorCol + delta;
674
+
675
+ // Only short-circuit line-local movement; preserve canonical key replay for
676
+ // any potential cross-line traversal semantics.
677
+ if (target < 0 || target > line.length) return false;
678
+
679
+ state.cursorCol = target;
680
+ editor.preferredVisualCol = target;
681
+ editor.tui?.requestRender?.();
682
+ return true;
683
+ }
684
+
685
+ private moveCursorBy(delta: number): void {
686
+ if (delta === 0) return;
687
+
688
+ if (this.tryMoveCursorByState(delta)) return;
689
+
690
+ const seq = delta > 0 ? ESC_RIGHT : ESC_LEFT;
691
+ for (let i = 0; i < Math.abs(delta); i++) {
692
+ super.handleInput(seq);
693
+ }
694
+ }
695
+
696
+ private moveCursorToLineStart(lineIndex: number): void {
697
+ const lines = this.getLines();
698
+ if (lines.length === 0) {
699
+ super.handleInput(CTRL_A);
700
+ return;
701
+ }
702
+
703
+ const targetLine = Math.max(0, Math.min(lineIndex, lines.length - 1));
704
+ const currentLine = this.getCursor().line;
705
+ const delta = targetLine - currentLine;
706
+
707
+ if (delta > 0) {
708
+ for (let i = 0; i < delta; i++) {
709
+ super.handleInput(ESC_DOWN);
710
+ }
711
+ } else if (delta < 0) {
712
+ for (let i = 0; i < Math.abs(delta); i++) {
713
+ super.handleInput(ESC_UP);
714
+ }
715
+ }
716
+
717
+ super.handleInput(CTRL_A);
718
+ }
719
+
720
+ private moveCursorToBufferStart(): void {
721
+ this.moveCursorToLineStart(0);
722
+ }
723
+
724
+ private moveCursorToBufferEnd(): void {
725
+ const lines = this.getLines();
726
+ this.moveCursorToLineStart(Math.max(0, lines.length - 1));
727
+ }
728
+
729
+ private isWordChar(ch: string): boolean {
730
+ return /\w/.test(ch);
731
+ }
732
+
733
+ private charType(ch: string | undefined): "space" | "word" | "other" {
734
+ if (!ch || /\s/.test(ch)) return "space";
735
+ if (this.isWordChar(ch)) return "word";
736
+ return "other";
737
+ }
738
+
739
+ private getAbsoluteIndex(line: number, col: number): number {
740
+ const lines = this.getLines();
741
+ let idx = 0;
742
+ for (let i = 0; i < line; i++) {
743
+ idx += (lines[i] ?? "").length + 1;
744
+ }
745
+ return idx + col;
746
+ }
747
+
748
+ private getAbsoluteIndexFromCursor(): number {
749
+ const cursor = this.getCursor();
750
+ return this.getAbsoluteIndex(cursor.line, cursor.col);
751
+ }
752
+
753
+ private findWordTargetInText(
754
+ text: string,
755
+ abs: number,
756
+ direction: "forward" | "backward",
757
+ target: "start" | "end",
758
+ ): number {
759
+ const len = text.length;
760
+ if (len === 0) return 0;
761
+
762
+ let i = Math.max(0, Math.min(abs, len));
763
+
764
+ if (direction === "forward") {
765
+ if (i >= len) return len;
766
+
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++;
771
+ }
772
+ while (i < len && this.charType(text[i]) === "space") i++;
773
+ return i;
774
+ }
775
+
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;
782
+ }
783
+
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
+ return i;
790
+ }
791
+
792
+ private tryFindWordTargetLineLocal(
793
+ direction: WordMotionDirection,
794
+ target: WordMotionTarget,
795
+ allowSameColumn: boolean = false,
796
+ ): 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;
804
+
805
+ if (direction === "forward") {
806
+ if (col >= lineSnapshot.length) return null;
807
+ } else {
808
+ if (col <= 0) return null;
809
+ if (!/\S/.test(lineSnapshot.slice(0, col))) return null;
810
+ }
811
+
812
+ const targetCol = this.wordBoundaryCache.tryFindTarget(
813
+ lineSnapshot,
814
+ col,
815
+ direction,
816
+ target,
817
+ );
818
+ if (targetCol === null) return null;
819
+
820
+ const liveLine = this.getLines()[lineIndex] ?? "";
821
+ const liveCol = this.getCursor().col;
822
+ if (liveLine !== lineSnapshot || liveCol !== col) return null;
823
+
824
+ if (direction === "forward") {
825
+ if (targetCol >= lineSnapshot.length) return null;
826
+ if (allowSameColumn) {
827
+ if (targetCol < col) return null;
828
+ } else if (targetCol <= col) {
829
+ return null;
830
+ }
831
+ return targetCol;
832
+ }
833
+
834
+ if (allowSameColumn) {
835
+ if (targetCol > col) return null;
836
+ } else if (targetCol >= col) {
837
+ return null;
838
+ }
839
+
840
+ return targetCol;
841
+ }
842
+
843
+ private tryMoveWordLineLocal(
844
+ direction: "forward" | "backward",
845
+ target: "start" | "end",
846
+ ): boolean {
847
+ const col = this.getCursor().col;
848
+ const targetCol = this.tryFindWordTargetLineLocal(direction, target);
849
+ if (targetCol === null || targetCol === col) return false;
850
+
851
+ this.moveCursorBy(targetCol - col);
852
+ return true;
853
+ }
854
+
855
+ private tryWordMotionLineLocalRange(
856
+ motion: "w" | "e" | "b",
857
+ ): { col: number; targetCol: number; inclusive: boolean } | null {
858
+ const col = this.getCursor().col;
859
+ const direction: WordMotionDirection = motion === "b" ? "backward" : "forward";
860
+ const target: WordMotionTarget = motion === "e" ? "end" : "start";
861
+ const targetCol = this.tryFindWordTargetLineLocal(direction, target, motion === "e");
862
+
863
+ if (targetCol === null) return null;
864
+
865
+ return {
866
+ col,
867
+ targetCol,
868
+ inclusive: motion === "e",
869
+ };
870
+ }
871
+
872
+ private moveWord(direction: "forward" | "backward", target: "start" | "end"): void {
873
+ if (this.tryMoveWordLineLocal(direction, target)) return;
874
+
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);
880
+ }
881
+ }
882
+
883
+ private writeToRegister(text: string): void {
884
+ this.unnamedRegister = text;
885
+ if (!text) return;
886
+ this.clipboardFn(text);
887
+ }
888
+
889
+ private getCurrentLineAndCol(): { line: string; col: number } {
890
+ const line = this.getLines()[this.getCursor().line] ?? "";
891
+ const col = this.getCursor().col;
892
+ return { line, col };
893
+ }
894
+
895
+ private isCursorAtOrPastEol(): boolean {
896
+ const { line, col } = this.getCurrentLineAndCol();
897
+ return col >= line.length;
898
+ }
899
+
900
+ private cutCharUnderCursor(): void {
901
+ const { line, col } = this.getCurrentLineAndCol();
902
+ if (line.length === 0) return; // Don't merge empty lines with x
903
+ if (col >= line.length) return; // Don't delete past end of line
904
+
905
+ const deleted = line.slice(col, col + 1);
906
+ this.writeToRegister(deleted);
907
+ super.handleInput(ESC_DELETE);
908
+ }
909
+
910
+ private cutToEndOfLine(): void {
911
+ const lines = this.getLines();
912
+ const cursorLine = this.getCursor().line;
913
+ const { line, col } = this.getCurrentLineAndCol();
914
+
915
+ const hasNextLine = cursorLine < lines.length - 1;
916
+ const deleted = col < line.length ? line.slice(col) : hasNextLine ? "\n" : "";
917
+
918
+ this.writeToRegister(deleted);
919
+ super.handleInput(CTRL_K);
920
+ }
921
+
922
+ private cutCurrentLineContent(): void {
923
+ const lines = this.getLines();
924
+ const cursorLine = this.getCursor().line;
925
+ const { line } = this.getCurrentLineAndCol();
926
+
927
+ const hasNextLine = cursorLine < lines.length - 1;
928
+ const deleted = line.length > 0 ? line : hasNextLine ? "\n" : "";
929
+
930
+ this.writeToRegister(deleted);
931
+ super.handleInput(CTRL_A);
932
+ super.handleInput(CTRL_K);
933
+ }
934
+
935
+ private cutLine(): void {
936
+ this.cutCurrentLineContent();
937
+ }
938
+
939
+ private getNormalizedLineRange(startLine: number, endLine: number): { start: number; end: number } {
940
+ const lines = this.getLines();
941
+ const last = Math.max(0, lines.length - 1);
942
+ const clampedStart = Math.max(0, Math.min(startLine, last));
943
+ const clampedEnd = Math.max(0, Math.min(endLine, last));
944
+ return {
945
+ start: Math.min(clampedStart, clampedEnd),
946
+ end: Math.max(clampedStart, clampedEnd),
947
+ };
948
+ }
949
+
950
+ private getLinewisePayload(startLine: number, endLine: number): string {
951
+ const lines = this.getLines();
952
+ const { start, end } = this.getNormalizedLineRange(startLine, endLine);
953
+ return `${lines.slice(start, end + 1).join("\n")}\n`;
954
+ }
955
+
956
+ private getLineDeleteAbsoluteRange(startLine: number, endLine: number): { startAbs: number; endAbs: number } {
957
+ const lines = this.getLines();
958
+ const text = this.getText();
959
+ const { start, end } = this.getNormalizedLineRange(startLine, endLine);
960
+ const lastLine = Math.max(0, lines.length - 1);
961
+
962
+ let startAbs = this.getAbsoluteIndex(start, 0);
963
+ let endAbs: number;
964
+
965
+ if (end < lastLine) {
966
+ const endLineText = lines[end] ?? "";
967
+ endAbs = this.getAbsoluteIndex(end, endLineText.length) + 1;
968
+ } else {
969
+ endAbs = text.length;
970
+ if (start > 0) {
971
+ startAbs = Math.max(0, startAbs - 1);
972
+ }
973
+ }
974
+
975
+ return { startAbs, endAbs };
976
+ }
977
+
978
+ private deleteLineRange(startLine: number, endLine: number): void {
979
+ const lines = this.getLines();
980
+ if (lines.length === 0) return;
981
+
982
+ const payload = this.getLinewisePayload(startLine, endLine);
983
+ const { startAbs, endAbs } = this.getLineDeleteAbsoluteRange(startLine, endLine);
984
+
985
+ this.writeToRegister(payload);
986
+
987
+ if (endAbs > startAbs) {
988
+ const cursor = this.getCursor();
989
+ const cursorAbs = this.getAbsoluteIndex(cursor.line, cursor.col);
990
+ if (cursorAbs !== startAbs) {
991
+ this.moveCursorBy(startAbs - cursorAbs);
992
+ }
993
+
994
+ const count = endAbs - startAbs;
995
+ for (let i = 0; i < count; i++) {
996
+ super.handleInput(ESC_DELETE);
997
+ }
998
+ }
999
+
1000
+ super.handleInput(CTRL_A);
1001
+ }
1002
+
1003
+ private yankLineRange(startLine: number, endLine: number): void {
1004
+ if (this.getLines().length === 0) return;
1005
+ this.writeToRegister(this.getLinewisePayload(startLine, endLine));
1006
+ }
1007
+
1008
+ private deleteLinewiseByDelta(delta: number): void {
1009
+ const currentLine = this.getCursor().line;
1010
+ this.deleteLineRange(currentLine, currentLine + delta);
1011
+ }
1012
+
1013
+ private yankLinewiseByDelta(delta: number): void {
1014
+ const currentLine = this.getCursor().line;
1015
+ this.yankLineRange(currentLine, currentLine + delta);
1016
+ }
1017
+
1018
+ private deleteToBufferEndLinewise(): void {
1019
+ this.deleteLineRange(this.getCursor().line, this.getLines().length - 1);
1020
+ }
1021
+
1022
+ private yankToBufferEndLinewise(): void {
1023
+ this.yankLineRange(this.getCursor().line, this.getLines().length - 1);
1024
+ }
1025
+
1026
+ private deleteWithMotion(motion: string): boolean {
1027
+ const cursor = this.getCursor();
1028
+ const line = this.getLines()[cursor.line] ?? "";
1029
+ const col = cursor.col;
1030
+
1031
+ if (motion === "$") {
1032
+ // Match D/C behavior exactly, including newline kill at EOL.
1033
+ this.cutToEndOfLine();
1034
+ return true;
1035
+ }
1036
+
1037
+ if (motion === "0") {
1038
+ this.deleteRange(col, 0, false);
1039
+ return true;
1040
+ }
1041
+
1042
+ if (motion === "w" || motion === "e" || motion === "b") {
1043
+ const lineLocalRange = this.tryWordMotionLineLocalRange(motion);
1044
+ if (lineLocalRange) {
1045
+ this.deleteRange(
1046
+ lineLocalRange.col,
1047
+ lineLocalRange.targetCol,
1048
+ lineLocalRange.inclusive,
1049
+ );
1050
+ return true;
1051
+ }
1052
+
1053
+ const text = this.getText();
1054
+ const currentAbs = this.getAbsoluteIndex(cursor.line, col);
1055
+ const targetAbs = this.findWordTargetInText(
1056
+ text,
1057
+ currentAbs,
1058
+ motion === "b" ? "backward" : "forward",
1059
+ motion === "e" ? "end" : "start",
1060
+ );
1061
+ this.deleteRangeByAbsolute(currentAbs, targetAbs, motion === "e");
1062
+ return true;
1063
+ }
1064
+
1065
+ return false;
1066
+ }
1067
+
1068
+ private deleteWithCharMotion(motion: CharMotion, targetChar: string): void {
1069
+ const line = this.getLines()[this.getCursor().line] ?? "";
1070
+ const col = this.getCursor().col;
1071
+ const targetCol = findCharMotionTarget(line, col, motion, targetChar);
1072
+
1073
+ if (targetCol === null) return;
1074
+
1075
+ this.lastCharMotion = { motion, char: targetChar };
1076
+ this.deleteRange(col, targetCol, true); // char motions are inclusive
1077
+ }
1078
+
1079
+ private handlePendingYank(data: string): void {
1080
+ if (this.isDigit(data)) {
1081
+ if (this.pendingCount.length === 0) {
1082
+ if (data !== "0") {
1083
+ this.pendingCount = data;
1084
+ this.pendingCountKind = "operator";
1085
+ return;
1086
+ }
1087
+ } else if (this.pendingCountKind === "operator") {
1088
+ this.pendingCount += data;
1089
+ return;
1090
+ } else {
1091
+ // Dual counts like 2y3k are out of scope; fail closed.
1092
+ this.cancelPendingOperator(data);
1093
+ return;
1094
+ }
1095
+ }
1096
+
1097
+ if (data === "y") {
1098
+ const count = this.takePendingCount(1);
1099
+ this.yankLinewiseByDelta(count - 1);
1100
+ this.pendingOperator = null;
1101
+ return;
1102
+ }
1103
+
1104
+ 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);
1112
+ this.pendingOperator = null;
1113
+ return;
1114
+ }
1115
+
1116
+ if (data === "G") {
1117
+ if (this.pendingCount.length > 0) {
1118
+ this.cancelPendingOperator(data);
1119
+ return;
1120
+ }
1121
+
1122
+ this.yankToBufferEndLinewise();
1123
+ this.pendingOperator = null;
1124
+ return;
1125
+ }
1126
+
1127
+ if (this.pendingCount.length > 0) {
1128
+ // Counted forms beyond yy and y{count}j/k are intentionally out of scope.
1129
+ this.cancelPendingOperator(data);
1130
+ return;
1131
+ }
1132
+
1133
+ if (data === "i" || data === "a") {
1134
+ this.pendingTextObject = data;
1135
+ return;
1136
+ }
1137
+ if (CHAR_MOTION_KEYS.has(data)) {
1138
+ this.pendingMotion = data as PendingMotion;
1139
+ return;
1140
+ }
1141
+
1142
+ if (this.yankWithMotion(data)) {
1143
+ this.pendingOperator = null;
1144
+ } else {
1145
+ this.cancelPendingOperator(data); // cancel on unrecognised motion
1146
+ }
1147
+ }
1148
+
1149
+ private yankWithMotion(motion: string): boolean {
1150
+ const cursor = this.getCursor();
1151
+ const line = this.getLines()[cursor.line] ?? "";
1152
+ const col = cursor.col;
1153
+
1154
+ if (motion === "$") {
1155
+ this.yankRange(col, line.length, false);
1156
+ return true;
1157
+ }
1158
+
1159
+ if (motion === "0") {
1160
+ this.yankRange(col, 0, false);
1161
+ return true;
1162
+ }
1163
+
1164
+ if (motion === "w" || motion === "e" || motion === "b") {
1165
+ const lineLocalRange = this.tryWordMotionLineLocalRange(motion);
1166
+ if (lineLocalRange) {
1167
+ this.yankRange(
1168
+ lineLocalRange.col,
1169
+ lineLocalRange.targetCol,
1170
+ lineLocalRange.inclusive,
1171
+ );
1172
+ return true;
1173
+ }
1174
+
1175
+ const text = this.getText();
1176
+ const currentAbs = this.getAbsoluteIndex(cursor.line, col);
1177
+ const targetAbs = this.findWordTargetInText(
1178
+ text,
1179
+ currentAbs,
1180
+ motion === "b" ? "backward" : "forward",
1181
+ motion === "e" ? "end" : "start",
1182
+ );
1183
+ this.yankRangeByAbsolute(currentAbs, targetAbs, motion === "e");
1184
+ return true;
1185
+ }
1186
+
1187
+ return false;
1188
+ }
1189
+
1190
+ private yankWithCharMotion(motion: CharMotion, targetChar: string): void {
1191
+ const line = this.getLines()[this.getCursor().line] ?? "";
1192
+ const col = this.getCursor().col;
1193
+ const targetCol = findCharMotionTarget(line, col, motion, targetChar);
1194
+
1195
+ if (targetCol === null) return;
1196
+
1197
+ this.lastCharMotion = { motion, char: targetChar };
1198
+ this.yankRange(col, targetCol, true); // char motions are inclusive
1199
+ }
1200
+
1201
+ private yankRange(col: number, targetCol: number, inclusive: boolean): void {
1202
+ const line = this.getLines()[this.getCursor().line] ?? "";
1203
+ const start = Math.min(col, targetCol);
1204
+ const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
1205
+ const end = Math.min(rawEnd, line.length);
1206
+
1207
+ if (end <= start) return;
1208
+
1209
+ // Yank only — no cursor movement, no text mutation
1210
+ this.writeToRegister(line.slice(start, end));
1211
+ }
1212
+
1213
+ private yankRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean): void {
1214
+ const text = this.getText();
1215
+ const start = Math.min(currentAbs, targetAbs);
1216
+ const rawEnd = Math.max(currentAbs, targetAbs) + (inclusive ? 1 : 0);
1217
+ const end = Math.min(rawEnd, text.length);
1218
+ if (end <= start) return;
1219
+ this.writeToRegister(text.slice(start, end));
1220
+ }
1221
+
1222
+ private deleteRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean = false): void {
1223
+ const text = this.getText();
1224
+ const start = Math.min(currentAbs, targetAbs);
1225
+ const rawEnd = Math.max(currentAbs, targetAbs) + (inclusive ? 1 : 0);
1226
+ const end = Math.min(rawEnd, text.length);
1227
+
1228
+ if (end <= start) return;
1229
+
1230
+ this.writeToRegister(text.slice(start, end));
1231
+
1232
+ const cursor = this.getCursor();
1233
+ const cursorAbs = this.getAbsoluteIndex(cursor.line, cursor.col);
1234
+ if (cursorAbs !== start) {
1235
+ this.moveCursorBy(start - cursorAbs);
1236
+ }
1237
+
1238
+ const count = end - start;
1239
+ for (let i = 0; i < count; i++) {
1240
+ super.handleInput(ESC_DELETE);
1241
+ }
1242
+ }
1243
+
1244
+ private getWordObjectRange(kind: "i" | "a"): { startAbs: number; endAbs: number } | null {
1245
+ const lines = this.getLines();
1246
+ const cursor = this.getCursor();
1247
+ const line = lines[cursor.line] ?? "";
1248
+ if (!line) return null;
1249
+
1250
+ let col = Math.min(cursor.col, Math.max(0, line.length - 1));
1251
+
1252
+ const hasWordChar = (idx: number) => idx >= 0 && idx < line.length && this.isWordChar(line[idx]!);
1253
+
1254
+ if (!hasWordChar(col)) {
1255
+ let right = col;
1256
+ while (right < line.length && !hasWordChar(right)) right++;
1257
+ if (right < line.length) {
1258
+ col = right;
1259
+ } else {
1260
+ let left = Math.min(col, line.length - 1);
1261
+ while (left >= 0 && !hasWordChar(left)) left--;
1262
+ if (left < 0) return null;
1263
+ col = left;
1264
+ }
1265
+ }
1266
+
1267
+ let start = col;
1268
+ while (start > 0 && hasWordChar(start - 1)) start--;
1269
+
1270
+ let end = col + 1;
1271
+ while (end < line.length && hasWordChar(end)) end++;
1272
+
1273
+ if (kind === "a") {
1274
+ let aroundStart = start;
1275
+ let aroundEnd = end;
1276
+
1277
+ 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
+
1282
+ start = aroundStart;
1283
+ end = aroundEnd;
1284
+ }
1285
+
1286
+ return {
1287
+ startAbs: this.getAbsoluteIndex(cursor.line, start),
1288
+ endAbs: this.getAbsoluteIndex(cursor.line, end),
1289
+ };
1290
+ }
1291
+
1292
+ private putAfter(): void {
1293
+ const text = this.unnamedRegister;
1294
+ if (!text) return;
1295
+
1296
+ if (text.endsWith("\n")) {
1297
+ // Line-wise: insert new line below and fill it
1298
+ super.handleInput(CTRL_E);
1299
+ super.handleInput(NEWLINE);
1300
+ 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);
1308
+ }
1309
+ for (const char of text) {
1310
+ super.handleInput(char === "\n" ? NEWLINE : char);
1311
+ }
1312
+ }
1313
+ }
1314
+
1315
+ private putBefore(): void {
1316
+ const text = this.unnamedRegister;
1317
+ if (!text) return;
1318
+
1319
+ 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
+ const content = text.slice(0, -1);
1325
+ for (const char of content) {
1326
+ super.handleInput(char === "\n" ? NEWLINE : char);
1327
+ }
1328
+ } else {
1329
+ // Character-wise: insert before cursor (just type it)
1330
+ for (const char of text) {
1331
+ super.handleInput(char === "\n" ? NEWLINE : char);
1332
+ }
1333
+ }
1334
+ }
1335
+
1336
+ private deleteRange(col: number, targetCol: number, inclusive: boolean): void {
1337
+ const line = this.getLines()[this.getCursor().line] ?? "";
1338
+
1339
+ const start = Math.min(col, targetCol);
1340
+ const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
1341
+ const end = Math.min(rawEnd, line.length);
1342
+
1343
+ if (end <= start) return;
1344
+
1345
+ this.writeToRegister(line.slice(start, end));
1346
+
1347
+ if (start !== col) {
1348
+ this.moveCursorBy(start - col);
1349
+ }
1350
+
1351
+ const count = end - start;
1352
+ for (let i = 0; i < count; i++) {
1353
+ super.handleInput(ESC_DELETE);
1354
+ }
1355
+ }
1356
+
1357
+ render(width: number): string[] {
1358
+ const lines = super.render(width);
1359
+ if (lines.length === 0) return lines;
1360
+
1361
+ const label = this.getModeLabel();
1362
+ const last = lines.length - 1;
1363
+ if (visibleWidth(lines[last]!) >= label.length) {
1364
+ lines[last] = truncateToWidth(lines[last]!, width - label.length, "") + label;
1365
+ }
1366
+ return lines;
1367
+ }
1368
+
1369
+ private getModeLabel(): string {
1370
+ if (this.mode === "insert") return " INSERT ";
1371
+
1372
+ const count = this.pendingCount;
1373
+
1374
+ 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}_ `;
1378
+ }
1379
+ if (this.pendingOperator) {
1380
+ const prefix = this.pendingCountKind === "prefix" ? count : "";
1381
+ const opCount = this.pendingCountKind === "operator" ? count : "";
1382
+ return ` NORMAL ${prefix}${this.pendingOperator}${opCount}_ `;
1383
+ }
1384
+ if (this.pendingMotion) return ` NORMAL ${this.pendingMotion}_ `;
1385
+ if (this.pendingG) return " NORMAL g_ ";
1386
+ if (count) return ` NORMAL ${count}_ `;
1387
+ return " NORMAL ";
1388
+ }
1389
+ }
1390
+
1391
+ export default function (pi: ExtensionAPI) {
1392
+ pi.on("session_start", (_event, ctx) => {
1393
+ ctx.ui.setEditorComponent((tui, theme, kb) => new ModalEditor(tui, theme, kb));
1394
+ });
1395
+ }