@involvex/fresh-editor 0.1.76 → 0.1.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/bin/CHANGELOG.md +1017 -0
  2. package/bin/LICENSE +117 -0
  3. package/bin/README.md +248 -0
  4. package/bin/fresh.exe +0 -0
  5. package/bin/plugins/README.md +71 -0
  6. package/bin/plugins/audit_mode.i18n.json +821 -0
  7. package/bin/plugins/audit_mode.ts +1810 -0
  8. package/bin/plugins/buffer_modified.i18n.json +67 -0
  9. package/bin/plugins/buffer_modified.ts +281 -0
  10. package/bin/plugins/calculator.i18n.json +93 -0
  11. package/bin/plugins/calculator.ts +770 -0
  12. package/bin/plugins/clangd-lsp.ts +168 -0
  13. package/bin/plugins/clangd_support.i18n.json +223 -0
  14. package/bin/plugins/clangd_support.md +20 -0
  15. package/bin/plugins/clangd_support.ts +325 -0
  16. package/bin/plugins/color_highlighter.i18n.json +145 -0
  17. package/bin/plugins/color_highlighter.ts +304 -0
  18. package/bin/plugins/config-schema.json +768 -0
  19. package/bin/plugins/csharp-lsp.ts +147 -0
  20. package/bin/plugins/csharp_support.i18n.json +80 -0
  21. package/bin/plugins/csharp_support.ts +170 -0
  22. package/bin/plugins/css-lsp.ts +143 -0
  23. package/bin/plugins/diagnostics_panel.i18n.json +236 -0
  24. package/bin/plugins/diagnostics_panel.ts +642 -0
  25. package/bin/plugins/examples/README.md +85 -0
  26. package/bin/plugins/examples/async_demo.ts +165 -0
  27. package/bin/plugins/examples/bookmarks.ts +329 -0
  28. package/bin/plugins/examples/buffer_query_demo.ts +110 -0
  29. package/bin/plugins/examples/git_grep.ts +262 -0
  30. package/bin/plugins/examples/hello_world.ts +93 -0
  31. package/bin/plugins/examples/virtual_buffer_demo.ts +116 -0
  32. package/bin/plugins/find_references.i18n.json +275 -0
  33. package/bin/plugins/find_references.ts +359 -0
  34. package/bin/plugins/git_blame.i18n.json +496 -0
  35. package/bin/plugins/git_blame.ts +707 -0
  36. package/bin/plugins/git_find_file.i18n.json +314 -0
  37. package/bin/plugins/git_find_file.ts +300 -0
  38. package/bin/plugins/git_grep.i18n.json +171 -0
  39. package/bin/plugins/git_grep.ts +191 -0
  40. package/bin/plugins/git_gutter.i18n.json +93 -0
  41. package/bin/plugins/git_gutter.ts +477 -0
  42. package/bin/plugins/git_log.i18n.json +481 -0
  43. package/bin/plugins/git_log.ts +1285 -0
  44. package/bin/plugins/go-lsp.ts +143 -0
  45. package/bin/plugins/html-lsp.ts +145 -0
  46. package/bin/plugins/json-lsp.ts +145 -0
  47. package/bin/plugins/lib/fresh.d.ts +1321 -0
  48. package/bin/plugins/lib/index.ts +24 -0
  49. package/bin/plugins/lib/navigation-controller.ts +214 -0
  50. package/bin/plugins/lib/panel-manager.ts +220 -0
  51. package/bin/plugins/lib/types.ts +72 -0
  52. package/bin/plugins/lib/virtual-buffer-factory.ts +130 -0
  53. package/bin/plugins/live_grep.i18n.json +171 -0
  54. package/bin/plugins/live_grep.ts +422 -0
  55. package/bin/plugins/markdown_compose.i18n.json +223 -0
  56. package/bin/plugins/markdown_compose.ts +630 -0
  57. package/bin/plugins/merge_conflict.i18n.json +821 -0
  58. package/bin/plugins/merge_conflict.ts +1810 -0
  59. package/bin/plugins/path_complete.i18n.json +80 -0
  60. package/bin/plugins/path_complete.ts +165 -0
  61. package/bin/plugins/python-lsp.ts +162 -0
  62. package/bin/plugins/rust-lsp.ts +166 -0
  63. package/bin/plugins/search_replace.i18n.json +405 -0
  64. package/bin/plugins/search_replace.ts +484 -0
  65. package/bin/plugins/test_i18n.i18n.json +67 -0
  66. package/bin/plugins/test_i18n.ts +18 -0
  67. package/bin/plugins/theme_editor.i18n.json +3746 -0
  68. package/bin/plugins/theme_editor.ts +2063 -0
  69. package/bin/plugins/todo_highlighter.i18n.json +184 -0
  70. package/bin/plugins/todo_highlighter.ts +206 -0
  71. package/bin/plugins/typescript-lsp.ts +167 -0
  72. package/bin/plugins/vi_mode.i18n.json +1549 -0
  73. package/bin/plugins/vi_mode.ts +2747 -0
  74. package/bin/plugins/welcome.i18n.json +236 -0
  75. package/bin/plugins/welcome.ts +76 -0
  76. package/bin/themes/dark.json +102 -0
  77. package/bin/themes/dracula.json +62 -0
  78. package/bin/themes/high-contrast.json +102 -0
  79. package/bin/themes/light.json +102 -0
  80. package/bin/themes/nord.json +62 -0
  81. package/bin/themes/nostalgia.json +102 -0
  82. package/bin/themes/solarized-dark.json +62 -0
  83. package/binary-install.js +1 -1
  84. package/dist/bin/fresh.js +9 -0
  85. package/dist/binary-install.js +149 -0
  86. package/dist/binary.js +30 -0
  87. package/dist/fresh-6yhknp07.exe +0 -0
  88. package/dist/install.js +158 -0
  89. package/dist/run-fresh.js +43 -0
  90. package/package.json +7 -2
@@ -0,0 +1,2747 @@
1
+ /// <reference path="./lib/fresh.d.ts" />
2
+ const editor = getEditor();
3
+
4
+
5
+ /**
6
+ * Vi Mode Plugin for Fresh Editor
7
+ *
8
+ * Implements vi-style modal editing with:
9
+ * - Normal mode: navigation and commands
10
+ * - Insert mode: text input
11
+ * - Operator-pending mode: composable operators with motions
12
+ *
13
+ * Uses the plugin API's executeAction() for true operator+motion composability:
14
+ * any operator works with any motion via O(operators + motions) code.
15
+ */
16
+
17
+ // Vi mode state
18
+ type ViMode = "normal" | "insert" | "operator-pending" | "find-char" | "visual" | "visual-line" | "visual-block" | "text-object";
19
+ type FindCharType = "f" | "t" | "F" | "T" | null;
20
+ type TextObjectType = "inner" | "around" | null;
21
+
22
+ // Types for tracking repeatable changes
23
+ type ChangeType = "simple" | "operator-motion" | "operator-textobj" | "insert" | "line-op";
24
+
25
+ interface LastChange {
26
+ type: ChangeType;
27
+ action?: string; // For simple actions like "delete_forward", "delete_line"
28
+ operator?: string; // For operator+motion/textobj: "d", "c", "y"
29
+ motion?: string; // For operator+motion: the motion action
30
+ textObject?: { modifier: TextObjectType; object: string }; // For operator+textobj
31
+ count?: number; // Count used with the command
32
+ insertedText?: string; // Text inserted during insert mode
33
+ }
34
+
35
+ interface ViState {
36
+ mode: ViMode;
37
+ pendingOperator: string | null;
38
+ pendingFindChar: FindCharType; // For f/t/F/T motions
39
+ pendingTextObject: TextObjectType; // For i/a text objects
40
+ lastFindChar: { type: FindCharType; char: string } | null; // For ; and , repeat
41
+ count: number | null;
42
+ lastChange: LastChange | null; // For '.' repeat
43
+ lastYankWasLinewise: boolean; // Track if last yank was line-wise for proper paste
44
+ visualAnchor: number | null; // Starting position for visual mode selection
45
+ insertStartPos: number | null; // Cursor position when entering insert mode
46
+ visualBlockAnchor: { line: number; col: number } | null; // For visual block mode
47
+ }
48
+
49
+ const state: ViState = {
50
+ mode: "normal",
51
+ pendingOperator: null,
52
+ pendingFindChar: null,
53
+ pendingTextObject: null,
54
+ lastFindChar: null,
55
+ count: null,
56
+ lastChange: null,
57
+ lastYankWasLinewise: false,
58
+ visualAnchor: null,
59
+ insertStartPos: null,
60
+ visualBlockAnchor: null,
61
+ };
62
+
63
+ // Mode indicator for status bar
64
+ function getModeIndicator(mode: ViMode): string {
65
+ const countPrefix = state.count !== null ? `${state.count} ` : "";
66
+ switch (mode) {
67
+ case "normal":
68
+ return `-- ${editor.t("mode.normal")} --${countPrefix ? ` (${state.count})` : ""}`;
69
+ case "insert":
70
+ return `-- ${editor.t("mode.insert")} --`;
71
+ case "operator-pending":
72
+ return `-- ${editor.t("mode.operator")} (${state.pendingOperator}) --${countPrefix ? ` (${state.count})` : ""}`;
73
+ case "find-char":
74
+ return `-- ${editor.t("mode.find")} (${state.pendingFindChar}) --`;
75
+ case "visual":
76
+ return `-- ${editor.t("mode.visual")} --${countPrefix ? ` (${state.count})` : ""}`;
77
+ case "visual-line":
78
+ return `-- ${editor.t("mode.visual_line")} --${countPrefix ? ` (${state.count})` : ""}`;
79
+ case "visual-block":
80
+ return `-- ${editor.t("mode.visual_block")} --${countPrefix ? ` (${state.count})` : ""}`;
81
+ case "text-object":
82
+ return `-- ${state.pendingOperator}${state.pendingTextObject === "inner" ? "i" : "a"}? --`;
83
+ default:
84
+ return "";
85
+ }
86
+ }
87
+
88
+ // Switch between modes
89
+ function switchMode(newMode: ViMode): void {
90
+ const oldMode = state.mode;
91
+ state.mode = newMode;
92
+
93
+ // Only clear pendingOperator when leaving operator-pending and text-object modes
94
+ if (newMode !== "operator-pending" && newMode !== "text-object") {
95
+ state.pendingOperator = null;
96
+ }
97
+
98
+ // Clear text object type when leaving text-object mode
99
+ if (newMode !== "text-object") {
100
+ state.pendingTextObject = null;
101
+ }
102
+
103
+ // Preserve count when entering operator-pending or text-object mode (for 3dw = delete 3 words)
104
+ // Also preserve count in visual modes
105
+ if (newMode !== "operator-pending" && newMode !== "text-object" &&
106
+ newMode !== "visual" && newMode !== "visual-line" && newMode !== "visual-block") {
107
+ state.count = null;
108
+ }
109
+
110
+ // Clear visual anchor when leaving visual modes
111
+ if (newMode !== "visual" && newMode !== "visual-line" && newMode !== "visual-block") {
112
+ state.visualAnchor = null;
113
+ state.visualBlockAnchor = null;
114
+ // Clear any selection when leaving visual mode by moving cursor
115
+ // (any non-select movement clears selection in Fresh)
116
+ if (oldMode === "visual" || oldMode === "visual-line" || oldMode === "visual-block") {
117
+ editor.executeAction("move_left");
118
+ editor.executeAction("move_right");
119
+ }
120
+ }
121
+
122
+ // Track insert mode start position for '.' repeat
123
+ if (newMode === "insert" && oldMode !== "insert") {
124
+ state.insertStartPos = editor.getCursorPosition();
125
+ }
126
+
127
+ // Capture inserted text when leaving insert mode (for '.' repeat)
128
+ if (oldMode === "insert" && newMode !== "insert" && state.insertStartPos !== null) {
129
+ captureInsertedText();
130
+ }
131
+
132
+ // All modes use vi-{mode} naming, including insert mode
133
+ // vi-insert has read_only=false so normal typing works, but Escape is bound
134
+ editor.setEditorMode(`vi-${newMode}`);
135
+ editor.setStatus(getModeIndicator(newMode));
136
+ }
137
+
138
+ // Capture text inserted during insert mode for '.' repeat
139
+ async function captureInsertedText(): Promise<void> {
140
+ if (state.insertStartPos === null) return;
141
+
142
+ const endPos = editor.getCursorPosition();
143
+ if (endPos === null || endPos <= state.insertStartPos) {
144
+ state.insertStartPos = null;
145
+ return;
146
+ }
147
+
148
+ const bufferId = editor.getActiveBufferId();
149
+ const text = await editor.getBufferText(bufferId, state.insertStartPos, endPos);
150
+
151
+ if (text && text.length > 0) {
152
+ // Only record if we have a pending insert change or if there was actual text inserted
153
+ if (state.lastChange?.type === "insert" || !state.lastChange) {
154
+ state.lastChange = {
155
+ type: "insert",
156
+ insertedText: text,
157
+ };
158
+ } else if (state.lastChange.type === "simple" || state.lastChange.type === "operator-motion" ||
159
+ state.lastChange.type === "operator-textobj" || state.lastChange.type === "line-op") {
160
+ // A change command (c, s, etc.) was used - append the inserted text
161
+ state.lastChange.insertedText = text;
162
+ }
163
+ }
164
+
165
+ state.insertStartPos = null;
166
+ }
167
+
168
+ // Get the current count (defaults to 1 if no count specified)
169
+ // Does NOT clear the count - that's done in switchMode or explicitly
170
+ function getCount(): number {
171
+ return state.count ?? 1;
172
+ }
173
+
174
+ // Consume the current count and clear it
175
+ // Returns the count (defaults to 1)
176
+ function consumeCount(): number {
177
+ const count = state.count ?? 1;
178
+ state.count = null;
179
+ return count;
180
+ }
181
+
182
+ // Accumulate a digit into the count
183
+ function accumulateCount(digit: number): void {
184
+ if (state.count === null) {
185
+ state.count = digit;
186
+ } else {
187
+ state.count = state.count * 10 + digit;
188
+ }
189
+ // Update status to show accumulated count
190
+ editor.setStatus(getModeIndicator(state.mode));
191
+ }
192
+
193
+ // Execute a single action with count (uses new executeActions API for efficiency)
194
+ function executeWithCount(action: string, count?: number): void {
195
+ const n = count ?? consumeCount();
196
+ if (n === 1) {
197
+ editor.executeAction(action);
198
+ } else {
199
+ editor.executeActions([{ action, count: n }]);
200
+ }
201
+ }
202
+
203
+ // Map motion actions to their selection equivalents
204
+ const motionToSelection: Record<string, string> = {
205
+ move_left: "select_left",
206
+ move_right: "select_right",
207
+ move_up: "select_up",
208
+ move_down: "select_down",
209
+ move_word_left: "select_word_left",
210
+ move_word_right: "select_word_right",
211
+ move_line_start: "select_line_start",
212
+ move_line_end: "select_line_end",
213
+ move_document_start: "select_document_start",
214
+ move_document_end: "select_document_end",
215
+ };
216
+
217
+ // Map (operator, motion) pairs to atomic Rust actions
218
+ // These are single actions that combine the operator and motion atomically
219
+ // This avoids async issues with selection-based approach
220
+ type OperatorMotionMap = Record<string, Record<string, string>>;
221
+ const atomicOperatorActions: OperatorMotionMap = {
222
+ d: {
223
+ // Delete operators
224
+ move_word_right: "delete_word_forward",
225
+ move_word_left: "delete_word_backward",
226
+ move_line_end: "delete_to_line_end",
227
+ move_line_start: "delete_to_line_start",
228
+ },
229
+ y: {
230
+ // Yank operators
231
+ move_word_right: "yank_word_forward",
232
+ move_word_left: "yank_word_backward",
233
+ move_line_end: "yank_to_line_end",
234
+ move_line_start: "yank_to_line_start",
235
+ },
236
+ };
237
+
238
+ // Apply an operator using atomic actions if available, otherwise selection-based approach
239
+ // The count parameter specifies how many times to apply the motion (e.g., d3w = delete 3 words)
240
+ function applyOperatorWithMotion(operator: string, motionAction: string, count: number = 1): void {
241
+ // Record last change for '.' repeat (only for delete and change, not yank)
242
+ if (operator === "d" || operator === "c") {
243
+ state.lastChange = { type: "operator-motion", operator, motion: motionAction, count };
244
+ }
245
+
246
+ // For "change" operator, use delete action and then enter insert mode
247
+ const lookupOperator = operator === "c" ? "d" : operator;
248
+
249
+ // Check if we have an atomic action for this operator+motion combination
250
+ const operatorActions = atomicOperatorActions[lookupOperator];
251
+ const atomicAction = operatorActions?.[motionAction];
252
+
253
+ if (atomicAction) {
254
+ // Use the atomic action - single command, no async issues
255
+ // Apply count times for 3dw, etc.
256
+ if (count === 1) {
257
+ editor.executeAction(atomicAction);
258
+ } else {
259
+ editor.executeActions([{ action: atomicAction, count }]);
260
+ }
261
+ if (operator === "y") {
262
+ state.lastYankWasLinewise = false;
263
+ }
264
+ if (operator === "c") {
265
+ switchMode("insert");
266
+ return;
267
+ }
268
+ switchMode("normal");
269
+ return;
270
+ }
271
+
272
+ // Fall back to selection-based approach for motions without atomic actions
273
+ const selectAction = motionToSelection[motionAction];
274
+ if (!selectAction) {
275
+ editor.debug(`No selection equivalent for motion: ${motionAction}`);
276
+ switchMode("normal");
277
+ return;
278
+ }
279
+
280
+ // Execute the selection action count times (synchronous - extends selection to target)
281
+ if (count === 1) {
282
+ editor.executeAction(selectAction);
283
+ } else {
284
+ editor.executeActions([{ action: selectAction, count }]);
285
+ }
286
+
287
+ switch (operator) {
288
+ case "d": // delete
289
+ editor.executeAction("cut"); // Cut removes selection
290
+ break;
291
+ case "c": // change (delete and enter insert mode)
292
+ editor.executeAction("cut");
293
+ switchMode("insert");
294
+ return; // Don't switch back to normal mode
295
+ case "y": // yank
296
+ state.lastYankWasLinewise = false; // Motion-based yank is character-wise
297
+ editor.executeAction("copy");
298
+ // Move cursor back to start of selection (left side)
299
+ editor.executeAction("move_left");
300
+ break;
301
+ }
302
+
303
+ switchMode("normal");
304
+ }
305
+
306
+ // Handle motion in operator-pending mode
307
+ // Consumes any pending count and applies it to the motion
308
+ function handleMotionWithOperator(motionAction: string): void {
309
+ if (!state.pendingOperator) {
310
+ switchMode("normal");
311
+ return;
312
+ }
313
+
314
+ const count = consumeCount();
315
+ applyOperatorWithMotion(state.pendingOperator, motionAction, count);
316
+ }
317
+
318
+ // ============================================================================
319
+ // Normal Mode Commands
320
+ // ============================================================================
321
+
322
+ // Navigation (all support count prefix, e.g., 5j moves down 5 lines)
323
+ globalThis.vi_left = function (): void {
324
+ executeWithCount("move_left");
325
+ };
326
+
327
+ globalThis.vi_down = function (): void {
328
+ executeWithCount("move_down");
329
+ };
330
+
331
+ globalThis.vi_up = function (): void {
332
+ executeWithCount("move_up");
333
+ };
334
+
335
+ globalThis.vi_right = function (): void {
336
+ executeWithCount("move_right");
337
+ };
338
+
339
+ globalThis.vi_word = function (): void {
340
+ executeWithCount("move_word_right");
341
+ };
342
+
343
+ globalThis.vi_word_back = function (): void {
344
+ executeWithCount("move_word_left");
345
+ };
346
+
347
+ globalThis.vi_word_end = function (): void {
348
+ // Move to end of word - for count, repeat the whole operation
349
+ const count = consumeCount();
350
+ for (let i = 0; i < count; i++) {
351
+ editor.executeAction("move_word_right");
352
+ editor.executeAction("move_left");
353
+ }
354
+ };
355
+
356
+ globalThis.vi_line_start = function (): void {
357
+ consumeCount(); // Count doesn't apply to line start
358
+ editor.executeAction("move_line_start");
359
+ };
360
+
361
+ globalThis.vi_line_end = function (): void {
362
+ consumeCount(); // Count doesn't apply to line end
363
+ editor.executeAction("move_line_end");
364
+ };
365
+
366
+ globalThis.vi_first_non_blank = function (): void {
367
+ consumeCount(); // Count doesn't apply
368
+ editor.executeAction("move_line_start");
369
+ // TODO: skip whitespace
370
+ };
371
+
372
+ globalThis.vi_doc_start = function (): void {
373
+ consumeCount(); // Count doesn't apply
374
+ editor.executeAction("move_document_start");
375
+ };
376
+
377
+ globalThis.vi_doc_end = function (): void {
378
+ consumeCount(); // Count doesn't apply
379
+ editor.executeAction("move_document_end");
380
+ };
381
+
382
+ globalThis.vi_page_down = function (): void {
383
+ executeWithCount("page_down");
384
+ };
385
+
386
+ globalThis.vi_page_up = function (): void {
387
+ executeWithCount("page_up");
388
+ };
389
+
390
+ globalThis.vi_matching_bracket = function (): void {
391
+ editor.executeAction("go_to_matching_bracket");
392
+ };
393
+
394
+ // Mode switching
395
+ globalThis.vi_insert_before = function (): void {
396
+ switchMode("insert");
397
+ };
398
+
399
+ globalThis.vi_insert_after = function (): void {
400
+ editor.executeAction("move_right");
401
+ switchMode("insert");
402
+ };
403
+
404
+ globalThis.vi_insert_line_start = function (): void {
405
+ editor.executeAction("move_line_start");
406
+ switchMode("insert");
407
+ };
408
+
409
+ globalThis.vi_insert_line_end = function (): void {
410
+ editor.executeAction("move_line_end");
411
+ switchMode("insert");
412
+ };
413
+
414
+ globalThis.vi_open_below = function (): void {
415
+ editor.executeAction("move_line_end");
416
+ editor.executeAction("insert_newline");
417
+ switchMode("insert");
418
+ };
419
+
420
+ globalThis.vi_open_above = function (): void {
421
+ editor.executeAction("move_line_start");
422
+ editor.executeAction("insert_newline");
423
+ editor.executeAction("move_up");
424
+ switchMode("insert");
425
+ };
426
+
427
+ globalThis.vi_escape = function (): void {
428
+ switchMode("normal");
429
+ };
430
+
431
+ // Operators
432
+ globalThis.vi_delete_operator = function (): void {
433
+ state.pendingOperator = "d";
434
+ switchMode("operator-pending");
435
+ };
436
+
437
+ globalThis.vi_change_operator = function (): void {
438
+ state.pendingOperator = "c";
439
+ switchMode("operator-pending");
440
+ };
441
+
442
+ globalThis.vi_yank_operator = function (): void {
443
+ state.pendingOperator = "y";
444
+ switchMode("operator-pending");
445
+ };
446
+
447
+ // Line operations (dd, cc, yy) - support count prefix (3dd = delete 3 lines)
448
+ globalThis.vi_delete_line = function (): void {
449
+ const count = consumeCount();
450
+ state.lastChange = { type: "line-op", action: "delete_line", count };
451
+ if (count === 1) {
452
+ editor.executeAction("delete_line");
453
+ } else {
454
+ editor.executeActions([{ action: "delete_line", count }]);
455
+ }
456
+ switchMode("normal");
457
+ };
458
+
459
+ globalThis.vi_change_line = function (): void {
460
+ const count = consumeCount();
461
+ state.lastChange = { type: "line-op", action: "change_line", count };
462
+ editor.executeAction("move_line_start");
463
+ const start = editor.getCursorPosition();
464
+ editor.executeAction("move_line_end");
465
+ const end = editor.getCursorPosition();
466
+ if (start !== null && end !== null) {
467
+ editor.deleteRange(editor.getActiveBufferId(), start, end);
468
+ }
469
+ switchMode("insert");
470
+ };
471
+
472
+ globalThis.vi_yank_line = function (): void {
473
+ const count = consumeCount();
474
+ // select_line selects current line and moves cursor to next line
475
+ if (count === 1) {
476
+ editor.executeAction("select_line");
477
+ } else {
478
+ editor.executeActions([{ action: "select_line", count }]);
479
+ }
480
+ editor.executeAction("copy");
481
+ // Move back to original line using synchronous actions
482
+ // (setBufferCursor is async and doesn't take effect in time)
483
+ editor.executeAction("move_up");
484
+ editor.executeAction("move_line_start");
485
+ state.lastYankWasLinewise = true;
486
+ editor.setStatus(editor.t("status.yanked_lines", { count: String(count) }));
487
+ switchMode("normal");
488
+ };
489
+
490
+ // Single character operations - support count prefix (3x = delete 3 chars)
491
+ globalThis.vi_delete_char = function (): void {
492
+ const count = consumeCount();
493
+ state.lastChange = { type: "simple", action: "delete_forward", count };
494
+ executeWithCount("delete_forward", count);
495
+ };
496
+
497
+ globalThis.vi_delete_char_before = function (): void {
498
+ const count = consumeCount();
499
+ state.lastChange = { type: "simple", action: "delete_backward", count };
500
+ executeWithCount("delete_backward", count);
501
+ };
502
+
503
+ globalThis.vi_replace_char = function (): void {
504
+ // TODO: implement character replacement (need to read next char)
505
+ editor.setStatus(editor.t("status.replace_not_implemented"));
506
+ };
507
+
508
+ // Substitute (delete char and enter insert mode)
509
+ globalThis.vi_substitute = function (): void {
510
+ const count = consumeCount();
511
+ state.lastChange = { type: "simple", action: "substitute", count };
512
+ if (count > 1) {
513
+ editor.executeActions([{ action: "delete_forward", count }]);
514
+ } else {
515
+ editor.executeAction("delete_forward");
516
+ }
517
+ switchMode("insert");
518
+ };
519
+
520
+ // Delete to end of line
521
+ globalThis.vi_delete_to_end = function (): void {
522
+ state.lastChange = { type: "operator-motion", operator: "d", motion: "move_line_end" };
523
+ const start = editor.getCursorPosition();
524
+ editor.executeAction("move_line_end");
525
+ const end = editor.getCursorPosition();
526
+ if (start !== null && end !== null && end > start) {
527
+ editor.deleteRange(editor.getActiveBufferId(), start, end);
528
+ }
529
+ };
530
+
531
+ // Change to end of line
532
+ globalThis.vi_change_to_end = function (): void {
533
+ state.lastChange = { type: "operator-motion", operator: "c", motion: "move_line_end" };
534
+ const start = editor.getCursorPosition();
535
+ editor.executeAction("move_line_end");
536
+ const end = editor.getCursorPosition();
537
+ if (start !== null && end !== null && end > start) {
538
+ editor.deleteRange(editor.getActiveBufferId(), start, end);
539
+ }
540
+ switchMode("insert");
541
+ };
542
+
543
+ // Clipboard
544
+ globalThis.vi_paste_after = function (): void {
545
+ if (state.lastYankWasLinewise) {
546
+ // Line-wise paste: go to next line start and paste there
547
+ // The yanked text includes trailing \n which pushes subsequent lines down
548
+ editor.executeAction("move_down");
549
+ editor.executeAction("move_line_start");
550
+ editor.executeAction("paste");
551
+ editor.executeAction("move_up"); // Stay on the pasted line
552
+ editor.executeAction("move_line_start");
553
+ } else {
554
+ // Character-wise paste: insert after cursor
555
+ editor.executeAction("move_right");
556
+ editor.executeAction("paste");
557
+ }
558
+ };
559
+
560
+ globalThis.vi_paste_before = function (): void {
561
+ if (state.lastYankWasLinewise) {
562
+ // Line-wise paste: paste at current line start
563
+ // The yanked text includes trailing \n which pushes current line down
564
+ editor.executeAction("move_line_start");
565
+ editor.executeAction("paste");
566
+ editor.executeAction("move_up"); // Stay on the pasted line
567
+ editor.executeAction("move_line_start");
568
+ } else {
569
+ // Character-wise paste: insert at cursor
570
+ editor.executeAction("paste");
571
+ }
572
+ };
573
+
574
+ // Undo/Redo
575
+ globalThis.vi_undo = function (): void {
576
+ editor.executeAction("undo");
577
+ };
578
+
579
+ globalThis.vi_redo = function (): void {
580
+ editor.executeAction("redo");
581
+ };
582
+
583
+ // Repeat last change (. command)
584
+ globalThis.vi_repeat = async function (): Promise<void> {
585
+ if (!state.lastChange) {
586
+ editor.setStatus(editor.t("status.no_change_to_repeat"));
587
+ return;
588
+ }
589
+
590
+ const change = state.lastChange;
591
+ const count = consumeCount() || change.count || 1;
592
+
593
+ switch (change.type) {
594
+ case "simple": {
595
+ // Simple actions like x, X, s
596
+ if (change.action === "substitute") {
597
+ // Substitute: delete chars and insert text
598
+ if (count > 1) {
599
+ editor.executeActions([{ action: "delete_forward", count }]);
600
+ } else {
601
+ editor.executeAction("delete_forward");
602
+ }
603
+ if (change.insertedText) {
604
+ editor.insertText(change.insertedText);
605
+ }
606
+ } else if (change.action) {
607
+ // Simple action like delete_forward, delete_backward
608
+ if (count > 1) {
609
+ editor.executeActions([{ action: change.action, count }]);
610
+ } else {
611
+ editor.executeAction(change.action);
612
+ }
613
+ }
614
+ break;
615
+ }
616
+
617
+ case "line-op": {
618
+ // Line operations like dd, cc
619
+ if (change.action === "delete_line") {
620
+ if (count > 1) {
621
+ editor.executeActions([{ action: "delete_line", count }]);
622
+ } else {
623
+ editor.executeAction("delete_line");
624
+ }
625
+ } else if (change.action === "change_line") {
626
+ // Change line: delete line content and insert text
627
+ editor.executeAction("move_line_start");
628
+ const start = editor.getCursorPosition();
629
+ editor.executeAction("move_line_end");
630
+ const end = editor.getCursorPosition();
631
+ if (start !== null && end !== null) {
632
+ editor.deleteRange(editor.getActiveBufferId(), start, end);
633
+ }
634
+ if (change.insertedText) {
635
+ editor.insertText(change.insertedText);
636
+ }
637
+ }
638
+ break;
639
+ }
640
+
641
+ case "operator-motion": {
642
+ // Operator + motion like dw, cw, d$
643
+ if (change.operator && change.motion) {
644
+ if (change.operator === "c") {
645
+ // For change: do the delete part, then insert the text
646
+ applyOperatorWithMotion("d", change.motion, count);
647
+ if (change.insertedText) {
648
+ editor.insertText(change.insertedText);
649
+ }
650
+ } else {
651
+ applyOperatorWithMotion(change.operator, change.motion, count);
652
+ }
653
+ }
654
+ break;
655
+ }
656
+
657
+ case "operator-textobj": {
658
+ // Operator + text object like diw, ci"
659
+ if (change.operator && change.textObject) {
660
+ // Set up the pending state and call applyTextObject
661
+ state.pendingOperator = change.operator === "c" ? "d" : change.operator;
662
+ state.pendingTextObject = change.textObject.modifier;
663
+ await applyTextObject(change.textObject.object);
664
+ if (change.operator === "c" && change.insertedText) {
665
+ editor.insertText(change.insertedText);
666
+ }
667
+ }
668
+ break;
669
+ }
670
+
671
+ case "insert": {
672
+ // Pure insert (i, a, o, O)
673
+ if (change.insertedText) {
674
+ editor.insertText(change.insertedText);
675
+ }
676
+ break;
677
+ }
678
+ }
679
+ };
680
+
681
+ // Join lines
682
+ globalThis.vi_join = function (): void {
683
+ editor.executeAction("move_line_end");
684
+ editor.executeAction("delete_forward");
685
+ editor.executeAction("insert_text_at_cursor");
686
+ };
687
+
688
+ // Search
689
+ globalThis.vi_search_forward = function (): void {
690
+ editor.executeAction("search");
691
+ };
692
+
693
+ globalThis.vi_search_backward = function (): void {
694
+ // Use same search dialog, user can search backward manually
695
+ editor.executeAction("search");
696
+ };
697
+
698
+ globalThis.vi_find_next = function (): void {
699
+ editor.executeAction("find_next");
700
+ };
701
+
702
+ globalThis.vi_find_prev = function (): void {
703
+ editor.executeAction("find_previous");
704
+ };
705
+
706
+ // Center view
707
+ globalThis.vi_center_cursor = function (): void {
708
+ editor.executeAction("center_cursor");
709
+ };
710
+
711
+ // Half page movements
712
+ globalThis.vi_half_page_down = function (): void {
713
+ // Approximate half page with multiple down movements
714
+ const count = consumeCount();
715
+ editor.executeActions([{ action: "move_down", count: 10 * count }]);
716
+ };
717
+
718
+ globalThis.vi_half_page_up = function (): void {
719
+ const count = consumeCount();
720
+ editor.executeActions([{ action: "move_up", count: 10 * count }]);
721
+ };
722
+
723
+ // ============================================================================
724
+ // Count Prefix (digit keys 1-9, and 0 after initial digit)
725
+ // ============================================================================
726
+
727
+ // Digit handlers for count prefix
728
+ globalThis.vi_digit_1 = function (): void { accumulateCount(1); };
729
+ globalThis.vi_digit_2 = function (): void { accumulateCount(2); };
730
+ globalThis.vi_digit_3 = function (): void { accumulateCount(3); };
731
+ globalThis.vi_digit_4 = function (): void { accumulateCount(4); };
732
+ globalThis.vi_digit_5 = function (): void { accumulateCount(5); };
733
+ globalThis.vi_digit_6 = function (): void { accumulateCount(6); };
734
+ globalThis.vi_digit_7 = function (): void { accumulateCount(7); };
735
+ globalThis.vi_digit_8 = function (): void { accumulateCount(8); };
736
+ globalThis.vi_digit_9 = function (): void { accumulateCount(9); };
737
+
738
+ // 0 is special: if count is already started, it appends; otherwise it's "go to line start"
739
+ globalThis.vi_digit_0_or_line_start = function (): void {
740
+ if (state.count !== null) {
741
+ accumulateCount(0);
742
+ } else {
743
+ editor.executeAction("move_line_start");
744
+ }
745
+ };
746
+
747
+ // 0 in operator-pending mode: if count is started, append; otherwise apply operator to line start
748
+ globalThis.vi_op_digit_0_or_line_start = function (): void {
749
+ if (state.count !== null) {
750
+ accumulateCount(0);
751
+ } else {
752
+ handleMotionWithOperator("move_line_start");
753
+ }
754
+ };
755
+
756
+ // ============================================================================
757
+ // Visual Mode
758
+ // ============================================================================
759
+
760
+ // Enter character-wise visual mode
761
+ globalThis.vi_visual_char = function (): void {
762
+ state.visualAnchor = editor.getCursorPosition();
763
+ // Select current character to start visual selection
764
+ editor.executeAction("select_right");
765
+ switchMode("visual");
766
+ };
767
+
768
+ // Enter line-wise visual mode
769
+ globalThis.vi_visual_line = function (): void {
770
+ state.visualAnchor = editor.getCursorPosition();
771
+ // Select current line
772
+ editor.executeAction("move_line_start");
773
+ editor.executeAction("select_line");
774
+ switchMode("visual-line");
775
+ };
776
+
777
+ // Toggle between visual and visual-line modes
778
+ globalThis.vi_visual_toggle_line = function (): void {
779
+ if (state.mode === "visual") {
780
+ // Switch to line mode - extend selection to full lines
781
+ editor.executeAction("select_line");
782
+ state.mode = "visual-line";
783
+ editor.setEditorMode("vi-visual-line");
784
+ editor.setStatus(getModeIndicator("visual-line"));
785
+ } else if (state.mode === "visual-line") {
786
+ // Switch to char mode (keep selection but change mode)
787
+ state.mode = "visual";
788
+ editor.setEditorMode("vi-visual");
789
+ editor.setStatus(getModeIndicator("visual"));
790
+ }
791
+ };
792
+
793
+ // Enter visual block mode (Ctrl-v)
794
+ globalThis.vi_visual_block = function (): void {
795
+ // Store anchor position for block selection
796
+ state.visualAnchor = editor.getCursorPosition();
797
+
798
+ // Calculate line and column for block anchor
799
+ const cursorPos = editor.getCursorPosition();
800
+ if (cursorPos !== null) {
801
+ const line = editor.getCursorLine() ?? 1;
802
+ const lineStart = editor.getLineStartPosition(line);
803
+ const col = lineStart !== null ? cursorPos - lineStart : 0;
804
+ state.visualBlockAnchor = { line, col };
805
+ }
806
+
807
+ // Select current character to start
808
+ editor.executeAction("select_right");
809
+ switchMode("visual-block");
810
+ };
811
+
812
+ // Visual block mode motions - these extend the rectangular selection
813
+ globalThis.vi_vblock_left = function (): void {
814
+ executeWithCount("select_left");
815
+ };
816
+
817
+ globalThis.vi_vblock_down = function (): void {
818
+ executeWithCount("select_down");
819
+ };
820
+
821
+ globalThis.vi_vblock_up = function (): void {
822
+ executeWithCount("select_up");
823
+ };
824
+
825
+ globalThis.vi_vblock_right = function (): void {
826
+ executeWithCount("select_right");
827
+ };
828
+
829
+ globalThis.vi_vblock_line_start = function (): void {
830
+ consumeCount();
831
+ editor.executeAction("select_line_start");
832
+ };
833
+
834
+ globalThis.vi_vblock_line_end = function (): void {
835
+ consumeCount();
836
+ editor.executeAction("select_line_end");
837
+ };
838
+
839
+ // Visual block delete - delete the selected block
840
+ globalThis.vi_vblock_delete = function (): void {
841
+ editor.executeAction("cut");
842
+ state.lastYankWasLinewise = false;
843
+ switchMode("normal");
844
+ };
845
+
846
+ // Visual block change - delete and enter insert mode
847
+ globalThis.vi_vblock_change = function (): void {
848
+ editor.executeAction("cut");
849
+ switchMode("insert");
850
+ };
851
+
852
+ // Visual block yank
853
+ globalThis.vi_vblock_yank = function (): void {
854
+ editor.executeAction("copy");
855
+ state.lastYankWasLinewise = false;
856
+ // Move cursor to start of selection
857
+ editor.executeAction("move_left");
858
+ switchMode("normal");
859
+ };
860
+
861
+ // Exit visual block mode
862
+ globalThis.vi_vblock_escape = function (): void {
863
+ switchMode("normal");
864
+ };
865
+
866
+ // Toggle from visual block to other visual modes
867
+ globalThis.vi_vblock_toggle_char = function (): void {
868
+ // Switch to character visual mode
869
+ state.mode = "visual";
870
+ editor.setEditorMode("vi-visual");
871
+ editor.setStatus(getModeIndicator("visual"));
872
+ };
873
+
874
+ globalThis.vi_vblock_toggle_line = function (): void {
875
+ // Switch to line visual mode
876
+ editor.executeAction("select_line");
877
+ state.mode = "visual-line";
878
+ editor.setEditorMode("vi-visual-line");
879
+ editor.setStatus(getModeIndicator("visual-line"));
880
+ };
881
+
882
+ // Visual mode motions - these extend the selection
883
+ globalThis.vi_vis_left = function (): void {
884
+ executeWithCount("select_left");
885
+ };
886
+
887
+ globalThis.vi_vis_down = function (): void {
888
+ executeWithCount("select_down");
889
+ };
890
+
891
+ globalThis.vi_vis_up = function (): void {
892
+ executeWithCount("select_up");
893
+ };
894
+
895
+ globalThis.vi_vis_right = function (): void {
896
+ executeWithCount("select_right");
897
+ };
898
+
899
+ globalThis.vi_vis_word = function (): void {
900
+ executeWithCount("select_word_right");
901
+ };
902
+
903
+ globalThis.vi_vis_word_back = function (): void {
904
+ executeWithCount("select_word_left");
905
+ };
906
+
907
+ globalThis.vi_vis_word_end = function (): void {
908
+ const count = consumeCount();
909
+ for (let i = 0; i < count; i++) {
910
+ editor.executeAction("select_word_right");
911
+ editor.executeAction("select_left");
912
+ }
913
+ };
914
+
915
+ globalThis.vi_vis_line_start = function (): void {
916
+ consumeCount();
917
+ editor.executeAction("select_line_start");
918
+ };
919
+
920
+ globalThis.vi_vis_line_end = function (): void {
921
+ consumeCount();
922
+ editor.executeAction("select_line_end");
923
+ };
924
+
925
+ globalThis.vi_vis_doc_start = function (): void {
926
+ consumeCount();
927
+ editor.executeAction("select_document_start");
928
+ };
929
+
930
+ globalThis.vi_vis_doc_end = function (): void {
931
+ consumeCount();
932
+ editor.executeAction("select_document_end");
933
+ };
934
+
935
+ // Visual line mode motions - extend selection by whole lines
936
+ globalThis.vi_vline_down = function (): void {
937
+ executeWithCount("select_down");
938
+ // Ensure full line selection
939
+ editor.executeAction("select_line_end");
940
+ };
941
+
942
+ globalThis.vi_vline_up = function (): void {
943
+ executeWithCount("select_up");
944
+ // Ensure full line selection
945
+ editor.executeAction("select_line_start");
946
+ };
947
+
948
+ // Visual mode operators - act on selection
949
+ globalThis.vi_vis_delete = function (): void {
950
+ const wasLinewise = state.mode === "visual-line";
951
+ editor.executeAction("cut");
952
+ state.lastYankWasLinewise = wasLinewise;
953
+ switchMode("normal");
954
+ };
955
+
956
+ globalThis.vi_vis_change = function (): void {
957
+ editor.executeAction("cut");
958
+ switchMode("insert");
959
+ };
960
+
961
+ globalThis.vi_vis_yank = function (): void {
962
+ const wasLinewise = state.mode === "visual-line";
963
+ editor.executeAction("copy");
964
+ state.lastYankWasLinewise = wasLinewise;
965
+ // Move cursor to start of selection (vim behavior)
966
+ editor.executeAction("move_left");
967
+ switchMode("normal");
968
+ };
969
+
970
+ // Exit visual mode without doing anything
971
+ globalThis.vi_vis_escape = function (): void {
972
+ switchMode("normal");
973
+ };
974
+
975
+ // ============================================================================
976
+ // Text Objects (iw, aw, i", a", etc.)
977
+ // ============================================================================
978
+
979
+ // Enter text-object mode with "inner" modifier
980
+ globalThis.vi_text_object_inner = function (): void {
981
+ state.pendingTextObject = "inner";
982
+ state.mode = "text-object";
983
+ editor.setEditorMode("vi-text-object");
984
+ editor.setStatus(getModeIndicator("text-object"));
985
+ };
986
+
987
+ // Enter text-object mode with "around" modifier
988
+ globalThis.vi_text_object_around = function (): void {
989
+ state.pendingTextObject = "around";
990
+ state.mode = "text-object";
991
+ editor.setEditorMode("vi-text-object");
992
+ editor.setStatus(getModeIndicator("text-object"));
993
+ };
994
+
995
+ // Apply text object selection and then the pending operator
996
+ async function applyTextObject(objectType: string): Promise<void> {
997
+ const operator = state.pendingOperator;
998
+ const isInner = state.pendingTextObject === "inner";
999
+ const modifier = state.pendingTextObject;
1000
+
1001
+ if (!operator) {
1002
+ switchMode("normal");
1003
+ return;
1004
+ }
1005
+
1006
+ // Record last change for '.' repeat (only for delete and change, not yank)
1007
+ if ((operator === "d" || operator === "c") && modifier) {
1008
+ state.lastChange = { type: "operator-textobj", operator, textObject: { modifier, object: objectType } };
1009
+ }
1010
+
1011
+ const bufferId = editor.getActiveBufferId();
1012
+ const cursorPos = editor.getCursorPosition();
1013
+ if (cursorPos === null) {
1014
+ switchMode("normal");
1015
+ return;
1016
+ }
1017
+
1018
+ // Get text around cursor to find the text object boundaries
1019
+ const windowSize = 1000;
1020
+ const startOffset = Math.max(0, cursorPos - windowSize);
1021
+ const bufLen = editor.getBufferLength(bufferId);
1022
+ const endOffset = Math.min(bufLen, cursorPos + windowSize);
1023
+ const text = await editor.getBufferText(bufferId, startOffset, endOffset);
1024
+ if (!text) {
1025
+ switchMode("normal");
1026
+ return;
1027
+ }
1028
+
1029
+ const posInChunk = cursorPos - startOffset;
1030
+ let selectStart = -1;
1031
+ let selectEnd = -1;
1032
+
1033
+ switch (objectType) {
1034
+ case "word": {
1035
+ // Find word boundaries
1036
+ const wordChars = /[a-zA-Z0-9_]/;
1037
+ let start = posInChunk;
1038
+ let end = posInChunk;
1039
+
1040
+ // Expand to find word start
1041
+ while (start > 0 && wordChars.test(text[start - 1])) start--;
1042
+ // Expand to find word end
1043
+ while (end < text.length && wordChars.test(text[end])) end++;
1044
+
1045
+ if (!isInner) {
1046
+ // "a word" includes trailing whitespace
1047
+ while (end < text.length && /\s/.test(text[end]) && text[end] !== '\n') end++;
1048
+ }
1049
+
1050
+ selectStart = startOffset + start;
1051
+ selectEnd = startOffset + end;
1052
+ break;
1053
+ }
1054
+
1055
+ case "WORD": {
1056
+ // WORD is whitespace-delimited
1057
+ let start = posInChunk;
1058
+ let end = posInChunk;
1059
+
1060
+ while (start > 0 && !/\s/.test(text[start - 1])) start--;
1061
+ while (end < text.length && !/\s/.test(text[end])) end++;
1062
+
1063
+ if (!isInner) {
1064
+ while (end < text.length && /\s/.test(text[end]) && text[end] !== '\n') end++;
1065
+ }
1066
+
1067
+ selectStart = startOffset + start;
1068
+ selectEnd = startOffset + end;
1069
+ break;
1070
+ }
1071
+
1072
+ case "\"":
1073
+ case "'":
1074
+ case "`": {
1075
+ // Find matching quotes on current line
1076
+ // First find line boundaries
1077
+ let lineStart = posInChunk;
1078
+ let lineEnd = posInChunk;
1079
+ while (lineStart > 0 && text[lineStart - 1] !== '\n') lineStart--;
1080
+ while (lineEnd < text.length && text[lineEnd] !== '\n') lineEnd++;
1081
+
1082
+ const line = text.substring(lineStart, lineEnd);
1083
+ const colInLine = posInChunk - lineStart;
1084
+
1085
+ // Find quote pair containing cursor
1086
+ let quoteStart = -1;
1087
+ let quoteEnd = -1;
1088
+ let inQuote = false;
1089
+
1090
+ for (let i = 0; i < line.length; i++) {
1091
+ if (line[i] === objectType) {
1092
+ if (!inQuote) {
1093
+ quoteStart = i;
1094
+ inQuote = true;
1095
+ } else {
1096
+ quoteEnd = i;
1097
+ if (colInLine >= quoteStart && colInLine <= quoteEnd) {
1098
+ break; // Found the pair containing cursor
1099
+ }
1100
+ inQuote = false;
1101
+ }
1102
+ }
1103
+ }
1104
+
1105
+ if (quoteStart !== -1 && quoteEnd !== -1 && colInLine >= quoteStart && colInLine <= quoteEnd) {
1106
+ if (isInner) {
1107
+ selectStart = startOffset + lineStart + quoteStart + 1;
1108
+ selectEnd = startOffset + lineStart + quoteEnd;
1109
+ } else {
1110
+ selectStart = startOffset + lineStart + quoteStart;
1111
+ selectEnd = startOffset + lineStart + quoteEnd + 1;
1112
+ }
1113
+ }
1114
+ break;
1115
+ }
1116
+
1117
+ case "(":
1118
+ case ")":
1119
+ case "b": {
1120
+ // Find matching parentheses
1121
+ const result = findMatchingPair(text, posInChunk, '(', ')');
1122
+ if (result) {
1123
+ if (isInner) {
1124
+ selectStart = startOffset + result.start + 1;
1125
+ selectEnd = startOffset + result.end;
1126
+ } else {
1127
+ selectStart = startOffset + result.start;
1128
+ selectEnd = startOffset + result.end + 1;
1129
+ }
1130
+ }
1131
+ break;
1132
+ }
1133
+
1134
+ case "{":
1135
+ case "}":
1136
+ case "B": {
1137
+ const result = findMatchingPair(text, posInChunk, '{', '}');
1138
+ if (result) {
1139
+ if (isInner) {
1140
+ selectStart = startOffset + result.start + 1;
1141
+ selectEnd = startOffset + result.end;
1142
+ } else {
1143
+ selectStart = startOffset + result.start;
1144
+ selectEnd = startOffset + result.end + 1;
1145
+ }
1146
+ }
1147
+ break;
1148
+ }
1149
+
1150
+ case "[":
1151
+ case "]": {
1152
+ const result = findMatchingPair(text, posInChunk, '[', ']');
1153
+ if (result) {
1154
+ if (isInner) {
1155
+ selectStart = startOffset + result.start + 1;
1156
+ selectEnd = startOffset + result.end;
1157
+ } else {
1158
+ selectStart = startOffset + result.start;
1159
+ selectEnd = startOffset + result.end + 1;
1160
+ }
1161
+ }
1162
+ break;
1163
+ }
1164
+
1165
+ case "<":
1166
+ case ">": {
1167
+ const result = findMatchingPair(text, posInChunk, '<', '>');
1168
+ if (result) {
1169
+ if (isInner) {
1170
+ selectStart = startOffset + result.start + 1;
1171
+ selectEnd = startOffset + result.end;
1172
+ } else {
1173
+ selectStart = startOffset + result.start;
1174
+ selectEnd = startOffset + result.end + 1;
1175
+ }
1176
+ }
1177
+ break;
1178
+ }
1179
+ }
1180
+
1181
+ if (selectStart === -1 || selectEnd === -1 || selectStart >= selectEnd) {
1182
+ switchMode("normal");
1183
+ return;
1184
+ }
1185
+
1186
+ // Apply the operator directly using deleteRange/copyRange
1187
+ switch (operator) {
1188
+ case "d": {
1189
+ // Delete the range directly
1190
+ editor.deleteRange(bufferId, selectStart, selectEnd);
1191
+ state.lastYankWasLinewise = false;
1192
+ break;
1193
+ }
1194
+ case "c": {
1195
+ // Delete and enter insert mode
1196
+ editor.deleteRange(bufferId, selectStart, selectEnd);
1197
+ switchMode("insert");
1198
+ return;
1199
+ }
1200
+ case "y": {
1201
+ // For yank, we need to select the range and copy
1202
+ // First move cursor to start
1203
+ editor.setBufferCursor(bufferId, selectStart);
1204
+ // Select the range
1205
+ for (let i = 0; i < selectEnd - selectStart; i++) {
1206
+ editor.executeAction("select_right");
1207
+ }
1208
+ editor.executeAction("copy");
1209
+ state.lastYankWasLinewise = false;
1210
+ // Move back to start
1211
+ editor.setBufferCursor(bufferId, selectStart);
1212
+ break;
1213
+ }
1214
+ }
1215
+
1216
+ switchMode("normal");
1217
+ }
1218
+
1219
+ // Helper to find matching bracket pair containing the cursor
1220
+ function findMatchingPair(text: string, pos: number, openChar: string, closeChar: string): { start: number; end: number } | null {
1221
+ let depth = 0;
1222
+ let start = -1;
1223
+
1224
+ // Search backward for opening bracket
1225
+ for (let i = pos; i >= 0; i--) {
1226
+ if (text[i] === closeChar) depth++;
1227
+ if (text[i] === openChar) {
1228
+ if (depth === 0) {
1229
+ start = i;
1230
+ break;
1231
+ }
1232
+ depth--;
1233
+ }
1234
+ }
1235
+
1236
+ if (start === -1) return null;
1237
+
1238
+ // Search forward for closing bracket
1239
+ depth = 0;
1240
+ for (let i = start; i < text.length; i++) {
1241
+ if (text[i] === openChar) depth++;
1242
+ if (text[i] === closeChar) {
1243
+ depth--;
1244
+ if (depth === 0) {
1245
+ return { start, end: i };
1246
+ }
1247
+ }
1248
+ }
1249
+
1250
+ return null;
1251
+ }
1252
+
1253
+ // Text object handlers
1254
+ globalThis.vi_to_word = async function (): Promise<void> { await applyTextObject("word"); };
1255
+ globalThis.vi_to_WORD = async function (): Promise<void> { await applyTextObject("WORD"); };
1256
+ globalThis.vi_to_dquote = async function (): Promise<void> { await applyTextObject("\""); };
1257
+ globalThis.vi_to_squote = async function (): Promise<void> { await applyTextObject("'"); };
1258
+ globalThis.vi_to_backtick = async function (): Promise<void> { await applyTextObject("`"); };
1259
+ globalThis.vi_to_paren = async function (): Promise<void> { await applyTextObject("("); };
1260
+ globalThis.vi_to_brace = async function (): Promise<void> { await applyTextObject("{"); };
1261
+ globalThis.vi_to_bracket = async function (): Promise<void> { await applyTextObject("["); };
1262
+ globalThis.vi_to_angle = async function (): Promise<void> { await applyTextObject("<"); };
1263
+
1264
+ // Cancel text object mode
1265
+ globalThis.vi_to_cancel = function (): void {
1266
+ switchMode("normal");
1267
+ };
1268
+
1269
+ // ============================================================================
1270
+ // Find Character Motions (f/t/F/T)
1271
+ // ============================================================================
1272
+
1273
+ // Enter find-char mode waiting for the target character
1274
+ function enterFindCharMode(findType: FindCharType): void {
1275
+ state.pendingFindChar = findType;
1276
+ state.mode = "find-char";
1277
+ editor.setEditorMode("vi-find-char");
1278
+ editor.setStatus(getModeIndicator("find-char"));
1279
+ }
1280
+
1281
+ // Execute find char motion (async because getBufferText is async)
1282
+ async function executeFindChar(findType: FindCharType, char: string): Promise<void> {
1283
+ if (!findType) return;
1284
+
1285
+ const bufferId = editor.getActiveBufferId();
1286
+ const cursorPos = editor.getCursorPosition();
1287
+ if (cursorPos === null || (cursorPos === 0 && (findType === "F" || findType === "T"))) {
1288
+ // Can't search backward from position 0
1289
+ return;
1290
+ }
1291
+
1292
+ // Get text around cursor to find line boundaries
1293
+ // Read up to 10KB before and after cursor for context
1294
+ const windowSize = 10000;
1295
+ const startOffset = Math.max(0, cursorPos - windowSize);
1296
+ const bufLen = editor.getBufferLength(bufferId);
1297
+ const endOffset = Math.min(bufLen, cursorPos + windowSize);
1298
+
1299
+ // Get buffer text around cursor
1300
+ const text = await editor.getBufferText(bufferId, startOffset, endOffset);
1301
+ if (!text) return;
1302
+
1303
+ // Calculate position within this text chunk
1304
+ const posInChunk = cursorPos - startOffset;
1305
+
1306
+ // Find line start (last newline before cursor, or start of chunk)
1307
+ let lineStart = 0;
1308
+ for (let i = posInChunk - 1; i >= 0; i--) {
1309
+ if (text[i] === '\n') {
1310
+ lineStart = i + 1;
1311
+ break;
1312
+ }
1313
+ }
1314
+
1315
+ // Find line end (next newline after cursor, or end of chunk)
1316
+ let lineEnd = text.length;
1317
+ for (let i = posInChunk; i < text.length; i++) {
1318
+ if (text[i] === '\n') {
1319
+ lineEnd = i;
1320
+ break;
1321
+ }
1322
+ }
1323
+
1324
+ // Extract line text and calculate column
1325
+ const lineText = text.substring(lineStart, lineEnd);
1326
+ const col = posInChunk - lineStart;
1327
+
1328
+ let targetCol = -1;
1329
+
1330
+ if (findType === "f" || findType === "t") {
1331
+ // Search forward on the line
1332
+ for (let i = col + 1; i < lineText.length; i++) {
1333
+ if (lineText[i] === char) {
1334
+ targetCol = findType === "f" ? i : i - 1;
1335
+ break;
1336
+ }
1337
+ }
1338
+ } else {
1339
+ // Search backward (F/T)
1340
+ for (let i = col - 1; i >= 0; i--) {
1341
+ if (lineText[i] === char) {
1342
+ targetCol = findType === "F" ? i : i + 1;
1343
+ break;
1344
+ }
1345
+ }
1346
+ }
1347
+
1348
+ if (targetCol >= 0 && targetCol !== col) {
1349
+ // Move to target column
1350
+ const diff = targetCol - col;
1351
+ const moveAction = diff > 0 ? "move_right" : "move_left";
1352
+ const steps = Math.abs(diff);
1353
+ for (let i = 0; i < steps; i++) {
1354
+ editor.executeAction(moveAction);
1355
+ }
1356
+ // Save for ; and , repeat
1357
+ state.lastFindChar = { type: findType, char };
1358
+ }
1359
+ }
1360
+
1361
+ // Handler for when a character is typed in find-char mode (async)
1362
+ globalThis.vi_find_char_handler = async function (char: string): Promise<void> {
1363
+ if (state.pendingFindChar) {
1364
+ await executeFindChar(state.pendingFindChar, char);
1365
+ }
1366
+ // Return to normal mode
1367
+ state.pendingFindChar = null;
1368
+ switchMode("normal");
1369
+ };
1370
+
1371
+ // Commands to enter find-char mode
1372
+ globalThis.vi_find_char_f = function (): void {
1373
+ enterFindCharMode("f");
1374
+ };
1375
+
1376
+ globalThis.vi_find_char_t = function (): void {
1377
+ enterFindCharMode("t");
1378
+ };
1379
+
1380
+ globalThis.vi_find_char_F = function (): void {
1381
+ enterFindCharMode("F");
1382
+ };
1383
+
1384
+ globalThis.vi_find_char_T = function (): void {
1385
+ enterFindCharMode("T");
1386
+ };
1387
+
1388
+ // Repeat last find char (async)
1389
+ globalThis.vi_find_char_repeat = async function (): Promise<void> {
1390
+ if (state.lastFindChar) {
1391
+ await executeFindChar(state.lastFindChar.type, state.lastFindChar.char);
1392
+ }
1393
+ };
1394
+
1395
+ // Repeat last find char in opposite direction (async)
1396
+ globalThis.vi_find_char_repeat_reverse = async function (): Promise<void> {
1397
+ if (state.lastFindChar) {
1398
+ const reversedType: FindCharType =
1399
+ state.lastFindChar.type === "f" ? "F" :
1400
+ state.lastFindChar.type === "F" ? "f" :
1401
+ state.lastFindChar.type === "t" ? "T" : "t";
1402
+ await executeFindChar(reversedType, state.lastFindChar.char);
1403
+ }
1404
+ };
1405
+
1406
+ // Cancel find-char mode
1407
+ globalThis.vi_find_char_cancel = function (): void {
1408
+ state.pendingFindChar = null;
1409
+ switchMode("normal");
1410
+ };
1411
+
1412
+ // ============================================================================
1413
+ // Operator-Pending Mode Commands
1414
+ // ============================================================================
1415
+
1416
+ globalThis.vi_op_left = function (): void {
1417
+ handleMotionWithOperator("move_left");
1418
+ };
1419
+
1420
+ globalThis.vi_op_down = function (): void {
1421
+ handleMotionWithOperator("move_down");
1422
+ };
1423
+
1424
+ globalThis.vi_op_up = function (): void {
1425
+ handleMotionWithOperator("move_up");
1426
+ };
1427
+
1428
+ globalThis.vi_op_right = function (): void {
1429
+ handleMotionWithOperator("move_right");
1430
+ };
1431
+
1432
+ globalThis.vi_op_word = function (): void {
1433
+ handleMotionWithOperator("move_word_right");
1434
+ };
1435
+
1436
+ globalThis.vi_op_word_back = function (): void {
1437
+ handleMotionWithOperator("move_word_left");
1438
+ };
1439
+
1440
+ globalThis.vi_op_line_start = function (): void {
1441
+ handleMotionWithOperator("move_line_start");
1442
+ };
1443
+
1444
+ globalThis.vi_op_line_end = function (): void {
1445
+ handleMotionWithOperator("move_line_end");
1446
+ };
1447
+
1448
+ globalThis.vi_op_doc_start = function (): void {
1449
+ handleMotionWithOperator("move_document_start");
1450
+ };
1451
+
1452
+ globalThis.vi_op_doc_end = function (): void {
1453
+ handleMotionWithOperator("move_document_end");
1454
+ };
1455
+
1456
+ globalThis.vi_op_matching_bracket = function (): void {
1457
+ handleMotionWithOperator("go_to_matching_bracket");
1458
+ };
1459
+
1460
+ globalThis.vi_cancel = function (): void {
1461
+ switchMode("normal");
1462
+ };
1463
+
1464
+ // ============================================================================
1465
+ // Mode Definitions
1466
+ // ============================================================================
1467
+
1468
+ // Define vi-normal mode
1469
+ editor.defineMode("vi-normal", null, [
1470
+ // Count prefix (digits 1-9 start count, 0 is special)
1471
+ ["1", "vi_digit_1"],
1472
+ ["2", "vi_digit_2"],
1473
+ ["3", "vi_digit_3"],
1474
+ ["4", "vi_digit_4"],
1475
+ ["5", "vi_digit_5"],
1476
+ ["6", "vi_digit_6"],
1477
+ ["7", "vi_digit_7"],
1478
+ ["8", "vi_digit_8"],
1479
+ ["9", "vi_digit_9"],
1480
+ ["0", "vi_digit_0_or_line_start"], // 0 appends to count, or moves to line start
1481
+
1482
+ // Navigation
1483
+ ["h", "vi_left"],
1484
+ ["j", "vi_down"],
1485
+ ["k", "vi_up"],
1486
+ ["l", "vi_right"],
1487
+ ["w", "vi_word"],
1488
+ ["b", "vi_word_back"],
1489
+ ["e", "vi_word_end"],
1490
+ ["$", "vi_line_end"],
1491
+ ["^", "vi_first_non_blank"],
1492
+ ["g g", "vi_doc_start"],
1493
+ ["G", "vi_doc_end"],
1494
+ ["C-f", "vi_page_down"],
1495
+ ["C-b", "vi_page_up"],
1496
+ ["C-d", "vi_half_page_down"],
1497
+ ["C-u", "vi_half_page_up"],
1498
+ ["%", "vi_matching_bracket"],
1499
+ ["z z", "vi_center_cursor"],
1500
+
1501
+ // Search
1502
+ ["/", "vi_search_forward"],
1503
+ ["?", "vi_search_backward"],
1504
+ ["n", "vi_find_next"],
1505
+ ["N", "vi_find_prev"],
1506
+
1507
+ // Find character on line
1508
+ ["f", "vi_find_char_f"],
1509
+ ["t", "vi_find_char_t"],
1510
+ ["F", "vi_find_char_F"],
1511
+ ["T", "vi_find_char_T"],
1512
+ [";", "vi_find_char_repeat"],
1513
+ [",", "vi_find_char_repeat_reverse"],
1514
+
1515
+ // Mode switching
1516
+ ["i", "vi_insert_before"],
1517
+ ["a", "vi_insert_after"],
1518
+ ["I", "vi_insert_line_start"],
1519
+ ["A", "vi_insert_line_end"],
1520
+ ["o", "vi_open_below"],
1521
+ ["O", "vi_open_above"],
1522
+ ["Escape", "vi_escape"],
1523
+
1524
+ // Operators (single key - switches to operator-pending mode)
1525
+ // The second d/c/y is handled in operator-pending mode
1526
+ ["d", "vi_delete_operator"],
1527
+ ["c", "vi_change_operator"],
1528
+ ["y", "vi_yank_operator"],
1529
+
1530
+ // Single char operations
1531
+ ["x", "vi_delete_char"],
1532
+ ["X", "vi_delete_char_before"],
1533
+ ["r", "vi_replace_char"],
1534
+ ["s", "vi_substitute"],
1535
+ ["S", "vi_change_line"],
1536
+ ["D", "vi_delete_to_end"],
1537
+ ["C", "vi_change_to_end"],
1538
+
1539
+ // Clipboard
1540
+ ["p", "vi_paste_after"],
1541
+ ["P", "vi_paste_before"],
1542
+
1543
+ // Undo/Redo
1544
+ ["u", "vi_undo"],
1545
+ ["C-r", "vi_redo"],
1546
+
1547
+ // Repeat last change
1548
+ [".", "vi_repeat"],
1549
+
1550
+ // Visual mode
1551
+ ["v", "vi_visual_char"],
1552
+ ["V", "vi_visual_line"],
1553
+ ["C-v", "vi_visual_block"],
1554
+
1555
+ // Other
1556
+ ["J", "vi_join"],
1557
+
1558
+ // Command mode
1559
+ [":", "vi_command_mode"],
1560
+ ], true); // read_only = true to prevent character insertion
1561
+
1562
+ // Define vi-insert mode - only Escape is special, other keys insert text
1563
+ editor.defineMode("vi-insert", null, [
1564
+ ["Escape", "vi_escape"],
1565
+ ], false); // read_only = false to allow normal typing
1566
+
1567
+ // Define vi-find-char mode - binds all printable chars to the handler
1568
+ // This mode waits for a single character input for f/t/F/T motions
1569
+
1570
+ // Explicitly define handlers for each character to ensure they're accessible
1571
+ // These return Promises so the runtime can await them
1572
+ globalThis.vi_fc_a = async function(): Promise<void> { return globalThis.vi_find_char_handler("a"); };
1573
+ globalThis.vi_fc_b = async function(): Promise<void> { return globalThis.vi_find_char_handler("b"); };
1574
+ globalThis.vi_fc_c = async function(): Promise<void> { return globalThis.vi_find_char_handler("c"); };
1575
+ globalThis.vi_fc_d = async function(): Promise<void> { return globalThis.vi_find_char_handler("d"); };
1576
+ globalThis.vi_fc_e = async function(): Promise<void> { return globalThis.vi_find_char_handler("e"); };
1577
+ globalThis.vi_fc_f = async function(): Promise<void> { return globalThis.vi_find_char_handler("f"); };
1578
+ globalThis.vi_fc_g = async function(): Promise<void> { return globalThis.vi_find_char_handler("g"); };
1579
+ globalThis.vi_fc_h = async function(): Promise<void> { return globalThis.vi_find_char_handler("h"); };
1580
+ globalThis.vi_fc_i = async function(): Promise<void> { return globalThis.vi_find_char_handler("i"); };
1581
+ globalThis.vi_fc_j = async function(): Promise<void> { return globalThis.vi_find_char_handler("j"); };
1582
+ globalThis.vi_fc_k = async function(): Promise<void> { return globalThis.vi_find_char_handler("k"); };
1583
+ globalThis.vi_fc_l = async function(): Promise<void> { return globalThis.vi_find_char_handler("l"); };
1584
+ globalThis.vi_fc_m = async function(): Promise<void> { return globalThis.vi_find_char_handler("m"); };
1585
+ globalThis.vi_fc_n = async function(): Promise<void> { return globalThis.vi_find_char_handler("n"); };
1586
+ globalThis.vi_fc_o = async function(): Promise<void> { return globalThis.vi_find_char_handler("o"); };
1587
+ globalThis.vi_fc_p = async function(): Promise<void> { return globalThis.vi_find_char_handler("p"); };
1588
+ globalThis.vi_fc_q = async function(): Promise<void> { return globalThis.vi_find_char_handler("q"); };
1589
+ globalThis.vi_fc_r = async function(): Promise<void> { return globalThis.vi_find_char_handler("r"); };
1590
+ globalThis.vi_fc_s = async function(): Promise<void> { return globalThis.vi_find_char_handler("s"); };
1591
+ globalThis.vi_fc_t = async function(): Promise<void> { return globalThis.vi_find_char_handler("t"); };
1592
+ globalThis.vi_fc_u = async function(): Promise<void> { return globalThis.vi_find_char_handler("u"); };
1593
+ globalThis.vi_fc_v = async function(): Promise<void> { return globalThis.vi_find_char_handler("v"); };
1594
+ globalThis.vi_fc_w = async function(): Promise<void> { return globalThis.vi_find_char_handler("w"); };
1595
+ globalThis.vi_fc_x = async function(): Promise<void> { return globalThis.vi_find_char_handler("x"); };
1596
+ globalThis.vi_fc_y = async function(): Promise<void> { return globalThis.vi_find_char_handler("y"); };
1597
+ globalThis.vi_fc_z = async function(): Promise<void> { return globalThis.vi_find_char_handler("z"); };
1598
+ globalThis.vi_fc_A = async function(): Promise<void> { return globalThis.vi_find_char_handler("A"); };
1599
+ globalThis.vi_fc_B = async function(): Promise<void> { return globalThis.vi_find_char_handler("B"); };
1600
+ globalThis.vi_fc_C = async function(): Promise<void> { return globalThis.vi_find_char_handler("C"); };
1601
+ globalThis.vi_fc_D = async function(): Promise<void> { return globalThis.vi_find_char_handler("D"); };
1602
+ globalThis.vi_fc_E = async function(): Promise<void> { return globalThis.vi_find_char_handler("E"); };
1603
+ globalThis.vi_fc_F = async function(): Promise<void> { return globalThis.vi_find_char_handler("F"); };
1604
+ globalThis.vi_fc_G = async function(): Promise<void> { return globalThis.vi_find_char_handler("G"); };
1605
+ globalThis.vi_fc_H = async function(): Promise<void> { return globalThis.vi_find_char_handler("H"); };
1606
+ globalThis.vi_fc_I = async function(): Promise<void> { return globalThis.vi_find_char_handler("I"); };
1607
+ globalThis.vi_fc_J = async function(): Promise<void> { return globalThis.vi_find_char_handler("J"); };
1608
+ globalThis.vi_fc_K = async function(): Promise<void> { return globalThis.vi_find_char_handler("K"); };
1609
+ globalThis.vi_fc_L = async function(): Promise<void> { return globalThis.vi_find_char_handler("L"); };
1610
+ globalThis.vi_fc_M = async function(): Promise<void> { return globalThis.vi_find_char_handler("M"); };
1611
+ globalThis.vi_fc_N = async function(): Promise<void> { return globalThis.vi_find_char_handler("N"); };
1612
+ globalThis.vi_fc_O = async function(): Promise<void> { return globalThis.vi_find_char_handler("O"); };
1613
+ globalThis.vi_fc_P = async function(): Promise<void> { return globalThis.vi_find_char_handler("P"); };
1614
+ globalThis.vi_fc_Q = async function(): Promise<void> { return globalThis.vi_find_char_handler("Q"); };
1615
+ globalThis.vi_fc_R = async function(): Promise<void> { return globalThis.vi_find_char_handler("R"); };
1616
+ globalThis.vi_fc_S = async function(): Promise<void> { return globalThis.vi_find_char_handler("S"); };
1617
+ globalThis.vi_fc_T = async function(): Promise<void> { return globalThis.vi_find_char_handler("T"); };
1618
+ globalThis.vi_fc_U = async function(): Promise<void> { return globalThis.vi_find_char_handler("U"); };
1619
+ globalThis.vi_fc_V = async function(): Promise<void> { return globalThis.vi_find_char_handler("V"); };
1620
+ globalThis.vi_fc_W = async function(): Promise<void> { return globalThis.vi_find_char_handler("W"); };
1621
+ globalThis.vi_fc_X = async function(): Promise<void> { return globalThis.vi_find_char_handler("X"); };
1622
+ globalThis.vi_fc_Y = async function(): Promise<void> { return globalThis.vi_find_char_handler("Y"); };
1623
+ globalThis.vi_fc_Z = async function(): Promise<void> { return globalThis.vi_find_char_handler("Z"); };
1624
+ globalThis.vi_fc_0 = async function(): Promise<void> { return globalThis.vi_find_char_handler("0"); };
1625
+ globalThis.vi_fc_1 = async function(): Promise<void> { return globalThis.vi_find_char_handler("1"); };
1626
+ globalThis.vi_fc_2 = async function(): Promise<void> { return globalThis.vi_find_char_handler("2"); };
1627
+ globalThis.vi_fc_3 = async function(): Promise<void> { return globalThis.vi_find_char_handler("3"); };
1628
+ globalThis.vi_fc_4 = async function(): Promise<void> { return globalThis.vi_find_char_handler("4"); };
1629
+ globalThis.vi_fc_5 = async function(): Promise<void> { return globalThis.vi_find_char_handler("5"); };
1630
+ globalThis.vi_fc_6 = async function(): Promise<void> { return globalThis.vi_find_char_handler("6"); };
1631
+ globalThis.vi_fc_7 = async function(): Promise<void> { return globalThis.vi_find_char_handler("7"); };
1632
+ globalThis.vi_fc_8 = async function(): Promise<void> { return globalThis.vi_find_char_handler("8"); };
1633
+ globalThis.vi_fc_9 = async function(): Promise<void> { return globalThis.vi_find_char_handler("9"); };
1634
+ globalThis.vi_fc_space = async function(): Promise<void> { return globalThis.vi_find_char_handler(" "); };
1635
+
1636
+ // Define vi-find-char mode with all the character bindings
1637
+ editor.defineMode("vi-find-char", null, [
1638
+ ["Escape", "vi_find_char_cancel"],
1639
+ // Letters
1640
+ ["a", "vi_fc_a"], ["b", "vi_fc_b"], ["c", "vi_fc_c"], ["d", "vi_fc_d"],
1641
+ ["e", "vi_fc_e"], ["f", "vi_fc_f"], ["g", "vi_fc_g"], ["h", "vi_fc_h"],
1642
+ ["i", "vi_fc_i"], ["j", "vi_fc_j"], ["k", "vi_fc_k"], ["l", "vi_fc_l"],
1643
+ ["m", "vi_fc_m"], ["n", "vi_fc_n"], ["o", "vi_fc_o"], ["p", "vi_fc_p"],
1644
+ ["q", "vi_fc_q"], ["r", "vi_fc_r"], ["s", "vi_fc_s"], ["t", "vi_fc_t"],
1645
+ ["u", "vi_fc_u"], ["v", "vi_fc_v"], ["w", "vi_fc_w"], ["x", "vi_fc_x"],
1646
+ ["y", "vi_fc_y"], ["z", "vi_fc_z"],
1647
+ ["A", "vi_fc_A"], ["B", "vi_fc_B"], ["C", "vi_fc_C"], ["D", "vi_fc_D"],
1648
+ ["E", "vi_fc_E"], ["F", "vi_fc_F"], ["G", "vi_fc_G"], ["H", "vi_fc_H"],
1649
+ ["I", "vi_fc_I"], ["J", "vi_fc_J"], ["K", "vi_fc_K"], ["L", "vi_fc_L"],
1650
+ ["M", "vi_fc_M"], ["N", "vi_fc_N"], ["O", "vi_fc_O"], ["P", "vi_fc_P"],
1651
+ ["Q", "vi_fc_Q"], ["R", "vi_fc_R"], ["S", "vi_fc_S"], ["T", "vi_fc_T"],
1652
+ ["U", "vi_fc_U"], ["V", "vi_fc_V"], ["W", "vi_fc_W"], ["X", "vi_fc_X"],
1653
+ ["Y", "vi_fc_Y"], ["Z", "vi_fc_Z"],
1654
+ // Digits
1655
+ ["0", "vi_fc_0"], ["1", "vi_fc_1"], ["2", "vi_fc_2"], ["3", "vi_fc_3"],
1656
+ ["4", "vi_fc_4"], ["5", "vi_fc_5"], ["6", "vi_fc_6"], ["7", "vi_fc_7"],
1657
+ ["8", "vi_fc_8"], ["9", "vi_fc_9"],
1658
+ // Common punctuation
1659
+ ["Space", "vi_fc_space"],
1660
+ ], true);
1661
+
1662
+ // Define vi-operator-pending mode
1663
+ editor.defineMode("vi-operator-pending", null, [
1664
+ // Count prefix in operator-pending mode (for d3w = delete 3 words)
1665
+ ["1", "vi_digit_1"],
1666
+ ["2", "vi_digit_2"],
1667
+ ["3", "vi_digit_3"],
1668
+ ["4", "vi_digit_4"],
1669
+ ["5", "vi_digit_5"],
1670
+ ["6", "vi_digit_6"],
1671
+ ["7", "vi_digit_7"],
1672
+ ["8", "vi_digit_8"],
1673
+ ["9", "vi_digit_9"],
1674
+ ["0", "vi_op_digit_0_or_line_start"], // 0 appends to count, or is motion to line start
1675
+
1676
+ // Motions for operators
1677
+ ["h", "vi_op_left"],
1678
+ ["j", "vi_op_down"],
1679
+ ["k", "vi_op_up"],
1680
+ ["l", "vi_op_right"],
1681
+ ["w", "vi_op_word"],
1682
+ ["b", "vi_op_word_back"],
1683
+ ["$", "vi_op_line_end"],
1684
+ ["g g", "vi_op_doc_start"],
1685
+ ["G", "vi_op_doc_end"],
1686
+ ["%", "vi_op_matching_bracket"],
1687
+
1688
+ // Text objects
1689
+ ["i", "vi_text_object_inner"],
1690
+ ["a", "vi_text_object_around"],
1691
+
1692
+ // Double operator = line operation
1693
+ ["d", "vi_delete_line"],
1694
+ ["c", "vi_change_line"],
1695
+ ["y", "vi_yank_line"],
1696
+
1697
+ // Cancel
1698
+ ["Escape", "vi_cancel"],
1699
+ ], true);
1700
+
1701
+ // Define vi-text-object mode (waiting for object type: w, ", (, etc.)
1702
+ editor.defineMode("vi-text-object", null, [
1703
+ // Word objects
1704
+ ["w", "vi_to_word"],
1705
+ ["W", "vi_to_WORD"],
1706
+
1707
+ // Quote objects
1708
+ ["\"", "vi_to_dquote"],
1709
+ ["'", "vi_to_squote"],
1710
+ ["`", "vi_to_backtick"],
1711
+
1712
+ // Bracket objects
1713
+ ["(", "vi_to_paren"],
1714
+ [")", "vi_to_paren"],
1715
+ ["b", "vi_to_paren"],
1716
+ ["{", "vi_to_brace"],
1717
+ ["}", "vi_to_brace"],
1718
+ ["B", "vi_to_brace"],
1719
+ ["[", "vi_to_bracket"],
1720
+ ["]", "vi_to_bracket"],
1721
+ ["<", "vi_to_angle"],
1722
+ [">", "vi_to_angle"],
1723
+
1724
+ // Cancel
1725
+ ["Escape", "vi_to_cancel"],
1726
+ ], true);
1727
+
1728
+ // Define vi-visual mode (character-wise)
1729
+ editor.defineMode("vi-visual", null, [
1730
+ // Count prefix
1731
+ ["1", "vi_digit_1"],
1732
+ ["2", "vi_digit_2"],
1733
+ ["3", "vi_digit_3"],
1734
+ ["4", "vi_digit_4"],
1735
+ ["5", "vi_digit_5"],
1736
+ ["6", "vi_digit_6"],
1737
+ ["7", "vi_digit_7"],
1738
+ ["8", "vi_digit_8"],
1739
+ ["9", "vi_digit_9"],
1740
+ ["0", "vi_vis_line_start"], // 0 moves to line start in visual mode
1741
+
1742
+ // Motions (extend selection)
1743
+ ["h", "vi_vis_left"],
1744
+ ["j", "vi_vis_down"],
1745
+ ["k", "vi_vis_up"],
1746
+ ["l", "vi_vis_right"],
1747
+ ["w", "vi_vis_word"],
1748
+ ["b", "vi_vis_word_back"],
1749
+ ["e", "vi_vis_word_end"],
1750
+ ["$", "vi_vis_line_end"],
1751
+ ["^", "vi_vis_line_start"],
1752
+ ["g g", "vi_vis_doc_start"],
1753
+ ["G", "vi_vis_doc_end"],
1754
+
1755
+ // Switch to line mode
1756
+ ["V", "vi_visual_toggle_line"],
1757
+
1758
+ // Operators
1759
+ ["d", "vi_vis_delete"],
1760
+ ["x", "vi_vis_delete"],
1761
+ ["c", "vi_vis_change"],
1762
+ ["s", "vi_vis_change"],
1763
+ ["y", "vi_vis_yank"],
1764
+
1765
+ // Exit
1766
+ ["Escape", "vi_vis_escape"],
1767
+ ["v", "vi_vis_escape"], // v again exits visual mode
1768
+ ], true);
1769
+
1770
+ // Define vi-visual-line mode (line-wise)
1771
+ editor.defineMode("vi-visual-line", null, [
1772
+ // Count prefix
1773
+ ["1", "vi_digit_1"],
1774
+ ["2", "vi_digit_2"],
1775
+ ["3", "vi_digit_3"],
1776
+ ["4", "vi_digit_4"],
1777
+ ["5", "vi_digit_5"],
1778
+ ["6", "vi_digit_6"],
1779
+ ["7", "vi_digit_7"],
1780
+ ["8", "vi_digit_8"],
1781
+ ["9", "vi_digit_9"],
1782
+
1783
+ // Line motions (extend selection by lines)
1784
+ ["j", "vi_vline_down"],
1785
+ ["k", "vi_vline_up"],
1786
+ ["g g", "vi_vis_doc_start"],
1787
+ ["G", "vi_vis_doc_end"],
1788
+
1789
+ // Switch to char mode
1790
+ ["v", "vi_visual_toggle_line"],
1791
+
1792
+ // Operators
1793
+ ["d", "vi_vis_delete"],
1794
+ ["x", "vi_vis_delete"],
1795
+ ["c", "vi_vis_change"],
1796
+ ["s", "vi_vis_change"],
1797
+ ["y", "vi_vis_yank"],
1798
+
1799
+ // Exit
1800
+ ["Escape", "vi_vis_escape"],
1801
+ ["V", "vi_vis_escape"], // V again exits visual-line mode
1802
+ ], true);
1803
+
1804
+ // Define vi-visual-block mode (column/block selection)
1805
+ editor.defineMode("vi-visual-block", null, [
1806
+ // Count prefix
1807
+ ["1", "vi_digit_1"],
1808
+ ["2", "vi_digit_2"],
1809
+ ["3", "vi_digit_3"],
1810
+ ["4", "vi_digit_4"],
1811
+ ["5", "vi_digit_5"],
1812
+ ["6", "vi_digit_6"],
1813
+ ["7", "vi_digit_7"],
1814
+ ["8", "vi_digit_8"],
1815
+ ["9", "vi_digit_9"],
1816
+ ["0", "vi_vblock_line_start"],
1817
+
1818
+ // Motions (extend block selection)
1819
+ ["h", "vi_vblock_left"],
1820
+ ["j", "vi_vblock_down"],
1821
+ ["k", "vi_vblock_up"],
1822
+ ["l", "vi_vblock_right"],
1823
+ ["$", "vi_vblock_line_end"],
1824
+ ["^", "vi_vblock_line_start"],
1825
+
1826
+ // Switch to other visual modes
1827
+ ["v", "vi_vblock_toggle_char"],
1828
+ ["V", "vi_vblock_toggle_line"],
1829
+
1830
+ // Operators
1831
+ ["d", "vi_vblock_delete"],
1832
+ ["x", "vi_vblock_delete"],
1833
+ ["c", "vi_vblock_change"],
1834
+ ["s", "vi_vblock_change"],
1835
+ ["y", "vi_vblock_yank"],
1836
+
1837
+ // Exit
1838
+ ["Escape", "vi_vblock_escape"],
1839
+ ["C-v", "vi_vblock_escape"], // Ctrl-v again exits visual-block mode
1840
+ ], true);
1841
+
1842
+ // ============================================================================
1843
+ // Register Commands
1844
+ // ============================================================================
1845
+
1846
+ // Navigation commands
1847
+ const navCommands = [
1848
+ ["vi_left", "move_left"],
1849
+ ["vi_down", "move_down"],
1850
+ ["vi_up", "move_up"],
1851
+ ["vi_right", "move_right"],
1852
+ ["vi_word", "move_word"],
1853
+ ["vi_word_back", "move_word_back"],
1854
+ ["vi_word_end", "move_word_end"],
1855
+ ["vi_line_start", "move_line_start"],
1856
+ ["vi_line_end", "move_line_end"],
1857
+ ["vi_doc_start", "move_doc_start"],
1858
+ ["vi_doc_end", "move_doc_end"],
1859
+ ["vi_page_down", "page_down"],
1860
+ ["vi_page_up", "page_up"],
1861
+ ["vi_half_page_down", "half_page_down"],
1862
+ ["vi_half_page_up", "half_page_up"],
1863
+ ["vi_center_cursor", "center_cursor"],
1864
+ ["vi_search_forward", "search_forward"],
1865
+ ["vi_search_backward", "search_backward"],
1866
+ ["vi_find_next", "find_next"],
1867
+ ["vi_find_prev", "find_prev"],
1868
+ ["vi_find_char_f", "find_char_f"],
1869
+ ["vi_find_char_t", "find_char_t"],
1870
+ ["vi_find_char_F", "find_char_F"],
1871
+ ["vi_find_char_T", "find_char_T"],
1872
+ ["vi_find_char_repeat", "find_char_repeat"],
1873
+ ["vi_find_char_repeat_reverse", "find_char_repeat_reverse"],
1874
+ ];
1875
+
1876
+ for (const [name, key] of navCommands) {
1877
+ editor.registerCommand(`%cmd.${key}`, `%cmd.${key}`, name, "vi-normal");
1878
+ }
1879
+
1880
+ // Mode commands
1881
+ const modeCommands = [
1882
+ ["vi_insert_before", "insert_before"],
1883
+ ["vi_insert_after", "insert_after"],
1884
+ ["vi_insert_line_start", "insert_line_start"],
1885
+ ["vi_insert_line_end", "insert_line_end"],
1886
+ ["vi_open_below", "open_below"],
1887
+ ["vi_open_above", "open_above"],
1888
+ ["vi_escape", "return_to_normal"],
1889
+ ];
1890
+
1891
+ for (const [name, key] of modeCommands) {
1892
+ editor.registerCommand(`%cmd.${key}`, `%cmd.${key}`, name, "vi-normal");
1893
+ }
1894
+
1895
+ // Operator commands
1896
+ const opCommands = [
1897
+ ["vi_delete_operator", "delete_operator"],
1898
+ ["vi_change_operator", "change_operator"],
1899
+ ["vi_yank_operator", "yank_operator"],
1900
+ ["vi_delete_line", "delete_line"],
1901
+ ["vi_change_line", "change_line"],
1902
+ ["vi_yank_line", "yank_line"],
1903
+ ["vi_delete_char", "delete_char"],
1904
+ ["vi_delete_char_before", "delete_char_before"],
1905
+ ["vi_substitute", "substitute"],
1906
+ ["vi_delete_to_end", "delete_to_end"],
1907
+ ["vi_change_to_end", "change_to_end"],
1908
+ ["vi_paste_after", "paste_after"],
1909
+ ["vi_paste_before", "paste_before"],
1910
+ ["vi_undo", "undo"],
1911
+ ["vi_redo", "redo"],
1912
+ ["vi_join", "join_lines"],
1913
+ ];
1914
+
1915
+ for (const [name, key] of opCommands) {
1916
+ editor.registerCommand(`%cmd.${key}`, `%cmd.${key}`, name, "vi-normal");
1917
+ }
1918
+
1919
+ // ============================================================================
1920
+ // Colon Command Mode (:w, :q, :wq, :q!, :e, etc.)
1921
+ // ============================================================================
1922
+
1923
+ // Start command mode - shows ":" prompt at the bottom
1924
+ globalThis.vi_command_mode = function (): void {
1925
+ editor.startPrompt(":", "vi-command");
1926
+ };
1927
+
1928
+ // Handle command execution when user presses Enter
1929
+ globalThis.vi_command_handler = async function (args: { prompt_type: string; input: string }): Promise<boolean> {
1930
+ if (args.prompt_type !== "vi-command") {
1931
+ return false; // Not our prompt, let other handlers process it
1932
+ }
1933
+
1934
+ const input = args.input.trim();
1935
+ if (!input) {
1936
+ return true; // Empty command, just dismiss
1937
+ }
1938
+
1939
+ // Parse the command
1940
+ const result = await executeViCommand(input);
1941
+
1942
+ if (result.error) {
1943
+ editor.setStatus(`E: ${result.error}`);
1944
+ } else if (result.message) {
1945
+ editor.setStatus(result.message);
1946
+ }
1947
+
1948
+ return true; // We handled it
1949
+ };
1950
+
1951
+ interface CommandResult {
1952
+ error?: string;
1953
+ message?: string;
1954
+ }
1955
+
1956
+ // Command definition for the command table
1957
+ interface CommandDef {
1958
+ name: string; // Full command name
1959
+ minAbbrev: number; // Minimum abbreviation length (e.g., 1 for "w" -> "write")
1960
+ allowBang: boolean; // Whether command accepts ! suffix
1961
+ hasArgs: boolean; // Whether command accepts arguments
1962
+ }
1963
+
1964
+ // Command table - defines all supported commands with their abbreviations
1965
+ // Vim allows any unambiguous prefix of a command name
1966
+ const commandTable: CommandDef[] = [
1967
+ // File operations
1968
+ { name: "write", minAbbrev: 1, allowBang: true, hasArgs: true }, // :w, :wri, :write
1969
+ { name: "quit", minAbbrev: 1, allowBang: true, hasArgs: false }, // :q, :qu, :quit
1970
+ { name: "wq", minAbbrev: 2, allowBang: true, hasArgs: false }, // :wq
1971
+ { name: "wall", minAbbrev: 2, allowBang: false, hasArgs: false }, // :wa, :wall
1972
+ { name: "qall", minAbbrev: 2, allowBang: true, hasArgs: false }, // :qa, :qall
1973
+ { name: "wqall", minAbbrev: 3, allowBang: false, hasArgs: false }, // :wqa, :wqall
1974
+ { name: "xit", minAbbrev: 1, allowBang: false, hasArgs: false }, // :x, :xit (same as :wq)
1975
+ { name: "exit", minAbbrev: 3, allowBang: false, hasArgs: false }, // :exi, :exit
1976
+ { name: "edit", minAbbrev: 1, allowBang: true, hasArgs: true }, // :e, :ed, :edit
1977
+ { name: "enew", minAbbrev: 3, allowBang: true, hasArgs: false }, // :ene, :enew
1978
+ { name: "saveas", minAbbrev: 3, allowBang: false, hasArgs: true }, // :sav, :saveas
1979
+
1980
+ // Buffer navigation
1981
+ { name: "next", minAbbrev: 1, allowBang: true, hasArgs: false }, // :n, :next
1982
+ { name: "previous", minAbbrev: 4, allowBang: true, hasArgs: false }, // :prev, :previous
1983
+ { name: "bnext", minAbbrev: 2, allowBang: false, hasArgs: false }, // :bn, :bnext
1984
+ { name: "bprevious", minAbbrev: 2, allowBang: false, hasArgs: false },// :bp, :bprev, :bprevious
1985
+ { name: "bdelete", minAbbrev: 2, allowBang: true, hasArgs: false }, // :bd, :bdelete
1986
+ { name: "buffer", minAbbrev: 1, allowBang: false, hasArgs: true }, // :b, :buffer
1987
+ { name: "buffers", minAbbrev: 2, allowBang: false, hasArgs: false }, // :bu, :buffers (same as :ls)
1988
+ { name: "ls", minAbbrev: 2, allowBang: false, hasArgs: false }, // :ls
1989
+ { name: "files", minAbbrev: 3, allowBang: false, hasArgs: false }, // :fil, :files
1990
+
1991
+ // Splits
1992
+ { name: "split", minAbbrev: 2, allowBang: false, hasArgs: true }, // :sp, :split
1993
+ { name: "vsplit", minAbbrev: 2, allowBang: false, hasArgs: true }, // :vs, :vsplit
1994
+ { name: "new", minAbbrev: 3, allowBang: false, hasArgs: true }, // :new
1995
+ { name: "vnew", minAbbrev: 3, allowBang: false, hasArgs: true }, // :vne, :vnew
1996
+ { name: "only", minAbbrev: 2, allowBang: true, hasArgs: false }, // :on, :only
1997
+ { name: "close", minAbbrev: 3, allowBang: true, hasArgs: false }, // :clo, :close
1998
+
1999
+ // Tabs (mapped to buffers in Fresh)
2000
+ { name: "tabnew", minAbbrev: 4, allowBang: false, hasArgs: true }, // :tabn, :tabnew
2001
+ { name: "tabedit", minAbbrev: 4, allowBang: false, hasArgs: true }, // :tabe, :tabedit
2002
+ { name: "tabclose", minAbbrev: 4, allowBang: true, hasArgs: false }, // :tabc, :tabclose
2003
+ { name: "tabnext", minAbbrev: 5, allowBang: false, hasArgs: false }, // :tabne, :tabnext (note: different from :tabn)
2004
+ { name: "tabprevious", minAbbrev: 4, allowBang: false, hasArgs: false }, // :tabp, :tabprevious
2005
+
2006
+ // Quickfix (mapped to diagnostics in Fresh)
2007
+ { name: "copen", minAbbrev: 3, allowBang: false, hasArgs: false }, // :cop, :copen
2008
+ { name: "cclose", minAbbrev: 3, allowBang: false, hasArgs: false }, // :ccl, :cclose
2009
+ { name: "cnext", minAbbrev: 2, allowBang: true, hasArgs: false }, // :cn, :cnext
2010
+ { name: "cprevious", minAbbrev: 2, allowBang: true, hasArgs: false },// :cp, :cprev, :cprevious
2011
+ { name: "cfirst", minAbbrev: 3, allowBang: true, hasArgs: false }, // :cfir, :cfirst
2012
+ { name: "clast", minAbbrev: 3, allowBang: true, hasArgs: false }, // :cla, :clast
2013
+
2014
+ // Search and replace
2015
+ { name: "nohlsearch", minAbbrev: 3, allowBang: false, hasArgs: false }, // :noh, :nohlsearch
2016
+ { name: "substitute", minAbbrev: 1, allowBang: false, hasArgs: true }, // :s, :substitute
2017
+ { name: "global", minAbbrev: 1, allowBang: false, hasArgs: true }, // :g, :global
2018
+ { name: "vglobal", minAbbrev: 2, allowBang: false, hasArgs: true }, // :vg, :vglobal
2019
+
2020
+ // Undo/redo
2021
+ { name: "undo", minAbbrev: 1, allowBang: true, hasArgs: false }, // :u, :undo
2022
+ { name: "redo", minAbbrev: 3, allowBang: false, hasArgs: false }, // :red, :redo
2023
+
2024
+ // Settings
2025
+ { name: "set", minAbbrev: 2, allowBang: false, hasArgs: true }, // :se, :set
2026
+
2027
+ // Info commands
2028
+ { name: "pwd", minAbbrev: 2, allowBang: false, hasArgs: false }, // :pw, :pwd
2029
+ { name: "cd", minAbbrev: 2, allowBang: false, hasArgs: true }, // :cd
2030
+ { name: "file", minAbbrev: 1, allowBang: false, hasArgs: true }, // :f, :file
2031
+ { name: "help", minAbbrev: 1, allowBang: false, hasArgs: true }, // :h, :help
2032
+ { name: "version", minAbbrev: 3, allowBang: false, hasArgs: false }, // :ver, :version
2033
+
2034
+ // Other
2035
+ { name: "marks", minAbbrev: 4, allowBang: false, hasArgs: false }, // :mark, :marks
2036
+ { name: "registers", minAbbrev: 3, allowBang: false, hasArgs: false },// :reg, :registers
2037
+ { name: "jumps", minAbbrev: 2, allowBang: false, hasArgs: false }, // :ju, :jumps
2038
+ { name: "syntax", minAbbrev: 2, allowBang: false, hasArgs: true }, // :sy, :syntax
2039
+ { name: "read", minAbbrev: 1, allowBang: false, hasArgs: true }, // :r, :read
2040
+ { name: "grep", minAbbrev: 2, allowBang: false, hasArgs: true }, // :gr, :grep
2041
+ { name: "vimgrep", minAbbrev: 3, allowBang: false, hasArgs: true }, // :vim, :vimgrep
2042
+ { name: "make", minAbbrev: 3, allowBang: true, hasArgs: true }, // :mak, :make
2043
+ { name: "ascii", minAbbrev: 2, allowBang: false, hasArgs: false }, // :as, :ascii
2044
+ { name: "revert", minAbbrev: 3, allowBang: false, hasArgs: false }, // :rev, :revert (Fresh-specific)
2045
+ ];
2046
+
2047
+ // Find a command by name or abbreviation
2048
+ function findCommand(input: string): CommandDef | null {
2049
+ // Exact match first
2050
+ for (const cmd of commandTable) {
2051
+ if (cmd.name === input) {
2052
+ return cmd;
2053
+ }
2054
+ }
2055
+
2056
+ // Then try abbreviation matching
2057
+ const matches: CommandDef[] = [];
2058
+ for (const cmd of commandTable) {
2059
+ // Input must be at least minAbbrev chars and be a prefix of the command name
2060
+ if (input.length >= cmd.minAbbrev && cmd.name.startsWith(input)) {
2061
+ matches.push(cmd);
2062
+ }
2063
+ }
2064
+
2065
+ // Return only if unambiguous
2066
+ if (matches.length === 1) {
2067
+ return matches[0];
2068
+ }
2069
+
2070
+ // Handle special short aliases that vim supports even if ambiguous
2071
+ // These are the classic vim abbreviations that always work
2072
+ const shortAliases: Record<string, string> = {
2073
+ "w": "write",
2074
+ "q": "quit",
2075
+ "e": "edit",
2076
+ "n": "next",
2077
+ "N": "previous",
2078
+ "b": "buffer",
2079
+ "f": "file",
2080
+ "h": "help",
2081
+ "u": "undo",
2082
+ "r": "read",
2083
+ "s": "substitute",
2084
+ "g": "global",
2085
+ "x": "xit",
2086
+ };
2087
+
2088
+ if (shortAliases[input]) {
2089
+ return commandTable.find(c => c.name === shortAliases[input]) || null;
2090
+ }
2091
+
2092
+ return null;
2093
+ }
2094
+
2095
+ // Execute a vi command and return result
2096
+ async function executeViCommand(cmd: string): Promise<CommandResult> {
2097
+ // Handle pure line numbers first (e.g., :42)
2098
+ const lineNumMatch = cmd.match(/^(\d+)$/);
2099
+ if (lineNumMatch) {
2100
+ const lineNum = parseInt(lineNumMatch[1], 10);
2101
+ return gotoLine(lineNum);
2102
+ }
2103
+
2104
+ // Handle range prefix with command (e.g., :1,10d or :%d)
2105
+ // Supported range formats: %, ., $, 'a, line numbers, and combinations with ,
2106
+ let processedCmd = cmd;
2107
+ let range: string | null = null;
2108
+
2109
+ const rangePattern = /^([%.$]|\d+|'[a-z])?(?:,([%.$]|\d+|'[a-z]))?\s*(.*)$/;
2110
+ const rangeMatch = cmd.match(rangePattern);
2111
+ if (rangeMatch && rangeMatch[3]) {
2112
+ // There's a command after the range
2113
+ range = (rangeMatch[1] || "") + (rangeMatch[2] ? "," + rangeMatch[2] : "");
2114
+ processedCmd = rangeMatch[3];
2115
+ }
2116
+
2117
+ // Handle special commands that start with symbols
2118
+ if (processedCmd.startsWith("!")) {
2119
+ // Shell command - not implemented
2120
+ return { error: editor.t("error.shell_not_supported") };
2121
+ }
2122
+
2123
+ // Handle +cmd syntax for :e +10 file (open file at line 10)
2124
+ let plusCmd: string | null = null;
2125
+ if (processedCmd.startsWith("+")) {
2126
+ const plusMatch = processedCmd.match(/^\+(\S*)\s*(.*)/);
2127
+ if (plusMatch) {
2128
+ plusCmd = plusMatch[1] || "$"; // + alone means go to end
2129
+ processedCmd = plusMatch[2];
2130
+ }
2131
+ }
2132
+
2133
+ // Split command into command name and arguments
2134
+ // Supports: cmd, cmd!, cmd args, cmd! args
2135
+ const match = processedCmd.match(/^([a-zA-Z]\w*)(!)?(?:\s+(.*))?$/);
2136
+ if (!match) {
2137
+ // Maybe it's just a command name without arguments
2138
+ if (processedCmd.match(/^[a-zA-Z]+$/)) {
2139
+ const cmdDef = findCommand(processedCmd);
2140
+ if (cmdDef) {
2141
+ return executeCommand(cmdDef.name, false, null, range);
2142
+ }
2143
+ }
2144
+ return { error: editor.t("error.not_valid_command", { cmd: processedCmd }) };
2145
+ }
2146
+
2147
+ const [, commandInput, bang, args] = match;
2148
+ const force = bang === "!";
2149
+
2150
+ // Look up the command
2151
+ const cmdDef = findCommand(commandInput);
2152
+ if (!cmdDef) {
2153
+ return { error: editor.t("error.unknown_command", { cmd: commandInput }) };
2154
+ }
2155
+
2156
+ // Validate bang usage
2157
+ if (force && !cmdDef.allowBang) {
2158
+ return { error: editor.t("error.command_no_bang", { cmd: cmdDef.name }) };
2159
+ }
2160
+
2161
+ // Execute the command
2162
+ return executeCommand(cmdDef.name, force, args || null, range);
2163
+ }
2164
+
2165
+ // Execute a resolved command
2166
+ async function executeCommand(
2167
+ command: string,
2168
+ force: boolean,
2169
+ args: string | null,
2170
+ _range: string | null // Range support is limited for now
2171
+ ): Promise<CommandResult> {
2172
+
2173
+ switch (command) {
2174
+ case "write": {
2175
+ // :w - save current file
2176
+ // :w filename - save as filename (not implemented yet)
2177
+ if (args) {
2178
+ return { error: editor.t("error.save_as_not_implemented") };
2179
+ }
2180
+ editor.executeAction("save");
2181
+ return { message: editor.t("status.file_saved") };
2182
+ }
2183
+
2184
+ case "quit": {
2185
+ // :q - quit (close buffer)
2186
+ // :q! - force quit (discard changes)
2187
+ const bufferId = editor.getActiveBufferId();
2188
+ if (!force && editor.isBufferModified(bufferId)) {
2189
+ return { error: editor.t("error.no_write_since_change", { cmd: ":q!" }) };
2190
+ }
2191
+ editor.executeAction("close_buffer");
2192
+ return {};
2193
+ }
2194
+
2195
+ case "wq":
2196
+ case "xit":
2197
+ case "exit": {
2198
+ // :wq or :x - save and quit
2199
+ editor.executeAction("save");
2200
+ editor.executeAction("close_buffer");
2201
+ return {};
2202
+ }
2203
+
2204
+ case "wall": {
2205
+ // :wa - save all buffers
2206
+ editor.executeAction("save_all");
2207
+ return { message: editor.t("status.all_files_saved") };
2208
+ }
2209
+
2210
+ case "qall": {
2211
+ // :qa - quit all
2212
+ // :qa! - force quit all
2213
+ if (force) {
2214
+ editor.executeAction("quit_all");
2215
+ } else {
2216
+ // Check if any buffer is modified
2217
+ const buffers = editor.listBuffers();
2218
+ for (const buf of buffers) {
2219
+ if (buf.modified) {
2220
+ return { error: editor.t("error.no_write_since_change", { cmd: ":qa!" }) };
2221
+ }
2222
+ }
2223
+ editor.executeAction("quit_all");
2224
+ }
2225
+ return {};
2226
+ }
2227
+
2228
+ case "wqall": {
2229
+ // :wqa or :xa - save all and quit
2230
+ editor.executeAction("save_all");
2231
+ editor.executeAction("quit_all");
2232
+ return {};
2233
+ }
2234
+
2235
+ case "edit": {
2236
+ // :e - reload current file
2237
+ // :e filename - open file
2238
+ // :e! - force reload (discard changes)
2239
+ if (!args) {
2240
+ if (force) {
2241
+ editor.executeAction("revert");
2242
+ return { message: editor.t("status.file_reverted_discarded") };
2243
+ }
2244
+ const bufferId = editor.getActiveBufferId();
2245
+ if (editor.isBufferModified(bufferId)) {
2246
+ return { error: editor.t("error.no_write_since_change", { cmd: ":e!" }) };
2247
+ }
2248
+ editor.executeAction("revert");
2249
+ return { message: editor.t("status.file_reverted") };
2250
+ }
2251
+ // Open the specified file
2252
+ const path = args.trim();
2253
+ editor.openFile(path, 0, 0);
2254
+ return {};
2255
+ }
2256
+
2257
+ case "enew": {
2258
+ // :enew - create new buffer in current split
2259
+ if (!force) {
2260
+ const bufferId = editor.getActiveBufferId();
2261
+ if (editor.isBufferModified(bufferId)) {
2262
+ return { error: editor.t("error.no_write_since_change", { cmd: ":enew!" }) };
2263
+ }
2264
+ }
2265
+ editor.executeAction("new_buffer");
2266
+ return {};
2267
+ }
2268
+
2269
+ case "revert": {
2270
+ // :revert - Fresh-specific command to reload file
2271
+ editor.executeAction("revert");
2272
+ return { message: editor.t("status.file_reverted") };
2273
+ }
2274
+
2275
+ case "next": {
2276
+ // :n - next buffer
2277
+ editor.executeAction("next_buffer");
2278
+ return {};
2279
+ }
2280
+
2281
+ case "previous": {
2282
+ // :prev - previous buffer
2283
+ editor.executeAction("prev_buffer");
2284
+ return {};
2285
+ }
2286
+
2287
+ case "bnext": {
2288
+ // :bn - next buffer
2289
+ editor.executeAction("next_buffer");
2290
+ return {};
2291
+ }
2292
+
2293
+ case "bprevious": {
2294
+ // :bp - previous buffer
2295
+ editor.executeAction("prev_buffer");
2296
+ return {};
2297
+ }
2298
+
2299
+ case "bdelete": {
2300
+ // :bd - delete buffer (close)
2301
+ // :bd! - force close even if modified
2302
+ const bufferId = editor.getActiveBufferId();
2303
+ if (!force && editor.isBufferModified(bufferId)) {
2304
+ return { error: editor.t("error.no_write_since_change", { cmd: ":bd!" }) };
2305
+ }
2306
+ editor.executeAction("close_buffer");
2307
+ return {};
2308
+ }
2309
+
2310
+ case "buffer": {
2311
+ // :b [N] - go to buffer N
2312
+ // :b name - go to buffer matching name
2313
+ if (!args) {
2314
+ // Show current buffer info
2315
+ const bufferId = editor.getActiveBufferId();
2316
+ const info = editor.getBufferInfo(bufferId);
2317
+ if (info) {
2318
+ const name = info.path ? editor.pathBasename(info.path) : editor.t("info.no_name");
2319
+ return { message: editor.t("info.buffer", { id: String(info.id), name }) };
2320
+ }
2321
+ return {};
2322
+ }
2323
+ // Try to parse as buffer number
2324
+ const bufNum = parseInt(args.trim(), 10);
2325
+ if (!isNaN(bufNum)) {
2326
+ const buffers = editor.listBuffers();
2327
+ const target = buffers.find(b => b.id === bufNum);
2328
+ if (target) {
2329
+ editor.showBuffer(target.id);
2330
+ return {};
2331
+ }
2332
+ return { error: editor.t("error.buffer_not_found", { id: String(bufNum) }) };
2333
+ }
2334
+ // Try to match buffer by name
2335
+ const buffers = editor.listBuffers();
2336
+ const pattern = args.trim().toLowerCase();
2337
+ const matches = buffers.filter(b => {
2338
+ const name = b.path ? editor.pathBasename(b.path).toLowerCase() : "";
2339
+ return name.includes(pattern);
2340
+ });
2341
+ if (matches.length === 1) {
2342
+ editor.showBuffer(matches[0].id);
2343
+ return {};
2344
+ } else if (matches.length > 1) {
2345
+ return { error: editor.t("error.multiple_buffers_match", { pattern: args }) };
2346
+ }
2347
+ return { error: editor.t("error.no_buffer_matching", { pattern: args }) };
2348
+ }
2349
+
2350
+ case "buffers":
2351
+ case "ls":
2352
+ case "files": {
2353
+ // :ls - list buffers
2354
+ const buffers = editor.listBuffers();
2355
+ const lines = buffers.map(buf => {
2356
+ const modified = buf.modified ? " [+]" : "";
2357
+ const current = buf.id === editor.getActiveBufferId() ? "%" : " ";
2358
+ const name = buf.path ? editor.pathBasename(buf.path) : editor.t("info.no_name");
2359
+ return `${current}${buf.id}: ${name}${modified}`;
2360
+ });
2361
+ return { message: lines.join(" | ") || editor.t("info.no_buffers") };
2362
+ }
2363
+
2364
+ case "split": {
2365
+ // :sp - horizontal split
2366
+ editor.executeAction("split_horizontal");
2367
+ if (args) {
2368
+ // Open file in new split
2369
+ const path = args.trim();
2370
+ editor.openFile(path, 0, 0);
2371
+ }
2372
+ return {};
2373
+ }
2374
+
2375
+ case "vsplit": {
2376
+ // :vs - vertical split
2377
+ editor.executeAction("split_vertical");
2378
+ if (args) {
2379
+ // Open file in new split
2380
+ const path = args.trim();
2381
+ editor.openFile(path, 0, 0);
2382
+ }
2383
+ return {};
2384
+ }
2385
+
2386
+ case "new": {
2387
+ // :new - create new buffer in horizontal split
2388
+ editor.executeAction("split_horizontal");
2389
+ editor.executeAction("new_buffer");
2390
+ if (args) {
2391
+ const path = args.trim();
2392
+ editor.openFile(path, 0, 0);
2393
+ }
2394
+ return {};
2395
+ }
2396
+
2397
+ case "vnew": {
2398
+ // :vnew - create new buffer in vertical split
2399
+ editor.executeAction("split_vertical");
2400
+ editor.executeAction("new_buffer");
2401
+ if (args) {
2402
+ const path = args.trim();
2403
+ editor.openFile(path, 0, 0);
2404
+ }
2405
+ return {};
2406
+ }
2407
+
2408
+ case "only": {
2409
+ // :only - close all other splits
2410
+ editor.executeAction("close_other_splits");
2411
+ return {};
2412
+ }
2413
+
2414
+ case "close": {
2415
+ // :close - close current split (same as :q for Fresh)
2416
+ const bufferId = editor.getActiveBufferId();
2417
+ if (!force && editor.isBufferModified(bufferId)) {
2418
+ return { error: editor.t("error.no_write_since_change", { cmd: ":close!" }) };
2419
+ }
2420
+ editor.executeAction("close_buffer");
2421
+ return {};
2422
+ }
2423
+
2424
+ case "tabnew":
2425
+ case "tabedit": {
2426
+ // :tabnew - new tab (creates new buffer in Fresh)
2427
+ editor.executeAction("new_buffer");
2428
+ if (args) {
2429
+ const path = args.trim();
2430
+ editor.openFile(path, 0, 0);
2431
+ }
2432
+ return {};
2433
+ }
2434
+
2435
+ case "tabclose": {
2436
+ // :tabclose - close current tab/buffer
2437
+ const bufferId = editor.getActiveBufferId();
2438
+ if (!force && editor.isBufferModified(bufferId)) {
2439
+ return { error: editor.t("error.no_write_since_change", { cmd: ":tabclose!" }) };
2440
+ }
2441
+ editor.executeAction("close_buffer");
2442
+ return {};
2443
+ }
2444
+
2445
+ case "tabnext": {
2446
+ // :tabnext - next tab/buffer
2447
+ editor.executeAction("next_buffer");
2448
+ return {};
2449
+ }
2450
+
2451
+ case "tabprevious": {
2452
+ // :tabprev - previous tab/buffer
2453
+ editor.executeAction("prev_buffer");
2454
+ return {};
2455
+ }
2456
+
2457
+ case "copen": {
2458
+ // :copen - open diagnostics panel (Fresh equivalent)
2459
+ editor.executeAction("show_diagnostics");
2460
+ return {};
2461
+ }
2462
+
2463
+ case "cclose": {
2464
+ // :cclose - close diagnostics panel
2465
+ return { message: editor.t("info.close_diagnostics") };
2466
+ }
2467
+
2468
+ case "cnext": {
2469
+ // :cnext - next diagnostic
2470
+ editor.executeAction("goto_next_diagnostic");
2471
+ return {};
2472
+ }
2473
+
2474
+ case "cprevious": {
2475
+ // :cprev - previous diagnostic
2476
+ editor.executeAction("goto_prev_diagnostic");
2477
+ return {};
2478
+ }
2479
+
2480
+ case "cfirst": {
2481
+ // :cfirst - first diagnostic
2482
+ editor.executeAction("goto_first_diagnostic");
2483
+ return {};
2484
+ }
2485
+
2486
+ case "clast": {
2487
+ // :clast - last diagnostic
2488
+ editor.executeAction("goto_last_diagnostic");
2489
+ return {};
2490
+ }
2491
+
2492
+ case "nohlsearch": {
2493
+ // :noh - clear search highlighting
2494
+ editor.executeAction("clear_search");
2495
+ return {};
2496
+ }
2497
+
2498
+ case "substitute": {
2499
+ // :s - substitute (not implemented)
2500
+ // This would require parsing /pattern/replacement/flags
2501
+ return { error: editor.t("error.substitute_not_implemented") };
2502
+ }
2503
+
2504
+ case "global":
2505
+ case "vglobal": {
2506
+ // :g - global command (not implemented)
2507
+ return { error: editor.t("error.global_not_implemented") };
2508
+ }
2509
+
2510
+ case "undo": {
2511
+ // :undo - undo
2512
+ editor.executeAction("undo");
2513
+ return {};
2514
+ }
2515
+
2516
+ case "redo": {
2517
+ // :redo - redo
2518
+ editor.executeAction("redo");
2519
+ return {};
2520
+ }
2521
+
2522
+ case "set": {
2523
+ // :set - set options (limited implementation)
2524
+ if (!args) {
2525
+ return { error: editor.t("error.set_usage") };
2526
+ }
2527
+ return handleSetCommand(args);
2528
+ }
2529
+
2530
+ case "pwd": {
2531
+ // :pwd - print working directory
2532
+ const cwd = editor.getCwd();
2533
+ return { message: cwd };
2534
+ }
2535
+
2536
+ case "cd": {
2537
+ // :cd - change directory (info only, can't actually change)
2538
+ if (!args) {
2539
+ return { message: editor.getCwd() };
2540
+ }
2541
+ return { error: editor.t("error.cannot_change_directory") };
2542
+ }
2543
+
2544
+ case "file": {
2545
+ // :f - show current file info
2546
+ // :f name - rename current buffer (not implemented)
2547
+ if (args) {
2548
+ return { error: editor.t("error.rename_not_implemented") };
2549
+ }
2550
+ const bufferId = editor.getActiveBufferId();
2551
+ const info = editor.getBufferInfo(bufferId);
2552
+ if (info) {
2553
+ const modified = info.modified ? editor.t("info.modified") : "";
2554
+ const path = info.path || editor.t("info.no_name");
2555
+ const line = editor.getCursorLine();
2556
+ return { message: editor.t("info.file", { path, modified, line: String(line), bytes: String(info.length) }) };
2557
+ }
2558
+ return { error: editor.t("error.no_buffer") };
2559
+ }
2560
+
2561
+ case "help": {
2562
+ // :help - show help
2563
+ if (args) {
2564
+ return { message: editor.t("info.help_not_available", { topic: args }) };
2565
+ }
2566
+ return {
2567
+ message: editor.t("info.help_commands")
2568
+ };
2569
+ }
2570
+
2571
+ case "version": {
2572
+ // :version - show version
2573
+ return { message: editor.t("info.version") };
2574
+ }
2575
+
2576
+ case "marks": {
2577
+ // :marks - show marks (not implemented)
2578
+ return { error: editor.t("error.marks_not_implemented") };
2579
+ }
2580
+
2581
+ case "registers": {
2582
+ // :registers - show registers (not implemented)
2583
+ return { error: editor.t("error.registers_not_implemented") };
2584
+ }
2585
+
2586
+ case "jumps": {
2587
+ // :jumps - show jump list (not implemented)
2588
+ return { error: editor.t("error.jump_list_not_implemented") };
2589
+ }
2590
+
2591
+ case "syntax": {
2592
+ // :syntax - syntax info
2593
+ if (args === "off") {
2594
+ return { error: editor.t("error.syntax_cannot_disable") };
2595
+ }
2596
+ return { message: editor.t("status.syntax_always_on") };
2597
+ }
2598
+
2599
+ case "read": {
2600
+ // :r - read file into buffer (not implemented)
2601
+ return { error: editor.t("error.read_not_implemented") };
2602
+ }
2603
+
2604
+ case "saveas": {
2605
+ // :saveas - save as (not implemented)
2606
+ return { error: editor.t("error.saveas_not_implemented") };
2607
+ }
2608
+
2609
+ case "grep":
2610
+ case "vimgrep": {
2611
+ // :grep - search (use Fresh's grep)
2612
+ if (args) {
2613
+ // Could potentially pass args to search, but for now just open search
2614
+ editor.executeAction("search");
2615
+ return { message: editor.t("info.use_search_dialog", { pattern: args }) };
2616
+ }
2617
+ editor.executeAction("search");
2618
+ return {};
2619
+ }
2620
+
2621
+ case "make": {
2622
+ // :make - run build command (not implemented)
2623
+ return { error: editor.t("error.use_terminal") };
2624
+ }
2625
+
2626
+ case "ascii": {
2627
+ // :ascii - show ASCII value of char under cursor
2628
+ return { message: editor.t("info.status_bar_char") };
2629
+ }
2630
+
2631
+ default: {
2632
+ return { error: editor.t("error.unknown_command", { cmd: command }) };
2633
+ }
2634
+ }
2635
+ }
2636
+
2637
+ // Go to a specific line number
2638
+ async function gotoLine(lineNum: number): Promise<CommandResult> {
2639
+ if (lineNum < 1) {
2640
+ return { error: editor.t("error.line_must_be_positive") };
2641
+ }
2642
+
2643
+ const bufferId = editor.getActiveBufferId();
2644
+ const bufferLength = editor.getBufferLength(bufferId);
2645
+
2646
+ // Get the text to find the line offset
2647
+ const text = await editor.getBufferText(bufferId, 0, bufferLength);
2648
+ if (!text) {
2649
+ return { error: editor.t("error.cannot_read_buffer") };
2650
+ }
2651
+
2652
+ let lineStart = 0;
2653
+ let currentLine = 1;
2654
+
2655
+ for (let i = 0; i < text.length && currentLine < lineNum; i++) {
2656
+ if (text[i] === '\n') {
2657
+ currentLine++;
2658
+ lineStart = i + 1;
2659
+ }
2660
+ }
2661
+
2662
+ if (currentLine >= lineNum || lineStart < text.length) {
2663
+ editor.setBufferCursor(bufferId, lineStart);
2664
+ return {};
2665
+ }
2666
+
2667
+ // If requested line is beyond file, go to last line
2668
+ editor.executeAction("move_document_end");
2669
+ return { message: editor.t("status.line_beyond_end", { line: String(lineNum) }) };
2670
+ }
2671
+
2672
+ // Handle :set command options
2673
+ function handleSetCommand(args: string): CommandResult {
2674
+ const parts = args.split("=");
2675
+ const option = parts[0].trim();
2676
+ const value = parts.length > 1 ? parts[1].trim() : null;
2677
+
2678
+ switch (option) {
2679
+ case "number":
2680
+ case "nu": {
2681
+ // :set number - show line numbers
2682
+ const bufferId = editor.getActiveBufferId();
2683
+ editor.setLineNumbers(bufferId, true);
2684
+ return { message: editor.t("status.line_numbers_on") };
2685
+ }
2686
+
2687
+ case "nonumber":
2688
+ case "nonu": {
2689
+ // :set nonumber - hide line numbers
2690
+ const bufferId = editor.getActiveBufferId();
2691
+ editor.setLineNumbers(bufferId, false);
2692
+ return { message: editor.t("status.line_numbers_off") };
2693
+ }
2694
+
2695
+ case "wrap": {
2696
+ // :set wrap - enable line wrap
2697
+ editor.executeAction("toggle_wrap");
2698
+ return { message: editor.t("status.line_wrap_toggled") };
2699
+ }
2700
+
2701
+ case "nowrap": {
2702
+ // :set nowrap - disable line wrap
2703
+ editor.executeAction("toggle_wrap");
2704
+ return { message: editor.t("status.line_wrap_toggled") };
2705
+ }
2706
+
2707
+ default: {
2708
+ return { error: editor.t("error.unknown_option", { option }) };
2709
+ }
2710
+ }
2711
+ }
2712
+
2713
+ // Register event handler for prompt confirmation
2714
+ editor.on("prompt_confirmed", "vi_command_handler");
2715
+
2716
+ // ============================================================================
2717
+ // Toggle Command
2718
+ // ============================================================================
2719
+
2720
+ let viModeEnabled = false;
2721
+
2722
+ globalThis.vi_mode_toggle = function (): void {
2723
+ viModeEnabled = !viModeEnabled;
2724
+
2725
+ if (viModeEnabled) {
2726
+ switchMode("normal");
2727
+ editor.setStatus(editor.t("status.enabled"));
2728
+ } else {
2729
+ editor.setEditorMode(null);
2730
+ state.mode = "normal";
2731
+ state.pendingOperator = null;
2732
+ editor.setStatus(editor.t("status.disabled"));
2733
+ }
2734
+ };
2735
+
2736
+ editor.registerCommand(
2737
+ "%cmd.toggle_vi_mode",
2738
+ "%cmd.toggle_vi_mode_desc",
2739
+ "vi_mode_toggle",
2740
+ "normal",
2741
+ );
2742
+
2743
+ // ============================================================================
2744
+ // Initialization
2745
+ // ============================================================================
2746
+
2747
+ editor.setStatus(editor.t("status.loaded"));