@wwwyo/pi-vim 0.0.0-stage → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1,4359 @@
1
+ import {
2
+ CustomEditor,
3
+ type ExtensionAPI,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ Key,
7
+ matchesKey,
8
+ truncateToWidth,
9
+ visibleWidth,
10
+ } from "@earendil-works/pi-tui";
11
+ import {
12
+ ClipboardMirror,
13
+ type ClipboardReadFn,
14
+ readClipboardInChildProcess,
15
+ writeClipboardInChildProcess,
16
+ } from "./clipboard-mirror.js";
17
+ import {
18
+ type ClipboardMirrorPolicy,
19
+ DEFAULT_CLIPBOARD_MIRROR_POLICY,
20
+ type RegisterWriteSource,
21
+ resolveClipboardMirrorPolicy,
22
+ } from "./clipboard-policy.js";
23
+ import {
24
+ BLOCK_CURSOR_SHAPE,
25
+ type CursorShapeCleanup,
26
+ type CursorShapeRuntime,
27
+ type CursorShapeSequence,
28
+ enableCursorShapeSupport,
29
+ getCursorShapeRuntime,
30
+ hasPromptCursorMarker,
31
+ INSERT_CURSOR_SHAPE,
32
+ stripSoftwareCursorWhenHardwareCursorIsUsed,
33
+ } from "./cursor-shape.js";
34
+ import {
35
+ isBackspaceLikeInput,
36
+ isCountStarter,
37
+ isDigit,
38
+ isEnterLikeInput,
39
+ isEscapeLikeInput,
40
+ isPrintableChunk,
41
+ isPrintableInput,
42
+ } from "./input-keys.js";
43
+ import {
44
+ cancelModeChangeCommands,
45
+ createModeChangeHandler,
46
+ setModeChangeCommandRunnerForTests,
47
+ } from "./mode-change-command.js";
48
+ import {
49
+ buildModeColorizers,
50
+ buildOffBorderColor,
51
+ isNeutralBorder,
52
+ type ModeColorizers,
53
+ type ModeColorKey,
54
+ resolveModeColors,
55
+ } from "./mode-colors.js";
56
+ import { fitModeLabel } from "./mode-label.js";
57
+ import {
58
+ findCharMotionTarget,
59
+ findFirstNonWhitespaceColumn,
60
+ findParagraphMotionTarget,
61
+ getLineGraphemes,
62
+ reverseCharMotion,
63
+ type WordMotionClass,
64
+ } from "./motions.js";
65
+ import {
66
+ DEFAULT_EX_COMMAND_SETTINGS,
67
+ DEFAULT_INSERT_ESCAPE_SETTINGS,
68
+ type ExCommandSettings,
69
+ type InsertEscapeSettings,
70
+ readPiVimSettings,
71
+ resolveExCommandSettings,
72
+ resolveInsertEscapeSettings,
73
+ resolveSurfaceSyncMaps,
74
+ type SurfaceSyncMap,
75
+ } from "./settings.js";
76
+ import {
77
+ resolveDelimitedTextObjectRange,
78
+ resolveMatchingPairMotionTarget,
79
+ resolveWordTextObjectRange,
80
+ type TextObjectKind,
81
+ type TextObjectRange,
82
+ type WordTextObjectClass,
83
+ } from "./text-objects.js";
84
+ import type {
85
+ CharMotion,
86
+ LastCharMotion,
87
+ Mode,
88
+ PendingMotion,
89
+ PendingOperator,
90
+ } from "./types.js";
91
+ import {
92
+ CHAR_MOTION_KEYS,
93
+ CTRL_A,
94
+ CTRL_E,
95
+ CTRL_K,
96
+ CTRL_R,
97
+ CTRL_UNDERSCORE,
98
+ ESC_DOWN,
99
+ ESC_LEFT,
100
+ ESC_RIGHT,
101
+ ESC_UP,
102
+ NEWLINE,
103
+ NORMAL_KEYS,
104
+ } from "./types.js";
105
+ import {
106
+ clampVisualPosition,
107
+ getInclusiveEndColumn,
108
+ getVisualLineRange,
109
+ isVisualMode,
110
+ orderVisualEndpoints,
111
+ type VisualMode,
112
+ type VisualPosition,
113
+ } from "./visual.js";
114
+ import {
115
+ WordBoundaryCache,
116
+ type WordMotionDirection,
117
+ type WordMotionTarget,
118
+ } from "./word-boundary-cache.js";
119
+
120
+ export { setModeChangeCommandRunnerForTests };
121
+
122
+ const MODE_COLOR_KEYS: readonly ModeColorKey[] = [
123
+ "insert",
124
+ "normal",
125
+ "visual",
126
+ "ex",
127
+ ];
128
+
129
+ const BRACKETED_PASTE_START = "\x1b[200~";
130
+ const BRACKETED_PASTE_END = "\x1b[201~";
131
+ const BRACKETED_PASTE_END_TAIL = BRACKETED_PASTE_END.slice(1);
132
+ const MAX_COUNT = 9999;
133
+ const TEXT_INSERT_REPEAT_KEYS = new Set(["i", "a", "A", "I"]);
134
+ const OPEN_LINE_REPEAT_KEYS = new Set(["o", "O"]);
135
+ const REPEATABLE_COMMAND_START_KEYS = new Set([
136
+ "d",
137
+ "c",
138
+ "r",
139
+ "p",
140
+ "P",
141
+ "J",
142
+ "g",
143
+ ...TEXT_INSERT_REPEAT_KEYS,
144
+ ...OPEN_LINE_REPEAT_KEYS,
145
+ "D",
146
+ "C",
147
+ "S",
148
+ "s",
149
+ "x",
150
+ "X",
151
+ ]);
152
+ // Normal-mode commands that must never run while a visual selection is live.
153
+ // They are swallowed instead of falling through to the normal-mode dispatch.
154
+ const VISUAL_IGNORED_KEYS = new Set([
155
+ "p",
156
+ "P",
157
+ "r",
158
+ "J",
159
+ "u",
160
+ "U",
161
+ "~",
162
+ ">",
163
+ "<",
164
+ ".",
165
+ ":",
166
+ "i",
167
+ "a",
168
+ "A",
169
+ "I",
170
+ "R",
171
+ CTRL_R,
172
+ CTRL_UNDERSCORE,
173
+ ]);
174
+
175
+ // Pi's builtin slash commands. `pi.getCommands()` reports only extension,
176
+ // prompt and skill commands; the builtin list is not re-exported from the
177
+ // package's public entry point, so it is mirrored here. A name missing from
178
+ // this mirror simply falls through to "Unsupported ex command".
179
+ const EX_BUILTIN_COMMAND_NAMES: ReadonlySet<string> = new Set([
180
+ "settings",
181
+ "model",
182
+ "scoped-models",
183
+ "export",
184
+ "import",
185
+ "share",
186
+ "copy",
187
+ "name",
188
+ "session",
189
+ "changelog",
190
+ "hotkeys",
191
+ "fork",
192
+ "clone",
193
+ "tree",
194
+ "login",
195
+ "logout",
196
+ "new",
197
+ "compact",
198
+ "resume",
199
+ "reload",
200
+ "quit",
201
+ "trust",
202
+ ]);
203
+
204
+ type EditorSnapshot = {
205
+ text: string;
206
+ cursor: { line: number; col: number };
207
+ };
208
+
209
+ type CommandDispatchResult = void | PromiseLike<void>;
210
+
211
+ type DispatchState = {
212
+ snapshot: EditorSnapshot;
213
+ restore: () => void;
214
+ };
215
+
216
+ type RepeatRecording = {
217
+ /** Raw key inputs that reconstruct one repeatable normal-mode change. */
218
+ keys: string[];
219
+ /** Replay keys without command counts, used when `{count}.` overrides them. */
220
+ countOverrideKeys: string[];
221
+ /** Buffer mutation version before the command started. */
222
+ startChangeVersion: number;
223
+ /** True while the command is collecting insert-mode input until Escape. */
224
+ captureInsert: boolean;
225
+ /** True when a command completed even if it emitted no mutation event. */
226
+ forceCommit: boolean;
227
+ };
228
+
229
+ type RepeatableCommand = Pick<RepeatRecording, "keys" | "countOverrideKeys">;
230
+
231
+ type TransitionState = "none" | "undo" | "redo" | "restore";
232
+
233
+ type ModalEditorInternals = {
234
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
235
+ preferredVisualCol?: number | null;
236
+ lastAction?: string | null;
237
+ historyIndex?: number;
238
+ onChange?: (text: string) => void;
239
+ tui?: { requestRender?: () => void };
240
+ pushUndoSnapshot?: () => void;
241
+ setCursorCol?: (col: number) => void;
242
+ undoStack?: { readonly length: number; pop(): unknown; stack: unknown[] };
243
+ };
244
+
245
+ type CustomEditorConstructorArgs = ConstructorParameters<typeof CustomEditor>;
246
+
247
+ type ModalEditorOptions = {
248
+ labelColorizers?: ModeColorizers | null;
249
+ borderColorizers?: ModeColorizers | null;
250
+ // Per-mode paint policy for each surface. "mode" paints the mode color;
251
+ // "host" shows the host's current border color; "thinking" shows the host
252
+ // color only while the host border is away from its neutral resting default.
253
+ // Absent → every mode "mode" (paint the colorizers this editor was given).
254
+ borderSync?: SurfaceSyncMap | null;
255
+ labelSync?: SurfaceSyncMap | null;
256
+ // Renders Pi's neutral "thinking off" border color. Lets "thinking" tell the
257
+ // resting border apart from an active thinking level or another extension's
258
+ // highlight. When omitted, "thinking" treats every host border as neutral and
259
+ // always paints the mode color.
260
+ offBorderColor?: ((s: string) => string) | null;
261
+ // Reverse-video transform applied to the label. When the label defers to the
262
+ // host color it is wrapped with this so it keeps its block styling.
263
+ labelTransform?: ((s: string) => string) | null;
264
+ };
265
+
266
+ export class ModalEditor extends CustomEditor {
267
+ private mode: Mode = "insert";
268
+ private pendingMotion: PendingMotion = null;
269
+ private pendingTextObject: TextObjectKind | null = null;
270
+ private pendingOperator: PendingOperator = null;
271
+ private prefixCount: string = "";
272
+ private operatorCount: string = "";
273
+ private pendingG: boolean = false;
274
+ private pendingGCount: string = "";
275
+ private pendingReplace: boolean = false;
276
+ private visualAnchor: VisualPosition | null = null;
277
+ private pendingExCommand: string | null = null;
278
+ private acceptingBracketedPasteInExCommand: boolean = false;
279
+ private pendingEscWhileAcceptingBracketedPasteInExCommand: boolean = false;
280
+ private discardingPasteAfterNewlineInExCommand: boolean = false;
281
+ private lastCharMotion: LastCharMotion | null = null;
282
+ private discardingBracketedPasteInNormalMode: boolean = false;
283
+ private pendingEscWhileDiscardingBracketedPasteInNormalMode: boolean = false;
284
+ private wordBoundaryCache = new WordBoundaryCache();
285
+ private readonly redoStack: EditorSnapshot[] = [];
286
+ private lastRepeatableCommand: RepeatableCommand | null = null;
287
+ private repeatRecording: RepeatRecording | null = null;
288
+ private implicitInsertSuppressed: boolean = false;
289
+ private replayingRepeat: boolean = false;
290
+ private repeatReplayFailed: boolean = false;
291
+ private bufferChangeVersion: number = 0;
292
+ /**
293
+ * One undo window per vim change, opened on the recorder's change boundaries
294
+ * (or a visual mutating operator). `floor` is the host `undoStack.length` at
295
+ * open; `openVersion` the `bufferChangeVersion`. On close the host stack is
296
+ * collapsed to `floor + 1`, so one host snapshot equals one vim change.
297
+ */
298
+ private undoWindow: { floor: number; openVersion: number } | null = null;
299
+ private currentTransition: TransitionState = "none";
300
+ private insertEscapeSettings: InsertEscapeSettings = {
301
+ ...DEFAULT_INSERT_ESCAPE_SETTINGS,
302
+ };
303
+ // Pending `jj` insert-escape: armed by a single `j` in insert mode with the
304
+ // host undo depth at arm time, so the rollback can trim its own pushes when
305
+ // no undo window owns the session (e.g. after a host injection tainted it).
306
+ private pendingInsertEscape: { at: number; undoDepth: number } | null = null;
307
+ private insertEscapeNow: () => number = () => Date.now();
308
+ private onChangeHooked: boolean = false;
309
+ private readonly labelColorizers: ModeColorizers | null;
310
+ private readonly borderColorizers: ModeColorizers | null;
311
+ private readonly offBorderColor: ((s: string) => string) | null;
312
+ // Latest host-assigned border color and whether it is the neutral resting
313
+ // default. Kept as mutable instance fields (not closure locals) so the label
314
+ // resolver can read the same thinking state the border uses.
315
+ private borderBase: ((s: string) => string) | null = null;
316
+ private borderBaseIsNeutral = true;
317
+ // Per-mode paint policy for each surface (border and label). Null → every
318
+ // mode defaults to "mode" (paint the mode color).
319
+ private readonly borderSync: SurfaceSyncMap | null;
320
+ private readonly labelSync: SurfaceSyncMap | null;
321
+ // Reverse-video transform used by the label, so its defer-to-host branch
322
+ // matches the label's usual background-block styling.
323
+ private labelTransform: ((s: string) => string) | null = null;
324
+ private readonly cursorShapeRuntime: CursorShapeRuntime | null;
325
+ private lastCursorShapeSequence: CursorShapeSequence | null = null;
326
+ private lastLineCache = { l: "", w: 0, label: "", result: "" };
327
+
328
+ private unnamedRegister: string = "";
329
+ private preferRegisterForPut = false;
330
+ private clipboardMirrorPolicy: ClipboardMirrorPolicy =
331
+ DEFAULT_CLIPBOARD_MIRROR_POLICY;
332
+ private readonly clipboardMirror = new ClipboardMirror(
333
+ writeClipboardInChildProcess,
334
+ );
335
+ private clipboardReadFn: ClipboardReadFn = readClipboardInChildProcess;
336
+ private quitFn: () => void = () => {};
337
+ private notifyFn: (message: string) => void = () => {};
338
+ private modeChangeFn: (mode: Mode, prevMode: Mode) => void = () => {};
339
+ private exCommandSettings: ExCommandSettings = DEFAULT_EX_COMMAND_SETTINGS;
340
+ private commandNamesFn: () => ReadonlySet<string> = () =>
341
+ EX_BUILTIN_COMMAND_NAMES;
342
+ // Pi has no public command-dispatch API. The editor's own submit path is the
343
+ // one mechanism that reaches every command kind (builtin, extension, skill,
344
+ // prompt), so dispatching `:name` is literally the user typing `/name` and
345
+ // pressing Enter. Injectable so tests need no live pi runtime.
346
+ private runCommandFn: (commandLine: string) => CommandDispatchResult = (
347
+ commandLine,
348
+ ) => {
349
+ const submit = this.onSubmit as
350
+ | ((text: string) => CommandDispatchResult)
351
+ | undefined;
352
+ if (!submit) return;
353
+ this.setText(commandLine);
354
+ return submit(commandLine);
355
+ };
356
+ private pendingAsyncDispatches: number = 0;
357
+ private restoreLatestDispatchStateFn: (() => void) | null = null;
358
+
359
+ constructor(
360
+ tui: CustomEditorConstructorArgs[0],
361
+ theme: CustomEditorConstructorArgs[1],
362
+ kb: CustomEditorConstructorArgs[2],
363
+ opts?: ModalEditorOptions,
364
+ ) {
365
+ super(tui, theme, kb);
366
+ this.cursorShapeRuntime = getCursorShapeRuntime(tui);
367
+ this.labelColorizers = opts?.labelColorizers ?? null;
368
+ this.borderColorizers = opts?.borderColorizers ?? null;
369
+ this.offBorderColor = opts?.offBorderColor ?? null;
370
+ this.borderSync = opts?.borderSync ?? null;
371
+ this.labelSync = opts?.labelSync ?? null;
372
+ this.labelTransform = opts?.labelTransform ?? null;
373
+ this.installModeBorderColorizer();
374
+ }
375
+
376
+ setClipboardFn(fn: (text: string, signal?: AbortSignal) => unknown): void {
377
+ this.clipboardMirror.setWriteFn(
378
+ async (text: string, signal: AbortSignal) => {
379
+ await fn(text, signal);
380
+ },
381
+ );
382
+ }
383
+ setClipboardWriteTimeoutMs(timeoutMs: number): void {
384
+ this.clipboardMirror.setTimeoutMs(timeoutMs);
385
+ }
386
+ setClipboardReadFn(fn: ClipboardReadFn): void {
387
+ this.clipboardReadFn = fn;
388
+ }
389
+ setClipboardMirrorPolicy(policy: ClipboardMirrorPolicy): void {
390
+ this.clipboardMirrorPolicy = policy;
391
+ }
392
+ getClipboardMirrorPolicy(): ClipboardMirrorPolicy {
393
+ return this.clipboardMirrorPolicy;
394
+ }
395
+ setQuitFn(fn: () => void): void {
396
+ this.quitFn = fn;
397
+ }
398
+ setNotifyFn(fn: (message: string) => void): void {
399
+ this.notifyFn = fn;
400
+ }
401
+ setModeChangeFn(fn: (mode: Mode, prevMode: Mode) => void): void {
402
+ this.modeChangeFn = fn;
403
+ }
404
+ setRunCommandFn(fn: (commandLine: string) => CommandDispatchResult): void {
405
+ this.runCommandFn = fn;
406
+ }
407
+ setCommandNamesFn(fn: () => ReadonlySet<string>): void {
408
+ this.commandNamesFn = fn;
409
+ }
410
+ setExCommandSettings(settings: ExCommandSettings): void {
411
+ this.exCommandSettings = settings;
412
+ }
413
+ setInsertEscapeSettings(settings: InsertEscapeSettings): void {
414
+ this.insertEscapeSettings = settings;
415
+ }
416
+ setInsertEscapeClock(now: () => number): void {
417
+ this.insertEscapeNow = now;
418
+ }
419
+ getRegister(): string {
420
+ return this.unnamedRegister;
421
+ }
422
+ setRegister(text: string): void {
423
+ this.unnamedRegister = text;
424
+ this.preferRegisterForPut = false;
425
+ }
426
+ getMode(): Mode {
427
+ return this.mode;
428
+ }
429
+ getText(): string {
430
+ return this.getLines().join("\n");
431
+ }
432
+
433
+ private getActiveMode(): ModeColorKey {
434
+ if (this.pendingExCommand !== null) return "ex";
435
+ if (this.mode === "insert") return "insert";
436
+ if (isVisualMode(this.mode)) return "visual";
437
+ return "normal";
438
+ }
439
+
440
+ /**
441
+ * Resolve a surface (border or label) color for the active mode under its
442
+ * per-mode paint policy:
443
+ * - "mode" always paints the mode color;
444
+ * - "host" always shows the host's current border color;
445
+ * - "thinking" shows the host color while the host border is away from its
446
+ * neutral resting default, otherwise paints the mode color.
447
+ * A missing policy map defaults every mode to "mode". `deferWrap` is identity
448
+ * for the border and reverse-video for the label, so a deferred host color
449
+ * inherits the surface's normal styling.
450
+ */
451
+ private resolveSurfaceColor(
452
+ colorizers: ModeColorizers,
453
+ syncMap: SurfaceSyncMap | null,
454
+ text: string,
455
+ deferWrap: (s: string) => string,
456
+ ): string {
457
+ const mode = this.getActiveMode();
458
+ const modeColorFn = colorizers[mode] ?? ((s: string) => s);
459
+ const policy = syncMap ? syncMap[mode] : "mode";
460
+ if (
461
+ policy === "mode" ||
462
+ (policy === "thinking" && this.borderBaseIsNeutral)
463
+ ) {
464
+ return modeColorFn(text);
465
+ }
466
+ // Defer to the host border color ("host" always; "thinking" only while the
467
+ // host border is non-neutral), wrapped the way this surface styles its text.
468
+ const host = this.borderBase ?? modeColorFn;
469
+ return deferWrap(host(text));
470
+ }
471
+
472
+ // The border trap recolors the border getter and tracks the host's assigned
473
+ // border base for both surfaces. It is unnecessary — and skipped, so the
474
+ // border reference stays exactly what the host set — only when every border
475
+ // mode is "host" and every label mode is "mode" (the released-`false`
476
+ // defaults): the border shows the host color untouched and the label needs no
477
+ // host tracking.
478
+ private borderTrapNeeded(): boolean {
479
+ const borderAllHost = MODE_COLOR_KEYS.every(
480
+ (mode) => (this.borderSync ? this.borderSync[mode] : "mode") === "host",
481
+ );
482
+ const labelAllMode = MODE_COLOR_KEYS.every(
483
+ (mode) => (this.labelSync ? this.labelSync[mode] : "mode") === "mode",
484
+ );
485
+ return !(borderAllHost && labelAllMode);
486
+ }
487
+
488
+ private installModeBorderColorizer(): void {
489
+ if (!this.borderColorizers) return;
490
+ if (!this.borderTrapNeeded()) return;
491
+ // Capture the values the getter/setter close over so the traps never
492
+ // depend on late `this` rebinding. The accessors themselves are arrow
493
+ // functions, so their `this` is this editor instance.
494
+ const borderColorizers = this.borderColorizers;
495
+ const offBorderColor = this.offBorderColor;
496
+ // Seed the instance fields from whatever the host assigned before the trap
497
+ // was installed (usually the inherited default border).
498
+ this.borderBase = this.borderColor;
499
+ this.borderBaseIsNeutral = isNeutralBorder(this.borderBase, offBorderColor);
500
+ const resolvedBorderColor = (text: string) =>
501
+ this.resolveSurfaceColor(
502
+ borderColorizers,
503
+ this.borderSync,
504
+ text,
505
+ (s) => s,
506
+ );
507
+ // Pi assigns its default border color after extension editor construction.
508
+ // Keep a mode-aware getter installed and treat later assignments as the
509
+ // host base color, otherwise the border sync is overwritten in real
510
+ // sessions even though direct editor tests pass. Reclassify on every host
511
+ // assignment so a thinking-level cycle off -> on -> off is honored.
512
+ Object.defineProperty(this, "borderColor", {
513
+ get: () => resolvedBorderColor,
514
+ set: (next: unknown) => {
515
+ if (typeof next === "function") {
516
+ this.borderBase = next as (s: string) => string;
517
+ this.borderBaseIsNeutral = isNeutralBorder(
518
+ this.borderBase,
519
+ offBorderColor,
520
+ );
521
+ }
522
+ },
523
+ });
524
+ }
525
+
526
+ private setMode(mode: Mode = "insert"): void {
527
+ const prev = this.mode;
528
+ this.mode = mode;
529
+ // Leaving insert ends any host-tainted session, so the next implicit
530
+ // insert (post-submit or a re-entry) records normally again.
531
+ if (prev === "insert" && mode !== "insert") {
532
+ this.implicitInsertSuppressed = false;
533
+ }
534
+ if (prev !== mode) {
535
+ try {
536
+ this.modeChangeFn(mode, prev);
537
+ } catch {
538
+ // mode-change side effects must never break editing
539
+ }
540
+ }
541
+ }
542
+
543
+ override setText(text: string): void {
544
+ this.discardUndoWindow();
545
+ this.clearRedoStack();
546
+ this.clearRepeatState();
547
+ this.pendingInsertEscape = null;
548
+ // A full host buffer reset starts a fresh session: later typing is a new
549
+ // implicit insert, not a continuation of a host-tainted one.
550
+ this.implicitInsertSuppressed = false;
551
+ super.setText(text);
552
+ this.refreshPendingDispatchRestore(true);
553
+ }
554
+
555
+ override insertTextAtCursor(text: string): void {
556
+ this.discardUndoWindow();
557
+ this.cancelRepeatableCommand();
558
+ this.pendingInsertEscape = null;
559
+ // A host injection mid-insert taints the session: the injected text is not
560
+ // ours to replay, and neither is any typing that continues around it.
561
+ this.implicitInsertSuppressed = true;
562
+ super.insertTextAtCursor(text);
563
+ this.refreshPendingDispatchRestore(true);
564
+ }
565
+
566
+ /**
567
+ * While an async EX dispatch is pending, the prompt the settle handler will
568
+ * reapply must track the *latest* buffer, not only what `handleInput`
569
+ * produced. A wrapper mutating the prompt out of band through the public
570
+ * setters refreshes the snapshot here, so a late-settling dispatch that
571
+ * clears the prompt restores the wrapper's newer text instead of the stale
572
+ * pre-dispatch state.
573
+ *
574
+ * A public setter that empties the buffer is skipped: that is the async
575
+ * clear the restore exists to compensate for (or a wrapper clearing), so
576
+ * capturing it would defeat the reapply. `handleInput` still refreshes on any
577
+ * result, including an intentional emptying edit like `dd`.
578
+ */
579
+ private refreshPendingDispatchRestore(fromPublicSetter = false): void {
580
+ if (this.pendingAsyncDispatches === 0) return;
581
+ if (fromPublicSetter && this.getText() === "") return;
582
+ this.restoreLatestDispatchStateFn = this.captureDispatchState().restore;
583
+ }
584
+
585
+ private captureSnapshot(): EditorSnapshot {
586
+ const cursor = this.getCursor();
587
+ return {
588
+ text: this.getText(),
589
+ cursor: { line: cursor.line, col: cursor.col },
590
+ };
591
+ }
592
+
593
+ private requireRedoRestoreState(editor: ModalEditorInternals): {
594
+ lines: string[];
595
+ cursorLine?: number;
596
+ cursorCol?: number;
597
+ } {
598
+ const state = editor.state;
599
+ if (!state || !Array.isArray(state.lines)) {
600
+ throw new Error("Redo restore prerequisite: editor state unavailable");
601
+ }
602
+ return state as {
603
+ lines: string[];
604
+ cursorLine?: number;
605
+ cursorCol?: number;
606
+ };
607
+ }
608
+
609
+ private restoreSnapshot(snapshot: EditorSnapshot): void {
610
+ const editor = this as unknown as ModalEditorInternals;
611
+ const state = this.requireRedoRestoreState(editor);
612
+
613
+ const lines = snapshot.text.split("\n");
614
+ state.lines = lines.length > 0 ? lines : [""];
615
+
616
+ const maxLine = Math.max(0, state.lines.length - 1);
617
+ const cursorLine = Math.max(0, Math.min(snapshot.cursor.line, maxLine));
618
+ const line = state.lines[cursorLine] ?? "";
619
+ const cursorCol = Math.max(0, Math.min(snapshot.cursor.col, line.length));
620
+
621
+ state.cursorLine = cursorLine;
622
+ if (typeof editor.setCursorCol === "function") {
623
+ editor.setCursorCol(cursorCol);
624
+ } else {
625
+ state.cursorCol = cursorCol;
626
+ editor.preferredVisualCol = null;
627
+ }
628
+
629
+ this.invalidateWordBoundaryCache();
630
+
631
+ editor.historyIndex = -1;
632
+ editor.lastAction = null;
633
+ editor.onChange?.(this.getText());
634
+ editor.tui?.requestRender?.();
635
+ }
636
+
637
+ private snapshotChanged(a: EditorSnapshot, b: EditorSnapshot): boolean {
638
+ return (
639
+ a.text !== b.text ||
640
+ a.cursor.line !== b.cursor.line ||
641
+ a.cursor.col !== b.cursor.col
642
+ );
643
+ }
644
+
645
+ private withTransition<T>(
646
+ transition: Exclude<TransitionState, "none">,
647
+ action: () => T,
648
+ ): T {
649
+ const previousTransition = this.currentTransition;
650
+ this.currentTransition = transition;
651
+ try {
652
+ return action();
653
+ } finally {
654
+ this.currentTransition = previousTransition;
655
+ }
656
+ }
657
+
658
+ private performUndo(count: number = this.takeTotalCount(1)): void {
659
+ this.discardUndoWindow();
660
+ const maxSteps = Math.max(1, Math.min(MAX_COUNT, count));
661
+ for (let i = 0; i < maxSteps; i++) {
662
+ let changed = false;
663
+ this.withTransition("undo", () => {
664
+ const beforeUndo = this.captureSnapshot();
665
+ super.handleInput(CTRL_UNDERSCORE);
666
+ const afterUndo = this.captureSnapshot();
667
+
668
+ if (this.snapshotChanged(beforeUndo, afterUndo)) {
669
+ this.redoStack.push(beforeUndo);
670
+ changed = true;
671
+ }
672
+ });
673
+ if (!changed) break;
674
+ }
675
+ }
676
+
677
+ private performRedo(count: number = this.takeTotalCount(1)): void {
678
+ this.discardUndoWindow();
679
+ const maxSteps = Math.max(1, Math.min(MAX_COUNT, count));
680
+ const editor = this as unknown as ModalEditorInternals;
681
+
682
+ for (let i = 0; i < maxSteps; i++) {
683
+ const snapshot = this.redoStack[this.redoStack.length - 1];
684
+ if (!snapshot) break;
685
+
686
+ this.withTransition("redo", () => {
687
+ this.requireRedoRestoreState(editor);
688
+ if (typeof editor.pushUndoSnapshot !== "function") {
689
+ throw new Error(
690
+ "Redo restore prerequisite: pushUndoSnapshot unavailable",
691
+ );
692
+ }
693
+ editor.pushUndoSnapshot();
694
+ this.restoreSnapshot(snapshot);
695
+ this.redoStack.pop();
696
+ });
697
+ }
698
+ }
699
+
700
+ private clearRedoStack(): void {
701
+ this.redoStack.length = 0;
702
+ }
703
+
704
+ private invalidateWordBoundaryCache(): void {
705
+ this.wordBoundaryCache = new WordBoundaryCache();
706
+ }
707
+
708
+ private ensureOnChangeHook(): void {
709
+ if (this.onChangeHooked) return;
710
+
711
+ const editor = this as unknown as ModalEditorInternals;
712
+ const originalOnChange = editor.onChange;
713
+
714
+ editor.onChange = (text: string) => {
715
+ originalOnChange?.(text);
716
+ this.bufferChangeVersion++;
717
+ this.centralInvalidationCheck();
718
+ };
719
+
720
+ this.onChangeHooked = true;
721
+ }
722
+
723
+ private centralInvalidationCheck(): void {
724
+ if (this.redoStack.length === 0) return;
725
+ if (this.currentTransition !== "none") return;
726
+ this.clearRedoStack();
727
+ }
728
+
729
+ private isRepeatRecordingInProgress(): boolean {
730
+ return (
731
+ this.pendingMotion !== null ||
732
+ this.pendingTextObject !== null ||
733
+ this.pendingOperator !== null ||
734
+ this.pendingG ||
735
+ this.pendingReplace
736
+ );
737
+ }
738
+
739
+ private isRepeatableCommandStart(key: string): boolean {
740
+ return REPEATABLE_COMMAND_START_KEYS.has(key);
741
+ }
742
+
743
+ private isOperatorCountDigit(key: string): boolean {
744
+ return isDigit(key) && (key !== "0" || this.operatorCount.length > 0);
745
+ }
746
+
747
+ private shouldStripKeyFromCountOverride(key: string): boolean {
748
+ if (this.mode === "insert") return false;
749
+ if (this.pendingMotion || this.pendingTextObject || this.pendingReplace) {
750
+ return false;
751
+ }
752
+ if (!this.pendingOperator) return false;
753
+ return this.isOperatorCountDigit(key);
754
+ }
755
+
756
+ /**
757
+ * Every Enter encoding Pi submits on: legacy `\r` plus the Kitty keyboard
758
+ * protocol's CSI-u forms (`\x1b[13u`, numpad Enter, the modifier-0 variants).
759
+ * `\n` is deliberately excluded: pi-vim treats it as the shift+enter newline
760
+ * (see the implicit-insert newline dot-repeat test), so it stays captured as
761
+ * typed text rather than cancelling the recording.
762
+ */
763
+ private isSubmitEnterInput(key: string): boolean {
764
+ return key !== "\n" && matchesKey(key, "enter");
765
+ }
766
+
767
+ private shouldCancelInsertRepeatInput(key: string): boolean {
768
+ // A submit must cancel the recording, not be captured into it. A bare `\r`
769
+ // check missed the Kitty CSI-u Enter (`\x1b[13u`), letting it be recorded
770
+ // and then re-submitted on replay.
771
+ if (this.isSubmitEnterInput(key)) return true;
772
+ return key === "\t" && this.isShowingAutocomplete();
773
+ }
774
+
775
+ /**
776
+ * Dot-repeat is recorded by watching the key stream for one normal-mode
777
+ * change. The raw key stream is kept for plain `.`, while a second stream
778
+ * strips normal-mode command counts so `{count}.` can replace the stored
779
+ * count like Vim instead of multiplying it by repeated replay.
780
+ */
781
+ private prepareRepeatRecordingForInput(key: string): void {
782
+ if (this.replayingRepeat || this.pendingExCommand !== null) return;
783
+
784
+ // Visual-mode operators are not dot-repeatable; they clear the stored
785
+ // command instead of recording a normal-mode key stream that would replay
786
+ // as something else entirely.
787
+ if (isVisualMode(this.mode)) return;
788
+
789
+ if (this.mode === "insert") {
790
+ if (this.shouldCancelInsertRepeatInput(key)) {
791
+ this.clearRepeatState();
792
+ // A submit or a completion replaces the buffer context the armed `j`
793
+ // belonged to; a `j` typed after it starts a fresh sequence, if any.
794
+ this.pendingInsertEscape = null;
795
+ // A submit clears the host undo stack and an autocomplete accept injects
796
+ // host text; either way `floor` no longer indexes a valid snapshot, so
797
+ // discard the window. There is no cross-submit undo.
798
+ this.discardUndoWindow();
799
+ // Enter submits and opens a fresh prompt, so the next implicit insert
800
+ // is repeatable again. Tab-completion is a host edit inside the
801
+ // current session, so it taints the rest of that session — continued
802
+ // typing must not resurrect a recording.
803
+ this.implicitInsertSuppressed = !this.isSubmitEnterInput(key);
804
+ return;
805
+ }
806
+ // Implicit insert (startup / post-submit): the editor is already in
807
+ // insert mode with no recording open, so a vim `i`/`a`/… entry never
808
+ // ran to seed one. Synthesize an `i` entry on the first real keystroke
809
+ // so the typed run becomes dot-repeatable, exactly as if the user had
810
+ // pressed `i` in normal mode. Escape finalizes it as the last change;
811
+ // Enter and Tab-completion stay excluded via the cancel check above, so
812
+ // a later replay can never submit. A session tainted by a mid-insert
813
+ // host edit stays suppressed until Escape leaves insert mode.
814
+ if (
815
+ !this.repeatRecording &&
816
+ !this.implicitInsertSuppressed &&
817
+ !isEscapeLikeInput(key)
818
+ ) {
819
+ this.beginImplicitInsertRecording();
820
+ }
821
+ if (this.repeatRecording?.captureInsert) {
822
+ this.repeatRecording.keys.push(key);
823
+ this.repeatRecording.countOverrideKeys.push(key);
824
+ }
825
+ return;
826
+ }
827
+
828
+ if (this.repeatRecording && !this.isRepeatRecordingInProgress()) {
829
+ this.cancelRepeatableCommand();
830
+ }
831
+
832
+ if (this.repeatRecording) {
833
+ this.repeatRecording.keys.push(key);
834
+ if (!this.shouldStripKeyFromCountOverride(key)) {
835
+ this.repeatRecording.countOverrideKeys.push(key);
836
+ }
837
+ return;
838
+ }
839
+
840
+ if (this.isRepeatRecordingInProgress()) return;
841
+ if (!this.isRepeatableCommandStart(key)) return;
842
+
843
+ this.repeatRecording = {
844
+ keys: [...this.prefixCount, key],
845
+ countOverrideKeys: [key],
846
+ startChangeVersion: this.bufferChangeVersion,
847
+ captureInsert: false,
848
+ forceCommit: false,
849
+ };
850
+ // A repeatable command starts a vim change; bracket it so every host push
851
+ // it produces (the delete of `cw`, the per-character pushes of `p`)
852
+ // collapses to one unit. This runs before the command body, so `floor`
853
+ // precedes the mutation.
854
+ this.openUndoWindow();
855
+ }
856
+
857
+ /**
858
+ * Open a recording for an implicit insert session as if `i` had been pressed.
859
+ * `captureInsert` starts true so the triggering keystroke and everything up
860
+ * to Escape is captured; replaying the stored `["i", …, Esc]` re-enters
861
+ * insert mode and re-types the run without ever submitting.
862
+ */
863
+ private beginImplicitInsertRecording(): void {
864
+ this.repeatRecording = {
865
+ keys: ["i"],
866
+ countOverrideKeys: ["i"],
867
+ startChangeVersion: this.bufferChangeVersion,
868
+ captureInsert: true,
869
+ forceCommit: false,
870
+ };
871
+ this.openUndoWindow();
872
+ }
873
+
874
+ private clearRepeatState(): void {
875
+ if (this.replayingRepeat) return;
876
+ this.repeatRecording = null;
877
+ this.lastRepeatableCommand = null;
878
+ }
879
+
880
+ private cancelRepeatableCommand(): void {
881
+ if (this.replayingRepeat) {
882
+ this.repeatReplayFailed = true;
883
+ return;
884
+ }
885
+ this.repeatRecording = null;
886
+ }
887
+
888
+ private forceCommitRepeatRecording(): void {
889
+ if (this.replayingRepeat || !this.repeatRecording) return;
890
+ this.repeatRecording.forceCommit = true;
891
+ }
892
+
893
+ /**
894
+ * Commit only after the watched command has completed and changed the buffer.
895
+ * This naturally ignores motions, yanks, failed edits, and aborted pending
896
+ * commands while keeping insert-mode changes open until Escape returns to
897
+ * normal mode.
898
+ */
899
+ private finishRepeatRecordingAfterInput(): void {
900
+ if (this.replayingRepeat) return;
901
+
902
+ const recording = this.repeatRecording;
903
+ if (!recording) return;
904
+
905
+ if (this.mode === "insert") {
906
+ recording.captureInsert = true;
907
+ return;
908
+ }
909
+
910
+ if (this.isRepeatRecordingInProgress()) return;
911
+
912
+ this.repeatRecording = null;
913
+ if (recording.keys.length === 0) return;
914
+
915
+ if (
916
+ recording.forceCommit ||
917
+ this.bufferChangeVersion !== recording.startChangeVersion
918
+ ) {
919
+ this.lastRepeatableCommand = {
920
+ keys: [...recording.keys],
921
+ countOverrideKeys: [...recording.countOverrideKeys],
922
+ };
923
+ }
924
+ }
925
+
926
+ /**
927
+ * Open a window for the starting change, unless one is already open (a
928
+ * multi-key change reuses its first key's window) or a replay is in flight
929
+ * (the outer replay window owns the span). `floor` is captured before this
930
+ * turn's mutation, so `undoStack[floor]` becomes the change's first (pre-
931
+ * change) host push. Resetting `lastAction` forces a pure insert's first
932
+ * character to push that anchor despite leftover fish-style coalescing.
933
+ */
934
+ private openUndoWindow(): void {
935
+ if (this.replayingRepeat) return;
936
+ if (this.undoWindow !== null) return;
937
+ const editor = this as unknown as ModalEditorInternals;
938
+ const stack = editor.undoStack?.stack;
939
+ if (!stack) return;
940
+ this.undoWindow = {
941
+ floor: stack.length,
942
+ openVersion: this.bufferChangeVersion,
943
+ };
944
+ editor.lastAction = null;
945
+ }
946
+
947
+ /**
948
+ * Close the window at a change boundary. If the buffer mutated, collapse to
949
+ * `floor + 1` (keep only the pre-change snapshot); a no-op change trims any
950
+ * stray anchor back to `floor`, leaving prior history untouched.
951
+ */
952
+ private closeUndoWindow(): void {
953
+ const window = this.undoWindow;
954
+ this.undoWindow = null;
955
+ if (!window) return;
956
+ if (this.bufferChangeVersion === window.openVersion) {
957
+ this.collapseUndoStackTo(window.floor);
958
+ return;
959
+ }
960
+ this.collapseUndoStackTo(window.floor + 1);
961
+ }
962
+
963
+ /** Drop the open window without collapsing (undo/redo/reset/dispatch/submit). */
964
+ private discardUndoWindow(): void {
965
+ this.undoWindow = null;
966
+ }
967
+
968
+ /**
969
+ * Trim the host undo stack to `target` entries. A direct length trim (no
970
+ * `setText`/`onChange`) so `centralInvalidationCheck` is not tripped.
971
+ */
972
+ private collapseUndoStackTo(target: number): void {
973
+ const stack = (this as unknown as ModalEditorInternals).undoStack?.stack;
974
+ if (!stack) return;
975
+ if (stack.length > target) stack.length = target;
976
+ }
977
+
978
+ /**
979
+ * Settle the window in the input `finally`, mirroring the recorder: hold
980
+ * while in insert mode or while an operator/motion/text-object is pending;
981
+ * otherwise close. Suppressed during a replay (the outer window collapses).
982
+ */
983
+ private settleUndoWindowAfterInput(): void {
984
+ if (this.replayingRepeat) return;
985
+ if (this.undoWindow === null) return;
986
+ if (this.mode === "insert") return;
987
+ if (this.isRepeatRecordingInProgress()) return;
988
+ this.closeUndoWindow();
989
+ }
990
+
991
+ private isTextInsertRepeatCommand(command: RepeatableCommand): boolean {
992
+ return TEXT_INSERT_REPEAT_KEYS.has(command.countOverrideKeys[0] ?? "");
993
+ }
994
+
995
+ private isOpenLineRepeatCommand(command: RepeatableCommand): boolean {
996
+ return OPEN_LINE_REPEAT_KEYS.has(command.countOverrideKeys[0] ?? "");
997
+ }
998
+
999
+ private getOpenLineRepeatReplayKeys(
1000
+ command: RepeatableCommand,
1001
+ overrideCount: number,
1002
+ ): string[] {
1003
+ if (command.countOverrideKeys[0] !== "O" || overrideCount <= 1) {
1004
+ return Array.from(
1005
+ { length: overrideCount },
1006
+ () => command.countOverrideKeys,
1007
+ ).flat();
1008
+ }
1009
+
1010
+ return Array.from({ length: overrideCount }, (_, index) =>
1011
+ index === 0
1012
+ ? command.countOverrideKeys
1013
+ : ["j", ...command.countOverrideKeys],
1014
+ ).flat();
1015
+ }
1016
+
1017
+ private getRepeatReplayKeys(
1018
+ command: RepeatableCommand,
1019
+ hasOverrideCount: boolean,
1020
+ overrideCount: number,
1021
+ ): string[] {
1022
+ if (!hasOverrideCount) return command.keys;
1023
+
1024
+ if (this.isOpenLineRepeatCommand(command)) {
1025
+ return this.getOpenLineRepeatReplayKeys(command, overrideCount);
1026
+ }
1027
+
1028
+ if (this.isTextInsertRepeatCommand(command)) {
1029
+ const [startKey, ...continuation] = command.countOverrideKeys;
1030
+ const finalKey = continuation[continuation.length - 1];
1031
+ const hasFinalEscape =
1032
+ finalKey !== undefined && isEscapeLikeInput(finalKey);
1033
+ const insertKeys = hasFinalEscape
1034
+ ? continuation.slice(0, -1)
1035
+ : continuation;
1036
+ return [
1037
+ startKey,
1038
+ ...Array.from({ length: overrideCount }, () => insertKeys).flat(),
1039
+ ...(hasFinalEscape ? [finalKey] : []),
1040
+ ];
1041
+ }
1042
+
1043
+ return [...String(overrideCount), ...command.countOverrideKeys];
1044
+ }
1045
+
1046
+ /**
1047
+ * Replay the last committed change. Plain `.` replays the original command,
1048
+ * including its count. A count before `.` replaces the original command count.
1049
+ * Replay runs with recording disabled so `.` itself never changes what is
1050
+ * stored as the last repeatable command.
1051
+ */
1052
+ private repeatLastCommand(): void {
1053
+ const command = this.lastRepeatableCommand;
1054
+ const hasOverrideCount = this.hasPendingCount();
1055
+ const overrideCount = this.takeTotalCount(1);
1056
+ if (!command || command.keys.length === 0) return;
1057
+
1058
+ const replayKeys = this.getRepeatReplayKeys(
1059
+ command,
1060
+ hasOverrideCount,
1061
+ overrideCount,
1062
+ );
1063
+ const beforeReplay = this.captureSnapshot();
1064
+ const beforeRegister = this.unnamedRegister;
1065
+ const beforePreferRegisterForPut = this.preferRegisterForPut;
1066
+
1067
+ this.repeatRecording = null;
1068
+ this.repeatReplayFailed = false;
1069
+ // A `.` replay is one undo unit, separate from the change it repeats. Wrap
1070
+ // it in one outer window opened before `replayingRepeat` goes high;
1071
+ // per-keystroke windows are suppressed during replay, so only this one
1072
+ // collapses.
1073
+ this.openUndoWindow();
1074
+ this.replayingRepeat = true;
1075
+ try {
1076
+ for (const key of replayKeys) {
1077
+ this.handleInput(key);
1078
+ if (this.repeatReplayFailed) break;
1079
+ }
1080
+ } finally {
1081
+ this.replayingRepeat = false;
1082
+ }
1083
+
1084
+ if (this.repeatReplayFailed) {
1085
+ this.clearPendingState();
1086
+ this.withTransition("restore", () => {
1087
+ this.restoreSnapshot(beforeReplay);
1088
+ });
1089
+ this.unnamedRegister = beforeRegister;
1090
+ this.preferRegisterForPut = beforePreferRegisterForPut;
1091
+ this.repeatReplayFailed = false;
1092
+ this.discardUndoWindow();
1093
+ return;
1094
+ }
1095
+
1096
+ this.closeUndoWindow();
1097
+
1098
+ if (hasOverrideCount) {
1099
+ this.lastRepeatableCommand = {
1100
+ keys: [...replayKeys],
1101
+ countOverrideKeys: [...command.countOverrideKeys],
1102
+ };
1103
+ }
1104
+ }
1105
+
1106
+ private applySyntheticEdit(mutation: () => void): void {
1107
+ const editor = this as unknown as ModalEditorInternals;
1108
+ if (!editor.state || !Array.isArray(editor.state.lines)) {
1109
+ throw new Error("Synthetic edit prerequisite: editor state unavailable");
1110
+ }
1111
+
1112
+ if (typeof editor.pushUndoSnapshot !== "function") {
1113
+ throw new Error(
1114
+ "Synthetic edit prerequisite: pushUndoSnapshot unavailable",
1115
+ );
1116
+ }
1117
+
1118
+ const textBefore = this.getText();
1119
+ const preCursorLine = editor.state.cursorLine;
1120
+ const preCursorCol = editor.state.cursorCol;
1121
+
1122
+ mutation();
1123
+
1124
+ if (this.getText() === textBefore) return;
1125
+
1126
+ const postLines = editor.state.lines.slice();
1127
+ const postCursorLine = editor.state.cursorLine;
1128
+ const postCursorCol = editor.state.cursorCol;
1129
+ const postPreferredCol = editor.preferredVisualCol;
1130
+
1131
+ const preLines = textBefore.split("\n");
1132
+ editor.state.lines = preLines.length > 0 ? preLines : [""];
1133
+ editor.state.cursorLine = preCursorLine;
1134
+ editor.state.cursorCol = preCursorCol;
1135
+ editor.pushUndoSnapshot();
1136
+
1137
+ editor.state.lines = postLines;
1138
+ editor.state.cursorLine = postCursorLine;
1139
+ editor.state.cursorCol = postCursorCol;
1140
+ editor.preferredVisualCol = postPreferredCol;
1141
+
1142
+ editor.onChange?.(this.getText());
1143
+ editor.tui?.requestRender?.();
1144
+ }
1145
+
1146
+ private startPendingExCommand(): void {
1147
+ this.pendingExCommand = ":";
1148
+ this.endBracketedPasteInExCommand();
1149
+ }
1150
+
1151
+ private clearPendingExCommand(): void {
1152
+ const shouldDiscardBracketedPasteTail =
1153
+ this.acceptingBracketedPasteInExCommand ||
1154
+ this.pendingEscWhileAcceptingBracketedPasteInExCommand;
1155
+
1156
+ this.pendingExCommand = null;
1157
+ this.endBracketedPasteInExCommand();
1158
+
1159
+ if (shouldDiscardBracketedPasteTail) {
1160
+ this.discardingBracketedPasteInNormalMode = true;
1161
+ this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
1162
+ }
1163
+ }
1164
+
1165
+ private clearPendingState(): void {
1166
+ this.pendingMotion = null;
1167
+ this.pendingTextObject = null;
1168
+ this.pendingOperator = null;
1169
+ this.prefixCount = "";
1170
+ this.operatorCount = "";
1171
+ this.pendingG = false;
1172
+ this.pendingGCount = "";
1173
+ this.pendingReplace = false;
1174
+ this.clearPendingExCommand();
1175
+ }
1176
+
1177
+ private endBracketedPasteInExCommand(): void {
1178
+ this.acceptingBracketedPasteInExCommand = false;
1179
+ this.pendingEscWhileAcceptingBracketedPasteInExCommand = false;
1180
+ this.discardingPasteAfterNewlineInExCommand = false;
1181
+ }
1182
+
1183
+ private retainDiscardedExPasteTailState(tail: string): void {
1184
+ const lastStart = tail.lastIndexOf(BRACKETED_PASTE_START);
1185
+ const lastEnd = tail.lastIndexOf(BRACKETED_PASTE_END);
1186
+ let endsWithStartPrefix = false;
1187
+ for (let length = 1; length < BRACKETED_PASTE_START.length; length++) {
1188
+ if (tail.endsWith(BRACKETED_PASTE_START.slice(0, length))) {
1189
+ endsWithStartPrefix = true;
1190
+ break;
1191
+ }
1192
+ }
1193
+ if (lastStart <= lastEnd && !endsWithStartPrefix) return;
1194
+
1195
+ this.acceptingBracketedPasteInExCommand = true;
1196
+ this.pendingEscWhileAcceptingBracketedPasteInExCommand = false;
1197
+ this.discardingPasteAfterNewlineInExCommand = true;
1198
+ }
1199
+
1200
+ /**
1201
+ * First-line-wait: a pasted newline never submits. The payload up to the
1202
+ * first newline becomes the pending command; the rest of that paste is
1203
+ * dropped. Only a typed Enter reaches `submitPendingExCommand`.
1204
+ */
1205
+ private takePastedExCommandText(payload: string): string {
1206
+ if (this.discardingPasteAfterNewlineInExCommand) return "";
1207
+
1208
+ const newline = payload.search(/[\r\n]/);
1209
+ if (newline === -1) return payload;
1210
+
1211
+ this.discardingPasteAfterNewlineInExCommand = true;
1212
+ return payload.slice(0, newline);
1213
+ }
1214
+
1215
+ private normalizePendingExCommandInput(data: string): string | null {
1216
+ let chunk = data;
1217
+ let normalized = "";
1218
+
1219
+ while (true) {
1220
+ if (this.acceptingBracketedPasteInExCommand) {
1221
+ if (this.pendingEscWhileAcceptingBracketedPasteInExCommand) {
1222
+ if (chunk.startsWith(BRACKETED_PASTE_END_TAIL)) {
1223
+ const tail = chunk.slice(BRACKETED_PASTE_END_TAIL.length);
1224
+ this.endBracketedPasteInExCommand();
1225
+ this.retainDiscardedExPasteTailState(tail);
1226
+ return null;
1227
+ }
1228
+
1229
+ normalized += this.takePastedExCommandText("\x1b");
1230
+ this.pendingEscWhileAcceptingBracketedPasteInExCommand = false;
1231
+ }
1232
+
1233
+ const end = chunk.indexOf(BRACKETED_PASTE_END);
1234
+ if (end !== -1) {
1235
+ normalized += this.takePastedExCommandText(chunk.slice(0, end));
1236
+ const tail = chunk.slice(end + BRACKETED_PASTE_END.length);
1237
+ this.endBracketedPasteInExCommand();
1238
+ this.retainDiscardedExPasteTailState(tail);
1239
+ return normalized.length > 0 ? normalized : null;
1240
+ }
1241
+
1242
+ if (isEscapeLikeInput(chunk)) {
1243
+ this.pendingEscWhileAcceptingBracketedPasteInExCommand = true;
1244
+ return normalized.length > 0 ? normalized : null;
1245
+ }
1246
+
1247
+ normalized += this.takePastedExCommandText(chunk);
1248
+ return normalized.length > 0 ? normalized : null;
1249
+ }
1250
+
1251
+ const start = chunk.indexOf(BRACKETED_PASTE_START);
1252
+ if (start === -1) {
1253
+ normalized += chunk;
1254
+ return normalized.length > 0 ? normalized : null;
1255
+ }
1256
+
1257
+ normalized += chunk.slice(0, start);
1258
+ chunk = chunk.slice(start + BRACKETED_PASTE_START.length);
1259
+ this.acceptingBracketedPasteInExCommand = true;
1260
+ if (chunk.length === 0) {
1261
+ return normalized.length > 0 ? normalized : null;
1262
+ }
1263
+ }
1264
+ }
1265
+
1266
+ private stripBracketedPasteInNormalMode(data: string): {
1267
+ filtered: string | null;
1268
+ stripped: boolean;
1269
+ } {
1270
+ let chunk = data;
1271
+ let stripped = false;
1272
+
1273
+ while (true) {
1274
+ if (this.discardingBracketedPasteInNormalMode) {
1275
+ stripped = true;
1276
+ const end = chunk.indexOf(BRACKETED_PASTE_END);
1277
+ if (end === -1) {
1278
+ return { filtered: null, stripped };
1279
+ }
1280
+ this.discardingBracketedPasteInNormalMode = false;
1281
+ this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
1282
+ chunk = chunk.slice(end + BRACKETED_PASTE_END.length);
1283
+ if (!chunk) return { filtered: null, stripped };
1284
+ }
1285
+
1286
+ const start = chunk.indexOf(BRACKETED_PASTE_START);
1287
+ if (start === -1) {
1288
+ return { filtered: chunk, stripped };
1289
+ }
1290
+
1291
+ stripped = true;
1292
+ const end = chunk.indexOf(
1293
+ BRACKETED_PASTE_END,
1294
+ start + BRACKETED_PASTE_START.length,
1295
+ );
1296
+ if (end === -1) {
1297
+ this.discardingBracketedPasteInNormalMode = true;
1298
+ const leading = chunk.slice(0, start);
1299
+ return { filtered: leading.length > 0 ? leading : null, stripped };
1300
+ }
1301
+
1302
+ chunk =
1303
+ chunk.slice(0, start) + chunk.slice(end + BRACKETED_PASTE_END.length);
1304
+ if (!chunk) return { filtered: null, stripped };
1305
+ }
1306
+ }
1307
+
1308
+ handleInput(data: string): void {
1309
+ try {
1310
+ this.handleInputCore(data);
1311
+ } finally {
1312
+ this.refreshPendingDispatchRestore();
1313
+ }
1314
+ }
1315
+
1316
+ private handleInputCore(data: string): void {
1317
+ this.ensureOnChangeHook();
1318
+
1319
+ if (this.pendingExCommand !== null) {
1320
+ const normalized = this.normalizePendingExCommandInput(data);
1321
+ if (normalized === null) return;
1322
+ data = normalized;
1323
+ } else if (this.mode !== "insert") {
1324
+ if (this.discardingBracketedPasteInNormalMode) {
1325
+ if (isEscapeLikeInput(data)) {
1326
+ if (this.pendingEscWhileDiscardingBracketedPasteInNormalMode) {
1327
+ this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
1328
+ this.discardingBracketedPasteInNormalMode = false;
1329
+ this.clearPendingState();
1330
+ this.cancelRepeatableCommand();
1331
+ return;
1332
+ } else {
1333
+ this.pendingEscWhileDiscardingBracketedPasteInNormalMode = true;
1334
+ this.clearPendingState();
1335
+ this.cancelRepeatableCommand();
1336
+ return;
1337
+ }
1338
+ } else if (this.pendingEscWhileDiscardingBracketedPasteInNormalMode) {
1339
+ if (data.startsWith(BRACKETED_PASTE_END_TAIL)) {
1340
+ this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
1341
+ this.discardingBracketedPasteInNormalMode = false;
1342
+ data = data.slice(BRACKETED_PASTE_END_TAIL.length);
1343
+ if (data.length === 0) {
1344
+ this.clearPendingState();
1345
+ this.cancelRepeatableCommand();
1346
+ return;
1347
+ }
1348
+ } else {
1349
+ this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
1350
+ }
1351
+ }
1352
+ }
1353
+
1354
+ const { filtered, stripped } = this.stripBracketedPasteInNormalMode(data);
1355
+ if (stripped) {
1356
+ this.clearPendingState();
1357
+ this.cancelRepeatableCommand();
1358
+ }
1359
+ if (filtered === null) return;
1360
+ data = filtered;
1361
+ }
1362
+
1363
+ this.prepareRepeatRecordingForInput(data);
1364
+ try {
1365
+ if (isEscapeLikeInput(data)) {
1366
+ this.handleEscape();
1367
+ return;
1368
+ }
1369
+
1370
+ if ("insert" === this.mode) {
1371
+ if (this.tryInsertEscape(data)) return;
1372
+ if (matchesKey(data, Key.shiftAlt("a")) || data === "\x1bA") {
1373
+ super.handleInput(CTRL_E);
1374
+ return;
1375
+ }
1376
+ if (matchesKey(data, Key.shiftAlt("i")) || data === "\x1bI") {
1377
+ super.handleInput(CTRL_A);
1378
+ return;
1379
+ }
1380
+ if (matchesKey(data, Key.alt("o")) || data === "\x1bo") {
1381
+ this.openLineBelow();
1382
+ return;
1383
+ }
1384
+ if (matchesKey(data, Key.shiftAlt("o")) || data === "\x1bO") {
1385
+ this.openLineAbove();
1386
+ return;
1387
+ }
1388
+ super.handleInput(data);
1389
+ return;
1390
+ }
1391
+
1392
+ if (this.pendingReplace) {
1393
+ this.pendingReplace = false;
1394
+ if (!isPrintableInput(data)) {
1395
+ this.prefixCount = "";
1396
+ this.operatorCount = "";
1397
+ this.cancelRepeatableCommand();
1398
+ return;
1399
+ }
1400
+
1401
+ const count = this.takeTotalCount(1);
1402
+ const cursor = this.getCursor();
1403
+ const line = this.getLines()[cursor.line] ?? "";
1404
+ const range = this.getGraphemeRangeAtCol(line, cursor.col, count);
1405
+ if (!range) {
1406
+ this.cancelRepeatableCommand();
1407
+ return;
1408
+ }
1409
+
1410
+ const before = line.slice(0, range.start);
1411
+ const after = line.slice(range.end);
1412
+ const replacement = data.repeat(count);
1413
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
1414
+ const text = this.getText();
1415
+ const newText =
1416
+ text.slice(0, lineStartAbs) +
1417
+ before +
1418
+ replacement +
1419
+ after +
1420
+ text.slice(lineStartAbs + line.length);
1421
+ const newCursorAbs =
1422
+ lineStartAbs + before.length + data.length * (count - 1);
1423
+ this.forceCommitRepeatRecording();
1424
+ this.replaceTextInBuffer(newText, newCursorAbs);
1425
+ return;
1426
+ }
1427
+
1428
+ if (this.pendingExCommand !== null) {
1429
+ this.handlePendingExCommand(data);
1430
+ return;
1431
+ }
1432
+
1433
+ if (this.pendingTextObject) {
1434
+ this.handlePendingTextObject(data);
1435
+ return;
1436
+ }
1437
+
1438
+ if (this.pendingMotion) {
1439
+ this.handlePendingMotion(data);
1440
+ return;
1441
+ }
1442
+
1443
+ if (this.pendingOperator === "d") {
1444
+ this.handlePendingDelete(data);
1445
+ return;
1446
+ }
1447
+
1448
+ if (this.pendingOperator === "c") {
1449
+ this.handlePendingChange(data);
1450
+ return;
1451
+ }
1452
+
1453
+ if (this.pendingOperator === "y") {
1454
+ this.handlePendingYank(data);
1455
+ return;
1456
+ }
1457
+
1458
+ if (isVisualMode(this.mode) && this.handleVisualMode(data)) return;
1459
+
1460
+ this.handleNormalMode(data);
1461
+ } finally {
1462
+ this.finishRepeatRecordingAfterInput();
1463
+ this.settleUndoWindowAfterInput();
1464
+ }
1465
+ }
1466
+
1467
+ private clearUnderlyingPasteStateIfActive(): void {
1468
+ const editor = this as unknown as {
1469
+ isInPaste?: boolean;
1470
+ pasteBuffer?: string;
1471
+ pasteCounter?: number;
1472
+ };
1473
+
1474
+ if (!editor.isInPaste) return;
1475
+
1476
+ editor.isInPaste = false;
1477
+ if (typeof editor.pasteBuffer === "string") {
1478
+ editor.pasteBuffer = "";
1479
+ }
1480
+ if (typeof editor.pasteCounter === "number") {
1481
+ editor.pasteCounter = 0;
1482
+ }
1483
+ }
1484
+
1485
+ private isHostPasteActive(): boolean {
1486
+ return (this as unknown as { isInPaste?: boolean }).isInPaste === true;
1487
+ }
1488
+
1489
+ private hostUndoDepth(): number {
1490
+ const stack = (this as unknown as ModalEditorInternals).undoStack?.stack;
1491
+ return stack ? stack.length : 0;
1492
+ }
1493
+
1494
+ /**
1495
+ * Rollback-style `jj` → Normal. The first `j` is already in the buffer
1496
+ * (typed with no delay); a second lone `j` within `timeoutMs` inserts too
1497
+ * and then both are deleted. Rollback instead of delay keeps keystrokes
1498
+ * latency-free, at the cost of a transient buffer state no render ever sees
1499
+ * (`requestRender` only schedules). A lone `jj` arrives only as two bare
1500
+ * single-character chunks: a whole paste lands as one longer chunk (or
1501
+ * while the host tracks `isInPaste`), so pasted `jj` never arms this.
1502
+ *
1503
+ * Returns true only when the escape fired and fully handled `data`.
1504
+ */
1505
+ private tryInsertEscape(data: string): boolean {
1506
+ if (
1507
+ data !== "j" ||
1508
+ this.replayingRepeat ||
1509
+ !this.insertEscapeSettings.enabled ||
1510
+ this.isHostPasteActive()
1511
+ ) {
1512
+ this.pendingInsertEscape = null;
1513
+ return false;
1514
+ }
1515
+ const pending = this.pendingInsertEscape;
1516
+ if (
1517
+ pending !== null &&
1518
+ this.insertEscapeNow() - pending.at <= this.insertEscapeSettings.timeoutMs
1519
+ ) {
1520
+ this.pendingInsertEscape = null;
1521
+ super.handleInput(data);
1522
+ this.rollbackInsertEscape(pending.undoDepth);
1523
+ return true;
1524
+ }
1525
+ this.pendingInsertEscape = {
1526
+ at: this.insertEscapeNow(),
1527
+ undoDepth: this.hostUndoDepth(),
1528
+ };
1529
+ return false;
1530
+ }
1531
+
1532
+ private rollbackInsertEscape(undoDepth: number): void {
1533
+ // Both `j`s sit immediately before the cursor: the armed one plus the one
1534
+ // just inserted above. Verify before cutting so a surprising host rewrite
1535
+ // (an extension shortcut swallowing `j`, say) degrades to typed text in
1536
+ // insert mode, never to a wrong deletion or a wrong mode change.
1537
+ const text = this.getText();
1538
+ const cursorAbs = this.getAbsoluteIndexFromCursor();
1539
+ if (text.slice(cursorAbs - 2, cursorAbs) !== "jj") return;
1540
+ // Bypass the register: this deletes escape letters, not a user edit, so
1541
+ // the unnamed register must keep whatever the session last yanked.
1542
+ this.replaceTextInBuffer(
1543
+ text.slice(0, cursorAbs - 2) + text.slice(cursorAbs),
1544
+ cursorAbs - 2,
1545
+ );
1546
+ this.rewriteInsertEscapeInRecording();
1547
+ // The open insert window collapses these pushes on close; a windowless
1548
+ // session (host-tainted, no recording) trims them back to the arm depth
1549
+ // instead, so `u` can never resurrect the escape letters.
1550
+ if (this.undoWindow === null) this.collapseUndoStackTo(undoDepth);
1551
+ // Same landing as `handleEscape`: normal mode, cursor back one character.
1552
+ this.setMode("normal");
1553
+ if (this.getCursor().col > 0) this.moveCursorBy(-1);
1554
+ }
1555
+
1556
+ private rewriteInsertEscapeInRecording(): void {
1557
+ const recording = this.repeatRecording;
1558
+ if (!recording) return;
1559
+ // The recorder already captured both `j`s as typed text. Swap each
1560
+ // trailing `jj` for the single Escape an `Esc` exit would have recorded,
1561
+ // so `.` replays the insert text without the escape letters.
1562
+ for (const stream of [recording.keys, recording.countOverrideKeys]) {
1563
+ if (
1564
+ stream.length >= 2 &&
1565
+ stream[stream.length - 1] === "j" &&
1566
+ stream[stream.length - 2] === "j"
1567
+ ) {
1568
+ stream.splice(stream.length - 2, 2, "\x1b");
1569
+ }
1570
+ }
1571
+ }
1572
+
1573
+ private handleEscape(): void {
1574
+ this.pendingInsertEscape = null;
1575
+ if (this.pendingExCommand !== null) {
1576
+ this.clearPendingExCommand();
1577
+ return;
1578
+ }
1579
+
1580
+ if (
1581
+ this.pendingMotion ||
1582
+ this.pendingTextObject ||
1583
+ this.pendingOperator ||
1584
+ this.prefixCount ||
1585
+ this.operatorCount ||
1586
+ this.pendingG ||
1587
+ this.pendingGCount ||
1588
+ this.pendingReplace
1589
+ ) {
1590
+ this.clearPendingState();
1591
+ this.cancelRepeatableCommand();
1592
+ return;
1593
+ }
1594
+ if (isVisualMode(this.mode)) {
1595
+ this.exitVisualMode();
1596
+ return;
1597
+ }
1598
+ if ("insert" === this.mode) {
1599
+ this.clearUnderlyingPasteStateIfActive();
1600
+ this.setMode("normal");
1601
+ if (this.getCursor().col > 0) this.moveCursorBy(-1);
1602
+ } else {
1603
+ super.handleInput("\x1b"); // pass escape to abort agent
1604
+ }
1605
+ }
1606
+
1607
+ private deleteLastPendingExCommandGrapheme(): void {
1608
+ const current = this.pendingExCommand ?? "";
1609
+ const graphemes = getLineGraphemes(current);
1610
+
1611
+ if (graphemes.length <= 1) {
1612
+ this.clearPendingExCommand();
1613
+ return;
1614
+ }
1615
+
1616
+ const previousGrapheme = graphemes[graphemes.length - 2];
1617
+ if (!previousGrapheme) {
1618
+ this.clearPendingExCommand();
1619
+ return;
1620
+ }
1621
+
1622
+ this.pendingExCommand = current.slice(0, previousGrapheme.end);
1623
+ }
1624
+
1625
+ private handlePendingExCommandControlChunk(data: string): boolean {
1626
+ if (
1627
+ !data.includes("\r") &&
1628
+ !data.includes("\n") &&
1629
+ !data.includes("\x7f") &&
1630
+ !data.includes("\x08")
1631
+ ) {
1632
+ return false;
1633
+ }
1634
+
1635
+ let printable = "";
1636
+ const flushPrintable = () => {
1637
+ if (!printable) return;
1638
+ this.pendingExCommand += printable;
1639
+ printable = "";
1640
+ };
1641
+
1642
+ for (const char of data) {
1643
+ if (char === "\r" || char === "\n") {
1644
+ flushPrintable();
1645
+ this.submitPendingExCommand();
1646
+ return true;
1647
+ }
1648
+
1649
+ if (char === "\x7f" || char === "\x08") {
1650
+ flushPrintable();
1651
+ this.deleteLastPendingExCommandGrapheme();
1652
+ if (this.pendingExCommand === null) {
1653
+ return true;
1654
+ }
1655
+ continue;
1656
+ }
1657
+
1658
+ const codePoint = char.codePointAt(0);
1659
+ if (codePoint === undefined || codePoint < 32 || codePoint === 127) {
1660
+ this.clearPendingExCommand();
1661
+ return true;
1662
+ }
1663
+
1664
+ printable += char;
1665
+ }
1666
+
1667
+ flushPrintable();
1668
+ return true;
1669
+ }
1670
+
1671
+ private handlePendingExCommand(data: string): void {
1672
+ if (isEnterLikeInput(data)) {
1673
+ this.submitPendingExCommand();
1674
+ return;
1675
+ }
1676
+
1677
+ if (isBackspaceLikeInput(data)) {
1678
+ this.deleteLastPendingExCommandGrapheme();
1679
+ return;
1680
+ }
1681
+
1682
+ if (this.handlePendingExCommandControlChunk(data)) {
1683
+ return;
1684
+ }
1685
+
1686
+ if (!isPrintableChunk(data)) {
1687
+ this.clearPendingExCommand();
1688
+ this.handleInput(data);
1689
+ return;
1690
+ }
1691
+
1692
+ this.pendingExCommand += data;
1693
+ }
1694
+
1695
+ private hasNonEmptyPrompt(): boolean {
1696
+ return this.getText().trim().length > 0;
1697
+ }
1698
+
1699
+ private static readonly EX_QUIT_NAMES = new Set([
1700
+ "q",
1701
+ "qa",
1702
+ "quit",
1703
+ "qall",
1704
+ "quitall",
1705
+ ]);
1706
+
1707
+ // Names pi-vim claims for real vim ex semantics later. They are never
1708
+ // dispatched to Pi, so a `/s` or `/w` extension installed today cannot
1709
+ // shadow a future `:s` substitute or `:w` write.
1710
+ private static readonly EX_RESERVED_NAMES = new Set([
1711
+ "s",
1712
+ "g",
1713
+ "v",
1714
+ "d",
1715
+ "m",
1716
+ "t",
1717
+ "co",
1718
+ "j",
1719
+ "w",
1720
+ "r",
1721
+ "normal",
1722
+ "sort",
1723
+ "&",
1724
+ ">",
1725
+ "<",
1726
+ ]);
1727
+
1728
+ private submitPendingExCommand(): void {
1729
+ const command = this.pendingExCommand?.slice(1).trim() ?? "";
1730
+ this.clearPendingExCommand();
1731
+ if (!command) return;
1732
+
1733
+ const force = command.endsWith("!");
1734
+ const quitName = force ? command.slice(0, -1) : command;
1735
+
1736
+ if (ModalEditor.EX_QUIT_NAMES.has(quitName)) {
1737
+ if (!force && this.hasNonEmptyPrompt()) {
1738
+ this.notifyFn(`Prompt is not empty; use :${command}! to quit anyway`);
1739
+ return;
1740
+ }
1741
+
1742
+ this.quitFn();
1743
+ return;
1744
+ }
1745
+
1746
+ // `:!cmd` submits `!cmd` through the same seam the slash bridge uses, so it
1747
+ // reaches Pi's bash mode: `onSubmit` runs a leading `!` as a shell command
1748
+ // (`!!` excludes it from context). The quit check above already claimed the
1749
+ // `:q!` family, so only a bare leading `!` lands here. `:!` with no command
1750
+ // is unsupported, and piDispatch gates it like every other bridge dispatch.
1751
+ if (command.startsWith("!")) {
1752
+ const shellCommand = command.replace(/^!+/, "").trim();
1753
+ if (shellCommand && this.exCommandSettings.piDispatch) {
1754
+ this.dispatchThroughSubmit(command);
1755
+ } else {
1756
+ this.notifyFn(`Unsupported ex command: :${command}`);
1757
+ }
1758
+ return;
1759
+ }
1760
+
1761
+ const separator = command.search(/\s/);
1762
+ const name = separator === -1 ? command : command.slice(0, separator);
1763
+ const args = separator === -1 ? "" : command.slice(separator + 1).trim();
1764
+ const bareName = name.endsWith("!") ? name.slice(0, -1) : name;
1765
+
1766
+ if (ModalEditor.EX_RESERVED_NAMES.has(bareName)) {
1767
+ this.notifyFn(`Reserved ex command: :${name}`);
1768
+ return;
1769
+ }
1770
+
1771
+ if (this.exCommandSettings.piDispatch && this.commandNamesFn().has(name)) {
1772
+ this.dispatchSlashCommand(name, args);
1773
+ return;
1774
+ }
1775
+
1776
+ this.notifyFn(`Unsupported ex command: :${command}`);
1777
+ }
1778
+
1779
+ private captureDispatchState(): DispatchState {
1780
+ const snapshot = this.captureSnapshot();
1781
+ const savedRedo = [...this.redoStack];
1782
+ const savedRepeat = this.lastRepeatableCommand;
1783
+ const undoStack = (this as unknown as ModalEditorInternals).undoStack;
1784
+ const savedUndoDepth = undoStack?.length ?? 0;
1785
+
1786
+ return {
1787
+ snapshot,
1788
+ restore: () => {
1789
+ this.restoreSnapshot(snapshot);
1790
+ while (undoStack && undoStack.length > savedUndoDepth) undoStack.pop();
1791
+ this.redoStack.length = 0;
1792
+ this.redoStack.push(...savedRedo);
1793
+ this.lastRepeatableCommand = savedRepeat;
1794
+ },
1795
+ };
1796
+ }
1797
+
1798
+ private dispatchSlashCommand(name: string, args: string): void {
1799
+ this.dispatchThroughSubmit(args ? `/${name} ${args}` : `/${name}`);
1800
+ }
1801
+
1802
+ /**
1803
+ * Run a command line (`/name args` or `!cmd`) through the editor's own submit
1804
+ * path, then put the composed prompt back. Reapply the latest prompt state
1805
+ * when an asynchronous submit settles too, because some builtin routes clear
1806
+ * the prompt only after awaiting work. No command reads that buffer as an
1807
+ * argument, so restoring the composed state is always semantically correct.
1808
+ *
1809
+ * The dispatch is a side effect the user never typed, so it must leave no
1810
+ * trace: besides the text and cursor, the undo depth, the redo stack and the
1811
+ * repeatable command all go back to what they were. Without that, the
1812
+ * dispatch's own `setText("")` would sit on the undo stack and swallow the
1813
+ * next `u`.
1814
+ */
1815
+ private dispatchThroughSubmit(commandLine: string): void {
1816
+ // A dispatch is not a user change: discard any window so no collapse runs
1817
+ // mid-dispatch and no floor is stranded. The bridge's `savedUndoDepth`
1818
+ // restore then erases the phantom `setText("")` checkpoint.
1819
+ this.discardUndoWindow();
1820
+ const dispatchState = this.captureDispatchState();
1821
+
1822
+ if (
1823
+ this.exCommandSettings.copyInputToClipboard &&
1824
+ dispatchState.snapshot.text
1825
+ ) {
1826
+ this.clipboardMirror.mirror(dispatchState.snapshot.text);
1827
+ }
1828
+
1829
+ let result: CommandDispatchResult;
1830
+ try {
1831
+ result = this.runCommandFn(commandLine);
1832
+ } finally {
1833
+ dispatchState.restore();
1834
+ }
1835
+
1836
+ if (!result || typeof result.then !== "function") return;
1837
+
1838
+ this.pendingAsyncDispatches++;
1839
+ this.restoreLatestDispatchStateFn = dispatchState.restore;
1840
+ const settleDispatch = (): void => {
1841
+ try {
1842
+ if (this.getText() === "") this.restoreLatestDispatchStateFn?.();
1843
+ } finally {
1844
+ this.pendingAsyncDispatches--;
1845
+ if (this.pendingAsyncDispatches === 0) {
1846
+ this.restoreLatestDispatchStateFn = null;
1847
+ }
1848
+ }
1849
+ };
1850
+ void Promise.resolve(result).then(settleDispatch, settleDispatch);
1851
+ }
1852
+
1853
+ private takeTotalCount(defaultValue: number = 1): number {
1854
+ const prefixRaw = this.prefixCount;
1855
+ const operatorRaw = this.operatorCount;
1856
+ this.prefixCount = "";
1857
+ this.operatorCount = "";
1858
+
1859
+ if (!prefixRaw && !operatorRaw) return defaultValue;
1860
+
1861
+ const parse = (raw: string): number | null => {
1862
+ if (!raw) return null;
1863
+ const parsed = Number.parseInt(raw, 10);
1864
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
1865
+ return parsed;
1866
+ };
1867
+
1868
+ const prefix = parse(prefixRaw);
1869
+ const operator = parse(operatorRaw);
1870
+
1871
+ if (prefix === null && operator === null) return defaultValue;
1872
+
1873
+ const total =
1874
+ prefix !== null && operator !== null
1875
+ ? prefix * operator
1876
+ : (prefix ?? operator ?? defaultValue);
1877
+
1878
+ if (!Number.isFinite(total) || total <= 0) return defaultValue;
1879
+ return Math.min(MAX_COUNT, total);
1880
+ }
1881
+
1882
+ private hasPendingCount(): boolean {
1883
+ return this.prefixCount.length > 0 || this.operatorCount.length > 0;
1884
+ }
1885
+
1886
+ private opDigit(data: string): boolean {
1887
+ if (!this.isOperatorCountDigit(data)) return false;
1888
+ this.operatorCount += data;
1889
+ return true;
1890
+ }
1891
+
1892
+ private cancelPendingOperator(data: string): void {
1893
+ this.pendingOperator = null;
1894
+ this.prefixCount = "";
1895
+ this.operatorCount = "";
1896
+ this.cancelRepeatableCommand();
1897
+ if (!isPrintableChunk(data)) {
1898
+ super.handleInput(data);
1899
+ }
1900
+ }
1901
+
1902
+ private handlePendingMotion(data: string): void {
1903
+ if (!isPrintableInput(data)) {
1904
+ this.pendingMotion = null;
1905
+ this.cancelPendingOperator(data);
1906
+ return;
1907
+ }
1908
+
1909
+ const pendingMotion = this.pendingMotion;
1910
+ if (!pendingMotion) return;
1911
+
1912
+ if (this.pendingOperator === "d") {
1913
+ if (!this.deleteWithCharMotion(pendingMotion, data)) {
1914
+ this.cancelRepeatableCommand();
1915
+ }
1916
+ this.pendingOperator = null;
1917
+ } else if (this.pendingOperator === "c") {
1918
+ const changed = this.deleteWithCharMotion(pendingMotion, data);
1919
+ this.pendingOperator = null;
1920
+ if (changed) {
1921
+ this.setMode();
1922
+ } else {
1923
+ this.cancelRepeatableCommand();
1924
+ }
1925
+ } else if (this.pendingOperator === "y") {
1926
+ this.yankWithCharMotion(pendingMotion, data);
1927
+ this.pendingOperator = null;
1928
+ } else {
1929
+ this.executeCharMotion(pendingMotion, data);
1930
+ }
1931
+
1932
+ this.pendingMotion = null;
1933
+ }
1934
+
1935
+ private handlePendingTextObject(data: string): void {
1936
+ const pendingTextObject = this.pendingTextObject;
1937
+ this.pendingTextObject = null;
1938
+ if (!pendingTextObject) {
1939
+ this.pendingOperator = null;
1940
+ this.cancelRepeatableCommand();
1941
+ return;
1942
+ }
1943
+
1944
+ const hasCount = this.hasPendingCount();
1945
+
1946
+ if (this.pendingOperator === "y" && hasCount) {
1947
+ this.cancelPendingOperator(data);
1948
+ return;
1949
+ }
1950
+
1951
+ if (data === "w" || data === "W") {
1952
+ const semanticClass: WordTextObjectClass = data === "W" ? "WORD" : "word";
1953
+ const count = this.takeTotalCount(1);
1954
+ const range = this.getWordObjectRange(
1955
+ pendingTextObject,
1956
+ count,
1957
+ semanticClass,
1958
+ );
1959
+ if (!range || !this.pendingOperator) {
1960
+ // A word text object that cannot be satisfied (e.g. `2diw` on a lone
1961
+ // word, `daw` on trailing whitespace) leaves the buffer and register
1962
+ // untouched in nvim, but still drops the cursor on the last char of the
1963
+ // line where the object motion ran out. Match that cursor move so the
1964
+ // no-op stays in parity.
1965
+ if (range === null && this.pendingOperator !== null) {
1966
+ this.moveCursorToLastGraphemeOnLine();
1967
+ }
1968
+ this.pendingOperator = null;
1969
+ this.cancelRepeatableCommand();
1970
+ return;
1971
+ }
1972
+
1973
+ this.applyResolvedTextObjectRange(range);
1974
+ return;
1975
+ }
1976
+
1977
+ if (hasCount) {
1978
+ this.cancelPendingOperator(data);
1979
+ return;
1980
+ }
1981
+
1982
+ const range = resolveDelimitedTextObjectRange(
1983
+ this.getText(),
1984
+ this.getDelimitedTextObjectCursorAbs(),
1985
+ pendingTextObject,
1986
+ data,
1987
+ );
1988
+ if (!range) {
1989
+ this.cancelPendingOperator(data);
1990
+ return;
1991
+ }
1992
+
1993
+ this.applyResolvedTextObjectRange(range);
1994
+ }
1995
+
1996
+ private applyResolvedTextObjectRange(range: TextObjectRange): void {
1997
+ const pendingOperator = this.pendingOperator;
1998
+ this.pendingOperator = null;
1999
+
2000
+ if (!pendingOperator || range.endAbs < range.startAbs) {
2001
+ this.cancelRepeatableCommand();
2002
+ return;
2003
+ }
2004
+
2005
+ if (range.endAbs === range.startAbs) {
2006
+ if (pendingOperator === "c") {
2007
+ this.moveCursorToAbsoluteIndex(range.startAbs);
2008
+ this.setMode();
2009
+ } else {
2010
+ this.cancelRepeatableCommand();
2011
+ }
2012
+ return;
2013
+ }
2014
+
2015
+ if (pendingOperator === "d") {
2016
+ this.deleteRangeByAbsolute(range.startAbs, range.endAbs);
2017
+ this.clampCursorAfterTextObjectDeletion();
2018
+ return;
2019
+ }
2020
+
2021
+ if (pendingOperator === "c") {
2022
+ this.deleteRangeByAbsolute(range.startAbs, range.endAbs);
2023
+ this.setMode();
2024
+ return;
2025
+ }
2026
+
2027
+ if (pendingOperator === "y") {
2028
+ this.yankRangeByAbsolute(range.startAbs, range.endAbs);
2029
+ }
2030
+ }
2031
+
2032
+ // After a normal-mode text-object delete whose range ended at EOL, the cursor
2033
+ // is left at the deletion start, which now equals the line length — one past
2034
+ // the last grapheme. nvim clamps back onto the last grapheme; without this a
2035
+ // following `x` no-ops on the phantom column. Mirrors cutCharUnderCursor's
2036
+ // `col >= line.length` clamp, but grapheme-aware.
2037
+ private clampCursorAfterTextObjectDeletion(): void {
2038
+ const { line, col } = this.getCurrentLineAndCol();
2039
+ if (line.length > 0 && col >= line.length) {
2040
+ this.moveCursorToPreviousGraphemeStart();
2041
+ }
2042
+ }
2043
+
2044
+ private handlePendingDelete(data: string): void {
2045
+ if (this.opDigit(data)) return;
2046
+
2047
+ if (data === "%") {
2048
+ this.applyPercentOp();
2049
+ return;
2050
+ }
2051
+
2052
+ if (data === "d") {
2053
+ const count = this.takeTotalCount(1);
2054
+ this.deleteLinewiseByDelta(count - 1);
2055
+ this.pendingOperator = null;
2056
+ return;
2057
+ }
2058
+
2059
+ if (data === "j" || data === "k") {
2060
+ const hasDualCount =
2061
+ this.prefixCount.length > 0 && this.operatorCount.length > 0;
2062
+ const count = this.takeTotalCount(1);
2063
+ const delta = hasDualCount ? Math.max(0, count - 1) : count;
2064
+ this.deleteLinewiseByDelta(data === "j" ? delta : -delta);
2065
+ this.pendingOperator = null;
2066
+ return;
2067
+ }
2068
+
2069
+ if (data === "G") {
2070
+ if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
2071
+ this.cancelPendingOperator(data);
2072
+ return;
2073
+ }
2074
+
2075
+ this.deleteToBufferEndLinewise();
2076
+ this.pendingOperator = null;
2077
+ return;
2078
+ }
2079
+
2080
+ if (data === "_") {
2081
+ const count = this.takeTotalCount(1);
2082
+ this.deleteLinewiseByDelta(count - 1);
2083
+ this.pendingOperator = null;
2084
+ return;
2085
+ }
2086
+
2087
+ if (CHAR_MOTION_KEYS.has(data)) {
2088
+ this.pendingMotion = data as PendingMotion;
2089
+ return;
2090
+ }
2091
+
2092
+ const hasCount = this.hasPendingCount();
2093
+ const supportsCountedWordMotion =
2094
+ data === "w" ||
2095
+ data === "e" ||
2096
+ data === "b" ||
2097
+ data === "W" ||
2098
+ data === "E" ||
2099
+ data === "B";
2100
+ const supportsCountedTextObject = data === "i" || data === "a";
2101
+ const supportsCountedLineEnd = data === "$";
2102
+ const supportsIgnoredCountMotion = data === "0" || data === "^";
2103
+
2104
+ if (
2105
+ hasCount &&
2106
+ !supportsCountedWordMotion &&
2107
+ !supportsCountedTextObject &&
2108
+ !supportsCountedLineEnd &&
2109
+ !supportsIgnoredCountMotion
2110
+ ) {
2111
+ this.cancelPendingOperator(data);
2112
+ return;
2113
+ }
2114
+
2115
+ if (supportsCountedTextObject) {
2116
+ this.pendingTextObject = data;
2117
+ return;
2118
+ }
2119
+
2120
+ if (supportsCountedLineEnd) {
2121
+ const count = this.takeTotalCount(1);
2122
+ if (this.applyLineEndOperator(count, "delete")) {
2123
+ this.pendingOperator = null;
2124
+ return;
2125
+ }
2126
+ this.cancelPendingOperator(data);
2127
+ return;
2128
+ }
2129
+
2130
+ const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1;
2131
+ if (supportsIgnoredCountMotion) this.takeTotalCount(1);
2132
+ if (this.deleteWithMotion(data, motionCount)) {
2133
+ this.pendingOperator = null;
2134
+ return;
2135
+ }
2136
+
2137
+ this.cancelPendingOperator(data);
2138
+ }
2139
+
2140
+ private handlePendingChange(data: string): void {
2141
+ if (this.opDigit(data)) return;
2142
+
2143
+ if (data === "%") {
2144
+ this.applyPercentOp();
2145
+ return;
2146
+ }
2147
+
2148
+ if (data === "c") {
2149
+ if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
2150
+ this.cancelPendingOperator(data);
2151
+ return;
2152
+ }
2153
+
2154
+ this.cutLine();
2155
+ this.pendingOperator = null;
2156
+ this.setMode();
2157
+ return;
2158
+ }
2159
+
2160
+ if (data === "_") {
2161
+ const count = this.takeTotalCount(1);
2162
+ if (count <= 1) {
2163
+ this.cutLine();
2164
+ } else {
2165
+ const currentLine = this.getCursor().line;
2166
+ const lines = this.getLines();
2167
+ const clampedEnd = Math.min(currentLine + count - 1, lines.length - 1);
2168
+ this.writeToRegister(this.getLinewisePayload(currentLine, clampedEnd));
2169
+ const before = lines.slice(0, currentLine);
2170
+ const after = lines.slice(clampedEnd + 1);
2171
+ const newLines = [...before, "", ...after];
2172
+ const newText = newLines.join("\n");
2173
+ const cursorAbs = before.reduce((acc, l) => acc + l.length + 1, 0);
2174
+ this.replaceTextInBuffer(newText, cursorAbs);
2175
+ }
2176
+ this.pendingOperator = null;
2177
+ this.setMode();
2178
+ return;
2179
+ }
2180
+
2181
+ if (CHAR_MOTION_KEYS.has(data)) {
2182
+ this.pendingMotion = data as PendingMotion;
2183
+ return;
2184
+ }
2185
+
2186
+ const hasCount = this.hasPendingCount();
2187
+ const supportsCountedWordMotion =
2188
+ data === "w" ||
2189
+ data === "e" ||
2190
+ data === "b" ||
2191
+ data === "W" ||
2192
+ data === "E" ||
2193
+ data === "B";
2194
+ const supportsCountedTextObject = data === "i" || data === "a";
2195
+ const supportsCountedLineEnd = data === "$";
2196
+ const supportsIgnoredCountMotion = data === "0" || data === "^";
2197
+
2198
+ if (
2199
+ hasCount &&
2200
+ !supportsCountedWordMotion &&
2201
+ !supportsCountedTextObject &&
2202
+ !supportsCountedLineEnd &&
2203
+ !supportsIgnoredCountMotion
2204
+ ) {
2205
+ this.cancelPendingOperator(data);
2206
+ return;
2207
+ }
2208
+
2209
+ if (supportsCountedTextObject) {
2210
+ this.pendingTextObject = data;
2211
+ return;
2212
+ }
2213
+
2214
+ if (supportsCountedLineEnd) {
2215
+ const count = this.takeTotalCount(1);
2216
+ if (this.applyLineEndOperator(count, "change")) {
2217
+ this.pendingOperator = null;
2218
+ this.setMode();
2219
+ return;
2220
+ }
2221
+ this.cancelPendingOperator(data);
2222
+ return;
2223
+ }
2224
+
2225
+ const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1;
2226
+ if (supportsIgnoredCountMotion) this.takeTotalCount(1);
2227
+ const effectiveMotion =
2228
+ data === "W" && this.isCursorOnNonWhitespace() ? "E" : data;
2229
+ if (this.deleteWithMotion(effectiveMotion, motionCount)) {
2230
+ this.pendingOperator = null;
2231
+ this.setMode();
2232
+ return;
2233
+ }
2234
+
2235
+ this.cancelPendingOperator(data);
2236
+ }
2237
+
2238
+ private enterVisualMode(mode: VisualMode): void {
2239
+ if (!isVisualMode(this.mode)) {
2240
+ const cursor = this.getCursor();
2241
+ this.visualAnchor = { line: cursor.line, col: cursor.col };
2242
+ }
2243
+ this.takeTotalCount(1);
2244
+ this.setMode(mode);
2245
+ }
2246
+
2247
+ private exitVisualMode(): void {
2248
+ this.visualAnchor = null;
2249
+ this.setMode("normal");
2250
+ }
2251
+
2252
+ private getVisualAnchor(): VisualPosition {
2253
+ const cursor = this.getCursor();
2254
+ return clampVisualPosition(this.visualAnchor ?? cursor, this.getLines());
2255
+ }
2256
+
2257
+ /** Absolute `[startAbs, endAbs)` span of the inclusive char-wise selection. */
2258
+ private getVisualCharwiseRange(): { startAbs: number; endAbs: number } {
2259
+ const { start, end } = orderVisualEndpoints(
2260
+ this.getVisualAnchor(),
2261
+ this.getCursor(),
2262
+ );
2263
+ const lines = this.getLines();
2264
+ const endLine = lines[end.line] ?? "";
2265
+ const includesNewline =
2266
+ end.col >= endLine.length && end.line < lines.length - 1;
2267
+ return {
2268
+ startAbs: this.getAbsoluteIndex(start.line, start.col),
2269
+ endAbs:
2270
+ this.getAbsoluteIndex(end.line, 0) +
2271
+ getInclusiveEndColumn(endLine, end.col) +
2272
+ (includesNewline ? 1 : 0),
2273
+ };
2274
+ }
2275
+
2276
+ private clampCursorToLastGrapheme(): void {
2277
+ const { line, col } = this.getCurrentLineAndCol();
2278
+ if (col < line.length) return;
2279
+ const graphemes = getLineGraphemes(line);
2280
+ this.moveCursorToCol(graphemes[graphemes.length - 1]?.start ?? 0);
2281
+ }
2282
+
2283
+ /** Replace the selected lines with one empty line, then open insert there. */
2284
+ private changeVisualLines(startLine: number, endLine: number): void {
2285
+ const lines = this.getLines();
2286
+ this.writeToRegister(this.getLinewisePayload(startLine, endLine));
2287
+
2288
+ const before = lines.slice(0, startLine);
2289
+ const after = lines.slice(endLine + 1);
2290
+ const cursorAbs = before.reduce((abs, line) => abs + line.length + 1, 0);
2291
+ this.replaceTextInBuffer([...before, "", ...after].join("\n"), cursorAbs);
2292
+ }
2293
+
2294
+ private applyVisualOperator(
2295
+ operator: "d" | "y" | "c",
2296
+ linewise: boolean,
2297
+ ): void {
2298
+ this.takeTotalCount(1);
2299
+ // Mutating visual edits are excluded from dot-repeat and supersede the
2300
+ // older change. A visual yank is not a change, so it preserves that repeat.
2301
+ if (operator !== "y") {
2302
+ this.clearRepeatState();
2303
+ // Visual `c`/`d` bypass the recorder but still mutate and are single undo
2304
+ // units. Open the window before the delete; the input `finally` closes it
2305
+ // — at once for visual `d`, or on the `<Esc>` ending the visual-`c` insert.
2306
+ this.openUndoWindow();
2307
+ }
2308
+
2309
+ // Both branches resolve the selection before dropping the anchor.
2310
+ const anchor = this.getVisualAnchor();
2311
+ const cursor = this.getCursor();
2312
+
2313
+ if (linewise) {
2314
+ const { startLine, endLine } = getVisualLineRange(anchor, cursor);
2315
+ this.visualAnchor = null;
2316
+ if (operator === "c") {
2317
+ this.setMode("insert");
2318
+ this.changeVisualLines(startLine, endLine);
2319
+ return;
2320
+ }
2321
+ this.setMode("normal");
2322
+ if (operator === "d") {
2323
+ this.deleteLineRange(startLine, endLine);
2324
+ return;
2325
+ }
2326
+ this.yankLineRange(startLine, endLine);
2327
+ // Vim only pulls the cursor to the start of the yanked lines when it is
2328
+ // not already sitting on that end of the selection.
2329
+ if (cursor.line >= anchor.line) this.moveCursorToLineStart(startLine);
2330
+ return;
2331
+ }
2332
+
2333
+ const { startAbs, endAbs } = this.getVisualCharwiseRange();
2334
+ this.visualAnchor = null;
2335
+ if (operator === "c") {
2336
+ this.setMode("insert");
2337
+ this.deleteRangeByAbsolute(startAbs, endAbs);
2338
+ return;
2339
+ }
2340
+ this.setMode("normal");
2341
+ if (operator === "d") {
2342
+ this.deleteRangeByAbsolute(startAbs, endAbs);
2343
+ this.clampCursorToLastGrapheme();
2344
+ return;
2345
+ }
2346
+ this.yankRangeByAbsolute(startAbs, endAbs);
2347
+ this.moveCursorToAbsoluteIndex(startAbs);
2348
+ }
2349
+
2350
+ /** Swap the anchor and the cursor so the other end of the selection moves. */
2351
+ private swapVisualEnds(): void {
2352
+ const anchor = this.getVisualAnchor();
2353
+ const cursor = this.getCursor();
2354
+ this.visualAnchor = { line: cursor.line, col: cursor.col };
2355
+ this.moveCursorToAbsoluteIndex(
2356
+ this.getAbsoluteIndex(anchor.line, anchor.col),
2357
+ );
2358
+ }
2359
+
2360
+ /**
2361
+ * Visual-mode keys that are not motions. Returns true when the key was
2362
+ * consumed; motions and counts fall through to the normal-mode dispatch,
2363
+ * which moves the cursor and thereby resizes the selection.
2364
+ */
2365
+ private handleVisualMode(data: string): boolean {
2366
+ if (this.pendingG || this.pendingMotion) return false;
2367
+
2368
+ const linewise = this.mode === "visual-line";
2369
+
2370
+ switch (data) {
2371
+ case "v":
2372
+ if (linewise) this.setMode("visual");
2373
+ else this.exitVisualMode();
2374
+ return true;
2375
+ case "V":
2376
+ if (linewise) this.exitVisualMode();
2377
+ else this.setMode("visual-line");
2378
+ return true;
2379
+ case "o":
2380
+ case "O":
2381
+ this.swapVisualEnds();
2382
+ return true;
2383
+ case "d":
2384
+ case "x":
2385
+ this.applyVisualOperator("d", linewise);
2386
+ return true;
2387
+ case "y":
2388
+ this.applyVisualOperator("y", linewise);
2389
+ return true;
2390
+ case "c":
2391
+ case "s":
2392
+ this.applyVisualOperator("c", linewise);
2393
+ return true;
2394
+ case "D":
2395
+ case "X":
2396
+ this.applyVisualOperator("d", true);
2397
+ return true;
2398
+ case "Y":
2399
+ this.applyVisualOperator("y", true);
2400
+ return true;
2401
+ case "C":
2402
+ case "S":
2403
+ this.applyVisualOperator("c", true);
2404
+ return true;
2405
+ default:
2406
+ if (
2407
+ VISUAL_IGNORED_KEYS.has(data) ||
2408
+ matchesKey(data, "ctrl+r") ||
2409
+ matchesKey(data, "ctrl+_")
2410
+ ) {
2411
+ // Swallowed keys are inert in visual mode. Drop any pending count so
2412
+ // it cannot leak into the next motion: `v2pld` must extend by one and
2413
+ // delete one char, not swallow `p` yet still carry the 2 into `l`.
2414
+ this.prefixCount = "";
2415
+ this.operatorCount = "";
2416
+ return true;
2417
+ }
2418
+ return false;
2419
+ }
2420
+ }
2421
+
2422
+ private handleNormalMode(data: string): void {
2423
+ if (this.pendingG) {
2424
+ if (isDigit(data)) {
2425
+ this.pendingGCount += data;
2426
+ return;
2427
+ }
2428
+
2429
+ this.pendingG = false;
2430
+ const hadGCount = this.pendingGCount.length > 0;
2431
+ this.pendingGCount = "";
2432
+
2433
+ if (!hadGCount) {
2434
+ if (data === "g") {
2435
+ const count = this.takeTotalCount(1);
2436
+ this.moveCursorToLineStart(count - 1);
2437
+ return;
2438
+ }
2439
+
2440
+ if (data === "J" && !isVisualMode(this.mode)) {
2441
+ this.joinLines(false);
2442
+ return;
2443
+ }
2444
+
2445
+ if (data === "M") {
2446
+ // :h gM — halfway the text of the line; with a count of 1..100,
2447
+ // to that percentage of the line's text. nvim ignores counts
2448
+ // above 100 (falls back to halfway) and clamps 100gM to the
2449
+ // last character. nvim measures screen cells (linetabsize);
2450
+ // pi-vim measures graphemes — an intentional divergence for
2451
+ // tabs and double-width characters.
2452
+ const count = this.takeTotalCount(0);
2453
+ const { line } = this.getCurrentLineAndCol();
2454
+ const graphemes = getLineGraphemes(line);
2455
+ const target =
2456
+ count >= 1 && count <= 100
2457
+ ? Math.floor((graphemes.length * count) / 100)
2458
+ : Math.floor(graphemes.length / 2);
2459
+ const clamped = Math.min(target, graphemes.length - 1);
2460
+ this.moveCursorToCol(graphemes[clamped]?.start ?? 0);
2461
+ return;
2462
+ }
2463
+ }
2464
+
2465
+ this.clearPendingState();
2466
+ return;
2467
+ }
2468
+
2469
+ if (this.prefixCount.length > 0) {
2470
+ if (isDigit(data)) {
2471
+ this.prefixCount += data;
2472
+ return;
2473
+ }
2474
+
2475
+ if (data === "%") {
2476
+ this.prefixCount = "";
2477
+ this.operatorCount = "";
2478
+ return;
2479
+ }
2480
+
2481
+ if (data === "d" || data === "y") {
2482
+ this.pendingOperator = data;
2483
+ return;
2484
+ }
2485
+
2486
+ if (data === "c") {
2487
+ this.pendingOperator = "c";
2488
+ return;
2489
+ }
2490
+
2491
+ if (data === "g") {
2492
+ this.pendingGCount = "";
2493
+ this.pendingG = true;
2494
+ return;
2495
+ }
2496
+
2497
+ if (data === "G") {
2498
+ const count = this.takeTotalCount(1);
2499
+ this.moveCursorToLineStart(count - 1);
2500
+ return;
2501
+ }
2502
+
2503
+ const supportsCountedStandaloneEdit =
2504
+ data === "x" ||
2505
+ data === "X" ||
2506
+ data === "r" ||
2507
+ data === "s" ||
2508
+ data === "S" ||
2509
+ data === "D" ||
2510
+ data === "C" ||
2511
+ data === "p" ||
2512
+ data === "P" ||
2513
+ data === "Y" ||
2514
+ data === "J" ||
2515
+ data === "." ||
2516
+ data === "u" ||
2517
+ data === CTRL_UNDERSCORE ||
2518
+ matchesKey(data, "ctrl+_") ||
2519
+ data === CTRL_R ||
2520
+ matchesKey(data, "ctrl+r");
2521
+ const supportsCountedCharMotion =
2522
+ CHAR_MOTION_KEYS.has(data) || data === ";" || data === ",";
2523
+ const supportsCountedWordMotion =
2524
+ data === "w" ||
2525
+ data === "e" ||
2526
+ data === "b" ||
2527
+ data === "W" ||
2528
+ data === "E" ||
2529
+ data === "B";
2530
+ const supportsCountedParagraphMotion = data === "{" || data === "}";
2531
+ const supportsCountedNav =
2532
+ data === "h" || data === "j" || data === "k" || data === "l";
2533
+ const supportsCountedUnderscore = data === "_";
2534
+
2535
+ if (supportsCountedNav) {
2536
+ const count = this.takeTotalCount(1);
2537
+ const clamped = Math.min(count, MAX_COUNT);
2538
+ if ((data === "h" || data === "l") && isVisualMode(this.mode)) {
2539
+ const { line, col } = this.getCurrentLineAndCol();
2540
+ const graphemes = getLineGraphemes(line);
2541
+ const currentIndex = graphemes.findIndex(({ end }) => col < end);
2542
+ const graphemeIndex =
2543
+ currentIndex === -1 ? graphemes.length : currentIndex;
2544
+ const availableSteps =
2545
+ data === "h" ? graphemeIndex : graphemes.length - graphemeIndex;
2546
+ const steps = Math.min(clamped, availableSteps);
2547
+ this.moveCursorBy(data === "h" ? -steps : steps);
2548
+ } else if (data === "h") {
2549
+ this.moveCursorBy(-clamped);
2550
+ } else if (data === "l") {
2551
+ this.moveCursorBy(clamped);
2552
+ } else {
2553
+ const delta = data === "j" ? clamped : -clamped;
2554
+ this.moveCursorVertically(delta);
2555
+ }
2556
+ return;
2557
+ }
2558
+
2559
+ if (supportsCountedParagraphMotion) {
2560
+ this.executeParagraphMotion(data === "}" ? "forward" : "backward");
2561
+ return;
2562
+ }
2563
+
2564
+ if (
2565
+ !supportsCountedStandaloneEdit &&
2566
+ !supportsCountedCharMotion &&
2567
+ !supportsCountedWordMotion &&
2568
+ !supportsCountedParagraphMotion &&
2569
+ !supportsCountedUnderscore
2570
+ ) {
2571
+ this.prefixCount = "";
2572
+ this.operatorCount = "";
2573
+ }
2574
+ } else if (isCountStarter(data)) {
2575
+ this.prefixCount = data;
2576
+ return;
2577
+ }
2578
+
2579
+ if (data === "J") {
2580
+ this.joinLines(true);
2581
+ return;
2582
+ }
2583
+
2584
+ if (data === "g") {
2585
+ this.pendingGCount = "";
2586
+ this.pendingG = true;
2587
+ return;
2588
+ }
2589
+
2590
+ if (data === ":") {
2591
+ this.startPendingExCommand();
2592
+ return;
2593
+ }
2594
+
2595
+ if (data === "G") {
2596
+ this.moveCursorToBufferEnd();
2597
+ return;
2598
+ }
2599
+
2600
+ if (data === "r") {
2601
+ this.pendingReplace = true;
2602
+ return;
2603
+ }
2604
+
2605
+ if (data === "v" || data === "V") {
2606
+ this.enterVisualMode(data === "v" ? "visual" : "visual-line");
2607
+ return;
2608
+ }
2609
+
2610
+ if (data === "d") {
2611
+ this.pendingOperator = "d";
2612
+ return;
2613
+ }
2614
+
2615
+ if (data === "c") {
2616
+ this.pendingOperator = "c";
2617
+ return;
2618
+ }
2619
+
2620
+ if (data === "y") {
2621
+ this.pendingOperator = "y";
2622
+ return;
2623
+ }
2624
+
2625
+ if (data === "p") {
2626
+ this.putAfter();
2627
+ return;
2628
+ }
2629
+
2630
+ if (data === "P") {
2631
+ this.putBefore();
2632
+ return;
2633
+ }
2634
+
2635
+ if (data === "Y") {
2636
+ const count = this.takeTotalCount(1);
2637
+ this.yankLinewiseByDelta(count - 1);
2638
+ return;
2639
+ }
2640
+
2641
+ if (CHAR_MOTION_KEYS.has(data)) {
2642
+ this.pendingMotion = data as PendingMotion;
2643
+ return;
2644
+ }
2645
+
2646
+ if (data === ";" && this.lastCharMotion) {
2647
+ this.executeCharMotion(
2648
+ this.lastCharMotion.motion,
2649
+ this.lastCharMotion.char,
2650
+ false,
2651
+ );
2652
+ return;
2653
+ }
2654
+ if (data === "," && this.lastCharMotion) {
2655
+ this.executeCharMotion(
2656
+ reverseCharMotion(this.lastCharMotion.motion),
2657
+ this.lastCharMotion.char,
2658
+ false,
2659
+ );
2660
+ return;
2661
+ }
2662
+
2663
+ if (
2664
+ data === "u" ||
2665
+ data === CTRL_UNDERSCORE ||
2666
+ matchesKey(data, "ctrl+_")
2667
+ ) {
2668
+ this.performUndo();
2669
+ return;
2670
+ }
2671
+
2672
+ if (data === CTRL_R || matchesKey(data, "ctrl+r")) {
2673
+ this.performRedo();
2674
+ return;
2675
+ }
2676
+
2677
+ if (data === ".") {
2678
+ this.repeatLastCommand();
2679
+ return;
2680
+ }
2681
+
2682
+ if (data === "}" || data === "{") {
2683
+ this.executeParagraphMotion(data === "}" ? "forward" : "backward");
2684
+ return;
2685
+ }
2686
+
2687
+ if (data === "^") {
2688
+ this.moveCursorToFirstNonWhitespace();
2689
+ return;
2690
+ }
2691
+
2692
+ if (data === "_") {
2693
+ const count = this.takeTotalCount(1);
2694
+ if (count > 1) {
2695
+ this.moveCursorVertically(count - 1);
2696
+ }
2697
+ this.moveCursorToFirstNonWhitespace();
2698
+ return;
2699
+ }
2700
+
2701
+ if (data === "w") {
2702
+ const count = this.takeTotalCount(1);
2703
+ this.moveWord("forward", "start", count, "word");
2704
+ return;
2705
+ }
2706
+ if (data === "b") {
2707
+ this.moveWord("backward", "start", this.takeTotalCount(1), "word");
2708
+ return;
2709
+ }
2710
+ if (data === "e") {
2711
+ this.moveWord("forward", "end", this.takeTotalCount(1), "word");
2712
+ return;
2713
+ }
2714
+ if (data === "W") {
2715
+ this.moveWord("forward", "start", this.takeTotalCount(1), "WORD");
2716
+ return;
2717
+ }
2718
+ if (data === "%") {
2719
+ this.moveToMatchingPairTarget();
2720
+ return;
2721
+ }
2722
+ if (data === "B") {
2723
+ this.moveWord("backward", "start", this.takeTotalCount(1), "WORD");
2724
+ return;
2725
+ }
2726
+ if (data === "E") {
2727
+ this.moveWord("forward", "end", this.takeTotalCount(1), "WORD");
2728
+ return;
2729
+ }
2730
+
2731
+ if (Object.hasOwn(NORMAL_KEYS, data)) {
2732
+ this.handleMappedKey(data);
2733
+ return;
2734
+ }
2735
+
2736
+ if (isPrintableChunk(data)) return;
2737
+ super.handleInput(data);
2738
+ }
2739
+
2740
+ private openLineBelow(): void {
2741
+ super.handleInput(CTRL_E);
2742
+ super.handleInput(NEWLINE);
2743
+ }
2744
+
2745
+ private openLineAbove(): void {
2746
+ super.handleInput(CTRL_A);
2747
+ super.handleInput(NEWLINE);
2748
+ super.handleInput(ESC_UP);
2749
+ }
2750
+
2751
+ private handleMappedKey(key: string): void {
2752
+ const seq = NORMAL_KEYS[key];
2753
+ switch (key) {
2754
+ case "i":
2755
+ this.setMode();
2756
+ break;
2757
+ case "a":
2758
+ this.setMode();
2759
+ if (!this.isCursorAtOrPastEol()) {
2760
+ super.handleInput(ESC_RIGHT);
2761
+ }
2762
+ break;
2763
+ case "A":
2764
+ this.setMode();
2765
+ super.handleInput(CTRL_E);
2766
+ break;
2767
+ case "I":
2768
+ this.setMode();
2769
+ this.moveCursorToFirstNonWhitespace();
2770
+ break;
2771
+ case "$": {
2772
+ const { line } = this.getCurrentLineAndCol();
2773
+ if (isVisualMode(this.mode)) {
2774
+ this.moveCursorToCol(line.length);
2775
+ break;
2776
+ }
2777
+ const graphemes = getLineGraphemes(line);
2778
+ this.moveCursorToCol(graphemes[graphemes.length - 1]?.start ?? 0);
2779
+ break;
2780
+ }
2781
+ case "o":
2782
+ this.openLineBelow();
2783
+ this.setMode();
2784
+ break;
2785
+ case "O":
2786
+ this.openLineAbove();
2787
+ this.setMode();
2788
+ break;
2789
+ case "D":
2790
+ this.takeTotalCount(1);
2791
+ this.cutToEndOfLine();
2792
+ break;
2793
+ case "C":
2794
+ this.takeTotalCount(1);
2795
+ this.cutToEndOfLine();
2796
+ this.setMode();
2797
+ break;
2798
+ case "S":
2799
+ this.takeTotalCount(1);
2800
+ this.cutCurrentLineContent();
2801
+ this.setMode();
2802
+ break;
2803
+ case "s":
2804
+ this.cutCharUnderCursor();
2805
+ this.setMode();
2806
+ break;
2807
+ case "x":
2808
+ this.cutCharUnderCursor(true);
2809
+ break;
2810
+ case "X":
2811
+ this.cutCharsBeforeCursor();
2812
+ break;
2813
+ case "j":
2814
+ this.moveCursorVertically(1);
2815
+ break;
2816
+ case "k":
2817
+ this.moveCursorVertically(-1);
2818
+ break;
2819
+ default:
2820
+ if (seq) super.handleInput(seq);
2821
+ }
2822
+ }
2823
+
2824
+ private executeCharMotion(
2825
+ motion: CharMotion,
2826
+ targetChar: string,
2827
+ saveMotion: boolean = true,
2828
+ ): void {
2829
+ const line = this.getLines()[this.getCursor().line] ?? "";
2830
+ const col = this.getCursor().col;
2831
+ const count = this.takeTotalCount(1);
2832
+ const targetCol = findCharMotionTarget(
2833
+ line,
2834
+ col,
2835
+ motion,
2836
+ targetChar,
2837
+ !saveMotion,
2838
+ count,
2839
+ );
2840
+
2841
+ if (targetCol !== null && saveMotion) {
2842
+ this.lastCharMotion = { motion, char: targetChar };
2843
+ }
2844
+
2845
+ if (targetCol !== null && targetCol !== col) {
2846
+ this.moveCursorToCol(targetCol);
2847
+ }
2848
+ }
2849
+
2850
+ private executeParagraphMotion(direction: "forward" | "backward"): void {
2851
+ const lines = this.getLines();
2852
+ const fromLine = this.getCursor().line;
2853
+ const count = this.takeTotalCount(1);
2854
+ const targetLine = findParagraphMotionTarget(
2855
+ lines,
2856
+ fromLine,
2857
+ direction,
2858
+ count,
2859
+ );
2860
+ this.moveCursorToLineStart(targetLine);
2861
+ }
2862
+
2863
+ private tryMoveCursorByState(delta: number): boolean {
2864
+ if (delta === 0) return true;
2865
+
2866
+ const editor = this as unknown as {
2867
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
2868
+ preferredVisualCol?: number;
2869
+ tui?: { requestRender?: () => void };
2870
+ };
2871
+
2872
+ const state = editor.state;
2873
+ if (!state || !Array.isArray(state.lines)) return false;
2874
+ if (
2875
+ !Number.isInteger(state.cursorLine) ||
2876
+ !Number.isInteger(state.cursorCol)
2877
+ )
2878
+ return false;
2879
+
2880
+ const cursorLine = state.cursorLine as number;
2881
+ const cursorCol = state.cursorCol as number;
2882
+ const line = state.lines[cursorLine] ?? "";
2883
+ if (this.hasMultiCodeUnitGraphemes(line)) return false;
2884
+
2885
+ const target = cursorCol + delta;
2886
+
2887
+ if (target < 0 || target > line.length) return false;
2888
+
2889
+ state.cursorCol = target;
2890
+ editor.preferredVisualCol = target;
2891
+ editor.tui?.requestRender?.();
2892
+ return true;
2893
+ }
2894
+
2895
+ private moveCursorBy(delta: number): void {
2896
+ if (delta === 0) return;
2897
+
2898
+ if (this.tryMoveCursorByState(delta)) return;
2899
+
2900
+ const seq = delta > 0 ? ESC_RIGHT : ESC_LEFT;
2901
+ for (let i = 0; i < Math.abs(delta); i++) {
2902
+ super.handleInput(seq);
2903
+ }
2904
+ }
2905
+
2906
+ private moveCursorVertically(delta: number): void {
2907
+ if (delta === 0) return;
2908
+
2909
+ const editor = this as unknown as {
2910
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
2911
+ preferredVisualCol?: number | null;
2912
+ lastAction?: string | null;
2913
+ tui?: { requestRender?: () => void };
2914
+ };
2915
+
2916
+ const state = editor.state;
2917
+ if (!state || !Array.isArray(state.lines) || state.lines.length === 0) {
2918
+ const seq = delta > 0 ? ESC_DOWN : ESC_UP;
2919
+ for (let i = 0; i < Math.abs(delta); i++) {
2920
+ super.handleInput(seq);
2921
+ }
2922
+ return;
2923
+ }
2924
+
2925
+ const currentLine = state.cursorLine ?? 0;
2926
+ const targetLine = Math.max(
2927
+ 0,
2928
+ Math.min(currentLine + delta, state.lines.length - 1),
2929
+ );
2930
+ if (targetLine === currentLine) return;
2931
+
2932
+ const preferredCol = editor.preferredVisualCol ?? state.cursorCol ?? 0;
2933
+ const targetLineText = state.lines[targetLine] ?? "";
2934
+ editor.lastAction = null;
2935
+ state.cursorLine = targetLine;
2936
+ state.cursorCol = Math.min(preferredCol, targetLineText.length);
2937
+ editor.preferredVisualCol = preferredCol;
2938
+ editor.tui?.requestRender?.();
2939
+ }
2940
+
2941
+ private moveCursorToCol(col: number): void {
2942
+ const editor = this as unknown as {
2943
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
2944
+ preferredVisualCol?: number | null;
2945
+ lastAction?: string | null;
2946
+ tui?: { requestRender?: () => void };
2947
+ };
2948
+
2949
+ const state = editor.state;
2950
+ if (!state || !Array.isArray(state.lines)) return;
2951
+
2952
+ editor.lastAction = null;
2953
+ state.cursorCol = col;
2954
+ editor.preferredVisualCol = col;
2955
+ editor.tui?.requestRender?.();
2956
+ }
2957
+
2958
+ private moveCursorToAbsoluteIndex(abs: number): void {
2959
+ const editor = this as unknown as {
2960
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
2961
+ preferredVisualCol?: number | null;
2962
+ lastAction?: string | null;
2963
+ tui?: { requestRender?: () => void };
2964
+ };
2965
+
2966
+ const state = editor.state;
2967
+ if (!state || !Array.isArray(state.lines)) return;
2968
+
2969
+ const { line, col } = this.getCursorFromAbsoluteIndex(this.getText(), abs);
2970
+ editor.lastAction = null;
2971
+ state.cursorLine = line;
2972
+ state.cursorCol = col;
2973
+ editor.preferredVisualCol = col;
2974
+ editor.tui?.requestRender?.();
2975
+ }
2976
+
2977
+ private moveCursorToLineStart(lineIndex: number): void {
2978
+ const editor = this as unknown as {
2979
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
2980
+ preferredVisualCol?: number | null;
2981
+ lastAction?: string | null;
2982
+ tui?: { requestRender?: () => void };
2983
+ };
2984
+
2985
+ const state = editor.state;
2986
+ if (!state || !Array.isArray(state.lines) || state.lines.length === 0) {
2987
+ super.handleInput(CTRL_A);
2988
+ return;
2989
+ }
2990
+
2991
+ const targetLine = Math.max(0, Math.min(lineIndex, state.lines.length - 1));
2992
+ editor.lastAction = null;
2993
+ state.cursorLine = targetLine;
2994
+ state.cursorCol = 0;
2995
+ editor.preferredVisualCol = null;
2996
+ editor.tui?.requestRender?.();
2997
+ }
2998
+
2999
+ private moveCursorToFirstNonWhitespace(): void {
3000
+ const { line } = this.getCurrentLineAndCol();
3001
+ const targetCol = findFirstNonWhitespaceColumn(line);
3002
+ this.moveCursorToCol(targetCol);
3003
+ }
3004
+
3005
+ private moveCursorToBufferEnd(): void {
3006
+ const lines = this.getLines();
3007
+ this.moveCursorToLineStart(Math.max(0, lines.length - 1));
3008
+ }
3009
+
3010
+ private joinLines(normalize: boolean): void {
3011
+ const count = this.takeTotalCount(2);
3012
+ const steps = Math.max(0, count - 1);
3013
+ if (steps === 0) return;
3014
+
3015
+ this.applySyntheticEdit(() => {
3016
+ const editor = this as unknown as ModalEditorInternals;
3017
+ const state = editor.state;
3018
+ if (!state || !Array.isArray(state.lines)) return;
3019
+
3020
+ const currentLine = state.cursorLine ?? 0;
3021
+ let joinPoint = state.cursorCol ?? 0;
3022
+
3023
+ if (this.replayingRepeat && currentLine >= state.lines.length - 1) {
3024
+ state.cursorCol = 0;
3025
+ editor.preferredVisualCol = 0;
3026
+ return;
3027
+ }
3028
+
3029
+ for (let i = 0; i < steps; i++) {
3030
+ if (currentLine >= state.lines.length - 1) break;
3031
+
3032
+ const left = state.lines[currentLine] ?? "";
3033
+ const right = state.lines[currentLine + 1] ?? "";
3034
+ let joined: string;
3035
+
3036
+ if (normalize) {
3037
+ const trimmedRight = right.trimStart();
3038
+ const leftLastChar = left[left.length - 1];
3039
+ const leftEndsWithSpace =
3040
+ leftLastChar !== undefined && /\s/.test(leftLastChar);
3041
+ const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
3042
+ joined = needsSeparator
3043
+ ? `${left} ${trimmedRight}`
3044
+ : left + trimmedRight;
3045
+ joinPoint = left.length;
3046
+ } else {
3047
+ joined = left + right;
3048
+ joinPoint = left.length;
3049
+ }
3050
+
3051
+ state.lines.splice(currentLine, 2, joined);
3052
+ }
3053
+
3054
+ state.cursorLine = currentLine;
3055
+ state.cursorCol = joinPoint;
3056
+ editor.preferredVisualCol = joinPoint;
3057
+ });
3058
+ }
3059
+
3060
+ private isWordChar(ch: string): boolean {
3061
+ return /\w/.test(ch);
3062
+ }
3063
+
3064
+ private charType(
3065
+ ch: string | undefined,
3066
+ semanticClass: WordMotionClass = "word",
3067
+ ): "space" | "word" | "other" {
3068
+ if (!ch || /\s/.test(ch)) return "space";
3069
+ if (semanticClass === "WORD") return "word";
3070
+ if (this.isWordChar(ch)) return "word";
3071
+ return "other";
3072
+ }
3073
+
3074
+ private resolveWordMotion(
3075
+ motion: string,
3076
+ ): { motion: "w" | "e" | "b"; semanticClass: WordMotionClass } | null {
3077
+ if (motion === "w" || motion === "e" || motion === "b") {
3078
+ return { motion, semanticClass: "word" };
3079
+ }
3080
+
3081
+ if (motion === "W" || motion === "E" || motion === "B") {
3082
+ const normalizedMotion = motion.toLowerCase() as "w" | "e" | "b";
3083
+ return { motion: normalizedMotion, semanticClass: "WORD" };
3084
+ }
3085
+
3086
+ return null;
3087
+ }
3088
+
3089
+ private getAbsoluteIndex(line: number, col: number): number {
3090
+ const lines = this.getLines();
3091
+ let idx = 0;
3092
+ for (let i = 0; i < line; i++) {
3093
+ idx += (lines[i] ?? "").length + 1;
3094
+ }
3095
+ return idx + col;
3096
+ }
3097
+
3098
+ private getAbsoluteIndexFromCursor(): number {
3099
+ const cursor = this.getCursor();
3100
+ return this.getAbsoluteIndex(cursor.line, cursor.col);
3101
+ }
3102
+
3103
+ private getMatchingPairMotionTarget() {
3104
+ const cursor = this.getCursor();
3105
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
3106
+ return resolveMatchingPairMotionTarget(
3107
+ this.getText(),
3108
+ this.getAbsoluteIndexFromCursor(),
3109
+ lineStartAbs,
3110
+ lineStartAbs + (this.getLines()[cursor.line] ?? "").length,
3111
+ );
3112
+ }
3113
+
3114
+ private moveToMatchingPairTarget(): void {
3115
+ const target = this.getMatchingPairMotionTarget();
3116
+ if (target) this.moveCursorToAbsoluteIndex(target.targetAbs);
3117
+ }
3118
+
3119
+ private applyPercentOp(): void {
3120
+ const op = this.pendingOperator;
3121
+ const counted = this.hasPendingCount();
3122
+ this.clearPendingState();
3123
+ if (!op || counted) {
3124
+ this.cancelRepeatableCommand();
3125
+ return;
3126
+ }
3127
+
3128
+ const t = this.getMatchingPairMotionTarget();
3129
+ if (!t) {
3130
+ this.cancelRepeatableCommand();
3131
+ return;
3132
+ }
3133
+
3134
+ if (op === "y") {
3135
+ this.yankRangeByAbsolute(t.rangeAnchorAbs, t.targetAbs, true);
3136
+ return;
3137
+ }
3138
+
3139
+ this.deleteRangeByAbsolute(t.rangeAnchorAbs, t.targetAbs, true);
3140
+ if (op === "c") {
3141
+ this.setMode("insert");
3142
+ return;
3143
+ }
3144
+ }
3145
+
3146
+ private getDelimitedTextObjectCursorAbs(): number {
3147
+ const lines = this.getLines();
3148
+ const cursor = this.getCursor();
3149
+ const line = lines[cursor.line] ?? "";
3150
+
3151
+ if (line.length > 0 && cursor.col >= line.length) {
3152
+ return this.getAbsoluteIndex(cursor.line, line.length - 1);
3153
+ }
3154
+
3155
+ return this.getAbsoluteIndex(cursor.line, cursor.col);
3156
+ }
3157
+
3158
+ private findWordTargetInText(
3159
+ text: string,
3160
+ abs: number,
3161
+ direction: "forward" | "backward",
3162
+ target: "start" | "end",
3163
+ count: number = 1,
3164
+ semanticClass: WordMotionClass = "word",
3165
+ ): number {
3166
+ const len = text.length;
3167
+ if (len === 0) return 0;
3168
+
3169
+ const steps = Math.max(1, Math.min(MAX_COUNT, count));
3170
+ let i = Math.max(0, Math.min(abs, len));
3171
+
3172
+ for (let step = 0; step < steps; step++) {
3173
+ let next = i;
3174
+
3175
+ if (direction === "forward") {
3176
+ if (next >= len) {
3177
+ next = len;
3178
+ } else if (target === "start") {
3179
+ const startType = this.charType(text[next], semanticClass);
3180
+ if (startType !== "space") {
3181
+ while (
3182
+ next < len &&
3183
+ this.charType(text[next], semanticClass) === startType
3184
+ )
3185
+ next++;
3186
+ }
3187
+ while (
3188
+ next < len &&
3189
+ this.charType(text[next], semanticClass) === "space"
3190
+ )
3191
+ next++;
3192
+ } else {
3193
+ if (next < len - 1) next++;
3194
+ while (
3195
+ next < len &&
3196
+ this.charType(text[next], semanticClass) === "space"
3197
+ )
3198
+ next++;
3199
+ if (next >= len) {
3200
+ next = len;
3201
+ } else {
3202
+ const t = this.charType(text[next], semanticClass);
3203
+ while (
3204
+ next < len - 1 &&
3205
+ this.charType(text[next + 1], semanticClass) === t
3206
+ )
3207
+ next++;
3208
+ }
3209
+ }
3210
+ } else {
3211
+ if (next >= len) next = len - 1;
3212
+ if (next > 0) next--;
3213
+ while (next > 0 && this.charType(text[next], semanticClass) === "space")
3214
+ next--;
3215
+ const t = this.charType(text[next], semanticClass);
3216
+ while (next > 0 && this.charType(text[next - 1], semanticClass) === t)
3217
+ next--;
3218
+ }
3219
+
3220
+ if (next === i) break;
3221
+ i = next;
3222
+ }
3223
+
3224
+ return i;
3225
+ }
3226
+
3227
+ private tryFindWordTargetInLine(
3228
+ line: string,
3229
+ col: number,
3230
+ direction: WordMotionDirection,
3231
+ target: WordMotionTarget,
3232
+ allowSameColumn: boolean = false,
3233
+ semanticClass: WordMotionClass = "word",
3234
+ ): number | null {
3235
+ if (line.length === 0) return null;
3236
+ if (col < 0 || col > line.length) return null;
3237
+
3238
+ if (direction === "forward") {
3239
+ if (col >= line.length) return null;
3240
+ } else {
3241
+ if (col <= 0) return null;
3242
+ if (!/\S/.test(line.slice(0, col))) return null;
3243
+ }
3244
+
3245
+ const targetCol = this.wordBoundaryCache.tryFindTarget(
3246
+ line,
3247
+ col,
3248
+ direction,
3249
+ target,
3250
+ semanticClass,
3251
+ );
3252
+ if (targetCol === null) return null;
3253
+
3254
+ if (direction === "forward") {
3255
+ if (targetCol >= line.length) return null;
3256
+ if (allowSameColumn) {
3257
+ if (targetCol < col) return null;
3258
+ } else if (targetCol <= col) {
3259
+ return null;
3260
+ }
3261
+ return targetCol;
3262
+ }
3263
+
3264
+ if (allowSameColumn) {
3265
+ if (targetCol > col) return null;
3266
+ } else if (targetCol >= col) {
3267
+ return null;
3268
+ }
3269
+
3270
+ return targetCol;
3271
+ }
3272
+
3273
+ private tryFindWordTargetLineLocal(
3274
+ direction: WordMotionDirection,
3275
+ target: WordMotionTarget,
3276
+ semanticClass: WordMotionClass = "word",
3277
+ ): number | null {
3278
+ const cursor = this.getCursor();
3279
+ const lineIndex = cursor.line;
3280
+ const col = cursor.col;
3281
+ const lineSnapshot = this.getLines()[lineIndex] ?? "";
3282
+
3283
+ const targetCol = this.tryFindWordTargetInLine(
3284
+ lineSnapshot,
3285
+ col,
3286
+ direction,
3287
+ target,
3288
+ false,
3289
+ semanticClass,
3290
+ );
3291
+ if (targetCol === null) return null;
3292
+
3293
+ const liveLine = this.getLines()[lineIndex] ?? "";
3294
+ const liveCol = this.getCursor().col;
3295
+ if (liveLine !== lineSnapshot || liveCol !== col) return null;
3296
+
3297
+ return targetCol;
3298
+ }
3299
+
3300
+ private tryMoveWordLineLocal(
3301
+ direction: "forward" | "backward",
3302
+ target: "start" | "end",
3303
+ semanticClass: WordMotionClass = "word",
3304
+ ): boolean {
3305
+ const col = this.getCursor().col;
3306
+ const targetCol = this.tryFindWordTargetLineLocal(
3307
+ direction,
3308
+ target,
3309
+ semanticClass,
3310
+ );
3311
+ if (targetCol === null || targetCol === col) return false;
3312
+
3313
+ this.moveCursorToCol(targetCol);
3314
+ return true;
3315
+ }
3316
+
3317
+ private tryWordMotionLineLocalRange(
3318
+ motion: "w" | "e" | "b",
3319
+ count: number = 1,
3320
+ semanticClass: WordMotionClass = "word",
3321
+ ): { col: number; targetCol: number; inclusive: boolean } | null {
3322
+ const cursor = this.getCursor();
3323
+ const lineIndex = cursor.line;
3324
+ const col = cursor.col;
3325
+ const lineSnapshot = this.getLines()[lineIndex] ?? "";
3326
+ const direction: WordMotionDirection =
3327
+ motion === "b" ? "backward" : "forward";
3328
+ const target: WordMotionTarget = motion === "e" ? "end" : "start";
3329
+ const steps = Math.max(1, Math.min(MAX_COUNT, count));
3330
+
3331
+ let currentCol = col;
3332
+ for (let step = 0; step < steps; step++) {
3333
+ const nextCol = this.tryFindWordTargetInLine(
3334
+ lineSnapshot,
3335
+ currentCol,
3336
+ direction,
3337
+ target,
3338
+ motion === "e",
3339
+ semanticClass,
3340
+ );
3341
+ if (nextCol === null) return null;
3342
+ if (nextCol === currentCol && step < steps - 1) return null;
3343
+ currentCol = nextCol;
3344
+ }
3345
+
3346
+ const liveLine = this.getLines()[lineIndex] ?? "";
3347
+ const liveCol = this.getCursor().col;
3348
+ if (liveLine !== lineSnapshot || liveCol !== col) return null;
3349
+
3350
+ return {
3351
+ col,
3352
+ targetCol: currentCol,
3353
+ inclusive: motion === "e",
3354
+ };
3355
+ }
3356
+
3357
+ private moveWord(
3358
+ direction: "forward" | "backward",
3359
+ target: "start" | "end",
3360
+ count: number = 1,
3361
+ semanticClass: WordMotionClass = "word",
3362
+ ): void {
3363
+ let remaining = Math.max(1, Math.min(MAX_COUNT, count));
3364
+
3365
+ while (remaining > 0) {
3366
+ if (this.tryMoveWordLineLocal(direction, target, semanticClass)) {
3367
+ remaining--;
3368
+ continue;
3369
+ }
3370
+
3371
+ const text = this.getText();
3372
+ const currentAbs = this.getAbsoluteIndexFromCursor();
3373
+ const targetAbs = this.findWordTargetInText(
3374
+ text,
3375
+ currentAbs,
3376
+ direction,
3377
+ target,
3378
+ remaining,
3379
+ semanticClass,
3380
+ );
3381
+ if (targetAbs !== currentAbs) {
3382
+ this.moveCursorToAbsoluteIndex(targetAbs);
3383
+ }
3384
+ return;
3385
+ }
3386
+ }
3387
+
3388
+ private shouldMirrorRegisterWrite(source: RegisterWriteSource): boolean {
3389
+ if (this.clipboardMirrorPolicy === "never") return false;
3390
+ if (this.clipboardMirrorPolicy === "yank") return source === "yank";
3391
+ return true;
3392
+ }
3393
+
3394
+ private writeToRegister(
3395
+ text: string,
3396
+ source: RegisterWriteSource = "mutation",
3397
+ ): void {
3398
+ this.unnamedRegister = text;
3399
+ const shouldMirror = text !== "" && this.shouldMirrorRegisterWrite(source);
3400
+ this.preferRegisterForPut = text !== "" && !shouldMirror;
3401
+ if (!shouldMirror) return;
3402
+
3403
+ this.clipboardMirror.mirror(text);
3404
+ }
3405
+
3406
+ private getCurrentLineAndCol(): { line: string; col: number } {
3407
+ const line = this.getLines()[this.getCursor().line] ?? "";
3408
+ const col = this.getCursor().col;
3409
+ return { line, col };
3410
+ }
3411
+
3412
+ private hasMultiCodeUnitGraphemes(line: string): boolean {
3413
+ return getLineGraphemes(line).some(
3414
+ (segment) => segment.end - segment.start > 1,
3415
+ );
3416
+ }
3417
+
3418
+ private getGraphemeRangeAtCol(
3419
+ line: string,
3420
+ col: number,
3421
+ count: number,
3422
+ clampToLine: boolean = false,
3423
+ ): { start: number; end: number } | null {
3424
+ const clampedCol = Math.max(0, Math.min(col, line.length));
3425
+ const segments = getLineGraphemes(line);
3426
+ const startIndex = segments.findIndex(
3427
+ (segment) => clampedCol < segment.end,
3428
+ );
3429
+ if (startIndex === -1) return null;
3430
+
3431
+ let endIndex = startIndex + Math.max(1, count) - 1;
3432
+ if (endIndex >= segments.length) {
3433
+ if (!clampToLine) return null;
3434
+ endIndex = segments.length - 1;
3435
+ }
3436
+
3437
+ const startSegment = segments[startIndex];
3438
+ const endSegment = segments[endIndex];
3439
+ if (!startSegment || !endSegment) return null;
3440
+
3441
+ return {
3442
+ start: startSegment.start,
3443
+ end: endSegment.end,
3444
+ };
3445
+ }
3446
+
3447
+ private isCursorOnNonWhitespace(): boolean {
3448
+ const { line, col } = this.getCurrentLineAndCol();
3449
+ const ch = line[col];
3450
+ return ch !== undefined && !/\s/.test(ch);
3451
+ }
3452
+
3453
+ private isCursorAtOrPastEol(): boolean {
3454
+ const { line, col } = this.getCurrentLineAndCol();
3455
+ return col >= line.length;
3456
+ }
3457
+
3458
+ private cutCharUnderCursor(normal: boolean = false): void {
3459
+ const count = Math.max(1, Math.min(MAX_COUNT, this.takeTotalCount(1)));
3460
+ const cursor = this.getCursor();
3461
+ const line = this.getLines()[cursor.line] ?? "";
3462
+ const range = this.getGraphemeRangeAtCol(line, cursor.col, count, true);
3463
+ if (!range) return;
3464
+
3465
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
3466
+ const text = this.getText();
3467
+ this.writeToRegister(line.slice(range.start, range.end));
3468
+ this.replaceTextInBuffer(
3469
+ text.slice(0, lineStartAbs + range.start) +
3470
+ text.slice(lineStartAbs + range.end),
3471
+ lineStartAbs + range.start,
3472
+ );
3473
+ if (normal) {
3474
+ const { line, col } = this.getCurrentLineAndCol();
3475
+ if (line && col >= line.length) this.moveCursorBy(-1);
3476
+ }
3477
+ }
3478
+
3479
+ // Vim `X`: delete up to {count} graphemes before the cursor on the current
3480
+ // line, clamping at column 0; a cursor already at column 0 is a no-op.
3481
+ private cutCharsBeforeCursor(): void {
3482
+ const count = Math.max(1, Math.min(MAX_COUNT, this.takeTotalCount(1)));
3483
+ const cursor = this.getCursor();
3484
+ if (cursor.col <= 0) return;
3485
+ const line = this.getLines()[cursor.line] ?? "";
3486
+ const segments = getLineGraphemes(line);
3487
+ const before = segments.filter((segment) => segment.end <= cursor.col);
3488
+ if (before.length === 0) return;
3489
+ const take = Math.min(count, before.length);
3490
+ const start = before[before.length - take].start;
3491
+ const end = before[before.length - 1].end;
3492
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
3493
+ const text = this.getText();
3494
+ this.writeToRegister(line.slice(start, end));
3495
+ this.replaceTextInBuffer(
3496
+ text.slice(0, lineStartAbs + start) + text.slice(lineStartAbs + end),
3497
+ lineStartAbs + start,
3498
+ );
3499
+ }
3500
+
3501
+ private cutToEndOfLine(): void {
3502
+ const lines = this.getLines();
3503
+ const cursorLine = this.getCursor().line;
3504
+ const { line, col } = this.getCurrentLineAndCol();
3505
+
3506
+ const hasNextLine = cursorLine < lines.length - 1;
3507
+ const deleted =
3508
+ col < line.length ? line.slice(col) : hasNextLine ? "\n" : "";
3509
+
3510
+ if (deleted !== "") this.writeToRegister(deleted);
3511
+ super.handleInput(CTRL_K);
3512
+ }
3513
+
3514
+ private cutCurrentLineContent(): void {
3515
+ const lines = this.getLines();
3516
+ const cursorLine = this.getCursor().line;
3517
+ const { line } = this.getCurrentLineAndCol();
3518
+
3519
+ const hasNextLine = cursorLine < lines.length - 1;
3520
+ const deleted = line.length > 0 ? line : hasNextLine ? "\n" : "";
3521
+
3522
+ this.writeToRegister(deleted);
3523
+ super.handleInput(CTRL_A);
3524
+ super.handleInput(CTRL_K);
3525
+ }
3526
+
3527
+ private cutLine(): void {
3528
+ this.cutCurrentLineContent();
3529
+ }
3530
+
3531
+ private getNormalizedLineRange(
3532
+ startLine: number,
3533
+ endLine: number,
3534
+ ): { start: number; end: number } {
3535
+ const lines = this.getLines();
3536
+ const last = Math.max(0, lines.length - 1);
3537
+ const clampedStart = Math.max(0, Math.min(startLine, last));
3538
+ const clampedEnd = Math.max(0, Math.min(endLine, last));
3539
+ return {
3540
+ start: Math.min(clampedStart, clampedEnd),
3541
+ end: Math.max(clampedStart, clampedEnd),
3542
+ };
3543
+ }
3544
+
3545
+ private getLinewisePayload(startLine: number, endLine: number): string {
3546
+ const lines = this.getLines();
3547
+ const { start, end } = this.getNormalizedLineRange(startLine, endLine);
3548
+ return `${lines.slice(start, end + 1).join("\n")}\n`;
3549
+ }
3550
+
3551
+ private getLineDeleteAbsoluteRange(
3552
+ startLine: number,
3553
+ endLine: number,
3554
+ ): { startAbs: number; endAbs: number } {
3555
+ const lines = this.getLines();
3556
+ const text = this.getText();
3557
+ const { start, end } = this.getNormalizedLineRange(startLine, endLine);
3558
+ const lastLine = Math.max(0, lines.length - 1);
3559
+
3560
+ let startAbs = this.getAbsoluteIndex(start, 0);
3561
+ let endAbs: number;
3562
+
3563
+ if (end < lastLine) {
3564
+ const endLineText = lines[end] ?? "";
3565
+ endAbs = this.getAbsoluteIndex(end, endLineText.length) + 1;
3566
+ } else {
3567
+ endAbs = text.length;
3568
+ if (start > 0) {
3569
+ startAbs = Math.max(0, startAbs - 1);
3570
+ }
3571
+ }
3572
+
3573
+ return { startAbs, endAbs };
3574
+ }
3575
+
3576
+ private deleteLineRange(startLine: number, endLine: number): void {
3577
+ const lines = this.getLines();
3578
+ if (lines.length === 0) return;
3579
+
3580
+ const payload = this.getLinewisePayload(startLine, endLine);
3581
+ const { startAbs, endAbs } = this.getLineDeleteAbsoluteRange(
3582
+ startLine,
3583
+ endLine,
3584
+ );
3585
+
3586
+ this.writeToRegister(payload);
3587
+
3588
+ if (endAbs > startAbs) {
3589
+ const text = this.getText();
3590
+ const newText = text.slice(0, startAbs) + text.slice(endAbs);
3591
+ this.replaceTextInBuffer(newText, startAbs);
3592
+
3593
+ super.handleInput(CTRL_A);
3594
+ }
3595
+ }
3596
+
3597
+ private yankLineRange(startLine: number, endLine: number): void {
3598
+ if (this.getLines().length === 0) return;
3599
+ this.writeToRegister(this.getLinewisePayload(startLine, endLine), "yank");
3600
+ }
3601
+
3602
+ private deleteLinewiseByDelta(delta: number): void {
3603
+ const currentLine = this.getCursor().line;
3604
+ this.deleteLineRange(currentLine, currentLine + delta);
3605
+ }
3606
+
3607
+ private yankLinewiseByDelta(delta: number): void {
3608
+ const currentLine = this.getCursor().line;
3609
+ this.yankLineRange(currentLine, currentLine + delta);
3610
+ }
3611
+
3612
+ private deleteToBufferEndLinewise(): void {
3613
+ this.deleteLineRange(this.getCursor().line, this.getLines().length - 1);
3614
+ }
3615
+
3616
+ private yankToBufferEndLinewise(): void {
3617
+ this.yankLineRange(this.getCursor().line, this.getLines().length - 1);
3618
+ }
3619
+
3620
+ private deleteWithMotion(motion: string, count: number = 1): boolean {
3621
+ const cursor = this.getCursor();
3622
+ const col = cursor.col;
3623
+
3624
+ if (motion === "$") {
3625
+ this.cutToEndOfLine();
3626
+ return true;
3627
+ }
3628
+
3629
+ if (motion === "0") {
3630
+ this.deleteRange(col, 0, false);
3631
+ return true;
3632
+ }
3633
+
3634
+ if (motion === "^") {
3635
+ this.deleteRange(
3636
+ col,
3637
+ findFirstNonWhitespaceColumn(this.getLines()[cursor.line] ?? ""),
3638
+ false,
3639
+ );
3640
+ return true;
3641
+ }
3642
+
3643
+ const wordMotion = this.resolveWordMotion(motion);
3644
+ if (wordMotion) {
3645
+ const lineLocalRange = this.tryWordMotionLineLocalRange(
3646
+ wordMotion.motion,
3647
+ count,
3648
+ wordMotion.semanticClass,
3649
+ );
3650
+ if (lineLocalRange) {
3651
+ this.deleteRange(
3652
+ lineLocalRange.col,
3653
+ lineLocalRange.targetCol,
3654
+ lineLocalRange.inclusive,
3655
+ );
3656
+ return true;
3657
+ }
3658
+
3659
+ const text = this.getText();
3660
+ const currentAbs = this.getAbsoluteIndexFromCursor();
3661
+ const targetAbs = this.findWordTargetInText(
3662
+ text,
3663
+ currentAbs,
3664
+ wordMotion.motion === "b" ? "backward" : "forward",
3665
+ wordMotion.motion === "e" ? "end" : "start",
3666
+ count,
3667
+ wordMotion.semanticClass,
3668
+ );
3669
+ this.deleteRangeByAbsolute(
3670
+ currentAbs,
3671
+ targetAbs,
3672
+ wordMotion.motion === "e",
3673
+ );
3674
+ return true;
3675
+ }
3676
+
3677
+ return false;
3678
+ }
3679
+
3680
+ // Handles counted `d$`/`c$`. `$` with a count N descends N-1 lines before
3681
+ // going to end of line, so the operator spans multiple lines. Semantics
3682
+ // match nvim (verified against the real editor): count 1 delegates to the
3683
+ // existing single-line path; on the last line a count >= 2 aborts as a
3684
+ // no-op; `d$` (but not `c$`) becomes linewise when the cursor is at or
3685
+ // before the first non-blank column.
3686
+ private applyLineEndOperator(
3687
+ count: number,
3688
+ mode: "change" | "delete",
3689
+ ): boolean {
3690
+ const clampedCount = Math.max(1, Math.min(MAX_COUNT, count));
3691
+ if (clampedCount <= 1) {
3692
+ this.cutToEndOfLine();
3693
+ return true;
3694
+ }
3695
+
3696
+ const lines = this.getLines();
3697
+ if (lines.length === 0) return false;
3698
+
3699
+ const cursor = this.getCursor();
3700
+ const lastLine = lines.length - 1;
3701
+ if (cursor.line >= lastLine) return false;
3702
+
3703
+ const targetLine = Math.min(cursor.line + clampedCount - 1, lastLine);
3704
+ const text = this.getText();
3705
+ const startAbs = this.getAbsoluteIndex(cursor.line, cursor.col);
3706
+ const targetLineText = lines[targetLine] ?? "";
3707
+ const targetEndAbs = this.getAbsoluteIndex(
3708
+ targetLine,
3709
+ targetLineText.length,
3710
+ );
3711
+
3712
+ const firstNonBlank = findFirstNonWhitespaceColumn(
3713
+ lines[cursor.line] ?? "",
3714
+ );
3715
+ const linewise = mode === "delete" && cursor.col <= firstNonBlank;
3716
+
3717
+ if (linewise) {
3718
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
3719
+ this.writeToRegister(`${text.slice(lineStartAbs, targetEndAbs)}\n`);
3720
+
3721
+ let removeStart = lineStartAbs;
3722
+ let removeEnd = targetEndAbs;
3723
+ if (targetLine < lastLine) {
3724
+ removeEnd = targetEndAbs + 1;
3725
+ } else if (cursor.line > 0) {
3726
+ removeStart = Math.max(0, lineStartAbs - 1);
3727
+ }
3728
+
3729
+ const newText = text.slice(0, removeStart) + text.slice(removeEnd);
3730
+ this.replaceTextInBuffer(newText, Math.min(startAbs, newText.length));
3731
+ this.moveCursorAfterDeleteToLineEnd(cursor.line, cursor.col);
3732
+ return true;
3733
+ }
3734
+
3735
+ this.writeToRegister(text.slice(startAbs, targetEndAbs));
3736
+ const newText = text.slice(0, startAbs) + text.slice(targetEndAbs);
3737
+ this.replaceTextInBuffer(newText, Math.min(startAbs, newText.length));
3738
+ if (mode === "delete") {
3739
+ this.moveCursorAfterDeleteToLineEnd(cursor.line, cursor.col);
3740
+ }
3741
+ return true;
3742
+ }
3743
+
3744
+ private moveCursorAfterDeleteToLineEnd(
3745
+ startLine: number,
3746
+ startCol: number,
3747
+ ): void {
3748
+ const lines = this.getLines();
3749
+ const lineIndex = Math.min(startLine, Math.max(0, lines.length - 1));
3750
+ if (lineIndex < startLine) {
3751
+ this.moveCursorToLineStart(lineIndex);
3752
+ return;
3753
+ }
3754
+
3755
+ const line = lines[lineIndex] ?? "";
3756
+ if (line.length > 0 && startCol >= line.length) {
3757
+ const graphemes = getLineGraphemes(line);
3758
+ this.moveCursorToCol(graphemes[graphemes.length - 1]?.start ?? 0);
3759
+ return;
3760
+ }
3761
+
3762
+ this.moveCursorToCol(Math.min(startCol, line.length));
3763
+ }
3764
+
3765
+ private deleteWithCharMotion(
3766
+ motion: CharMotion,
3767
+ targetChar: string,
3768
+ ): boolean {
3769
+ const line = this.getLines()[this.getCursor().line] ?? "";
3770
+ const col = this.getCursor().col;
3771
+ const count = this.takeTotalCount(1);
3772
+ const targetCol = findCharMotionTarget(
3773
+ line,
3774
+ col,
3775
+ motion,
3776
+ targetChar,
3777
+ false,
3778
+ count,
3779
+ );
3780
+
3781
+ if (targetCol === null) return false;
3782
+
3783
+ this.lastCharMotion = { motion, char: targetChar };
3784
+ this.deleteRange(col, targetCol, true);
3785
+ return true;
3786
+ }
3787
+
3788
+ private handlePendingYank(data: string): void {
3789
+ if (this.opDigit(data)) return;
3790
+
3791
+ if (data === "%") {
3792
+ this.applyPercentOp();
3793
+ return;
3794
+ }
3795
+
3796
+ if (data === "y") {
3797
+ const count = this.takeTotalCount(1);
3798
+ this.yankLinewiseByDelta(count - 1);
3799
+ this.pendingOperator = null;
3800
+ return;
3801
+ }
3802
+
3803
+ if (data === "j" || data === "k") {
3804
+ const hasDualCount =
3805
+ this.prefixCount.length > 0 && this.operatorCount.length > 0;
3806
+ const count = this.takeTotalCount(1);
3807
+ const delta = hasDualCount ? Math.max(0, count - 1) : count;
3808
+ this.yankLinewiseByDelta(data === "j" ? delta : -delta);
3809
+ this.pendingOperator = null;
3810
+ return;
3811
+ }
3812
+
3813
+ if (data === "G") {
3814
+ if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
3815
+ this.cancelPendingOperator(data);
3816
+ return;
3817
+ }
3818
+
3819
+ this.yankToBufferEndLinewise();
3820
+ this.pendingOperator = null;
3821
+ return;
3822
+ }
3823
+
3824
+ if (data === "_") {
3825
+ const count = this.takeTotalCount(1);
3826
+ this.yankLinewiseByDelta(count - 1);
3827
+ this.pendingOperator = null;
3828
+ return;
3829
+ }
3830
+
3831
+ if (CHAR_MOTION_KEYS.has(data)) {
3832
+ this.pendingMotion = data as PendingMotion;
3833
+ return;
3834
+ }
3835
+
3836
+ if (data === "i" || data === "a") {
3837
+ this.pendingTextObject = data;
3838
+ return;
3839
+ }
3840
+
3841
+ if (this.hasPendingCount()) {
3842
+ this.cancelPendingOperator(data);
3843
+ return;
3844
+ }
3845
+
3846
+ if (this.yankWithMotion(data)) {
3847
+ this.pendingOperator = null;
3848
+ } else {
3849
+ this.cancelPendingOperator(data); // cancel on unrecognised motion
3850
+ }
3851
+ }
3852
+
3853
+ private yankWithMotion(motion: string): boolean {
3854
+ const cursor = this.getCursor();
3855
+ const line = this.getLines()[cursor.line] ?? "";
3856
+ const col = cursor.col;
3857
+
3858
+ if (motion === "$") {
3859
+ this.yankRange(col, line.length, false);
3860
+ return true;
3861
+ }
3862
+
3863
+ if (motion === "0") {
3864
+ this.yankRange(col, 0, false);
3865
+ return true;
3866
+ }
3867
+
3868
+ if (motion === "^") {
3869
+ this.yankRange(col, findFirstNonWhitespaceColumn(line), false);
3870
+ return true;
3871
+ }
3872
+
3873
+ const wordMotion = this.resolveWordMotion(motion);
3874
+ if (wordMotion) {
3875
+ const lineLocalRange = this.tryWordMotionLineLocalRange(
3876
+ wordMotion.motion,
3877
+ 1,
3878
+ wordMotion.semanticClass,
3879
+ );
3880
+ if (lineLocalRange) {
3881
+ this.yankRange(
3882
+ lineLocalRange.col,
3883
+ lineLocalRange.targetCol,
3884
+ lineLocalRange.inclusive,
3885
+ );
3886
+ return true;
3887
+ }
3888
+
3889
+ const text = this.getText();
3890
+ const currentAbs = this.getAbsoluteIndexFromCursor();
3891
+ const targetAbs = this.findWordTargetInText(
3892
+ text,
3893
+ currentAbs,
3894
+ wordMotion.motion === "b" ? "backward" : "forward",
3895
+ wordMotion.motion === "e" ? "end" : "start",
3896
+ 1,
3897
+ wordMotion.semanticClass,
3898
+ );
3899
+ this.yankRangeByAbsolute(
3900
+ currentAbs,
3901
+ targetAbs,
3902
+ wordMotion.motion === "e",
3903
+ );
3904
+ return true;
3905
+ }
3906
+
3907
+ return false;
3908
+ }
3909
+
3910
+ private yankWithCharMotion(motion: CharMotion, targetChar: string): void {
3911
+ const line = this.getLines()[this.getCursor().line] ?? "";
3912
+ const col = this.getCursor().col;
3913
+ const count = this.takeTotalCount(1);
3914
+ const targetCol = findCharMotionTarget(
3915
+ line,
3916
+ col,
3917
+ motion,
3918
+ targetChar,
3919
+ false,
3920
+ count,
3921
+ );
3922
+
3923
+ if (targetCol === null) return;
3924
+
3925
+ this.lastCharMotion = { motion, char: targetChar };
3926
+ this.yankRange(col, targetCol, true);
3927
+ }
3928
+
3929
+ private yankRange(col: number, targetCol: number, inclusive: boolean): void {
3930
+ const line = this.getLines()[this.getCursor().line] ?? "";
3931
+ const start = Math.min(col, targetCol);
3932
+ const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
3933
+ let end = Math.min(rawEnd, line.length);
3934
+
3935
+ if (inclusive) {
3936
+ const targetRange = this.getGraphemeRangeAtCol(
3937
+ line,
3938
+ Math.max(col, targetCol),
3939
+ 1,
3940
+ );
3941
+ end = targetRange?.end ?? end;
3942
+ }
3943
+
3944
+ if (end <= start) return;
3945
+
3946
+ this.writeToRegister(line.slice(start, end), "yank");
3947
+ }
3948
+
3949
+ private yankRangeByAbsolute(
3950
+ currentAbs: number,
3951
+ targetAbs: number,
3952
+ inclusive: boolean = false,
3953
+ ): void {
3954
+ const text = this.getText();
3955
+ const start = Math.min(currentAbs, targetAbs);
3956
+ const rawEnd = Math.max(currentAbs, targetAbs) + (inclusive ? 1 : 0);
3957
+ const end = Math.min(rawEnd, text.length);
3958
+ if (end <= start) return;
3959
+ this.writeToRegister(text.slice(start, end), "yank");
3960
+ }
3961
+
3962
+ private getCursorFromAbsoluteIndex(
3963
+ text: string,
3964
+ abs: number,
3965
+ ): { line: number; col: number } {
3966
+ const lines = text.length === 0 ? [""] : text.split("\n");
3967
+ let remaining = Math.max(0, Math.min(abs, text.length));
3968
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
3969
+ const line = lines[lineIndex] ?? "";
3970
+ if (remaining <= line.length) return { line: lineIndex, col: remaining };
3971
+ remaining -= line.length + 1;
3972
+ }
3973
+ const lastLine = Math.max(0, lines.length - 1);
3974
+ return { line: lastLine, col: (lines[lastLine] ?? "").length };
3975
+ }
3976
+
3977
+ private replaceTextInBuffer(text: string, cursorAbs: number): void {
3978
+ const editor = this as unknown as {
3979
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
3980
+ preferredVisualCol?: number | null;
3981
+ historyIndex?: number;
3982
+ lastAction?: string | null;
3983
+ onChange?: (text: string) => void;
3984
+ tui?: { requestRender?: () => void };
3985
+ pushUndoSnapshot?: () => void;
3986
+ autocompleteState?: unknown;
3987
+ updateAutocomplete?: () => void;
3988
+ };
3989
+ const state = editor.state;
3990
+ if (!state) return;
3991
+ const currentText = this.getText();
3992
+ if (currentText !== text) editor.pushUndoSnapshot?.();
3993
+ const nextLines = text.length === 0 ? [""] : text.split("\n");
3994
+ const { line, col } = this.getCursorFromAbsoluteIndex(text, cursorAbs);
3995
+ editor.historyIndex = -1;
3996
+ editor.lastAction = null;
3997
+ state.lines = nextLines;
3998
+ state.cursorLine = line;
3999
+ state.cursorCol = col;
4000
+ editor.preferredVisualCol = null;
4001
+ editor.onChange?.(text);
4002
+ if (editor.autocompleteState) editor.updateAutocomplete?.();
4003
+ editor.tui?.requestRender?.();
4004
+ }
4005
+
4006
+ private deleteRangeByAbsolute(
4007
+ currentAbs: number,
4008
+ targetAbs: number,
4009
+ inclusive: boolean = false,
4010
+ ): void {
4011
+ const text = this.getText();
4012
+ const start = Math.min(currentAbs, targetAbs);
4013
+ const rawEnd = Math.max(currentAbs, targetAbs) + (inclusive ? 1 : 0);
4014
+ const end = Math.min(rawEnd, text.length);
4015
+
4016
+ if (end <= start) return;
4017
+
4018
+ this.writeToRegister(text.slice(start, end));
4019
+
4020
+ this.replaceTextInBuffer(text.slice(0, start) + text.slice(end), start);
4021
+ }
4022
+
4023
+ private getWordObjectRange(
4024
+ kind: TextObjectKind,
4025
+ count: number = 1,
4026
+ semanticClass: WordTextObjectClass = "word",
4027
+ ): TextObjectRange | null {
4028
+ const lines = this.getLines();
4029
+ const cursor = this.getCursor();
4030
+ const line = lines[cursor.line] ?? "";
4031
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
4032
+
4033
+ return resolveWordTextObjectRange(
4034
+ line,
4035
+ lineStartAbs,
4036
+ cursor.col,
4037
+ kind,
4038
+ count,
4039
+ semanticClass,
4040
+ );
4041
+ }
4042
+
4043
+ private static readonly PUT_SIZE_LIMIT = 512 * 1024; // 512 KB safety cap
4044
+
4045
+ private getPasteRegisterText(): string {
4046
+ // A failed or skipped mirror leaves the OS clipboard stale relative to
4047
+ // the register, so the register must win until a mirror lands again.
4048
+ if (
4049
+ this.preferRegisterForPut ||
4050
+ this.clipboardMirror.hasPendingWrite() ||
4051
+ (this.unnamedRegister !== "" && this.clipboardMirror.lastWriteFailed())
4052
+ ) {
4053
+ return this.unnamedRegister;
4054
+ }
4055
+
4056
+ try {
4057
+ const clipboardText = this.clipboardReadFn();
4058
+ return clipboardText ?? this.unnamedRegister;
4059
+ } catch {
4060
+ return this.unnamedRegister;
4061
+ }
4062
+ }
4063
+
4064
+ private moveCursorToPreviousGraphemeStart(): void {
4065
+ const cursor = this.getCursor();
4066
+ const line = this.getLines()[cursor.line] ?? "";
4067
+ const col = Math.max(0, Math.min(cursor.col, line.length));
4068
+ if (col <= 0) return;
4069
+
4070
+ const range = this.getGraphemeRangeAtCol(line, col - 1, 1);
4071
+ if (range) this.moveCursorToCol(range.start);
4072
+ }
4073
+
4074
+ private moveCursorToLastGraphemeOnLine(): void {
4075
+ const { line } = this.getCurrentLineAndCol();
4076
+ const graphemes = getLineGraphemes(line);
4077
+ this.moveCursorToCol(graphemes[graphemes.length - 1]?.start ?? 0);
4078
+ }
4079
+
4080
+ private putAfter(): void {
4081
+ const count = this.takeTotalCount(1);
4082
+ const text = this.getPasteRegisterText();
4083
+ if (!text) return;
4084
+ const safeCount = Math.min(
4085
+ count,
4086
+ Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)),
4087
+ );
4088
+
4089
+ if (text.endsWith("\n")) {
4090
+ const content = text.slice(0, -1);
4091
+ const targetLine = this.getCursor().line + 1;
4092
+ for (let i = 0; i < safeCount; i++) {
4093
+ super.handleInput(CTRL_E);
4094
+ super.handleInput(NEWLINE);
4095
+ for (const char of content) {
4096
+ super.handleInput(char === "\n" ? NEWLINE : char);
4097
+ }
4098
+ }
4099
+ // Vim: line-wise `p` leaves the cursor on the first non-blank of the
4100
+ // first inserted line (one line below the original cursor). An
4101
+ // all-whitespace first line lands at col 0, sharing the `^` divergence.
4102
+ this.moveCursorToLineStart(targetLine);
4103
+ this.moveCursorToFirstNonWhitespace();
4104
+ return;
4105
+ }
4106
+
4107
+ if (!this.isCursorAtOrPastEol()) {
4108
+ super.handleInput(ESC_RIGHT);
4109
+ }
4110
+ for (let i = 0; i < safeCount; i++) {
4111
+ for (const char of text) {
4112
+ super.handleInput(char === "\n" ? NEWLINE : char);
4113
+ }
4114
+ }
4115
+ this.moveCursorToPreviousGraphemeStart();
4116
+ }
4117
+
4118
+ private putBefore(): void {
4119
+ const count = this.takeTotalCount(1);
4120
+ const text = this.getPasteRegisterText();
4121
+ if (!text) return;
4122
+ const safeCount = Math.min(
4123
+ count,
4124
+ Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)),
4125
+ );
4126
+
4127
+ if (text.endsWith("\n")) {
4128
+ const content = text.slice(0, -1);
4129
+ const targetLine = this.getCursor().line;
4130
+ for (let i = 0; i < safeCount; i++) {
4131
+ super.handleInput(CTRL_A);
4132
+ super.handleInput(NEWLINE);
4133
+ super.handleInput(ESC_UP);
4134
+ for (const char of content) {
4135
+ super.handleInput(char === "\n" ? NEWLINE : char);
4136
+ }
4137
+ }
4138
+ // Vim: line-wise `P` leaves the cursor on the first non-blank of the
4139
+ // first inserted line (the cursor's original line, since `P` inserts
4140
+ // above). All-whitespace first line lands at col 0 (`^` divergence).
4141
+ this.moveCursorToLineStart(targetLine);
4142
+ this.moveCursorToFirstNonWhitespace();
4143
+ return;
4144
+ }
4145
+
4146
+ for (let i = 0; i < safeCount; i++) {
4147
+ for (const char of text) {
4148
+ super.handleInput(char === "\n" ? NEWLINE : char);
4149
+ }
4150
+ }
4151
+ this.moveCursorToPreviousGraphemeStart();
4152
+ }
4153
+
4154
+ private deleteRange(
4155
+ col: number,
4156
+ targetCol: number,
4157
+ inclusive: boolean,
4158
+ ): void {
4159
+ const cursor = this.getCursor();
4160
+ const line = this.getLines()[cursor.line] ?? "";
4161
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
4162
+ const start = Math.min(col, targetCol);
4163
+ const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
4164
+ let end = Math.min(rawEnd, line.length);
4165
+
4166
+ if (inclusive) {
4167
+ const targetRange = this.getGraphemeRangeAtCol(
4168
+ line,
4169
+ Math.max(col, targetCol),
4170
+ 1,
4171
+ );
4172
+ end = targetRange?.end ?? end;
4173
+ }
4174
+
4175
+ this.deleteRangeByAbsolute(lineStartAbs + start, lineStartAbs + end);
4176
+ }
4177
+
4178
+ private getDesiredCursorShapeSequence(): CursorShapeSequence {
4179
+ return "insert" === this.mode && this.pendingExCommand === null
4180
+ ? INSERT_CURSOR_SHAPE
4181
+ : BLOCK_CURSOR_SHAPE;
4182
+ }
4183
+
4184
+ private syncCursorShapeForRender(lines: string[]): void {
4185
+ if (!this.cursorShapeRuntime) return;
4186
+ if (!hasPromptCursorMarker(lines)) return;
4187
+
4188
+ // Pi re-applies its `showHardwareCursor` setting on reload and on settings
4189
+ // changes (applyRuntimeSettings), which resets the hardware cursor pi-vim
4190
+ // relies on for shape control back to its default (off). When that happens
4191
+ // pi falls back to a software block cursor and hides the hardware cursor,
4192
+ // so the thin/thick shape never recovers. Re-assert ownership here so the
4193
+ // next positioning pass re-shows the hardware cursor and we keep shaping.
4194
+ if (this.cursorShapeRuntime.getShowHardwareCursor?.() === false) {
4195
+ this.cursorShapeRuntime.setShowHardwareCursor(true);
4196
+ // The hardware cursor was hidden, so the terminal's current DECSCUSR
4197
+ // shape may no longer match our cache; force a fresh write.
4198
+ this.lastCursorShapeSequence = null;
4199
+ }
4200
+
4201
+ stripSoftwareCursorWhenHardwareCursorIsUsed(lines);
4202
+
4203
+ const sequence = this.getDesiredCursorShapeSequence();
4204
+ if (sequence === this.lastCursorShapeSequence) return;
4205
+
4206
+ this.cursorShapeRuntime.writeCursorShape(sequence);
4207
+ this.lastCursorShapeSequence = sequence;
4208
+ }
4209
+
4210
+ render(width: number): string[] {
4211
+ const lines = super.render(width);
4212
+ this.syncCursorShapeForRender(lines);
4213
+ if (lines.length === 0) return lines;
4214
+
4215
+ const rawLabel = fitModeLabel(this.getModeLabel(), width);
4216
+ const colorize = this.getModeLabelColorizer();
4217
+ const label = colorize ? colorize(rawLabel) : rawLabel;
4218
+ const last = lines.length - 1;
4219
+ const lastLine = lines[last];
4220
+ if (lastLine && visibleWidth(lastLine) >= visibleWidth(rawLabel)) {
4221
+ const contentWidth = width - visibleWidth(rawLabel);
4222
+ const c = this.lastLineCache;
4223
+ if (lastLine !== c.l || contentWidth !== c.w || label !== c.label) {
4224
+ c.l = lastLine;
4225
+ c.w = contentWidth;
4226
+ c.label = label;
4227
+ c.result = truncateToWidth(lastLine, contentWidth, "") + label;
4228
+ }
4229
+ lines[last] = c.result;
4230
+ } else {
4231
+ lines[last] = label;
4232
+ }
4233
+ return lines;
4234
+ }
4235
+
4236
+ private getModeLabelColorizer(): ((s: string) => string) | null {
4237
+ if (!this.labelColorizers) return null;
4238
+ const labelColorizers = this.labelColorizers;
4239
+ const wrap = this.labelTransform ?? ((s: string) => s);
4240
+ return (s: string) =>
4241
+ this.resolveSurfaceColor(labelColorizers, this.labelSync, s, wrap);
4242
+ }
4243
+
4244
+ private getModeLabel(): string {
4245
+ if ("insert" === this.mode) return " INSERT ";
4246
+ if (this.pendingExCommand !== null) return ` EX ${this.pendingExCommand}_ `;
4247
+
4248
+ const prefixCount = this.prefixCount;
4249
+ const operatorCount = this.operatorCount;
4250
+
4251
+ if (isVisualMode(this.mode)) {
4252
+ const name = this.mode === "visual" ? "VISUAL" : "V-LINE";
4253
+ const pending = `${prefixCount}${this.pendingG ? `g${this.pendingGCount}` : ""}${this.pendingMotion ?? ""}`;
4254
+ return pending ? ` ${name} ${pending}_ ` : ` ${name} `;
4255
+ }
4256
+
4257
+ if (this.pendingReplace) {
4258
+ return prefixCount ? ` NORMAL ${prefixCount}r_ ` : " NORMAL r_ ";
4259
+ }
4260
+ if (this.pendingOperator && this.pendingMotion) {
4261
+ return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}${this.pendingMotion}_ `;
4262
+ }
4263
+ if (this.pendingOperator) {
4264
+ return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}_ `;
4265
+ }
4266
+ if (this.pendingMotion) return ` NORMAL ${this.pendingMotion}_ `;
4267
+ if (this.pendingG) {
4268
+ return this.pendingGCount
4269
+ ? ` NORMAL g${this.pendingGCount}_ `
4270
+ : " NORMAL g_ ";
4271
+ }
4272
+
4273
+ const count = `${prefixCount}${operatorCount}`;
4274
+ if (count) return ` NORMAL ${count}_ `;
4275
+ return " NORMAL ";
4276
+ }
4277
+ }
4278
+
4279
+ export default function (pi: ExtensionAPI) {
4280
+ let cursorShapeCleanup: CursorShapeCleanup | null = null;
4281
+
4282
+ pi.on("session_start", (_event, ctx) => {
4283
+ const piVimSettings = readPiVimSettings(ctx.cwd);
4284
+ const clipboardMirrorPolicy = resolveClipboardMirrorPolicy(
4285
+ piVimSettings.clipboardMirror,
4286
+ );
4287
+ if (clipboardMirrorPolicy.warning && ctx.hasUI) {
4288
+ ctx.ui.notify(clipboardMirrorPolicy.warning, "warning");
4289
+ }
4290
+ const exCommand = resolveExCommandSettings(
4291
+ piVimSettings.exCommand,
4292
+ piVimSettings.globalExCommand,
4293
+ );
4294
+ if (exCommand.warning && ctx.hasUI) {
4295
+ ctx.ui.notify(exCommand.warning, "warning");
4296
+ }
4297
+ const insertEscape = resolveInsertEscapeSettings(
4298
+ piVimSettings.insertEscape,
4299
+ );
4300
+ if (insertEscape.warning && ctx.hasUI) {
4301
+ ctx.ui.notify(insertEscape.warning, "warning");
4302
+ }
4303
+
4304
+ const t = ctx.ui.theme;
4305
+ const modeColors = resolveModeColors(piVimSettings.modeColors);
4306
+ const reverseVideo = (s: string) => `\x1b[7m${s}\x1b[27m`;
4307
+ const { borderSync, labelSync } = resolveSurfaceSyncMaps(piVimSettings);
4308
+ const labelColorizers = t
4309
+ ? buildModeColorizers(t, modeColors, reverseVideo)
4310
+ : null;
4311
+ // Both surfaces get their colorizers unconditionally; the editor installs
4312
+ // the border trap only when a surface actually needs it (see
4313
+ // borderTrapNeeded), so the all-default case stays byte-identical to the
4314
+ // released `false` behavior with the host border untouched.
4315
+ const borderColorizers = t ? buildModeColorizers(t, modeColors) : null;
4316
+ // Enables the "thinking" neutral-default detection; unused by "mode"/"host".
4317
+ const offBorderColor = t ? buildOffBorderColor(t) : null;
4318
+ const modeChangeHandler = createModeChangeHandler(
4319
+ piVimSettings.modeChange,
4320
+ (event) => pi.events.emit("pi-vim:mode-change", event),
4321
+ );
4322
+ ctx.ui.setEditorComponent((tui, theme, kb) => {
4323
+ cursorShapeCleanup = enableCursorShapeSupport(tui);
4324
+ const editor = new ModalEditor(tui, theme, kb, {
4325
+ labelColorizers,
4326
+ borderColorizers,
4327
+ borderSync,
4328
+ labelSync,
4329
+ offBorderColor,
4330
+ labelTransform: reverseVideo,
4331
+ });
4332
+ editor.setClipboardMirrorPolicy(clipboardMirrorPolicy.policy);
4333
+ editor.setQuitFn(() => ctx.shutdown());
4334
+ editor.setNotifyFn((message) => ctx.ui.notify(message, "warning"));
4335
+ editor.setModeChangeFn(modeChangeHandler);
4336
+ editor.setExCommandSettings(exCommand.settings);
4337
+ editor.setInsertEscapeSettings(insertEscape.settings);
4338
+ // Resolved at submit time so commands registered or reloaded mid-session
4339
+ // are dispatchable without restarting.
4340
+ editor.setCommandNamesFn(
4341
+ () =>
4342
+ new Set([
4343
+ ...EX_BUILTIN_COMMAND_NAMES,
4344
+ ...pi.getCommands().map((command) => command.name),
4345
+ ]),
4346
+ );
4347
+ return editor;
4348
+ });
4349
+ });
4350
+
4351
+ pi.on("session_shutdown", (event) => {
4352
+ try {
4353
+ cursorShapeCleanup?.(event);
4354
+ } finally {
4355
+ cancelModeChangeCommands();
4356
+ cursorShapeCleanup = null;
4357
+ }
4358
+ });
4359
+ }