@fresh-editor/fresh-editor 0.1.65 → 0.1.67

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.
@@ -0,0 +1,1630 @@
1
+ /// <reference path="./lib/fresh.d.ts" />
2
+
3
+ /**
4
+ * Vi Mode Plugin for Fresh Editor
5
+ *
6
+ * Implements vi-style modal editing with:
7
+ * - Normal mode: navigation and commands
8
+ * - Insert mode: text input
9
+ * - Operator-pending mode: composable operators with motions
10
+ *
11
+ * Uses the plugin API's executeAction() for true operator+motion composability:
12
+ * any operator works with any motion via O(operators + motions) code.
13
+ */
14
+
15
+ // Vi mode state
16
+ type ViMode = "normal" | "insert" | "operator-pending" | "find-char" | "visual" | "visual-line" | "text-object";
17
+ type FindCharType = "f" | "t" | "F" | "T" | null;
18
+ type TextObjectType = "inner" | "around" | null;
19
+
20
+ interface ViState {
21
+ mode: ViMode;
22
+ pendingOperator: string | null;
23
+ pendingFindChar: FindCharType; // For f/t/F/T motions
24
+ pendingTextObject: TextObjectType; // For i/a text objects
25
+ lastFindChar: { type: FindCharType; char: string } | null; // For ; and , repeat
26
+ count: number | null;
27
+ lastCommand: (() => void) | null; // For '.' repeat
28
+ lastYankWasLinewise: boolean; // Track if last yank was line-wise for proper paste
29
+ visualAnchor: number | null; // Starting position for visual mode selection
30
+ }
31
+
32
+ const state: ViState = {
33
+ mode: "normal",
34
+ pendingOperator: null,
35
+ pendingFindChar: null,
36
+ pendingTextObject: null,
37
+ lastFindChar: null,
38
+ count: null,
39
+ lastCommand: null,
40
+ lastYankWasLinewise: false,
41
+ visualAnchor: null,
42
+ };
43
+
44
+ // Mode indicator for status bar
45
+ function getModeIndicator(mode: ViMode): string {
46
+ const countPrefix = state.count !== null ? `${state.count} ` : "";
47
+ switch (mode) {
48
+ case "normal":
49
+ return `-- NORMAL --${countPrefix ? ` (${state.count})` : ""}`;
50
+ case "insert":
51
+ return "-- INSERT --";
52
+ case "operator-pending":
53
+ return `-- OPERATOR (${state.pendingOperator}) --${countPrefix ? ` (${state.count})` : ""}`;
54
+ case "find-char":
55
+ return `-- FIND (${state.pendingFindChar}) --`;
56
+ case "visual":
57
+ return `-- VISUAL --${countPrefix ? ` (${state.count})` : ""}`;
58
+ case "visual-line":
59
+ return `-- VISUAL LINE --${countPrefix ? ` (${state.count})` : ""}`;
60
+ case "text-object":
61
+ return `-- ${state.pendingOperator}${state.pendingTextObject === "inner" ? "i" : "a"}? --`;
62
+ default:
63
+ return "";
64
+ }
65
+ }
66
+
67
+ // Switch between modes
68
+ function switchMode(newMode: ViMode): void {
69
+ const oldMode = state.mode;
70
+ state.mode = newMode;
71
+
72
+ // Only clear pendingOperator when leaving operator-pending and text-object modes
73
+ if (newMode !== "operator-pending" && newMode !== "text-object") {
74
+ state.pendingOperator = null;
75
+ }
76
+
77
+ // Clear text object type when leaving text-object mode
78
+ if (newMode !== "text-object") {
79
+ state.pendingTextObject = null;
80
+ }
81
+
82
+ // Preserve count when entering operator-pending or text-object mode (for 3dw = delete 3 words)
83
+ // Also preserve count in visual modes
84
+ if (newMode !== "operator-pending" && newMode !== "text-object" && newMode !== "visual" && newMode !== "visual-line") {
85
+ state.count = null;
86
+ }
87
+
88
+ // Clear visual anchor when leaving visual modes
89
+ if (newMode !== "visual" && newMode !== "visual-line") {
90
+ state.visualAnchor = null;
91
+ // Clear any selection when leaving visual mode by moving cursor
92
+ // (any non-select movement clears selection in Fresh)
93
+ if (oldMode === "visual" || oldMode === "visual-line") {
94
+ editor.executeAction("move_left");
95
+ editor.executeAction("move_right");
96
+ }
97
+ }
98
+
99
+ // All modes use vi-{mode} naming, including insert mode
100
+ // vi-insert has read_only=false so normal typing works, but Escape is bound
101
+ editor.setEditorMode(`vi-${newMode}`);
102
+ editor.setStatus(getModeIndicator(newMode));
103
+ }
104
+
105
+ // Get the current count (defaults to 1 if no count specified)
106
+ // Does NOT clear the count - that's done in switchMode or explicitly
107
+ function getCount(): number {
108
+ return state.count ?? 1;
109
+ }
110
+
111
+ // Consume the current count and clear it
112
+ // Returns the count (defaults to 1)
113
+ function consumeCount(): number {
114
+ const count = state.count ?? 1;
115
+ state.count = null;
116
+ return count;
117
+ }
118
+
119
+ // Accumulate a digit into the count
120
+ function accumulateCount(digit: number): void {
121
+ if (state.count === null) {
122
+ state.count = digit;
123
+ } else {
124
+ state.count = state.count * 10 + digit;
125
+ }
126
+ // Update status to show accumulated count
127
+ editor.setStatus(getModeIndicator(state.mode));
128
+ }
129
+
130
+ // Execute a single action with count (uses new executeActions API for efficiency)
131
+ function executeWithCount(action: string, count?: number): void {
132
+ const n = count ?? consumeCount();
133
+ if (n === 1) {
134
+ editor.executeAction(action);
135
+ } else {
136
+ editor.executeActions([{ action, count: n }]);
137
+ }
138
+ }
139
+
140
+ // Map motion actions to their selection equivalents
141
+ const motionToSelection: Record<string, string> = {
142
+ move_left: "select_left",
143
+ move_right: "select_right",
144
+ move_up: "select_up",
145
+ move_down: "select_down",
146
+ move_word_left: "select_word_left",
147
+ move_word_right: "select_word_right",
148
+ move_line_start: "select_line_start",
149
+ move_line_end: "select_line_end",
150
+ move_document_start: "select_document_start",
151
+ move_document_end: "select_document_end",
152
+ };
153
+
154
+ // Map (operator, motion) pairs to atomic Rust actions
155
+ // These are single actions that combine the operator and motion atomically
156
+ // This avoids async issues with selection-based approach
157
+ type OperatorMotionMap = Record<string, Record<string, string>>;
158
+ const atomicOperatorActions: OperatorMotionMap = {
159
+ d: {
160
+ // Delete operators
161
+ move_word_right: "delete_word_forward",
162
+ move_word_left: "delete_word_backward",
163
+ move_line_end: "delete_to_line_end",
164
+ move_line_start: "delete_to_line_start",
165
+ },
166
+ y: {
167
+ // Yank operators
168
+ move_word_right: "yank_word_forward",
169
+ move_word_left: "yank_word_backward",
170
+ move_line_end: "yank_to_line_end",
171
+ move_line_start: "yank_to_line_start",
172
+ },
173
+ };
174
+
175
+ // Apply an operator using atomic actions if available, otherwise selection-based approach
176
+ // The count parameter specifies how many times to apply the motion (e.g., d3w = delete 3 words)
177
+ function applyOperatorWithMotion(operator: string, motionAction: string, count: number = 1): void {
178
+ // For "change" operator, use delete action and then enter insert mode
179
+ const lookupOperator = operator === "c" ? "d" : operator;
180
+
181
+ // Check if we have an atomic action for this operator+motion combination
182
+ const operatorActions = atomicOperatorActions[lookupOperator];
183
+ const atomicAction = operatorActions?.[motionAction];
184
+
185
+ if (atomicAction) {
186
+ // Use the atomic action - single command, no async issues
187
+ // Apply count times for 3dw, etc.
188
+ if (count === 1) {
189
+ editor.executeAction(atomicAction);
190
+ } else {
191
+ editor.executeActions([{ action: atomicAction, count }]);
192
+ }
193
+ if (operator === "y") {
194
+ state.lastYankWasLinewise = false;
195
+ }
196
+ if (operator === "c") {
197
+ switchMode("insert");
198
+ return;
199
+ }
200
+ switchMode("normal");
201
+ return;
202
+ }
203
+
204
+ // Fall back to selection-based approach for motions without atomic actions
205
+ const selectAction = motionToSelection[motionAction];
206
+ if (!selectAction) {
207
+ editor.debug(`No selection equivalent for motion: ${motionAction}`);
208
+ switchMode("normal");
209
+ return;
210
+ }
211
+
212
+ // Execute the selection action count times (synchronous - extends selection to target)
213
+ if (count === 1) {
214
+ editor.executeAction(selectAction);
215
+ } else {
216
+ editor.executeActions([{ action: selectAction, count }]);
217
+ }
218
+
219
+ switch (operator) {
220
+ case "d": // delete
221
+ editor.executeAction("cut"); // Cut removes selection
222
+ break;
223
+ case "c": // change (delete and enter insert mode)
224
+ editor.executeAction("cut");
225
+ switchMode("insert");
226
+ return; // Don't switch back to normal mode
227
+ case "y": // yank
228
+ state.lastYankWasLinewise = false; // Motion-based yank is character-wise
229
+ editor.executeAction("copy");
230
+ // Move cursor back to start of selection (left side)
231
+ editor.executeAction("move_left");
232
+ break;
233
+ }
234
+
235
+ switchMode("normal");
236
+ }
237
+
238
+ // Handle motion in operator-pending mode
239
+ // Consumes any pending count and applies it to the motion
240
+ function handleMotionWithOperator(motionAction: string): void {
241
+ if (!state.pendingOperator) {
242
+ switchMode("normal");
243
+ return;
244
+ }
245
+
246
+ const count = consumeCount();
247
+ applyOperatorWithMotion(state.pendingOperator, motionAction, count);
248
+ }
249
+
250
+ // ============================================================================
251
+ // Normal Mode Commands
252
+ // ============================================================================
253
+
254
+ // Navigation (all support count prefix, e.g., 5j moves down 5 lines)
255
+ globalThis.vi_left = function (): void {
256
+ executeWithCount("move_left");
257
+ };
258
+
259
+ globalThis.vi_down = function (): void {
260
+ executeWithCount("move_down");
261
+ };
262
+
263
+ globalThis.vi_up = function (): void {
264
+ executeWithCount("move_up");
265
+ };
266
+
267
+ globalThis.vi_right = function (): void {
268
+ executeWithCount("move_right");
269
+ };
270
+
271
+ globalThis.vi_word = function (): void {
272
+ executeWithCount("move_word_right");
273
+ };
274
+
275
+ globalThis.vi_word_back = function (): void {
276
+ executeWithCount("move_word_left");
277
+ };
278
+
279
+ globalThis.vi_word_end = function (): void {
280
+ // Move to end of word - for count, repeat the whole operation
281
+ const count = consumeCount();
282
+ for (let i = 0; i < count; i++) {
283
+ editor.executeAction("move_word_right");
284
+ editor.executeAction("move_left");
285
+ }
286
+ };
287
+
288
+ globalThis.vi_line_start = function (): void {
289
+ consumeCount(); // Count doesn't apply to line start
290
+ editor.executeAction("move_line_start");
291
+ };
292
+
293
+ globalThis.vi_line_end = function (): void {
294
+ consumeCount(); // Count doesn't apply to line end
295
+ editor.executeAction("move_line_end");
296
+ };
297
+
298
+ globalThis.vi_first_non_blank = function (): void {
299
+ consumeCount(); // Count doesn't apply
300
+ editor.executeAction("move_line_start");
301
+ // TODO: skip whitespace
302
+ };
303
+
304
+ globalThis.vi_doc_start = function (): void {
305
+ consumeCount(); // Count doesn't apply
306
+ editor.executeAction("move_document_start");
307
+ };
308
+
309
+ globalThis.vi_doc_end = function (): void {
310
+ consumeCount(); // Count doesn't apply
311
+ editor.executeAction("move_document_end");
312
+ };
313
+
314
+ globalThis.vi_page_down = function (): void {
315
+ executeWithCount("page_down");
316
+ };
317
+
318
+ globalThis.vi_page_up = function (): void {
319
+ executeWithCount("page_up");
320
+ };
321
+
322
+ globalThis.vi_matching_bracket = function (): void {
323
+ editor.executeAction("go_to_matching_bracket");
324
+ };
325
+
326
+ // Mode switching
327
+ globalThis.vi_insert_before = function (): void {
328
+ switchMode("insert");
329
+ };
330
+
331
+ globalThis.vi_insert_after = function (): void {
332
+ editor.executeAction("move_right");
333
+ switchMode("insert");
334
+ };
335
+
336
+ globalThis.vi_insert_line_start = function (): void {
337
+ editor.executeAction("move_line_start");
338
+ switchMode("insert");
339
+ };
340
+
341
+ globalThis.vi_insert_line_end = function (): void {
342
+ editor.executeAction("move_line_end");
343
+ switchMode("insert");
344
+ };
345
+
346
+ globalThis.vi_open_below = function (): void {
347
+ editor.executeAction("move_line_end");
348
+ editor.executeAction("insert_newline");
349
+ switchMode("insert");
350
+ };
351
+
352
+ globalThis.vi_open_above = function (): void {
353
+ editor.executeAction("move_line_start");
354
+ editor.executeAction("insert_newline");
355
+ editor.executeAction("move_up");
356
+ switchMode("insert");
357
+ };
358
+
359
+ globalThis.vi_escape = function (): void {
360
+ switchMode("normal");
361
+ };
362
+
363
+ // Operators
364
+ globalThis.vi_delete_operator = function (): void {
365
+ state.pendingOperator = "d";
366
+ switchMode("operator-pending");
367
+ };
368
+
369
+ globalThis.vi_change_operator = function (): void {
370
+ state.pendingOperator = "c";
371
+ switchMode("operator-pending");
372
+ };
373
+
374
+ globalThis.vi_yank_operator = function (): void {
375
+ state.pendingOperator = "y";
376
+ switchMode("operator-pending");
377
+ };
378
+
379
+ // Line operations (dd, cc, yy) - support count prefix (3dd = delete 3 lines)
380
+ globalThis.vi_delete_line = function (): void {
381
+ const count = consumeCount();
382
+ if (count === 1) {
383
+ editor.executeAction("delete_line");
384
+ } else {
385
+ editor.executeActions([{ action: "delete_line", count }]);
386
+ }
387
+ switchMode("normal");
388
+ };
389
+
390
+ globalThis.vi_change_line = function (): void {
391
+ consumeCount(); // TODO: support count for change line
392
+ editor.executeAction("move_line_start");
393
+ const start = editor.getCursorPosition();
394
+ editor.executeAction("move_line_end");
395
+ const end = editor.getCursorPosition();
396
+ if (start !== null && end !== null) {
397
+ editor.deleteRange(editor.getActiveBufferId(), start, end);
398
+ }
399
+ switchMode("insert");
400
+ };
401
+
402
+ globalThis.vi_yank_line = function (): void {
403
+ const count = consumeCount();
404
+ // select_line selects current line and moves cursor to next line
405
+ if (count === 1) {
406
+ editor.executeAction("select_line");
407
+ } else {
408
+ editor.executeActions([{ action: "select_line", count }]);
409
+ }
410
+ editor.executeAction("copy");
411
+ // Move back to original line using synchronous actions
412
+ // (setBufferCursor is async and doesn't take effect in time)
413
+ editor.executeAction("move_up");
414
+ editor.executeAction("move_line_start");
415
+ state.lastYankWasLinewise = true;
416
+ editor.setStatus(`Yanked ${count} line${count > 1 ? "s" : ""}`);
417
+ switchMode("normal");
418
+ };
419
+
420
+ // Single character operations - support count prefix (3x = delete 3 chars)
421
+ globalThis.vi_delete_char = function (): void {
422
+ executeWithCount("delete_forward");
423
+ };
424
+
425
+ globalThis.vi_delete_char_before = function (): void {
426
+ executeWithCount("delete_backward");
427
+ };
428
+
429
+ globalThis.vi_replace_char = function (): void {
430
+ // TODO: implement character replacement (need to read next char)
431
+ editor.setStatus("Replace char not yet implemented");
432
+ };
433
+
434
+ // Substitute (delete char and enter insert mode)
435
+ globalThis.vi_substitute = function (): void {
436
+ editor.executeAction("delete_forward");
437
+ switchMode("insert");
438
+ };
439
+
440
+ // Delete to end of line
441
+ globalThis.vi_delete_to_end = function (): void {
442
+ const start = editor.getCursorPosition();
443
+ editor.executeAction("move_line_end");
444
+ const end = editor.getCursorPosition();
445
+ if (start !== null && end !== null && end > start) {
446
+ editor.deleteRange(editor.getActiveBufferId(), start, end);
447
+ }
448
+ };
449
+
450
+ // Change to end of line
451
+ globalThis.vi_change_to_end = function (): void {
452
+ const start = editor.getCursorPosition();
453
+ editor.executeAction("move_line_end");
454
+ const end = editor.getCursorPosition();
455
+ if (start !== null && end !== null && end > start) {
456
+ editor.deleteRange(editor.getActiveBufferId(), start, end);
457
+ }
458
+ switchMode("insert");
459
+ };
460
+
461
+ // Clipboard
462
+ globalThis.vi_paste_after = function (): void {
463
+ if (state.lastYankWasLinewise) {
464
+ // Line-wise paste: go to next line start and paste there
465
+ // The yanked text includes trailing \n which pushes subsequent lines down
466
+ editor.executeAction("move_down");
467
+ editor.executeAction("move_line_start");
468
+ editor.executeAction("paste");
469
+ editor.executeAction("move_up"); // Stay on the pasted line
470
+ editor.executeAction("move_line_start");
471
+ } else {
472
+ // Character-wise paste: insert after cursor
473
+ editor.executeAction("move_right");
474
+ editor.executeAction("paste");
475
+ }
476
+ };
477
+
478
+ globalThis.vi_paste_before = function (): void {
479
+ if (state.lastYankWasLinewise) {
480
+ // Line-wise paste: paste at current line start
481
+ // The yanked text includes trailing \n which pushes current line down
482
+ editor.executeAction("move_line_start");
483
+ editor.executeAction("paste");
484
+ editor.executeAction("move_up"); // Stay on the pasted line
485
+ editor.executeAction("move_line_start");
486
+ } else {
487
+ // Character-wise paste: insert at cursor
488
+ editor.executeAction("paste");
489
+ }
490
+ };
491
+
492
+ // Undo/Redo
493
+ globalThis.vi_undo = function (): void {
494
+ editor.executeAction("undo");
495
+ };
496
+
497
+ globalThis.vi_redo = function (): void {
498
+ editor.executeAction("redo");
499
+ };
500
+
501
+ // Join lines
502
+ globalThis.vi_join = function (): void {
503
+ editor.executeAction("move_line_end");
504
+ editor.executeAction("delete_forward");
505
+ editor.executeAction("insert_text_at_cursor");
506
+ };
507
+
508
+ // Search
509
+ globalThis.vi_search_forward = function (): void {
510
+ editor.executeAction("search");
511
+ };
512
+
513
+ globalThis.vi_search_backward = function (): void {
514
+ // Use same search dialog, user can search backward manually
515
+ editor.executeAction("search");
516
+ };
517
+
518
+ globalThis.vi_find_next = function (): void {
519
+ editor.executeAction("find_next");
520
+ };
521
+
522
+ globalThis.vi_find_prev = function (): void {
523
+ editor.executeAction("find_previous");
524
+ };
525
+
526
+ // Center view
527
+ globalThis.vi_center_cursor = function (): void {
528
+ editor.executeAction("center_cursor");
529
+ };
530
+
531
+ // Half page movements
532
+ globalThis.vi_half_page_down = function (): void {
533
+ // Approximate half page with multiple down movements
534
+ const count = consumeCount();
535
+ editor.executeActions([{ action: "move_down", count: 10 * count }]);
536
+ };
537
+
538
+ globalThis.vi_half_page_up = function (): void {
539
+ const count = consumeCount();
540
+ editor.executeActions([{ action: "move_up", count: 10 * count }]);
541
+ };
542
+
543
+ // ============================================================================
544
+ // Count Prefix (digit keys 1-9, and 0 after initial digit)
545
+ // ============================================================================
546
+
547
+ // Digit handlers for count prefix
548
+ globalThis.vi_digit_1 = function (): void { accumulateCount(1); };
549
+ globalThis.vi_digit_2 = function (): void { accumulateCount(2); };
550
+ globalThis.vi_digit_3 = function (): void { accumulateCount(3); };
551
+ globalThis.vi_digit_4 = function (): void { accumulateCount(4); };
552
+ globalThis.vi_digit_5 = function (): void { accumulateCount(5); };
553
+ globalThis.vi_digit_6 = function (): void { accumulateCount(6); };
554
+ globalThis.vi_digit_7 = function (): void { accumulateCount(7); };
555
+ globalThis.vi_digit_8 = function (): void { accumulateCount(8); };
556
+ globalThis.vi_digit_9 = function (): void { accumulateCount(9); };
557
+
558
+ // 0 is special: if count is already started, it appends; otherwise it's "go to line start"
559
+ globalThis.vi_digit_0_or_line_start = function (): void {
560
+ if (state.count !== null) {
561
+ accumulateCount(0);
562
+ } else {
563
+ editor.executeAction("move_line_start");
564
+ }
565
+ };
566
+
567
+ // 0 in operator-pending mode: if count is started, append; otherwise apply operator to line start
568
+ globalThis.vi_op_digit_0_or_line_start = function (): void {
569
+ if (state.count !== null) {
570
+ accumulateCount(0);
571
+ } else {
572
+ handleMotionWithOperator("move_line_start");
573
+ }
574
+ };
575
+
576
+ // ============================================================================
577
+ // Visual Mode
578
+ // ============================================================================
579
+
580
+ // Enter character-wise visual mode
581
+ globalThis.vi_visual_char = function (): void {
582
+ state.visualAnchor = editor.getCursorPosition();
583
+ // Select current character to start visual selection
584
+ editor.executeAction("select_right");
585
+ switchMode("visual");
586
+ };
587
+
588
+ // Enter line-wise visual mode
589
+ globalThis.vi_visual_line = function (): void {
590
+ state.visualAnchor = editor.getCursorPosition();
591
+ // Select current line
592
+ editor.executeAction("move_line_start");
593
+ editor.executeAction("select_line");
594
+ switchMode("visual-line");
595
+ };
596
+
597
+ // Toggle between visual and visual-line modes
598
+ globalThis.vi_visual_toggle_line = function (): void {
599
+ if (state.mode === "visual") {
600
+ // Switch to line mode - extend selection to full lines
601
+ editor.executeAction("select_line");
602
+ state.mode = "visual-line";
603
+ editor.setEditorMode("vi-visual-line");
604
+ editor.setStatus(getModeIndicator("visual-line"));
605
+ } else if (state.mode === "visual-line") {
606
+ // Switch to char mode (keep selection but change mode)
607
+ state.mode = "visual";
608
+ editor.setEditorMode("vi-visual");
609
+ editor.setStatus(getModeIndicator("visual"));
610
+ }
611
+ };
612
+
613
+ // Visual mode motions - these extend the selection
614
+ globalThis.vi_vis_left = function (): void {
615
+ executeWithCount("select_left");
616
+ };
617
+
618
+ globalThis.vi_vis_down = function (): void {
619
+ executeWithCount("select_down");
620
+ };
621
+
622
+ globalThis.vi_vis_up = function (): void {
623
+ executeWithCount("select_up");
624
+ };
625
+
626
+ globalThis.vi_vis_right = function (): void {
627
+ executeWithCount("select_right");
628
+ };
629
+
630
+ globalThis.vi_vis_word = function (): void {
631
+ executeWithCount("select_word_right");
632
+ };
633
+
634
+ globalThis.vi_vis_word_back = function (): void {
635
+ executeWithCount("select_word_left");
636
+ };
637
+
638
+ globalThis.vi_vis_word_end = function (): void {
639
+ const count = consumeCount();
640
+ for (let i = 0; i < count; i++) {
641
+ editor.executeAction("select_word_right");
642
+ editor.executeAction("select_left");
643
+ }
644
+ };
645
+
646
+ globalThis.vi_vis_line_start = function (): void {
647
+ consumeCount();
648
+ editor.executeAction("select_line_start");
649
+ };
650
+
651
+ globalThis.vi_vis_line_end = function (): void {
652
+ consumeCount();
653
+ editor.executeAction("select_line_end");
654
+ };
655
+
656
+ globalThis.vi_vis_doc_start = function (): void {
657
+ consumeCount();
658
+ editor.executeAction("select_document_start");
659
+ };
660
+
661
+ globalThis.vi_vis_doc_end = function (): void {
662
+ consumeCount();
663
+ editor.executeAction("select_document_end");
664
+ };
665
+
666
+ // Visual line mode motions - extend selection by whole lines
667
+ globalThis.vi_vline_down = function (): void {
668
+ executeWithCount("select_down");
669
+ // Ensure full line selection
670
+ editor.executeAction("select_line_end");
671
+ };
672
+
673
+ globalThis.vi_vline_up = function (): void {
674
+ executeWithCount("select_up");
675
+ // Ensure full line selection
676
+ editor.executeAction("select_line_start");
677
+ };
678
+
679
+ // Visual mode operators - act on selection
680
+ globalThis.vi_vis_delete = function (): void {
681
+ const wasLinewise = state.mode === "visual-line";
682
+ editor.executeAction("cut");
683
+ state.lastYankWasLinewise = wasLinewise;
684
+ switchMode("normal");
685
+ };
686
+
687
+ globalThis.vi_vis_change = function (): void {
688
+ editor.executeAction("cut");
689
+ switchMode("insert");
690
+ };
691
+
692
+ globalThis.vi_vis_yank = function (): void {
693
+ const wasLinewise = state.mode === "visual-line";
694
+ editor.executeAction("copy");
695
+ state.lastYankWasLinewise = wasLinewise;
696
+ // Move cursor to start of selection (vim behavior)
697
+ editor.executeAction("move_left");
698
+ switchMode("normal");
699
+ };
700
+
701
+ // Exit visual mode without doing anything
702
+ globalThis.vi_vis_escape = function (): void {
703
+ switchMode("normal");
704
+ };
705
+
706
+ // ============================================================================
707
+ // Text Objects (iw, aw, i", a", etc.)
708
+ // ============================================================================
709
+
710
+ // Enter text-object mode with "inner" modifier
711
+ globalThis.vi_text_object_inner = function (): void {
712
+ state.pendingTextObject = "inner";
713
+ state.mode = "text-object";
714
+ editor.setEditorMode("vi-text-object");
715
+ editor.setStatus(getModeIndicator("text-object"));
716
+ };
717
+
718
+ // Enter text-object mode with "around" modifier
719
+ globalThis.vi_text_object_around = function (): void {
720
+ state.pendingTextObject = "around";
721
+ state.mode = "text-object";
722
+ editor.setEditorMode("vi-text-object");
723
+ editor.setStatus(getModeIndicator("text-object"));
724
+ };
725
+
726
+ // Apply text object selection and then the pending operator
727
+ async function applyTextObject(objectType: string): Promise<void> {
728
+ const operator = state.pendingOperator;
729
+ const isInner = state.pendingTextObject === "inner";
730
+
731
+ if (!operator) {
732
+ switchMode("normal");
733
+ return;
734
+ }
735
+
736
+ const bufferId = editor.getActiveBufferId();
737
+ const cursorPos = editor.getCursorPosition();
738
+ if (cursorPos === null) {
739
+ switchMode("normal");
740
+ return;
741
+ }
742
+
743
+ // Get text around cursor to find the text object boundaries
744
+ const windowSize = 1000;
745
+ const startOffset = Math.max(0, cursorPos - windowSize);
746
+ const bufLen = editor.getBufferLength(bufferId);
747
+ const endOffset = Math.min(bufLen, cursorPos + windowSize);
748
+ const text = await editor.getBufferText(bufferId, startOffset, endOffset);
749
+ if (!text) {
750
+ switchMode("normal");
751
+ return;
752
+ }
753
+
754
+ const posInChunk = cursorPos - startOffset;
755
+ let selectStart = -1;
756
+ let selectEnd = -1;
757
+
758
+ switch (objectType) {
759
+ case "word": {
760
+ // Find word boundaries
761
+ const wordChars = /[a-zA-Z0-9_]/;
762
+ let start = posInChunk;
763
+ let end = posInChunk;
764
+
765
+ // Expand to find word start
766
+ while (start > 0 && wordChars.test(text[start - 1])) start--;
767
+ // Expand to find word end
768
+ while (end < text.length && wordChars.test(text[end])) end++;
769
+
770
+ if (!isInner) {
771
+ // "a word" includes trailing whitespace
772
+ while (end < text.length && /\s/.test(text[end]) && text[end] !== '\n') end++;
773
+ }
774
+
775
+ selectStart = startOffset + start;
776
+ selectEnd = startOffset + end;
777
+ break;
778
+ }
779
+
780
+ case "WORD": {
781
+ // WORD is whitespace-delimited
782
+ let start = posInChunk;
783
+ let end = posInChunk;
784
+
785
+ while (start > 0 && !/\s/.test(text[start - 1])) start--;
786
+ while (end < text.length && !/\s/.test(text[end])) end++;
787
+
788
+ if (!isInner) {
789
+ while (end < text.length && /\s/.test(text[end]) && text[end] !== '\n') end++;
790
+ }
791
+
792
+ selectStart = startOffset + start;
793
+ selectEnd = startOffset + end;
794
+ break;
795
+ }
796
+
797
+ case "\"":
798
+ case "'":
799
+ case "`": {
800
+ // Find matching quotes on current line
801
+ // First find line boundaries
802
+ let lineStart = posInChunk;
803
+ let lineEnd = posInChunk;
804
+ while (lineStart > 0 && text[lineStart - 1] !== '\n') lineStart--;
805
+ while (lineEnd < text.length && text[lineEnd] !== '\n') lineEnd++;
806
+
807
+ const line = text.substring(lineStart, lineEnd);
808
+ const colInLine = posInChunk - lineStart;
809
+
810
+ // Find quote pair containing cursor
811
+ let quoteStart = -1;
812
+ let quoteEnd = -1;
813
+ let inQuote = false;
814
+
815
+ for (let i = 0; i < line.length; i++) {
816
+ if (line[i] === objectType) {
817
+ if (!inQuote) {
818
+ quoteStart = i;
819
+ inQuote = true;
820
+ } else {
821
+ quoteEnd = i;
822
+ if (colInLine >= quoteStart && colInLine <= quoteEnd) {
823
+ break; // Found the pair containing cursor
824
+ }
825
+ inQuote = false;
826
+ }
827
+ }
828
+ }
829
+
830
+ if (quoteStart !== -1 && quoteEnd !== -1 && colInLine >= quoteStart && colInLine <= quoteEnd) {
831
+ if (isInner) {
832
+ selectStart = startOffset + lineStart + quoteStart + 1;
833
+ selectEnd = startOffset + lineStart + quoteEnd;
834
+ } else {
835
+ selectStart = startOffset + lineStart + quoteStart;
836
+ selectEnd = startOffset + lineStart + quoteEnd + 1;
837
+ }
838
+ }
839
+ break;
840
+ }
841
+
842
+ case "(":
843
+ case ")":
844
+ case "b": {
845
+ // Find matching parentheses
846
+ const result = findMatchingPair(text, posInChunk, '(', ')');
847
+ if (result) {
848
+ if (isInner) {
849
+ selectStart = startOffset + result.start + 1;
850
+ selectEnd = startOffset + result.end;
851
+ } else {
852
+ selectStart = startOffset + result.start;
853
+ selectEnd = startOffset + result.end + 1;
854
+ }
855
+ }
856
+ break;
857
+ }
858
+
859
+ case "{":
860
+ case "}":
861
+ case "B": {
862
+ const result = findMatchingPair(text, posInChunk, '{', '}');
863
+ if (result) {
864
+ if (isInner) {
865
+ selectStart = startOffset + result.start + 1;
866
+ selectEnd = startOffset + result.end;
867
+ } else {
868
+ selectStart = startOffset + result.start;
869
+ selectEnd = startOffset + result.end + 1;
870
+ }
871
+ }
872
+ break;
873
+ }
874
+
875
+ case "[":
876
+ case "]": {
877
+ const result = findMatchingPair(text, posInChunk, '[', ']');
878
+ if (result) {
879
+ if (isInner) {
880
+ selectStart = startOffset + result.start + 1;
881
+ selectEnd = startOffset + result.end;
882
+ } else {
883
+ selectStart = startOffset + result.start;
884
+ selectEnd = startOffset + result.end + 1;
885
+ }
886
+ }
887
+ break;
888
+ }
889
+
890
+ case "<":
891
+ case ">": {
892
+ const result = findMatchingPair(text, posInChunk, '<', '>');
893
+ if (result) {
894
+ if (isInner) {
895
+ selectStart = startOffset + result.start + 1;
896
+ selectEnd = startOffset + result.end;
897
+ } else {
898
+ selectStart = startOffset + result.start;
899
+ selectEnd = startOffset + result.end + 1;
900
+ }
901
+ }
902
+ break;
903
+ }
904
+ }
905
+
906
+ if (selectStart === -1 || selectEnd === -1 || selectStart >= selectEnd) {
907
+ switchMode("normal");
908
+ return;
909
+ }
910
+
911
+ // Apply the operator directly using deleteRange/copyRange
912
+ switch (operator) {
913
+ case "d": {
914
+ // Delete the range directly
915
+ editor.deleteRange(bufferId, selectStart, selectEnd);
916
+ state.lastYankWasLinewise = false;
917
+ break;
918
+ }
919
+ case "c": {
920
+ // Delete and enter insert mode
921
+ editor.deleteRange(bufferId, selectStart, selectEnd);
922
+ switchMode("insert");
923
+ return;
924
+ }
925
+ case "y": {
926
+ // For yank, we need to select the range and copy
927
+ // First move cursor to start
928
+ editor.setBufferCursor(bufferId, selectStart);
929
+ // Select the range
930
+ for (let i = 0; i < selectEnd - selectStart; i++) {
931
+ editor.executeAction("select_right");
932
+ }
933
+ editor.executeAction("copy");
934
+ state.lastYankWasLinewise = false;
935
+ // Move back to start
936
+ editor.setBufferCursor(bufferId, selectStart);
937
+ break;
938
+ }
939
+ }
940
+
941
+ switchMode("normal");
942
+ }
943
+
944
+ // Helper to find matching bracket pair containing the cursor
945
+ function findMatchingPair(text: string, pos: number, openChar: string, closeChar: string): { start: number; end: number } | null {
946
+ let depth = 0;
947
+ let start = -1;
948
+
949
+ // Search backward for opening bracket
950
+ for (let i = pos; i >= 0; i--) {
951
+ if (text[i] === closeChar) depth++;
952
+ if (text[i] === openChar) {
953
+ if (depth === 0) {
954
+ start = i;
955
+ break;
956
+ }
957
+ depth--;
958
+ }
959
+ }
960
+
961
+ if (start === -1) return null;
962
+
963
+ // Search forward for closing bracket
964
+ depth = 0;
965
+ for (let i = start; i < text.length; i++) {
966
+ if (text[i] === openChar) depth++;
967
+ if (text[i] === closeChar) {
968
+ depth--;
969
+ if (depth === 0) {
970
+ return { start, end: i };
971
+ }
972
+ }
973
+ }
974
+
975
+ return null;
976
+ }
977
+
978
+ // Text object handlers
979
+ globalThis.vi_to_word = async function (): Promise<void> { await applyTextObject("word"); };
980
+ globalThis.vi_to_WORD = async function (): Promise<void> { await applyTextObject("WORD"); };
981
+ globalThis.vi_to_dquote = async function (): Promise<void> { await applyTextObject("\""); };
982
+ globalThis.vi_to_squote = async function (): Promise<void> { await applyTextObject("'"); };
983
+ globalThis.vi_to_backtick = async function (): Promise<void> { await applyTextObject("`"); };
984
+ globalThis.vi_to_paren = async function (): Promise<void> { await applyTextObject("("); };
985
+ globalThis.vi_to_brace = async function (): Promise<void> { await applyTextObject("{"); };
986
+ globalThis.vi_to_bracket = async function (): Promise<void> { await applyTextObject("["); };
987
+ globalThis.vi_to_angle = async function (): Promise<void> { await applyTextObject("<"); };
988
+
989
+ // Cancel text object mode
990
+ globalThis.vi_to_cancel = function (): void {
991
+ switchMode("normal");
992
+ };
993
+
994
+ // ============================================================================
995
+ // Find Character Motions (f/t/F/T)
996
+ // ============================================================================
997
+
998
+ // Enter find-char mode waiting for the target character
999
+ function enterFindCharMode(findType: FindCharType): void {
1000
+ state.pendingFindChar = findType;
1001
+ state.mode = "find-char";
1002
+ editor.setEditorMode("vi-find-char");
1003
+ editor.setStatus(getModeIndicator("find-char"));
1004
+ }
1005
+
1006
+ // Execute find char motion (async because getBufferText is async)
1007
+ async function executeFindChar(findType: FindCharType, char: string): Promise<void> {
1008
+ if (!findType) return;
1009
+
1010
+ const bufferId = editor.getActiveBufferId();
1011
+ const cursorPos = editor.getCursorPosition();
1012
+ if (cursorPos === null || (cursorPos === 0 && (findType === "F" || findType === "T"))) {
1013
+ // Can't search backward from position 0
1014
+ return;
1015
+ }
1016
+
1017
+ // Get text around cursor to find line boundaries
1018
+ // Read up to 10KB before and after cursor for context
1019
+ const windowSize = 10000;
1020
+ const startOffset = Math.max(0, cursorPos - windowSize);
1021
+ const bufLen = editor.getBufferLength(bufferId);
1022
+ const endOffset = Math.min(bufLen, cursorPos + windowSize);
1023
+
1024
+ // Get buffer text around cursor
1025
+ const text = await editor.getBufferText(bufferId, startOffset, endOffset);
1026
+ if (!text) return;
1027
+
1028
+ // Calculate position within this text chunk
1029
+ const posInChunk = cursorPos - startOffset;
1030
+
1031
+ // Find line start (last newline before cursor, or start of chunk)
1032
+ let lineStart = 0;
1033
+ for (let i = posInChunk - 1; i >= 0; i--) {
1034
+ if (text[i] === '\n') {
1035
+ lineStart = i + 1;
1036
+ break;
1037
+ }
1038
+ }
1039
+
1040
+ // Find line end (next newline after cursor, or end of chunk)
1041
+ let lineEnd = text.length;
1042
+ for (let i = posInChunk; i < text.length; i++) {
1043
+ if (text[i] === '\n') {
1044
+ lineEnd = i;
1045
+ break;
1046
+ }
1047
+ }
1048
+
1049
+ // Extract line text and calculate column
1050
+ const lineText = text.substring(lineStart, lineEnd);
1051
+ const col = posInChunk - lineStart;
1052
+
1053
+ let targetCol = -1;
1054
+
1055
+ if (findType === "f" || findType === "t") {
1056
+ // Search forward on the line
1057
+ for (let i = col + 1; i < lineText.length; i++) {
1058
+ if (lineText[i] === char) {
1059
+ targetCol = findType === "f" ? i : i - 1;
1060
+ break;
1061
+ }
1062
+ }
1063
+ } else {
1064
+ // Search backward (F/T)
1065
+ for (let i = col - 1; i >= 0; i--) {
1066
+ if (lineText[i] === char) {
1067
+ targetCol = findType === "F" ? i : i + 1;
1068
+ break;
1069
+ }
1070
+ }
1071
+ }
1072
+
1073
+ if (targetCol >= 0 && targetCol !== col) {
1074
+ // Move to target column
1075
+ const diff = targetCol - col;
1076
+ const moveAction = diff > 0 ? "move_right" : "move_left";
1077
+ const steps = Math.abs(diff);
1078
+ for (let i = 0; i < steps; i++) {
1079
+ editor.executeAction(moveAction);
1080
+ }
1081
+ // Save for ; and , repeat
1082
+ state.lastFindChar = { type: findType, char };
1083
+ }
1084
+ }
1085
+
1086
+ // Handler for when a character is typed in find-char mode (async)
1087
+ globalThis.vi_find_char_handler = async function (char: string): Promise<void> {
1088
+ if (state.pendingFindChar) {
1089
+ await executeFindChar(state.pendingFindChar, char);
1090
+ }
1091
+ // Return to normal mode
1092
+ state.pendingFindChar = null;
1093
+ switchMode("normal");
1094
+ };
1095
+
1096
+ // Commands to enter find-char mode
1097
+ globalThis.vi_find_char_f = function (): void {
1098
+ enterFindCharMode("f");
1099
+ };
1100
+
1101
+ globalThis.vi_find_char_t = function (): void {
1102
+ enterFindCharMode("t");
1103
+ };
1104
+
1105
+ globalThis.vi_find_char_F = function (): void {
1106
+ enterFindCharMode("F");
1107
+ };
1108
+
1109
+ globalThis.vi_find_char_T = function (): void {
1110
+ enterFindCharMode("T");
1111
+ };
1112
+
1113
+ // Repeat last find char (async)
1114
+ globalThis.vi_find_char_repeat = async function (): Promise<void> {
1115
+ if (state.lastFindChar) {
1116
+ await executeFindChar(state.lastFindChar.type, state.lastFindChar.char);
1117
+ }
1118
+ };
1119
+
1120
+ // Repeat last find char in opposite direction (async)
1121
+ globalThis.vi_find_char_repeat_reverse = async function (): Promise<void> {
1122
+ if (state.lastFindChar) {
1123
+ const reversedType: FindCharType =
1124
+ state.lastFindChar.type === "f" ? "F" :
1125
+ state.lastFindChar.type === "F" ? "f" :
1126
+ state.lastFindChar.type === "t" ? "T" : "t";
1127
+ await executeFindChar(reversedType, state.lastFindChar.char);
1128
+ }
1129
+ };
1130
+
1131
+ // Cancel find-char mode
1132
+ globalThis.vi_find_char_cancel = function (): void {
1133
+ state.pendingFindChar = null;
1134
+ switchMode("normal");
1135
+ };
1136
+
1137
+ // ============================================================================
1138
+ // Operator-Pending Mode Commands
1139
+ // ============================================================================
1140
+
1141
+ globalThis.vi_op_left = function (): void {
1142
+ handleMotionWithOperator("move_left");
1143
+ };
1144
+
1145
+ globalThis.vi_op_down = function (): void {
1146
+ handleMotionWithOperator("move_down");
1147
+ };
1148
+
1149
+ globalThis.vi_op_up = function (): void {
1150
+ handleMotionWithOperator("move_up");
1151
+ };
1152
+
1153
+ globalThis.vi_op_right = function (): void {
1154
+ handleMotionWithOperator("move_right");
1155
+ };
1156
+
1157
+ globalThis.vi_op_word = function (): void {
1158
+ handleMotionWithOperator("move_word_right");
1159
+ };
1160
+
1161
+ globalThis.vi_op_word_back = function (): void {
1162
+ handleMotionWithOperator("move_word_left");
1163
+ };
1164
+
1165
+ globalThis.vi_op_line_start = function (): void {
1166
+ handleMotionWithOperator("move_line_start");
1167
+ };
1168
+
1169
+ globalThis.vi_op_line_end = function (): void {
1170
+ handleMotionWithOperator("move_line_end");
1171
+ };
1172
+
1173
+ globalThis.vi_op_doc_start = function (): void {
1174
+ handleMotionWithOperator("move_document_start");
1175
+ };
1176
+
1177
+ globalThis.vi_op_doc_end = function (): void {
1178
+ handleMotionWithOperator("move_document_end");
1179
+ };
1180
+
1181
+ globalThis.vi_op_matching_bracket = function (): void {
1182
+ handleMotionWithOperator("go_to_matching_bracket");
1183
+ };
1184
+
1185
+ globalThis.vi_cancel = function (): void {
1186
+ switchMode("normal");
1187
+ };
1188
+
1189
+ // ============================================================================
1190
+ // Mode Definitions
1191
+ // ============================================================================
1192
+
1193
+ // Define vi-normal mode
1194
+ editor.defineMode("vi-normal", null, [
1195
+ // Count prefix (digits 1-9 start count, 0 is special)
1196
+ ["1", "vi_digit_1"],
1197
+ ["2", "vi_digit_2"],
1198
+ ["3", "vi_digit_3"],
1199
+ ["4", "vi_digit_4"],
1200
+ ["5", "vi_digit_5"],
1201
+ ["6", "vi_digit_6"],
1202
+ ["7", "vi_digit_7"],
1203
+ ["8", "vi_digit_8"],
1204
+ ["9", "vi_digit_9"],
1205
+ ["0", "vi_digit_0_or_line_start"], // 0 appends to count, or moves to line start
1206
+
1207
+ // Navigation
1208
+ ["h", "vi_left"],
1209
+ ["j", "vi_down"],
1210
+ ["k", "vi_up"],
1211
+ ["l", "vi_right"],
1212
+ ["w", "vi_word"],
1213
+ ["b", "vi_word_back"],
1214
+ ["e", "vi_word_end"],
1215
+ ["$", "vi_line_end"],
1216
+ ["^", "vi_first_non_blank"],
1217
+ ["g g", "vi_doc_start"],
1218
+ ["G", "vi_doc_end"],
1219
+ ["C-f", "vi_page_down"],
1220
+ ["C-b", "vi_page_up"],
1221
+ ["C-d", "vi_half_page_down"],
1222
+ ["C-u", "vi_half_page_up"],
1223
+ ["%", "vi_matching_bracket"],
1224
+ ["z z", "vi_center_cursor"],
1225
+
1226
+ // Search
1227
+ ["/", "vi_search_forward"],
1228
+ ["?", "vi_search_backward"],
1229
+ ["n", "vi_find_next"],
1230
+ ["N", "vi_find_prev"],
1231
+
1232
+ // Find character on line
1233
+ ["f", "vi_find_char_f"],
1234
+ ["t", "vi_find_char_t"],
1235
+ ["F", "vi_find_char_F"],
1236
+ ["T", "vi_find_char_T"],
1237
+ [";", "vi_find_char_repeat"],
1238
+ [",", "vi_find_char_repeat_reverse"],
1239
+
1240
+ // Mode switching
1241
+ ["i", "vi_insert_before"],
1242
+ ["a", "vi_insert_after"],
1243
+ ["I", "vi_insert_line_start"],
1244
+ ["A", "vi_insert_line_end"],
1245
+ ["o", "vi_open_below"],
1246
+ ["O", "vi_open_above"],
1247
+ ["Escape", "vi_escape"],
1248
+
1249
+ // Operators (single key - switches to operator-pending mode)
1250
+ // The second d/c/y is handled in operator-pending mode
1251
+ ["d", "vi_delete_operator"],
1252
+ ["c", "vi_change_operator"],
1253
+ ["y", "vi_yank_operator"],
1254
+
1255
+ // Single char operations
1256
+ ["x", "vi_delete_char"],
1257
+ ["X", "vi_delete_char_before"],
1258
+ ["r", "vi_replace_char"],
1259
+ ["s", "vi_substitute"],
1260
+ ["S", "vi_change_line"],
1261
+ ["D", "vi_delete_to_end"],
1262
+ ["C", "vi_change_to_end"],
1263
+
1264
+ // Clipboard
1265
+ ["p", "vi_paste_after"],
1266
+ ["P", "vi_paste_before"],
1267
+
1268
+ // Undo/Redo
1269
+ ["u", "vi_undo"],
1270
+ ["C-r", "vi_redo"],
1271
+
1272
+ // Visual mode
1273
+ ["v", "vi_visual_char"],
1274
+ ["V", "vi_visual_line"],
1275
+
1276
+ // Other
1277
+ ["J", "vi_join"],
1278
+ ], true); // read_only = true to prevent character insertion
1279
+
1280
+ // Define vi-insert mode - only Escape is special, other keys insert text
1281
+ editor.defineMode("vi-insert", null, [
1282
+ ["Escape", "vi_escape"],
1283
+ ], false); // read_only = false to allow normal typing
1284
+
1285
+ // Define vi-find-char mode - binds all printable chars to the handler
1286
+ // This mode waits for a single character input for f/t/F/T motions
1287
+
1288
+ // Explicitly define handlers for each character to ensure they're accessible
1289
+ // These return Promises so the runtime can await them
1290
+ globalThis.vi_fc_a = async function(): Promise<void> { return globalThis.vi_find_char_handler("a"); };
1291
+ globalThis.vi_fc_b = async function(): Promise<void> { return globalThis.vi_find_char_handler("b"); };
1292
+ globalThis.vi_fc_c = async function(): Promise<void> { return globalThis.vi_find_char_handler("c"); };
1293
+ globalThis.vi_fc_d = async function(): Promise<void> { return globalThis.vi_find_char_handler("d"); };
1294
+ globalThis.vi_fc_e = async function(): Promise<void> { return globalThis.vi_find_char_handler("e"); };
1295
+ globalThis.vi_fc_f = async function(): Promise<void> { return globalThis.vi_find_char_handler("f"); };
1296
+ globalThis.vi_fc_g = async function(): Promise<void> { return globalThis.vi_find_char_handler("g"); };
1297
+ globalThis.vi_fc_h = async function(): Promise<void> { return globalThis.vi_find_char_handler("h"); };
1298
+ globalThis.vi_fc_i = async function(): Promise<void> { return globalThis.vi_find_char_handler("i"); };
1299
+ globalThis.vi_fc_j = async function(): Promise<void> { return globalThis.vi_find_char_handler("j"); };
1300
+ globalThis.vi_fc_k = async function(): Promise<void> { return globalThis.vi_find_char_handler("k"); };
1301
+ globalThis.vi_fc_l = async function(): Promise<void> { return globalThis.vi_find_char_handler("l"); };
1302
+ globalThis.vi_fc_m = async function(): Promise<void> { return globalThis.vi_find_char_handler("m"); };
1303
+ globalThis.vi_fc_n = async function(): Promise<void> { return globalThis.vi_find_char_handler("n"); };
1304
+ globalThis.vi_fc_o = async function(): Promise<void> { return globalThis.vi_find_char_handler("o"); };
1305
+ globalThis.vi_fc_p = async function(): Promise<void> { return globalThis.vi_find_char_handler("p"); };
1306
+ globalThis.vi_fc_q = async function(): Promise<void> { return globalThis.vi_find_char_handler("q"); };
1307
+ globalThis.vi_fc_r = async function(): Promise<void> { return globalThis.vi_find_char_handler("r"); };
1308
+ globalThis.vi_fc_s = async function(): Promise<void> { return globalThis.vi_find_char_handler("s"); };
1309
+ globalThis.vi_fc_t = async function(): Promise<void> { return globalThis.vi_find_char_handler("t"); };
1310
+ globalThis.vi_fc_u = async function(): Promise<void> { return globalThis.vi_find_char_handler("u"); };
1311
+ globalThis.vi_fc_v = async function(): Promise<void> { return globalThis.vi_find_char_handler("v"); };
1312
+ globalThis.vi_fc_w = async function(): Promise<void> { return globalThis.vi_find_char_handler("w"); };
1313
+ globalThis.vi_fc_x = async function(): Promise<void> { return globalThis.vi_find_char_handler("x"); };
1314
+ globalThis.vi_fc_y = async function(): Promise<void> { return globalThis.vi_find_char_handler("y"); };
1315
+ globalThis.vi_fc_z = async function(): Promise<void> { return globalThis.vi_find_char_handler("z"); };
1316
+ globalThis.vi_fc_A = async function(): Promise<void> { return globalThis.vi_find_char_handler("A"); };
1317
+ globalThis.vi_fc_B = async function(): Promise<void> { return globalThis.vi_find_char_handler("B"); };
1318
+ globalThis.vi_fc_C = async function(): Promise<void> { return globalThis.vi_find_char_handler("C"); };
1319
+ globalThis.vi_fc_D = async function(): Promise<void> { return globalThis.vi_find_char_handler("D"); };
1320
+ globalThis.vi_fc_E = async function(): Promise<void> { return globalThis.vi_find_char_handler("E"); };
1321
+ globalThis.vi_fc_F = async function(): Promise<void> { return globalThis.vi_find_char_handler("F"); };
1322
+ globalThis.vi_fc_G = async function(): Promise<void> { return globalThis.vi_find_char_handler("G"); };
1323
+ globalThis.vi_fc_H = async function(): Promise<void> { return globalThis.vi_find_char_handler("H"); };
1324
+ globalThis.vi_fc_I = async function(): Promise<void> { return globalThis.vi_find_char_handler("I"); };
1325
+ globalThis.vi_fc_J = async function(): Promise<void> { return globalThis.vi_find_char_handler("J"); };
1326
+ globalThis.vi_fc_K = async function(): Promise<void> { return globalThis.vi_find_char_handler("K"); };
1327
+ globalThis.vi_fc_L = async function(): Promise<void> { return globalThis.vi_find_char_handler("L"); };
1328
+ globalThis.vi_fc_M = async function(): Promise<void> { return globalThis.vi_find_char_handler("M"); };
1329
+ globalThis.vi_fc_N = async function(): Promise<void> { return globalThis.vi_find_char_handler("N"); };
1330
+ globalThis.vi_fc_O = async function(): Promise<void> { return globalThis.vi_find_char_handler("O"); };
1331
+ globalThis.vi_fc_P = async function(): Promise<void> { return globalThis.vi_find_char_handler("P"); };
1332
+ globalThis.vi_fc_Q = async function(): Promise<void> { return globalThis.vi_find_char_handler("Q"); };
1333
+ globalThis.vi_fc_R = async function(): Promise<void> { return globalThis.vi_find_char_handler("R"); };
1334
+ globalThis.vi_fc_S = async function(): Promise<void> { return globalThis.vi_find_char_handler("S"); };
1335
+ globalThis.vi_fc_T = async function(): Promise<void> { return globalThis.vi_find_char_handler("T"); };
1336
+ globalThis.vi_fc_U = async function(): Promise<void> { return globalThis.vi_find_char_handler("U"); };
1337
+ globalThis.vi_fc_V = async function(): Promise<void> { return globalThis.vi_find_char_handler("V"); };
1338
+ globalThis.vi_fc_W = async function(): Promise<void> { return globalThis.vi_find_char_handler("W"); };
1339
+ globalThis.vi_fc_X = async function(): Promise<void> { return globalThis.vi_find_char_handler("X"); };
1340
+ globalThis.vi_fc_Y = async function(): Promise<void> { return globalThis.vi_find_char_handler("Y"); };
1341
+ globalThis.vi_fc_Z = async function(): Promise<void> { return globalThis.vi_find_char_handler("Z"); };
1342
+ globalThis.vi_fc_0 = async function(): Promise<void> { return globalThis.vi_find_char_handler("0"); };
1343
+ globalThis.vi_fc_1 = async function(): Promise<void> { return globalThis.vi_find_char_handler("1"); };
1344
+ globalThis.vi_fc_2 = async function(): Promise<void> { return globalThis.vi_find_char_handler("2"); };
1345
+ globalThis.vi_fc_3 = async function(): Promise<void> { return globalThis.vi_find_char_handler("3"); };
1346
+ globalThis.vi_fc_4 = async function(): Promise<void> { return globalThis.vi_find_char_handler("4"); };
1347
+ globalThis.vi_fc_5 = async function(): Promise<void> { return globalThis.vi_find_char_handler("5"); };
1348
+ globalThis.vi_fc_6 = async function(): Promise<void> { return globalThis.vi_find_char_handler("6"); };
1349
+ globalThis.vi_fc_7 = async function(): Promise<void> { return globalThis.vi_find_char_handler("7"); };
1350
+ globalThis.vi_fc_8 = async function(): Promise<void> { return globalThis.vi_find_char_handler("8"); };
1351
+ globalThis.vi_fc_9 = async function(): Promise<void> { return globalThis.vi_find_char_handler("9"); };
1352
+ globalThis.vi_fc_space = async function(): Promise<void> { return globalThis.vi_find_char_handler(" "); };
1353
+
1354
+ // Define vi-find-char mode with all the character bindings
1355
+ editor.defineMode("vi-find-char", null, [
1356
+ ["Escape", "vi_find_char_cancel"],
1357
+ // Letters
1358
+ ["a", "vi_fc_a"], ["b", "vi_fc_b"], ["c", "vi_fc_c"], ["d", "vi_fc_d"],
1359
+ ["e", "vi_fc_e"], ["f", "vi_fc_f"], ["g", "vi_fc_g"], ["h", "vi_fc_h"],
1360
+ ["i", "vi_fc_i"], ["j", "vi_fc_j"], ["k", "vi_fc_k"], ["l", "vi_fc_l"],
1361
+ ["m", "vi_fc_m"], ["n", "vi_fc_n"], ["o", "vi_fc_o"], ["p", "vi_fc_p"],
1362
+ ["q", "vi_fc_q"], ["r", "vi_fc_r"], ["s", "vi_fc_s"], ["t", "vi_fc_t"],
1363
+ ["u", "vi_fc_u"], ["v", "vi_fc_v"], ["w", "vi_fc_w"], ["x", "vi_fc_x"],
1364
+ ["y", "vi_fc_y"], ["z", "vi_fc_z"],
1365
+ ["A", "vi_fc_A"], ["B", "vi_fc_B"], ["C", "vi_fc_C"], ["D", "vi_fc_D"],
1366
+ ["E", "vi_fc_E"], ["F", "vi_fc_F"], ["G", "vi_fc_G"], ["H", "vi_fc_H"],
1367
+ ["I", "vi_fc_I"], ["J", "vi_fc_J"], ["K", "vi_fc_K"], ["L", "vi_fc_L"],
1368
+ ["M", "vi_fc_M"], ["N", "vi_fc_N"], ["O", "vi_fc_O"], ["P", "vi_fc_P"],
1369
+ ["Q", "vi_fc_Q"], ["R", "vi_fc_R"], ["S", "vi_fc_S"], ["T", "vi_fc_T"],
1370
+ ["U", "vi_fc_U"], ["V", "vi_fc_V"], ["W", "vi_fc_W"], ["X", "vi_fc_X"],
1371
+ ["Y", "vi_fc_Y"], ["Z", "vi_fc_Z"],
1372
+ // Digits
1373
+ ["0", "vi_fc_0"], ["1", "vi_fc_1"], ["2", "vi_fc_2"], ["3", "vi_fc_3"],
1374
+ ["4", "vi_fc_4"], ["5", "vi_fc_5"], ["6", "vi_fc_6"], ["7", "vi_fc_7"],
1375
+ ["8", "vi_fc_8"], ["9", "vi_fc_9"],
1376
+ // Common punctuation
1377
+ ["Space", "vi_fc_space"],
1378
+ ], true);
1379
+
1380
+ // Define vi-operator-pending mode
1381
+ editor.defineMode("vi-operator-pending", null, [
1382
+ // Count prefix in operator-pending mode (for d3w = delete 3 words)
1383
+ ["1", "vi_digit_1"],
1384
+ ["2", "vi_digit_2"],
1385
+ ["3", "vi_digit_3"],
1386
+ ["4", "vi_digit_4"],
1387
+ ["5", "vi_digit_5"],
1388
+ ["6", "vi_digit_6"],
1389
+ ["7", "vi_digit_7"],
1390
+ ["8", "vi_digit_8"],
1391
+ ["9", "vi_digit_9"],
1392
+ ["0", "vi_op_digit_0_or_line_start"], // 0 appends to count, or is motion to line start
1393
+
1394
+ // Motions for operators
1395
+ ["h", "vi_op_left"],
1396
+ ["j", "vi_op_down"],
1397
+ ["k", "vi_op_up"],
1398
+ ["l", "vi_op_right"],
1399
+ ["w", "vi_op_word"],
1400
+ ["b", "vi_op_word_back"],
1401
+ ["$", "vi_op_line_end"],
1402
+ ["g g", "vi_op_doc_start"],
1403
+ ["G", "vi_op_doc_end"],
1404
+ ["%", "vi_op_matching_bracket"],
1405
+
1406
+ // Text objects
1407
+ ["i", "vi_text_object_inner"],
1408
+ ["a", "vi_text_object_around"],
1409
+
1410
+ // Double operator = line operation
1411
+ ["d", "vi_delete_line"],
1412
+ ["c", "vi_change_line"],
1413
+ ["y", "vi_yank_line"],
1414
+
1415
+ // Cancel
1416
+ ["Escape", "vi_cancel"],
1417
+ ], true);
1418
+
1419
+ // Define vi-text-object mode (waiting for object type: w, ", (, etc.)
1420
+ editor.defineMode("vi-text-object", null, [
1421
+ // Word objects
1422
+ ["w", "vi_to_word"],
1423
+ ["W", "vi_to_WORD"],
1424
+
1425
+ // Quote objects
1426
+ ["\"", "vi_to_dquote"],
1427
+ ["'", "vi_to_squote"],
1428
+ ["`", "vi_to_backtick"],
1429
+
1430
+ // Bracket objects
1431
+ ["(", "vi_to_paren"],
1432
+ [")", "vi_to_paren"],
1433
+ ["b", "vi_to_paren"],
1434
+ ["{", "vi_to_brace"],
1435
+ ["}", "vi_to_brace"],
1436
+ ["B", "vi_to_brace"],
1437
+ ["[", "vi_to_bracket"],
1438
+ ["]", "vi_to_bracket"],
1439
+ ["<", "vi_to_angle"],
1440
+ [">", "vi_to_angle"],
1441
+
1442
+ // Cancel
1443
+ ["Escape", "vi_to_cancel"],
1444
+ ], true);
1445
+
1446
+ // Define vi-visual mode (character-wise)
1447
+ editor.defineMode("vi-visual", null, [
1448
+ // Count prefix
1449
+ ["1", "vi_digit_1"],
1450
+ ["2", "vi_digit_2"],
1451
+ ["3", "vi_digit_3"],
1452
+ ["4", "vi_digit_4"],
1453
+ ["5", "vi_digit_5"],
1454
+ ["6", "vi_digit_6"],
1455
+ ["7", "vi_digit_7"],
1456
+ ["8", "vi_digit_8"],
1457
+ ["9", "vi_digit_9"],
1458
+ ["0", "vi_vis_line_start"], // 0 moves to line start in visual mode
1459
+
1460
+ // Motions (extend selection)
1461
+ ["h", "vi_vis_left"],
1462
+ ["j", "vi_vis_down"],
1463
+ ["k", "vi_vis_up"],
1464
+ ["l", "vi_vis_right"],
1465
+ ["w", "vi_vis_word"],
1466
+ ["b", "vi_vis_word_back"],
1467
+ ["e", "vi_vis_word_end"],
1468
+ ["$", "vi_vis_line_end"],
1469
+ ["^", "vi_vis_line_start"],
1470
+ ["g g", "vi_vis_doc_start"],
1471
+ ["G", "vi_vis_doc_end"],
1472
+
1473
+ // Switch to line mode
1474
+ ["V", "vi_visual_toggle_line"],
1475
+
1476
+ // Operators
1477
+ ["d", "vi_vis_delete"],
1478
+ ["x", "vi_vis_delete"],
1479
+ ["c", "vi_vis_change"],
1480
+ ["s", "vi_vis_change"],
1481
+ ["y", "vi_vis_yank"],
1482
+
1483
+ // Exit
1484
+ ["Escape", "vi_vis_escape"],
1485
+ ["v", "vi_vis_escape"], // v again exits visual mode
1486
+ ], true);
1487
+
1488
+ // Define vi-visual-line mode (line-wise)
1489
+ editor.defineMode("vi-visual-line", null, [
1490
+ // Count prefix
1491
+ ["1", "vi_digit_1"],
1492
+ ["2", "vi_digit_2"],
1493
+ ["3", "vi_digit_3"],
1494
+ ["4", "vi_digit_4"],
1495
+ ["5", "vi_digit_5"],
1496
+ ["6", "vi_digit_6"],
1497
+ ["7", "vi_digit_7"],
1498
+ ["8", "vi_digit_8"],
1499
+ ["9", "vi_digit_9"],
1500
+
1501
+ // Line motions (extend selection by lines)
1502
+ ["j", "vi_vline_down"],
1503
+ ["k", "vi_vline_up"],
1504
+ ["g g", "vi_vis_doc_start"],
1505
+ ["G", "vi_vis_doc_end"],
1506
+
1507
+ // Switch to char mode
1508
+ ["v", "vi_visual_toggle_line"],
1509
+
1510
+ // Operators
1511
+ ["d", "vi_vis_delete"],
1512
+ ["x", "vi_vis_delete"],
1513
+ ["c", "vi_vis_change"],
1514
+ ["s", "vi_vis_change"],
1515
+ ["y", "vi_vis_yank"],
1516
+
1517
+ // Exit
1518
+ ["Escape", "vi_vis_escape"],
1519
+ ["V", "vi_vis_escape"], // V again exits visual-line mode
1520
+ ], true);
1521
+
1522
+ // ============================================================================
1523
+ // Register Commands
1524
+ // ============================================================================
1525
+
1526
+ // Navigation commands
1527
+ const navCommands = [
1528
+ ["vi_left", "Move left"],
1529
+ ["vi_down", "Move down"],
1530
+ ["vi_up", "Move up"],
1531
+ ["vi_right", "Move right"],
1532
+ ["vi_word", "Move to next word"],
1533
+ ["vi_word_back", "Move to previous word"],
1534
+ ["vi_word_end", "Move to end of word"],
1535
+ ["vi_line_start", "Move to line start"],
1536
+ ["vi_line_end", "Move to line end"],
1537
+ ["vi_doc_start", "Move to document start"],
1538
+ ["vi_doc_end", "Move to document end"],
1539
+ ["vi_page_down", "Page down"],
1540
+ ["vi_page_up", "Page up"],
1541
+ ["vi_half_page_down", "Half page down"],
1542
+ ["vi_half_page_up", "Half page up"],
1543
+ ["vi_center_cursor", "Center cursor on screen"],
1544
+ ["vi_search_forward", "Search forward"],
1545
+ ["vi_search_backward", "Search backward"],
1546
+ ["vi_find_next", "Find next match"],
1547
+ ["vi_find_prev", "Find previous match"],
1548
+ ["vi_find_char_f", "Find char forward"],
1549
+ ["vi_find_char_t", "Find till char forward"],
1550
+ ["vi_find_char_F", "Find char backward"],
1551
+ ["vi_find_char_T", "Find till char backward"],
1552
+ ["vi_find_char_repeat", "Repeat last find char"],
1553
+ ["vi_find_char_repeat_reverse", "Repeat last find char (reverse)"],
1554
+ ];
1555
+
1556
+ for (const [name, desc] of navCommands) {
1557
+ editor.registerCommand(name, `Vi: ${desc}`, name, "vi-normal");
1558
+ }
1559
+
1560
+ // Mode commands
1561
+ const modeCommands = [
1562
+ ["vi_insert_before", "Insert before cursor"],
1563
+ ["vi_insert_after", "Insert after cursor"],
1564
+ ["vi_insert_line_start", "Insert at line start"],
1565
+ ["vi_insert_line_end", "Insert at line end"],
1566
+ ["vi_open_below", "Open line below"],
1567
+ ["vi_open_above", "Open line above"],
1568
+ ["vi_escape", "Return to normal mode"],
1569
+ ];
1570
+
1571
+ for (const [name, desc] of modeCommands) {
1572
+ editor.registerCommand(name, `Vi: ${desc}`, name, "vi-normal");
1573
+ }
1574
+
1575
+ // Operator commands
1576
+ const opCommands = [
1577
+ ["vi_delete_operator", "Delete operator"],
1578
+ ["vi_change_operator", "Change operator"],
1579
+ ["vi_yank_operator", "Yank operator"],
1580
+ ["vi_delete_line", "Delete line"],
1581
+ ["vi_change_line", "Change line"],
1582
+ ["vi_yank_line", "Yank line"],
1583
+ ["vi_delete_char", "Delete character"],
1584
+ ["vi_delete_char_before", "Delete char before cursor"],
1585
+ ["vi_substitute", "Substitute character"],
1586
+ ["vi_delete_to_end", "Delete to end of line"],
1587
+ ["vi_change_to_end", "Change to end of line"],
1588
+ ["vi_paste_after", "Paste after"],
1589
+ ["vi_paste_before", "Paste before"],
1590
+ ["vi_undo", "Undo"],
1591
+ ["vi_redo", "Redo"],
1592
+ ["vi_join", "Join lines"],
1593
+ ];
1594
+
1595
+ for (const [name, desc] of opCommands) {
1596
+ editor.registerCommand(name, `Vi: ${desc}`, name, "vi-normal");
1597
+ }
1598
+
1599
+ // ============================================================================
1600
+ // Toggle Command
1601
+ // ============================================================================
1602
+
1603
+ let viModeEnabled = false;
1604
+
1605
+ globalThis.vi_mode_toggle = function (): void {
1606
+ viModeEnabled = !viModeEnabled;
1607
+
1608
+ if (viModeEnabled) {
1609
+ switchMode("normal");
1610
+ editor.setStatus("Vi mode enabled - NORMAL");
1611
+ } else {
1612
+ editor.setEditorMode(null);
1613
+ state.mode = "normal";
1614
+ state.pendingOperator = null;
1615
+ editor.setStatus("Vi mode disabled");
1616
+ }
1617
+ };
1618
+
1619
+ editor.registerCommand(
1620
+ "Toggle Vi mode",
1621
+ "Enable or disable vi-style modal editing",
1622
+ "vi_mode_toggle",
1623
+ "normal",
1624
+ );
1625
+
1626
+ // ============================================================================
1627
+ // Initialization
1628
+ // ============================================================================
1629
+
1630
+ editor.setStatus("Vi mode plugin loaded. Use 'Toggle Vi mode' command to enable.");