pi-vim 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
 
3
- import {
4
- CustomEditor,
5
- type ExtensionAPI,
6
- } from "@mariozechner/pi-coding-agent";
3
+ import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
7
4
  import {
8
5
  CURSOR_MARKER,
9
6
  Key,
@@ -11,55 +8,55 @@ import {
11
8
  truncateToWidth,
12
9
  visibleWidth,
13
10
  } from "@mariozechner/pi-tui";
14
-
11
+ import {
12
+ type ClipboardMirrorPolicy,
13
+ DEFAULT_CLIPBOARD_MIRROR_POLICY,
14
+ type RegisterWriteSource,
15
+ resolveClipboardMirrorPolicy,
16
+ } from "./clipboard-policy.js";
17
+ import {
18
+ findCharMotionTarget,
19
+ findFirstNonWhitespaceColumn,
20
+ findParagraphMotionTarget,
21
+ getLineGraphemes,
22
+ reverseCharMotion,
23
+ type WordMotionClass,
24
+ } from "./motions.js";
25
+ import { type ModeColorSettings, readPiVimSettings } from "./settings.js";
26
+ import {
27
+ resolveDelimitedTextObjectRange,
28
+ resolveMatchingPairMotionTarget,
29
+ resolveWordTextObjectRange,
30
+ type TextObjectKind,
31
+ type TextObjectRange,
32
+ type WordTextObjectClass,
33
+ } from "./text-objects.js";
15
34
  import type {
16
- Mode,
17
35
  CharMotion,
36
+ LastCharMotion,
37
+ Mode,
18
38
  PendingMotion,
19
39
  PendingOperator,
20
- LastCharMotion,
21
40
  } from "./types.js";
22
41
  import {
23
- NORMAL_KEYS,
24
42
  CHAR_MOTION_KEYS,
25
- ESC_LEFT,
26
- ESC_RIGHT,
27
- ESC_UP,
28
43
  CTRL_A,
29
44
  CTRL_E,
30
45
  CTRL_K,
31
46
  CTRL_R,
32
47
  CTRL_UNDERSCORE,
33
- NEWLINE,
34
48
  ESC_DOWN,
49
+ ESC_LEFT,
50
+ ESC_RIGHT,
51
+ ESC_UP,
52
+ NEWLINE,
53
+ NORMAL_KEYS,
35
54
  } from "./types.js";
36
- import {
37
- reverseCharMotion,
38
- findCharMotionTarget,
39
- findParagraphMotionTarget,
40
- findFirstNonWhitespaceColumn,
41
- getLineGraphemes,
42
- type WordMotionClass,
43
- } from "./motions.js";
44
55
  import {
45
56
  WordBoundaryCache,
46
57
  type WordMotionDirection,
47
58
  type WordMotionTarget,
48
59
  } from "./word-boundary-cache.js";
49
- import {
50
- DEFAULT_CLIPBOARD_MIRROR_POLICY,
51
- readPiVimSettings,
52
- resolveClipboardMirrorPolicy,
53
- type ClipboardMirrorPolicy,
54
- type RegisterWriteSource,
55
- } from "./clipboard-policy.js";
56
- import {
57
- resolveDelimitedTextObjectRange,
58
- resolveWordTextObjectRange,
59
- type TextObjectKind,
60
- type TextObjectRange,
61
- type WordTextObjectClass,
62
- } from "./text-objects.js";
63
60
 
64
61
  const BRACKETED_PASTE_START = "\x1b[200~";
65
62
  const BRACKETED_PASTE_END = "\x1b[201~";
@@ -71,12 +68,16 @@ const SOFTWARE_CURSOR_RESETS = ["\x1b[0m", "\x1b[27m"] as const;
71
68
  const INSERT_CURSOR_SHAPE = "\x1b[5 q";
72
69
  const BLOCK_CURSOR_SHAPE = "\x1b[1 q";
73
70
  const RESET_CURSOR_SHAPE = "\x1b[0 q";
74
- // Pi emits OSC52 before its native clipboard fallback. Give that 5s fallback
75
- // a small grace so the parent does not kill the helper and discard stdout.
76
71
  const CLIPBOARD_WRITE_TIMEOUT_MS = PI_NATIVE_CLIPBOARD_TIMEOUT_MS + 500;
77
72
  const CLIPBOARD_SPAWN_FAILURE_LIMIT = 3;
78
73
  const CLIPBOARD_READ_TIMEOUT_MS = 750;
79
74
  const CLIPBOARD_READ_MAX_BUFFER_BYTES = 1024 * 1024;
75
+ const MODE_COLORS = {
76
+ insert: "borderMuted",
77
+ normal: "borderAccent",
78
+ ex: "warning",
79
+ } as const;
80
+ const TOKEN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
80
81
 
81
82
  type EditorSnapshot = {
82
83
  text: string;
@@ -101,11 +102,13 @@ type ClipboardWriteFn = (text: string, signal: AbortSignal) => Promise<void>;
101
102
  type ClipboardReadFn = () => string | null;
102
103
  type ClipboardProcess = ReturnType<typeof spawn>;
103
104
 
104
- type ModeLabelColorizers = {
105
- insert: (s: string) => string;
106
- normal: (s: string) => string;
107
- ex: (s: string) => string;
105
+ type ModeColorKey = keyof typeof MODE_COLORS;
106
+ type ModeColorizers = Record<ModeColorKey, (s: string) => string>;
107
+ type ModalEditorOptions = {
108
+ labelColorizers?: ModeColorizers | null;
109
+ borderColorizers?: ModeColorizers | null;
108
110
  };
111
+ type ThemeLike = { fg(token: string, text: string): string };
109
112
 
110
113
  type CursorShapeSequence =
111
114
  | typeof INSERT_CURSOR_SHAPE
@@ -120,6 +123,45 @@ type CursorShapeRuntime = {
120
123
 
121
124
  type CursorShapeCleanup = () => void;
122
125
 
126
+ function resolveModeColors(
127
+ colors?: ModeColorSettings,
128
+ ): Required<ModeColorSettings> {
129
+ return {
130
+ insert: colors?.insert ?? MODE_COLORS.insert,
131
+ normal: colors?.normal ?? MODE_COLORS.normal,
132
+ ex: colors?.ex ?? MODE_COLORS.ex,
133
+ };
134
+ }
135
+ function colorizeWithTheme(
136
+ theme: ThemeLike,
137
+ token: string,
138
+ fallback: string,
139
+ text: string,
140
+ ): string {
141
+ const trimmedToken = token.trim();
142
+ if (TOKEN.test(trimmedToken)) {
143
+ try {
144
+ return theme.fg(trimmedToken, text);
145
+ } catch {
146
+ return theme.fg(fallback, text);
147
+ }
148
+ }
149
+ return theme.fg(fallback, text);
150
+ }
151
+ function buildModeColorizers(
152
+ theme: ThemeLike,
153
+ colors: Required<ModeColorSettings>,
154
+ transform: (text: string) => string = (text) => text,
155
+ ): ModeColorizers {
156
+ const colorizer = (mode: ModeColorKey) => (text: string) =>
157
+ colorizeWithTheme(theme, colors[mode], MODE_COLORS[mode], transform(text));
158
+ return {
159
+ insert: colorizer("insert"),
160
+ normal: colorizer("normal"),
161
+ ex: colorizer("ex"),
162
+ };
163
+ }
164
+
123
165
  type CursorShapeTuiCandidate = {
124
166
  terminal?: { write?: unknown };
125
167
  setShowHardwareCursor?: unknown;
@@ -135,7 +177,10 @@ function getCursorShapeRuntime(tui: unknown): CursorShapeRuntime | null {
135
177
 
136
178
  const write = terminal.write;
137
179
  const setShowHardwareCursor = candidate.setShowHardwareCursor;
138
- if (typeof write !== "function" || typeof setShowHardwareCursor !== "function") {
180
+ if (
181
+ typeof write !== "function" ||
182
+ typeof setShowHardwareCursor !== "function"
183
+ ) {
139
184
  return null;
140
185
  }
141
186
 
@@ -178,7 +223,10 @@ function findSoftwareCursorReset(
178
223
  line: string,
179
224
  startIndex: number,
180
225
  ): { index: number; sequence: (typeof SOFTWARE_CURSOR_RESETS)[number] } | null {
181
- let firstReset: { index: number; sequence: (typeof SOFTWARE_CURSOR_RESETS)[number] } | null = null;
226
+ let firstReset: {
227
+ index: number;
228
+ sequence: (typeof SOFTWARE_CURSOR_RESETS)[number];
229
+ } | null = null;
182
230
 
183
231
  for (const sequence of SOFTWARE_CURSOR_RESETS) {
184
232
  const index = line.indexOf(sequence, startIndex);
@@ -203,9 +251,11 @@ function stripSoftwareCursorAfterMarker(line: string): string {
203
251
  const reset = findSoftwareCursorReset(line, cursorContentStart);
204
252
  if (!reset) return line;
205
253
 
206
- return line.slice(0, cursorStart)
207
- + line.slice(cursorContentStart, reset.index)
208
- + line.slice(reset.index + reset.sequence.length);
254
+ return (
255
+ line.slice(0, cursorStart) +
256
+ line.slice(cursorContentStart, reset.index) +
257
+ line.slice(reset.index + reset.sequence.length)
258
+ );
209
259
  }
210
260
 
211
261
  type ClipboardCircuitBreaker = {
@@ -236,17 +286,21 @@ function isNodeSpawnErrno(error: unknown): boolean {
236
286
  if (!(error instanceof Error)) return false;
237
287
 
238
288
  const candidate = error as SpawnErrnoLike;
239
- return typeof candidate.code === "string"
240
- && candidate.code.length > 0
241
- && typeof candidate.syscall === "string"
242
- && candidate.syscall.startsWith("spawn");
289
+ return (
290
+ typeof candidate.code === "string" &&
291
+ candidate.code.length > 0 &&
292
+ typeof candidate.syscall === "string" &&
293
+ candidate.syscall.startsWith("spawn")
294
+ );
243
295
  }
244
296
 
245
297
  function isClipboardEnvironmentFailure(error: unknown): boolean {
246
298
  return error instanceof ClipboardSpawnError || isNodeSpawnErrno(error);
247
299
  }
248
300
 
249
- const PI_CODING_AGENT_MODULE_URL = import.meta.resolve("@mariozechner/pi-coding-agent");
301
+ const PI_CODING_AGENT_MODULE_URL = import.meta.resolve(
302
+ "@mariozechner/pi-coding-agent",
303
+ );
250
304
  const CLIPBOARD_HELPER_SOURCE = `
251
305
  import { copyToClipboard } from ${JSON.stringify(PI_CODING_AGENT_MODULE_URL)};
252
306
 
@@ -257,10 +311,7 @@ for await (const chunk of process.stdin) {
257
311
 
258
312
  try {
259
313
  await Promise.resolve(copyToClipboard(Buffer.concat(chunks).toString("utf8")));
260
- } catch {
261
- // Pi clipboard writes are best-effort. Backend failures must not make the
262
- // helper exit non-zero and trip the parent spawn/environment breaker.
263
- }
314
+ } catch {}
264
315
  `;
265
316
 
266
317
  const CLIPBOARD_READ_HELPER_SOURCE = `
@@ -316,11 +367,14 @@ function killClipboardProcess(child: ClipboardProcess): void {
316
367
  try {
317
368
  child.kill("SIGKILL");
318
369
  } catch {
319
- // Best effort only; clipboard mirroring must not affect editing.
370
+ return;
320
371
  }
321
372
  }
322
373
 
323
- function writeClipboardInChildProcess(text: string, signal: AbortSignal): Promise<void> {
374
+ function writeClipboardInChildProcess(
375
+ text: string,
376
+ signal: AbortSignal,
377
+ ): Promise<void> {
324
378
  return new Promise<void>((resolve, reject) => {
325
379
  if (signal.aborted) {
326
380
  reject(getAbortError(signal));
@@ -350,12 +404,20 @@ function writeClipboardInChildProcess(text: string, signal: AbortSignal): Promis
350
404
  }
351
405
 
352
406
  try {
353
- child = spawn(process.execPath, ["--input-type=module", "-e", CLIPBOARD_HELPER_SOURCE], {
354
- stdio: ["pipe", "pipe", "ignore"],
355
- windowsHide: true,
356
- });
407
+ child = spawn(
408
+ process.execPath,
409
+ ["--input-type=module", "-e", CLIPBOARD_HELPER_SOURCE],
410
+ {
411
+ stdio: ["pipe", "pipe", "ignore"],
412
+ windowsHide: true,
413
+ },
414
+ );
357
415
  } catch (error) {
358
- finish(new ClipboardSpawnError("clipboard helper spawn failed", { cause: error }));
416
+ finish(
417
+ new ClipboardSpawnError("clipboard helper spawn failed", {
418
+ cause: error,
419
+ }),
420
+ );
359
421
  return;
360
422
  }
361
423
 
@@ -369,7 +431,11 @@ function writeClipboardInChildProcess(text: string, signal: AbortSignal): Promis
369
431
  });
370
432
 
371
433
  child.once("error", (error) => {
372
- finish(new ClipboardSpawnError("clipboard helper spawn failed", { cause: error }));
434
+ finish(
435
+ new ClipboardSpawnError("clipboard helper spawn failed", {
436
+ cause: error,
437
+ }),
438
+ );
373
439
  });
374
440
 
375
441
  child.once("close", (code) => {
@@ -393,7 +459,11 @@ function writeClipboardInChildProcess(text: string, signal: AbortSignal): Promis
393
459
  return;
394
460
  }
395
461
 
396
- finish(new ClipboardSpawnError(`clipboard helper failed with exit code ${code ?? "null"}`));
462
+ finish(
463
+ new ClipboardSpawnError(
464
+ `clipboard helper failed with exit code ${code ?? "null"}`,
465
+ ),
466
+ );
397
467
  });
398
468
 
399
469
  if (!child.stdin) {
@@ -436,7 +506,9 @@ class ClipboardMirror {
436
506
  ) {}
437
507
 
438
508
  setWriteFn(writeFn: ClipboardWriteFn): void {
439
- this.activeController?.abort(createClipboardAbortError("clipboard writer replaced"));
509
+ this.activeController?.abort(
510
+ createClipboardAbortError("clipboard writer replaced"),
511
+ );
440
512
  this.writeFn = writeFn;
441
513
  resetClipboardCircuitBreaker();
442
514
  }
@@ -446,7 +518,9 @@ class ClipboardMirror {
446
518
  }
447
519
 
448
520
  hasPendingWrite(): boolean {
449
- return this.activeText !== null || this.pendingText !== null || this.draining;
521
+ return (
522
+ this.activeText !== null || this.pendingText !== null || this.draining
523
+ );
450
524
  }
451
525
 
452
526
  mirror(text: string): void {
@@ -476,7 +550,6 @@ class ClipboardMirror {
476
550
  this.circuitBreaker.consecutiveEnvironmentFailures = 0;
477
551
  } catch (error) {
478
552
  this.recordWriteFailure(error);
479
- // Clipboard mirroring is best-effort; the register is authoritative.
480
553
  } finally {
481
554
  if (this.activeController === controller) {
482
555
  this.activeController = null;
@@ -503,13 +576,19 @@ class ClipboardMirror {
503
576
  }
504
577
 
505
578
  this.circuitBreaker.consecutiveEnvironmentFailures += 1;
506
- if (this.circuitBreaker.consecutiveEnvironmentFailures >= CLIPBOARD_SPAWN_FAILURE_LIMIT) {
579
+ if (
580
+ this.circuitBreaker.consecutiveEnvironmentFailures >=
581
+ CLIPBOARD_SPAWN_FAILURE_LIMIT
582
+ ) {
507
583
  this.circuitBreaker.disabled = true;
508
584
  this.pendingText = null;
509
585
  }
510
586
  }
511
587
 
512
- private async writeWithTimeout(text: string, controller: AbortController): Promise<void> {
588
+ private async writeWithTimeout(
589
+ text: string,
590
+ controller: AbortController,
591
+ ): Promise<void> {
513
592
  const timeoutError = createClipboardAbortError("clipboard write timed out");
514
593
  const timeoutId = setTimeout(() => {
515
594
  controller.abort(timeoutError);
@@ -548,15 +627,18 @@ export class ModalEditor extends CustomEditor {
548
627
  private readonly redoStack: EditorSnapshot[] = [];
549
628
  private currentTransition: TransitionState = "none";
550
629
  private onChangeHooked: boolean = false;
551
- private readonly labelColorizers: ModeLabelColorizers | null;
630
+ private readonly labelColorizers: ModeColorizers | null;
631
+ private readonly borderColorizers: ModeColorizers | null;
552
632
  private readonly cursorShapeRuntime: CursorShapeRuntime | null;
553
633
  private lastCursorShapeSequence: CursorShapeSequence | null = null;
554
634
 
555
- // Unnamed register
556
635
  private unnamedRegister: string = "";
557
636
  private preferRegisterForPut = false;
558
- private clipboardMirrorPolicy: ClipboardMirrorPolicy = DEFAULT_CLIPBOARD_MIRROR_POLICY;
559
- private readonly clipboardMirror = new ClipboardMirror(writeClipboardInChildProcess);
637
+ private clipboardMirrorPolicy: ClipboardMirrorPolicy =
638
+ DEFAULT_CLIPBOARD_MIRROR_POLICY;
639
+ private readonly clipboardMirror = new ClipboardMirror(
640
+ writeClipboardInChildProcess,
641
+ );
560
642
  private clipboardReadFn: ClipboardReadFn = readClipboardInChildProcess;
561
643
  private quitFn: () => void = () => {};
562
644
  private notifyFn: (message: string) => void = () => {};
@@ -565,18 +647,21 @@ export class ModalEditor extends CustomEditor {
565
647
  tui: CustomEditorConstructorArgs[0],
566
648
  theme: CustomEditorConstructorArgs[1],
567
649
  kb: CustomEditorConstructorArgs[2],
568
- labelColorizers?: ModeLabelColorizers | null,
650
+ opts?: ModalEditorOptions,
569
651
  ) {
570
652
  super(tui, theme, kb);
571
653
  this.cursorShapeRuntime = getCursorShapeRuntime(tui);
572
- this.labelColorizers = labelColorizers ?? null;
654
+ this.labelColorizers = opts?.labelColorizers ?? null;
655
+ this.borderColorizers = opts?.borderColorizers ?? null;
656
+ this.installModeBorderColorizer();
573
657
  }
574
658
 
575
- // Test seams
576
659
  setClipboardFn(fn: (text: string, signal?: AbortSignal) => unknown): void {
577
- this.clipboardMirror.setWriteFn(async (text: string, signal: AbortSignal) => {
578
- await fn(text, signal);
579
- });
660
+ this.clipboardMirror.setWriteFn(
661
+ async (text: string, signal: AbortSignal) => {
662
+ await fn(text, signal);
663
+ },
664
+ );
580
665
  }
581
666
  setClipboardWriteTimeoutMs(timeoutMs: number): void {
582
667
  this.clipboardMirror.setTimeoutMs(timeoutMs);
@@ -590,15 +675,51 @@ export class ModalEditor extends CustomEditor {
590
675
  getClipboardMirrorPolicy(): ClipboardMirrorPolicy {
591
676
  return this.clipboardMirrorPolicy;
592
677
  }
593
- setQuitFn(fn: () => void): void { this.quitFn = fn; }
594
- setNotifyFn(fn: (message: string) => void): void { this.notifyFn = fn; }
595
- getRegister(): string { return this.unnamedRegister; }
678
+ setQuitFn(fn: () => void): void {
679
+ this.quitFn = fn;
680
+ }
681
+ setNotifyFn(fn: (message: string) => void): void {
682
+ this.notifyFn = fn;
683
+ }
684
+ getRegister(): string {
685
+ return this.unnamedRegister;
686
+ }
596
687
  setRegister(text: string): void {
597
688
  this.unnamedRegister = text;
598
689
  this.preferRegisterForPut = false;
599
690
  }
600
- getMode(): Mode { return this.mode; }
601
- getText(): string { return this.getLines().join("\n"); }
691
+ getMode(): Mode {
692
+ return this.mode;
693
+ }
694
+ getText(): string {
695
+ return this.getLines().join("\n");
696
+ }
697
+
698
+ private getActiveMode(): Mode | "ex" {
699
+ if (this.pendingExCommand !== null) return "ex";
700
+ return this.mode;
701
+ }
702
+
703
+ private installModeBorderColorizer(): void {
704
+ if (!this.borderColorizers) return;
705
+ let base = this.borderColor;
706
+ const modeBorderColor = (text: string) =>
707
+ (this.borderColorizers?.[this.getActiveMode()] ?? base)(text);
708
+ // Pi assigns its default border color after extension editor construction.
709
+ // Keep a mode-aware getter installed and treat later assignments as the
710
+ // fallback/base color, otherwise syncBorderColorWithMode is overwritten in
711
+ // real sessions even though direct editor tests pass.
712
+ Object.defineProperty(this, "borderColor", {
713
+ get: () => modeBorderColor,
714
+ set(next: unknown) {
715
+ if (typeof next === "function") base = next as typeof base;
716
+ },
717
+ });
718
+ }
719
+
720
+ private setMode(mode: Mode = "insert"): void {
721
+ this.mode = mode;
722
+ }
602
723
 
603
724
  override setText(text: string): void {
604
725
  this.clearRedoStack();
@@ -613,14 +734,20 @@ export class ModalEditor extends CustomEditor {
613
734
  };
614
735
  }
615
736
 
616
- private requireRedoRestoreState(
617
- editor: ModalEditorInternals,
618
- ): { lines: string[]; cursorLine?: number; cursorCol?: number } {
737
+ private requireRedoRestoreState(editor: ModalEditorInternals): {
738
+ lines: string[];
739
+ cursorLine?: number;
740
+ cursorCol?: number;
741
+ } {
619
742
  const state = editor.state;
620
743
  if (!state || !Array.isArray(state.lines)) {
621
744
  throw new Error("Redo restore prerequisite: editor state unavailable");
622
745
  }
623
- return state as { lines: string[]; cursorLine?: number; cursorCol?: number };
746
+ return state as {
747
+ lines: string[];
748
+ cursorLine?: number;
749
+ cursorCol?: number;
750
+ };
624
751
  }
625
752
 
626
753
  private restoreSnapshot(snapshot: EditorSnapshot): void {
@@ -652,9 +779,11 @@ export class ModalEditor extends CustomEditor {
652
779
  }
653
780
 
654
781
  private snapshotChanged(a: EditorSnapshot, b: EditorSnapshot): boolean {
655
- return a.text !== b.text
656
- || a.cursor.line !== b.cursor.line
657
- || a.cursor.col !== b.cursor.col;
782
+ return (
783
+ a.text !== b.text ||
784
+ a.cursor.line !== b.cursor.line ||
785
+ a.cursor.col !== b.cursor.col
786
+ );
658
787
  }
659
788
 
660
789
  private withTransition<T>(
@@ -741,9 +870,7 @@ export class ModalEditor extends CustomEditor {
741
870
  private applySyntheticEdit(mutation: () => void): void {
742
871
  const editor = this as unknown as ModalEditorInternals;
743
872
  if (!editor.state || !Array.isArray(editor.state.lines)) {
744
- throw new Error(
745
- "Synthetic edit prerequisite: editor state unavailable",
746
- );
873
+ throw new Error("Synthetic edit prerequisite: editor state unavailable");
747
874
  }
748
875
 
749
876
  if (typeof editor.pushUndoSnapshot !== "function") {
@@ -760,9 +887,6 @@ export class ModalEditor extends CustomEditor {
760
887
 
761
888
  if (this.getText() === textBefore) return;
762
889
 
763
- // Text changed — push undo boundary for pre-mutation state.
764
- // Briefly swap pre-mutation state in for the snapshot, then
765
- // restore the post-mutation result.
766
890
  const postLines = editor.state.lines.slice();
767
891
  const postCursorLine = editor.state.cursorLine;
768
892
  const postCursorCol = editor.state.cursorCol;
@@ -790,8 +914,9 @@ export class ModalEditor extends CustomEditor {
790
914
  }
791
915
 
792
916
  private clearPendingExCommand(): void {
793
- const shouldDiscardBracketedPasteTail = this.acceptingBracketedPasteInExCommand
794
- || this.pendingEscWhileAcceptingBracketedPasteInExCommand;
917
+ const shouldDiscardBracketedPasteTail =
918
+ this.acceptingBracketedPasteInExCommand ||
919
+ this.pendingEscWhileAcceptingBracketedPasteInExCommand;
795
920
 
796
921
  this.pendingExCommand = null;
797
922
  this.acceptingBracketedPasteInExCommand = false;
@@ -875,7 +1000,10 @@ export class ModalEditor extends CustomEditor {
875
1000
  }
876
1001
  }
877
1002
 
878
- private stripBracketedPasteInNormalMode(data: string): { filtered: string | null; stripped: boolean } {
1003
+ private stripBracketedPasteInNormalMode(data: string): {
1004
+ filtered: string | null;
1005
+ stripped: boolean;
1006
+ } {
879
1007
  let chunk = data;
880
1008
  let stripped = false;
881
1009
 
@@ -898,14 +1026,18 @@ export class ModalEditor extends CustomEditor {
898
1026
  }
899
1027
 
900
1028
  stripped = true;
901
- const end = chunk.indexOf(BRACKETED_PASTE_END, start + BRACKETED_PASTE_START.length);
1029
+ const end = chunk.indexOf(
1030
+ BRACKETED_PASTE_END,
1031
+ start + BRACKETED_PASTE_START.length,
1032
+ );
902
1033
  if (end === -1) {
903
1034
  this.discardingBracketedPasteInNormalMode = true;
904
1035
  const leading = chunk.slice(0, start);
905
1036
  return { filtered: leading.length > 0 ? leading : null, stripped };
906
1037
  }
907
1038
 
908
- chunk = chunk.slice(0, start) + chunk.slice(end + BRACKETED_PASTE_END.length);
1039
+ chunk =
1040
+ chunk.slice(0, start) + chunk.slice(end + BRACKETED_PASTE_END.length);
909
1041
  if (!chunk) return { filtered: null, stripped };
910
1042
  }
911
1043
  }
@@ -958,24 +1090,19 @@ export class ModalEditor extends CustomEditor {
958
1090
  return;
959
1091
  }
960
1092
 
961
- if (this.mode === "insert") {
962
- // Shift+Alt+A: go to end of line (like Esc -> A but stay in insert)
1093
+ if ("insert" === this.mode) {
963
1094
  if (matchesKey(data, Key.shiftAlt("a")) || data === "\x1bA") {
964
1095
  super.handleInput(CTRL_E);
965
1096
  return;
966
1097
  }
967
- // Shift+Alt+I: go to start of line (like Esc -> I but stay in insert)
968
1098
  if (matchesKey(data, Key.shiftAlt("i")) || data === "\x1bI") {
969
1099
  super.handleInput(CTRL_A);
970
1100
  return;
971
1101
  }
972
- // Alt+o: open new line below (stay in insert mode)
973
1102
  if (matchesKey(data, Key.alt("o")) || data === "\x1bo") {
974
1103
  this.openLineBelow();
975
1104
  return;
976
1105
  }
977
- // Alt+Shift+o: open new line above (stay in insert mode)
978
- // \x1bO is the legacy sequence for Alt+Shift+O (VT100 SS3 prefix in non-Kitty terminals)
979
1106
  if (matchesKey(data, Key.shiftAlt("o")) || data === "\x1bO") {
980
1107
  this.openLineAbove();
981
1108
  return;
@@ -1003,9 +1130,14 @@ export class ModalEditor extends CustomEditor {
1003
1130
  const replacement = data.repeat(count);
1004
1131
  const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
1005
1132
  const text = this.getText();
1006
- const newText = text.slice(0, lineStartAbs) + before + replacement + after
1007
- + text.slice(lineStartAbs + line.length);
1008
- const newCursorAbs = lineStartAbs + before.length + data.length * (count - 1);
1133
+ const newText =
1134
+ text.slice(0, lineStartAbs) +
1135
+ before +
1136
+ replacement +
1137
+ after +
1138
+ text.slice(lineStartAbs + line.length);
1139
+ const newCursorAbs =
1140
+ lineStartAbs + before.length + data.length * (count - 1);
1009
1141
  this.replaceTextInBuffer(newText, newCursorAbs);
1010
1142
  return;
1011
1143
  }
@@ -1068,32 +1200,42 @@ export class ModalEditor extends CustomEditor {
1068
1200
  }
1069
1201
 
1070
1202
  if (
1071
- this.pendingMotion
1072
- || this.pendingTextObject
1073
- || this.pendingOperator
1074
- || this.prefixCount
1075
- || this.operatorCount
1076
- || this.pendingG
1077
- || this.pendingGCount
1078
- || this.pendingReplace
1203
+ this.pendingMotion ||
1204
+ this.pendingTextObject ||
1205
+ this.pendingOperator ||
1206
+ this.prefixCount ||
1207
+ this.operatorCount ||
1208
+ this.pendingG ||
1209
+ this.pendingGCount ||
1210
+ this.pendingReplace
1079
1211
  ) {
1080
1212
  this.clearPendingState();
1081
1213
  return;
1082
1214
  }
1083
- if (this.mode === "insert") {
1215
+ if ("insert" === this.mode) {
1084
1216
  this.clearUnderlyingPasteStateIfActive();
1085
- this.mode = "normal";
1217
+ this.setMode("normal");
1086
1218
  } else {
1087
1219
  super.handleInput("\x1b"); // pass escape to abort agent
1088
1220
  }
1089
1221
  }
1090
1222
 
1091
1223
  private isEnterLikeInput(data: string): boolean {
1092
- return data === "\r" || data === "\n" || matchesKey(data, "enter") || matchesKey(data, "return");
1224
+ return (
1225
+ data === "\r" ||
1226
+ data === "\n" ||
1227
+ matchesKey(data, "enter") ||
1228
+ matchesKey(data, "return")
1229
+ );
1093
1230
  }
1094
1231
 
1095
1232
  private isBackspaceLikeInput(data: string): boolean {
1096
- return data === "\x7f" || data === "\x08" || matchesKey(data, "backspace") || matchesKey(data, "ctrl+h");
1233
+ return (
1234
+ data === "\x7f" ||
1235
+ data === "\x08" ||
1236
+ matchesKey(data, "backspace") ||
1237
+ matchesKey(data, "ctrl+h")
1238
+ );
1097
1239
  }
1098
1240
 
1099
1241
  private deleteLastPendingExCommandGrapheme(): void {
@@ -1116,10 +1258,10 @@ export class ModalEditor extends CustomEditor {
1116
1258
 
1117
1259
  private handlePendingExCommandControlChunk(data: string): boolean {
1118
1260
  if (
1119
- !data.includes("\r")
1120
- && !data.includes("\n")
1121
- && !data.includes("\x7f")
1122
- && !data.includes("\x08")
1261
+ !data.includes("\r") &&
1262
+ !data.includes("\n") &&
1263
+ !data.includes("\x7f") &&
1264
+ !data.includes("\x08")
1123
1265
  ) {
1124
1266
  return false;
1125
1267
  }
@@ -1216,7 +1358,8 @@ export class ModalEditor extends CustomEditor {
1216
1358
  if (data.length === 0) return false;
1217
1359
  for (const char of data) {
1218
1360
  const codePoint = char.codePointAt(0);
1219
- if (codePoint === undefined || codePoint < 32 || codePoint === 127) return false;
1361
+ if (codePoint === undefined || codePoint < 32 || codePoint === 127)
1362
+ return false;
1220
1363
  }
1221
1364
  return true;
1222
1365
  }
@@ -1253,9 +1396,10 @@ export class ModalEditor extends CustomEditor {
1253
1396
 
1254
1397
  if (prefix === null && operator === null) return defaultValue;
1255
1398
 
1256
- const total = prefix !== null && operator !== null
1257
- ? prefix * operator
1258
- : prefix ?? operator ?? defaultValue;
1399
+ const total =
1400
+ prefix !== null && operator !== null
1401
+ ? prefix * operator
1402
+ : (prefix ?? operator ?? defaultValue);
1259
1403
 
1260
1404
  if (!Number.isFinite(total) || total <= 0) return defaultValue;
1261
1405
  return Math.min(MAX_COUNT, total);
@@ -1265,6 +1409,13 @@ export class ModalEditor extends CustomEditor {
1265
1409
  return this.prefixCount.length > 0 || this.operatorCount.length > 0;
1266
1410
  }
1267
1411
 
1412
+ private opDigit(data: string): boolean {
1413
+ if (!this.isDigit(data) || (data === "0" && !this.operatorCount))
1414
+ return false;
1415
+ this.operatorCount += data;
1416
+ return true;
1417
+ }
1418
+
1268
1419
  private cancelPendingOperator(data: string): void {
1269
1420
  this.pendingOperator = null;
1270
1421
  this.prefixCount = "";
@@ -1290,7 +1441,7 @@ export class ModalEditor extends CustomEditor {
1290
1441
  } else if (this.pendingOperator === "c") {
1291
1442
  this.deleteWithCharMotion(pendingMotion, data);
1292
1443
  this.pendingOperator = null;
1293
- this.mode = "insert";
1444
+ this.setMode();
1294
1445
  } else if (this.pendingOperator === "y") {
1295
1446
  this.yankWithCharMotion(pendingMotion, data);
1296
1447
  this.pendingOperator = null;
@@ -1319,7 +1470,11 @@ export class ModalEditor extends CustomEditor {
1319
1470
  if (data === "w" || data === "W") {
1320
1471
  const semanticClass: WordTextObjectClass = data === "W" ? "WORD" : "word";
1321
1472
  const count = this.takeTotalCount(1);
1322
- const range = this.getWordObjectRange(pendingTextObject, count, semanticClass);
1473
+ const range = this.getWordObjectRange(
1474
+ pendingTextObject,
1475
+ count,
1476
+ semanticClass,
1477
+ );
1323
1478
  if (!range || !this.pendingOperator) {
1324
1479
  this.pendingOperator = null;
1325
1480
  return;
@@ -1357,7 +1512,7 @@ export class ModalEditor extends CustomEditor {
1357
1512
  if (range.endAbs === range.startAbs) {
1358
1513
  if (pendingOperator === "c") {
1359
1514
  this.moveCursorToAbsoluteIndex(range.startAbs);
1360
- this.mode = "insert";
1515
+ this.setMode();
1361
1516
  }
1362
1517
  return;
1363
1518
  }
@@ -1369,7 +1524,7 @@ export class ModalEditor extends CustomEditor {
1369
1524
 
1370
1525
  if (pendingOperator === "c") {
1371
1526
  this.deleteRangeByAbsolute(range.startAbs, range.endAbs);
1372
- this.mode = "insert";
1527
+ this.setMode();
1373
1528
  return;
1374
1529
  }
1375
1530
 
@@ -1379,16 +1534,11 @@ export class ModalEditor extends CustomEditor {
1379
1534
  }
1380
1535
 
1381
1536
  private handlePendingDelete(data: string): void {
1382
- if (this.isDigit(data)) {
1383
- if (this.operatorCount.length === 0) {
1384
- if (data !== "0") {
1385
- this.operatorCount = data;
1386
- return;
1387
- }
1388
- } else {
1389
- this.operatorCount += data;
1390
- return;
1391
- }
1537
+ if (this.opDigit(data)) return;
1538
+
1539
+ if (data === "%") {
1540
+ this.applyPercentOp();
1541
+ return;
1392
1542
  }
1393
1543
 
1394
1544
  if (data === "d") {
@@ -1399,7 +1549,8 @@ export class ModalEditor extends CustomEditor {
1399
1549
  }
1400
1550
 
1401
1551
  if (data === "j" || data === "k") {
1402
- const hasDualCount = this.prefixCount.length > 0 && this.operatorCount.length > 0;
1552
+ const hasDualCount =
1553
+ this.prefixCount.length > 0 && this.operatorCount.length > 0;
1403
1554
  const count = this.takeTotalCount(1);
1404
1555
  const delta = hasDualCount ? Math.max(0, count - 1) : count;
1405
1556
  this.deleteLinewiseByDelta(data === "j" ? delta : -delta);
@@ -1430,20 +1581,17 @@ export class ModalEditor extends CustomEditor {
1430
1581
  return;
1431
1582
  }
1432
1583
 
1433
- const hasCount = this.prefixCount.length > 0 || this.operatorCount.length > 0;
1434
- const supportsCountedWordMotion = (
1435
- data === "w"
1436
- || data === "e"
1437
- || data === "b"
1438
- || data === "W"
1439
- || data === "E"
1440
- || data === "B"
1441
- );
1584
+ const hasCount = this.hasPendingCount();
1585
+ const supportsCountedWordMotion =
1586
+ data === "w" ||
1587
+ data === "e" ||
1588
+ data === "b" ||
1589
+ data === "W" ||
1590
+ data === "E" ||
1591
+ data === "B";
1442
1592
  const supportsCountedTextObject = data === "i" || data === "a";
1443
1593
 
1444
1594
  if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) {
1445
- // Counted forms beyond dd, d{count}j/k, d{count}{f/F/t/T}, and
1446
- // d{count}{w/e/b/W/E/B}/{i/a}w are out of scope.
1447
1595
  this.cancelPendingOperator(data);
1448
1596
  return;
1449
1597
  }
@@ -1459,21 +1607,15 @@ export class ModalEditor extends CustomEditor {
1459
1607
  return;
1460
1608
  }
1461
1609
 
1462
- // Invalid motion: cancel operator to avoid sticky surprising deletes.
1463
1610
  this.cancelPendingOperator(data);
1464
1611
  }
1465
1612
 
1466
1613
  private handlePendingChange(data: string): void {
1467
- if (this.isDigit(data)) {
1468
- if (this.operatorCount.length === 0) {
1469
- if (data !== "0") {
1470
- this.operatorCount = data;
1471
- return;
1472
- }
1473
- } else {
1474
- this.operatorCount += data;
1475
- return;
1476
- }
1614
+ if (this.opDigit(data)) return;
1615
+
1616
+ if (data === "%") {
1617
+ this.applyPercentOp();
1618
+ return;
1477
1619
  }
1478
1620
 
1479
1621
  if (data === "c") {
@@ -1484,7 +1626,7 @@ export class ModalEditor extends CustomEditor {
1484
1626
 
1485
1627
  this.cutLine();
1486
1628
  this.pendingOperator = null;
1487
- this.mode = "insert";
1629
+ this.setMode();
1488
1630
  return;
1489
1631
  }
1490
1632
 
@@ -1505,7 +1647,7 @@ export class ModalEditor extends CustomEditor {
1505
1647
  this.replaceTextInBuffer(newText, cursorAbs);
1506
1648
  }
1507
1649
  this.pendingOperator = null;
1508
- this.mode = "insert";
1650
+ this.setMode();
1509
1651
  return;
1510
1652
  }
1511
1653
 
@@ -1514,15 +1656,14 @@ export class ModalEditor extends CustomEditor {
1514
1656
  return;
1515
1657
  }
1516
1658
 
1517
- const hasCount = this.prefixCount.length > 0 || this.operatorCount.length > 0;
1518
- const supportsCountedWordMotion = (
1519
- data === "w"
1520
- || data === "e"
1521
- || data === "b"
1522
- || data === "W"
1523
- || data === "E"
1524
- || data === "B"
1525
- );
1659
+ const hasCount = this.hasPendingCount();
1660
+ const supportsCountedWordMotion =
1661
+ data === "w" ||
1662
+ data === "e" ||
1663
+ data === "b" ||
1664
+ data === "W" ||
1665
+ data === "E" ||
1666
+ data === "B";
1526
1667
  const supportsCountedTextObject = data === "i" || data === "a";
1527
1668
 
1528
1669
  if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) {
@@ -1536,16 +1677,14 @@ export class ModalEditor extends CustomEditor {
1536
1677
  }
1537
1678
 
1538
1679
  const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1;
1539
- const effectiveMotion = data === "W" && this.isCursorOnNonWhitespace()
1540
- ? "E"
1541
- : data;
1680
+ const effectiveMotion =
1681
+ data === "W" && this.isCursorOnNonWhitespace() ? "E" : data;
1542
1682
  if (this.deleteWithMotion(effectiveMotion, motionCount)) {
1543
1683
  this.pendingOperator = null;
1544
- this.mode = "insert";
1684
+ this.setMode();
1545
1685
  return;
1546
1686
  }
1547
1687
 
1548
- // Invalid motion: cancel operator to avoid sticky surprising changes.
1549
1688
  this.cancelPendingOperator(data);
1550
1689
  }
1551
1690
 
@@ -1583,6 +1722,12 @@ export class ModalEditor extends CustomEditor {
1583
1722
  return;
1584
1723
  }
1585
1724
 
1725
+ if (data === "%") {
1726
+ this.prefixCount = "";
1727
+ this.operatorCount = "";
1728
+ return;
1729
+ }
1730
+
1586
1731
  if (data === "d" || data === "y") {
1587
1732
  this.pendingOperator = data;
1588
1733
  return;
@@ -1605,43 +1750,34 @@ export class ModalEditor extends CustomEditor {
1605
1750
  return;
1606
1751
  }
1607
1752
 
1608
- const supportsCountedStandaloneEdit = (
1609
- data === "x"
1610
- || data === "r"
1611
- || data === "s"
1612
- || data === "S"
1613
- || data === "D"
1614
- || data === "C"
1615
- || data === "p"
1616
- || data === "P"
1617
- || data === "Y"
1618
- || data === "J"
1619
- || data === "u"
1620
- || data === CTRL_UNDERSCORE
1621
- || matchesKey(data, "ctrl+_")
1622
- || data === CTRL_R
1623
- || matchesKey(data, "ctrl+r")
1624
- );
1625
- const supportsCountedCharMotion = (
1626
- CHAR_MOTION_KEYS.has(data)
1627
- || data === ";"
1628
- || data === ","
1629
- );
1630
- const supportsCountedWordMotion = (
1631
- data === "w"
1632
- || data === "e"
1633
- || data === "b"
1634
- || data === "W"
1635
- || data === "E"
1636
- || data === "B"
1637
- );
1753
+ const supportsCountedStandaloneEdit =
1754
+ data === "x" ||
1755
+ data === "r" ||
1756
+ data === "s" ||
1757
+ data === "S" ||
1758
+ data === "D" ||
1759
+ data === "C" ||
1760
+ data === "p" ||
1761
+ data === "P" ||
1762
+ data === "Y" ||
1763
+ data === "J" ||
1764
+ data === "u" ||
1765
+ data === CTRL_UNDERSCORE ||
1766
+ matchesKey(data, "ctrl+_") ||
1767
+ data === CTRL_R ||
1768
+ matchesKey(data, "ctrl+r");
1769
+ const supportsCountedCharMotion =
1770
+ CHAR_MOTION_KEYS.has(data) || data === ";" || data === ",";
1771
+ const supportsCountedWordMotion =
1772
+ data === "w" ||
1773
+ data === "e" ||
1774
+ data === "b" ||
1775
+ data === "W" ||
1776
+ data === "E" ||
1777
+ data === "B";
1638
1778
  const supportsCountedParagraphMotion = data === "{" || data === "}";
1639
- const supportsCountedNav = (
1640
- data === "h"
1641
- || data === "j"
1642
- || data === "k"
1643
- || data === "l"
1644
- );
1779
+ const supportsCountedNav =
1780
+ data === "h" || data === "j" || data === "k" || data === "l";
1645
1781
  const supportsCountedUnderscore = data === "_";
1646
1782
 
1647
1783
  if (supportsCountedNav) {
@@ -1664,13 +1800,12 @@ export class ModalEditor extends CustomEditor {
1664
1800
  }
1665
1801
 
1666
1802
  if (
1667
- !supportsCountedStandaloneEdit
1668
- && !supportsCountedCharMotion
1669
- && !supportsCountedWordMotion
1670
- && !supportsCountedParagraphMotion
1671
- && !supportsCountedUnderscore
1803
+ !supportsCountedStandaloneEdit &&
1804
+ !supportsCountedCharMotion &&
1805
+ !supportsCountedWordMotion &&
1806
+ !supportsCountedParagraphMotion &&
1807
+ !supportsCountedUnderscore
1672
1808
  ) {
1673
- // Unsupported prefixed forms: drop count and keep processing this key.
1674
1809
  this.prefixCount = "";
1675
1810
  this.operatorCount = "";
1676
1811
  }
@@ -1742,7 +1877,11 @@ export class ModalEditor extends CustomEditor {
1742
1877
  }
1743
1878
 
1744
1879
  if (data === ";" && this.lastCharMotion) {
1745
- this.executeCharMotion(this.lastCharMotion.motion, this.lastCharMotion.char, false);
1880
+ this.executeCharMotion(
1881
+ this.lastCharMotion.motion,
1882
+ this.lastCharMotion.char,
1883
+ false,
1884
+ );
1746
1885
  return;
1747
1886
  }
1748
1887
  if (data === "," && this.lastCharMotion) {
@@ -1754,7 +1893,11 @@ export class ModalEditor extends CustomEditor {
1754
1893
  return;
1755
1894
  }
1756
1895
 
1757
- if (data === "u" || data === CTRL_UNDERSCORE || matchesKey(data, "ctrl+_")) {
1896
+ if (
1897
+ data === "u" ||
1898
+ data === CTRL_UNDERSCORE ||
1899
+ matchesKey(data, "ctrl+_")
1900
+ ) {
1758
1901
  this.performUndo();
1759
1902
  return;
1760
1903
  }
@@ -1800,6 +1943,10 @@ export class ModalEditor extends CustomEditor {
1800
1943
  this.moveWord("forward", "start", this.takeTotalCount(1), "WORD");
1801
1944
  return;
1802
1945
  }
1946
+ if (data === "%") {
1947
+ this.moveToMatchingPairTarget();
1948
+ return;
1949
+ }
1803
1950
  if (data === "B") {
1804
1951
  this.moveWord("backward", "start", this.takeTotalCount(1), "WORD");
1805
1952
  return;
@@ -1814,7 +1961,6 @@ export class ModalEditor extends CustomEditor {
1814
1961
  return;
1815
1962
  }
1816
1963
 
1817
- // Pass control sequences (ctrl+c, etc.) to super, ignore printable chars
1818
1964
  if (this.isPrintableChunk(data)) return;
1819
1965
  super.handleInput(data);
1820
1966
  }
@@ -1834,29 +1980,29 @@ export class ModalEditor extends CustomEditor {
1834
1980
  const seq = NORMAL_KEYS[key];
1835
1981
  switch (key) {
1836
1982
  case "i":
1837
- this.mode = "insert";
1983
+ this.setMode();
1838
1984
  break;
1839
1985
  case "a":
1840
- this.mode = "insert";
1986
+ this.setMode();
1841
1987
  if (!this.isCursorAtOrPastEol()) {
1842
1988
  super.handleInput(ESC_RIGHT);
1843
1989
  }
1844
1990
  break;
1845
1991
  case "A":
1846
- this.mode = "insert";
1992
+ this.setMode();
1847
1993
  super.handleInput(CTRL_E);
1848
1994
  break;
1849
1995
  case "I":
1850
- this.mode = "insert";
1996
+ this.setMode();
1851
1997
  this.moveCursorToFirstNonWhitespace();
1852
1998
  break;
1853
1999
  case "o":
1854
2000
  this.openLineBelow();
1855
- this.mode = "insert";
2001
+ this.setMode();
1856
2002
  break;
1857
2003
  case "O":
1858
2004
  this.openLineAbove();
1859
- this.mode = "insert";
2005
+ this.setMode();
1860
2006
  break;
1861
2007
  case "D":
1862
2008
  this.takeTotalCount(1);
@@ -1865,16 +2011,16 @@ export class ModalEditor extends CustomEditor {
1865
2011
  case "C":
1866
2012
  this.takeTotalCount(1);
1867
2013
  this.cutToEndOfLine();
1868
- this.mode = "insert";
2014
+ this.setMode();
1869
2015
  break;
1870
2016
  case "S":
1871
2017
  this.takeTotalCount(1);
1872
2018
  this.cutCurrentLineContent();
1873
- this.mode = "insert";
2019
+ this.setMode();
1874
2020
  break;
1875
2021
  case "s":
1876
2022
  this.cutCharUnderCursor();
1877
- this.mode = "insert";
2023
+ this.setMode();
1878
2024
  break;
1879
2025
  case "x":
1880
2026
  this.cutCharUnderCursor();
@@ -1890,11 +2036,22 @@ export class ModalEditor extends CustomEditor {
1890
2036
  }
1891
2037
  }
1892
2038
 
1893
- private executeCharMotion(motion: CharMotion, targetChar: string, saveMotion: boolean = true): void {
2039
+ private executeCharMotion(
2040
+ motion: CharMotion,
2041
+ targetChar: string,
2042
+ saveMotion: boolean = true,
2043
+ ): void {
1894
2044
  const line = this.getLines()[this.getCursor().line] ?? "";
1895
2045
  const col = this.getCursor().col;
1896
2046
  const count = this.takeTotalCount(1);
1897
- const targetCol = findCharMotionTarget(line, col, motion, targetChar, !saveMotion, count);
2047
+ const targetCol = findCharMotionTarget(
2048
+ line,
2049
+ col,
2050
+ motion,
2051
+ targetChar,
2052
+ !saveMotion,
2053
+ count,
2054
+ );
1898
2055
 
1899
2056
  if (targetCol !== null && saveMotion) {
1900
2057
  this.lastCharMotion = { motion, char: targetChar };
@@ -1909,7 +2066,12 @@ export class ModalEditor extends CustomEditor {
1909
2066
  const lines = this.getLines();
1910
2067
  const fromLine = this.getCursor().line;
1911
2068
  const count = this.takeTotalCount(1);
1912
- const targetLine = findParagraphMotionTarget(lines, fromLine, direction, count);
2069
+ const targetLine = findParagraphMotionTarget(
2070
+ lines,
2071
+ fromLine,
2072
+ direction,
2073
+ count,
2074
+ );
1913
2075
  this.moveCursorToLineStart(targetLine);
1914
2076
  }
1915
2077
 
@@ -1924,7 +2086,11 @@ export class ModalEditor extends CustomEditor {
1924
2086
 
1925
2087
  const state = editor.state;
1926
2088
  if (!state || !Array.isArray(state.lines)) return false;
1927
- if (!Number.isInteger(state.cursorLine) || !Number.isInteger(state.cursorCol)) return false;
2089
+ if (
2090
+ !Number.isInteger(state.cursorLine) ||
2091
+ !Number.isInteger(state.cursorCol)
2092
+ )
2093
+ return false;
1928
2094
 
1929
2095
  const cursorLine = state.cursorLine as number;
1930
2096
  const cursorCol = state.cursorCol as number;
@@ -1933,8 +2099,6 @@ export class ModalEditor extends CustomEditor {
1933
2099
 
1934
2100
  const target = cursorCol + delta;
1935
2101
 
1936
- // Only short-circuit line-local movement when each grapheme is one code
1937
- // unit; otherwise let the base editor keep cursor boundaries valid.
1938
2102
  if (target < 0 || target > line.length) return false;
1939
2103
 
1940
2104
  state.cursorCol = target;
@@ -1974,7 +2138,10 @@ export class ModalEditor extends CustomEditor {
1974
2138
  }
1975
2139
 
1976
2140
  const currentLine = state.cursorLine ?? 0;
1977
- const targetLine = Math.max(0, Math.min(currentLine + delta, state.lines.length - 1));
2141
+ const targetLine = Math.max(
2142
+ 0,
2143
+ Math.min(currentLine + delta, state.lines.length - 1),
2144
+ );
1978
2145
  if (targetLine === currentLine) return;
1979
2146
 
1980
2147
  const preferredCol = editor.preferredVisualCol ?? state.cursorCol ?? 0;
@@ -2078,9 +2245,12 @@ export class ModalEditor extends CustomEditor {
2078
2245
  if (normalize) {
2079
2246
  const trimmedRight = right.trimStart();
2080
2247
  const leftLastChar = left[left.length - 1];
2081
- const leftEndsWithSpace = leftLastChar !== undefined && /\s/.test(leftLastChar);
2248
+ const leftEndsWithSpace =
2249
+ leftLastChar !== undefined && /\s/.test(leftLastChar);
2082
2250
  const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
2083
- joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
2251
+ joined = needsSeparator
2252
+ ? `${left} ${trimmedRight}`
2253
+ : left + trimmedRight;
2084
2254
  joinPoint = left.length;
2085
2255
  } else {
2086
2256
  joined = left + right;
@@ -2139,6 +2309,40 @@ export class ModalEditor extends CustomEditor {
2139
2309
  return this.getAbsoluteIndex(cursor.line, cursor.col);
2140
2310
  }
2141
2311
 
2312
+ private getMatchingPairMotionTarget() {
2313
+ const cursor = this.getCursor();
2314
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
2315
+ return resolveMatchingPairMotionTarget(
2316
+ this.getText(),
2317
+ this.getAbsoluteIndexFromCursor(),
2318
+ lineStartAbs,
2319
+ lineStartAbs + (this.getLines()[cursor.line] ?? "").length,
2320
+ );
2321
+ }
2322
+
2323
+ private moveToMatchingPairTarget(): void {
2324
+ const target = this.getMatchingPairMotionTarget();
2325
+ if (target) this.moveCursorToAbsoluteIndex(target.targetAbs);
2326
+ }
2327
+
2328
+ private applyPercentOp(): void {
2329
+ const op = this.pendingOperator;
2330
+ const counted = this.hasPendingCount();
2331
+ this.clearPendingState();
2332
+ if (!op || counted) return;
2333
+
2334
+ const t = this.getMatchingPairMotionTarget();
2335
+ if (!t) return;
2336
+
2337
+ if (op === "y") {
2338
+ this.yankRangeByAbsolute(t.rangeAnchorAbs, t.targetAbs, true);
2339
+ return;
2340
+ }
2341
+
2342
+ this.deleteRangeByAbsolute(t.rangeAnchorAbs, t.targetAbs, true);
2343
+ if (op === "c") this.mode = "insert";
2344
+ }
2345
+
2142
2346
  private getDelimitedTextObjectCursorAbs(): number {
2143
2347
  const lines = this.getLines();
2144
2348
  const cursor = this.getCursor();
@@ -2174,25 +2378,43 @@ export class ModalEditor extends CustomEditor {
2174
2378
  } else if (target === "start") {
2175
2379
  const startType = this.charType(text[next], semanticClass);
2176
2380
  if (startType !== "space") {
2177
- while (next < len && this.charType(text[next], semanticClass) === startType) next++;
2381
+ while (
2382
+ next < len &&
2383
+ this.charType(text[next], semanticClass) === startType
2384
+ )
2385
+ next++;
2178
2386
  }
2179
- while (next < len && this.charType(text[next], semanticClass) === "space") next++;
2387
+ while (
2388
+ next < len &&
2389
+ this.charType(text[next], semanticClass) === "space"
2390
+ )
2391
+ next++;
2180
2392
  } else {
2181
2393
  if (next < len - 1) next++;
2182
- while (next < len && this.charType(text[next], semanticClass) === "space") next++;
2394
+ while (
2395
+ next < len &&
2396
+ this.charType(text[next], semanticClass) === "space"
2397
+ )
2398
+ next++;
2183
2399
  if (next >= len) {
2184
2400
  next = len;
2185
2401
  } else {
2186
2402
  const t = this.charType(text[next], semanticClass);
2187
- while (next < len - 1 && this.charType(text[next + 1], semanticClass) === t) next++;
2403
+ while (
2404
+ next < len - 1 &&
2405
+ this.charType(text[next + 1], semanticClass) === t
2406
+ )
2407
+ next++;
2188
2408
  }
2189
2409
  }
2190
2410
  } else {
2191
2411
  if (next >= len) next = len - 1;
2192
2412
  if (next > 0) next--;
2193
- while (next > 0 && this.charType(text[next], semanticClass) === "space") next--;
2413
+ while (next > 0 && this.charType(text[next], semanticClass) === "space")
2414
+ next--;
2194
2415
  const t = this.charType(text[next], semanticClass);
2195
- while (next > 0 && this.charType(text[next - 1], semanticClass) === t) next--;
2416
+ while (next > 0 && this.charType(text[next - 1], semanticClass) === t)
2417
+ next--;
2196
2418
  }
2197
2419
 
2198
2420
  if (next === i) break;
@@ -2281,7 +2503,11 @@ export class ModalEditor extends CustomEditor {
2281
2503
  semanticClass: WordMotionClass = "word",
2282
2504
  ): boolean {
2283
2505
  const col = this.getCursor().col;
2284
- const targetCol = this.tryFindWordTargetLineLocal(direction, target, semanticClass);
2506
+ const targetCol = this.tryFindWordTargetLineLocal(
2507
+ direction,
2508
+ target,
2509
+ semanticClass,
2510
+ );
2285
2511
  if (targetCol === null || targetCol === col) return false;
2286
2512
 
2287
2513
  this.moveCursorToCol(targetCol);
@@ -2297,7 +2523,8 @@ export class ModalEditor extends CustomEditor {
2297
2523
  const lineIndex = cursor.line;
2298
2524
  const col = cursor.col;
2299
2525
  const lineSnapshot = this.getLines()[lineIndex] ?? "";
2300
- const direction: WordMotionDirection = motion === "b" ? "backward" : "forward";
2526
+ const direction: WordMotionDirection =
2527
+ motion === "b" ? "backward" : "forward";
2301
2528
  const target: WordMotionTarget = motion === "e" ? "end" : "start";
2302
2529
  const steps = Math.max(1, Math.min(MAX_COUNT, count));
2303
2530
 
@@ -2364,7 +2591,10 @@ export class ModalEditor extends CustomEditor {
2364
2591
  return true;
2365
2592
  }
2366
2593
 
2367
- private writeToRegister(text: string, source: RegisterWriteSource = "mutation"): void {
2594
+ private writeToRegister(
2595
+ text: string,
2596
+ source: RegisterWriteSource = "mutation",
2597
+ ): void {
2368
2598
  this.unnamedRegister = text;
2369
2599
  const shouldMirror = text !== "" && this.shouldMirrorRegisterWrite(source);
2370
2600
  this.preferRegisterForPut = text !== "" && !shouldMirror;
@@ -2380,7 +2610,9 @@ export class ModalEditor extends CustomEditor {
2380
2610
  }
2381
2611
 
2382
2612
  private hasMultiCodeUnitGraphemes(line: string): boolean {
2383
- return getLineGraphemes(line).some((segment) => segment.end - segment.start > 1);
2613
+ return getLineGraphemes(line).some(
2614
+ (segment) => segment.end - segment.start > 1,
2615
+ );
2384
2616
  }
2385
2617
 
2386
2618
  private getGraphemeRangeAtCol(
@@ -2391,7 +2623,9 @@ export class ModalEditor extends CustomEditor {
2391
2623
  ): { start: number; end: number } | null {
2392
2624
  const clampedCol = Math.max(0, Math.min(col, line.length));
2393
2625
  const segments = getLineGraphemes(line);
2394
- const startIndex = segments.findIndex((segment) => clampedCol < segment.end);
2626
+ const startIndex = segments.findIndex(
2627
+ (segment) => clampedCol < segment.end,
2628
+ );
2395
2629
  if (startIndex === -1) return null;
2396
2630
 
2397
2631
  let endIndex = startIndex + Math.max(1, count) - 1;
@@ -2432,7 +2666,8 @@ export class ModalEditor extends CustomEditor {
2432
2666
  const text = this.getText();
2433
2667
  this.writeToRegister(line.slice(range.start, range.end));
2434
2668
  this.replaceTextInBuffer(
2435
- text.slice(0, lineStartAbs + range.start) + text.slice(lineStartAbs + range.end),
2669
+ text.slice(0, lineStartAbs + range.start) +
2670
+ text.slice(lineStartAbs + range.end),
2436
2671
  lineStartAbs + range.start,
2437
2672
  );
2438
2673
  }
@@ -2443,7 +2678,8 @@ export class ModalEditor extends CustomEditor {
2443
2678
  const { line, col } = this.getCurrentLineAndCol();
2444
2679
 
2445
2680
  const hasNextLine = cursorLine < lines.length - 1;
2446
- const deleted = col < line.length ? line.slice(col) : hasNextLine ? "\n" : "";
2681
+ const deleted =
2682
+ col < line.length ? line.slice(col) : hasNextLine ? "\n" : "";
2447
2683
 
2448
2684
  this.writeToRegister(deleted);
2449
2685
  super.handleInput(CTRL_K);
@@ -2466,7 +2702,10 @@ export class ModalEditor extends CustomEditor {
2466
2702
  this.cutCurrentLineContent();
2467
2703
  }
2468
2704
 
2469
- private getNormalizedLineRange(startLine: number, endLine: number): { start: number; end: number } {
2705
+ private getNormalizedLineRange(
2706
+ startLine: number,
2707
+ endLine: number,
2708
+ ): { start: number; end: number } {
2470
2709
  const lines = this.getLines();
2471
2710
  const last = Math.max(0, lines.length - 1);
2472
2711
  const clampedStart = Math.max(0, Math.min(startLine, last));
@@ -2483,7 +2722,10 @@ export class ModalEditor extends CustomEditor {
2483
2722
  return `${lines.slice(start, end + 1).join("\n")}\n`;
2484
2723
  }
2485
2724
 
2486
- private getLineDeleteAbsoluteRange(startLine: number, endLine: number): { startAbs: number; endAbs: number } {
2725
+ private getLineDeleteAbsoluteRange(
2726
+ startLine: number,
2727
+ endLine: number,
2728
+ ): { startAbs: number; endAbs: number } {
2487
2729
  const lines = this.getLines();
2488
2730
  const text = this.getText();
2489
2731
  const { start, end } = this.getNormalizedLineRange(startLine, endLine);
@@ -2510,7 +2752,10 @@ export class ModalEditor extends CustomEditor {
2510
2752
  if (lines.length === 0) return;
2511
2753
 
2512
2754
  const payload = this.getLinewisePayload(startLine, endLine);
2513
- const { startAbs, endAbs } = this.getLineDeleteAbsoluteRange(startLine, endLine);
2755
+ const { startAbs, endAbs } = this.getLineDeleteAbsoluteRange(
2756
+ startLine,
2757
+ endLine,
2758
+ );
2514
2759
 
2515
2760
  this.writeToRegister(payload);
2516
2761
 
@@ -2519,7 +2764,6 @@ export class ModalEditor extends CustomEditor {
2519
2764
  const newText = text.slice(0, startAbs) + text.slice(endAbs);
2520
2765
  this.replaceTextInBuffer(newText, startAbs);
2521
2766
 
2522
- // Ensure cursor is at column 0 of the landing line
2523
2767
  super.handleInput(CTRL_A);
2524
2768
  }
2525
2769
  }
@@ -2552,7 +2796,6 @@ export class ModalEditor extends CustomEditor {
2552
2796
  const col = cursor.col;
2553
2797
 
2554
2798
  if (motion === "$") {
2555
- // Match D/C behavior exactly, including newline kill at EOL.
2556
2799
  this.cutToEndOfLine();
2557
2800
  return true;
2558
2801
  }
@@ -2563,7 +2806,11 @@ export class ModalEditor extends CustomEditor {
2563
2806
  }
2564
2807
 
2565
2808
  if (motion === "^") {
2566
- this.deleteRange(col, findFirstNonWhitespaceColumn(this.getLines()[cursor.line] ?? ""), false);
2809
+ this.deleteRange(
2810
+ col,
2811
+ findFirstNonWhitespaceColumn(this.getLines()[cursor.line] ?? ""),
2812
+ false,
2813
+ );
2567
2814
  return true;
2568
2815
  }
2569
2816
 
@@ -2593,7 +2840,11 @@ export class ModalEditor extends CustomEditor {
2593
2840
  count,
2594
2841
  wordMotion.semanticClass,
2595
2842
  );
2596
- this.deleteRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e");
2843
+ this.deleteRangeByAbsolute(
2844
+ currentAbs,
2845
+ targetAbs,
2846
+ wordMotion.motion === "e",
2847
+ );
2597
2848
  return true;
2598
2849
  }
2599
2850
 
@@ -2604,25 +2855,27 @@ export class ModalEditor extends CustomEditor {
2604
2855
  const line = this.getLines()[this.getCursor().line] ?? "";
2605
2856
  const col = this.getCursor().col;
2606
2857
  const count = this.takeTotalCount(1);
2607
- const targetCol = findCharMotionTarget(line, col, motion, targetChar, false, count);
2858
+ const targetCol = findCharMotionTarget(
2859
+ line,
2860
+ col,
2861
+ motion,
2862
+ targetChar,
2863
+ false,
2864
+ count,
2865
+ );
2608
2866
 
2609
2867
  if (targetCol === null) return;
2610
2868
 
2611
2869
  this.lastCharMotion = { motion, char: targetChar };
2612
- this.deleteRange(col, targetCol, true); // char motions are inclusive
2870
+ this.deleteRange(col, targetCol, true);
2613
2871
  }
2614
2872
 
2615
2873
  private handlePendingYank(data: string): void {
2616
- if (this.isDigit(data)) {
2617
- if (this.operatorCount.length === 0) {
2618
- if (data !== "0") {
2619
- this.operatorCount = data;
2620
- return;
2621
- }
2622
- } else {
2623
- this.operatorCount += data;
2624
- return;
2625
- }
2874
+ if (this.opDigit(data)) return;
2875
+
2876
+ if (data === "%") {
2877
+ this.applyPercentOp();
2878
+ return;
2626
2879
  }
2627
2880
 
2628
2881
  if (data === "y") {
@@ -2633,7 +2886,8 @@ export class ModalEditor extends CustomEditor {
2633
2886
  }
2634
2887
 
2635
2888
  if (data === "j" || data === "k") {
2636
- const hasDualCount = this.prefixCount.length > 0 && this.operatorCount.length > 0;
2889
+ const hasDualCount =
2890
+ this.prefixCount.length > 0 && this.operatorCount.length > 0;
2637
2891
  const count = this.takeTotalCount(1);
2638
2892
  const delta = hasDualCount ? Math.max(0, count - 1) : count;
2639
2893
  this.yankLinewiseByDelta(data === "j" ? delta : -delta);
@@ -2670,7 +2924,6 @@ export class ModalEditor extends CustomEditor {
2670
2924
  }
2671
2925
 
2672
2926
  if (this.hasPendingCount()) {
2673
- // Counted forms beyond yy, y{count}j/k, and y{count}{f/F/t/T} are out of scope.
2674
2927
  this.cancelPendingOperator(data);
2675
2928
  return;
2676
2929
  }
@@ -2728,7 +2981,11 @@ export class ModalEditor extends CustomEditor {
2728
2981
  1,
2729
2982
  wordMotion.semanticClass,
2730
2983
  );
2731
- this.yankRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e");
2984
+ this.yankRangeByAbsolute(
2985
+ currentAbs,
2986
+ targetAbs,
2987
+ wordMotion.motion === "e",
2988
+ );
2732
2989
  return true;
2733
2990
  }
2734
2991
 
@@ -2739,12 +2996,19 @@ export class ModalEditor extends CustomEditor {
2739
2996
  const line = this.getLines()[this.getCursor().line] ?? "";
2740
2997
  const col = this.getCursor().col;
2741
2998
  const count = this.takeTotalCount(1);
2742
- const targetCol = findCharMotionTarget(line, col, motion, targetChar, false, count);
2999
+ const targetCol = findCharMotionTarget(
3000
+ line,
3001
+ col,
3002
+ motion,
3003
+ targetChar,
3004
+ false,
3005
+ count,
3006
+ );
2743
3007
 
2744
3008
  if (targetCol === null) return;
2745
3009
 
2746
3010
  this.lastCharMotion = { motion, char: targetChar };
2747
- this.yankRange(col, targetCol, true); // char motions are inclusive
3011
+ this.yankRange(col, targetCol, true);
2748
3012
  }
2749
3013
 
2750
3014
  private yankRange(col: number, targetCol: number, inclusive: boolean): void {
@@ -2754,17 +3018,24 @@ export class ModalEditor extends CustomEditor {
2754
3018
  let end = Math.min(rawEnd, line.length);
2755
3019
 
2756
3020
  if (inclusive) {
2757
- const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1);
3021
+ const targetRange = this.getGraphemeRangeAtCol(
3022
+ line,
3023
+ Math.max(col, targetCol),
3024
+ 1,
3025
+ );
2758
3026
  end = targetRange?.end ?? end;
2759
3027
  }
2760
3028
 
2761
3029
  if (end <= start) return;
2762
3030
 
2763
- // Yank only — no cursor movement, no text mutation
2764
3031
  this.writeToRegister(line.slice(start, end), "yank");
2765
3032
  }
2766
3033
 
2767
- private yankRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean = false): void {
3034
+ private yankRangeByAbsolute(
3035
+ currentAbs: number,
3036
+ targetAbs: number,
3037
+ inclusive: boolean = false,
3038
+ ): void {
2768
3039
  const text = this.getText();
2769
3040
  const start = Math.min(currentAbs, targetAbs);
2770
3041
  const rawEnd = Math.max(currentAbs, targetAbs) + (inclusive ? 1 : 0);
@@ -2773,7 +3044,10 @@ export class ModalEditor extends CustomEditor {
2773
3044
  this.writeToRegister(text.slice(start, end), "yank");
2774
3045
  }
2775
3046
 
2776
- private getCursorFromAbsoluteIndex(text: string, abs: number): { line: number; col: number } {
3047
+ private getCursorFromAbsoluteIndex(
3048
+ text: string,
3049
+ abs: number,
3050
+ ): { line: number; col: number } {
2777
3051
  const lines = text.length === 0 ? [""] : text.split("\n");
2778
3052
  let remaining = Math.max(0, Math.min(abs, text.length));
2779
3053
  for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
@@ -2814,7 +3088,11 @@ export class ModalEditor extends CustomEditor {
2814
3088
  editor.tui?.requestRender?.();
2815
3089
  }
2816
3090
 
2817
- private deleteRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean = false): void {
3091
+ private deleteRangeByAbsolute(
3092
+ currentAbs: number,
3093
+ targetAbs: number,
3094
+ inclusive: boolean = false,
3095
+ ): void {
2818
3096
  const text = this.getText();
2819
3097
  const start = Math.min(currentAbs, targetAbs);
2820
3098
  const rawEnd = Math.max(currentAbs, targetAbs) + (inclusive ? 1 : 0);
@@ -2866,12 +3144,14 @@ export class ModalEditor extends CustomEditor {
2866
3144
  const count = this.takeTotalCount(1);
2867
3145
  const text = this.getPasteRegisterText();
2868
3146
  if (!text) return;
2869
- const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
3147
+ const safeCount = Math.min(
3148
+ count,
3149
+ Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)),
3150
+ );
2870
3151
 
2871
3152
  if (text.endsWith("\n")) {
2872
3153
  const content = text.slice(0, -1);
2873
3154
  for (let i = 0; i < safeCount; i++) {
2874
- // Line-wise: insert new line below and fill it
2875
3155
  super.handleInput(CTRL_E);
2876
3156
  super.handleInput(NEWLINE);
2877
3157
  for (const char of content) {
@@ -2881,7 +3161,6 @@ export class ModalEditor extends CustomEditor {
2881
3161
  return;
2882
3162
  }
2883
3163
 
2884
- // Character-wise: insert after cursor
2885
3164
  if (!this.isCursorAtOrPastEol()) {
2886
3165
  super.handleInput(ESC_RIGHT);
2887
3166
  }
@@ -2896,12 +3175,14 @@ export class ModalEditor extends CustomEditor {
2896
3175
  const count = this.takeTotalCount(1);
2897
3176
  const text = this.getPasteRegisterText();
2898
3177
  if (!text) return;
2899
- const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
3178
+ const safeCount = Math.min(
3179
+ count,
3180
+ Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)),
3181
+ );
2900
3182
 
2901
3183
  if (text.endsWith("\n")) {
2902
3184
  const content = text.slice(0, -1);
2903
3185
  for (let i = 0; i < safeCount; i++) {
2904
- // Line-wise: insert new line above and fill it
2905
3186
  super.handleInput(CTRL_A);
2906
3187
  super.handleInput(NEWLINE);
2907
3188
  super.handleInput(ESC_UP);
@@ -2912,7 +3193,6 @@ export class ModalEditor extends CustomEditor {
2912
3193
  return;
2913
3194
  }
2914
3195
 
2915
- // Character-wise: insert before cursor (just type it)
2916
3196
  for (let i = 0; i < safeCount; i++) {
2917
3197
  for (const char of text) {
2918
3198
  super.handleInput(char === "\n" ? NEWLINE : char);
@@ -2920,7 +3200,11 @@ export class ModalEditor extends CustomEditor {
2920
3200
  }
2921
3201
  }
2922
3202
 
2923
- private deleteRange(col: number, targetCol: number, inclusive: boolean): void {
3203
+ private deleteRange(
3204
+ col: number,
3205
+ targetCol: number,
3206
+ inclusive: boolean,
3207
+ ): void {
2924
3208
  const cursor = this.getCursor();
2925
3209
  const line = this.getLines()[cursor.line] ?? "";
2926
3210
  const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
@@ -2929,7 +3213,11 @@ export class ModalEditor extends CustomEditor {
2929
3213
  let end = Math.min(rawEnd, line.length);
2930
3214
 
2931
3215
  if (inclusive) {
2932
- const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1);
3216
+ const targetRange = this.getGraphemeRangeAtCol(
3217
+ line,
3218
+ Math.max(col, targetCol),
3219
+ 1,
3220
+ );
2933
3221
  end = targetRange?.end ?? end;
2934
3222
  }
2935
3223
 
@@ -2978,7 +3266,7 @@ export class ModalEditor extends CustomEditor {
2978
3266
  }
2979
3267
 
2980
3268
  private getDesiredCursorShapeSequence(): CursorShapeSequence {
2981
- return this.mode === "insert" && this.pendingExCommand === null
3269
+ return "insert" === this.mode && this.pendingExCommand === null
2982
3270
  ? INSERT_CURSOR_SHAPE
2983
3271
  : BLOCK_CURSOR_SHAPE;
2984
3272
  }
@@ -3026,7 +3314,8 @@ export class ModalEditor extends CustomEditor {
3026
3314
  const last = lines.length - 1;
3027
3315
  const lastLine = lines[last];
3028
3316
  if (lastLine && visibleWidth(lastLine) >= visibleWidth(rawLabel)) {
3029
- lines[last] = truncateToWidth(lastLine, width - visibleWidth(rawLabel), "") + label;
3317
+ lines[last] =
3318
+ truncateToWidth(lastLine, width - visibleWidth(rawLabel), "") + label;
3030
3319
  } else {
3031
3320
  lines[last] = label;
3032
3321
  }
@@ -3034,13 +3323,11 @@ export class ModalEditor extends CustomEditor {
3034
3323
  }
3035
3324
 
3036
3325
  private getModeLabelColorizer(): ((s: string) => string) | null {
3037
- if (!this.labelColorizers) return null;
3038
- if (this.pendingExCommand !== null) return this.labelColorizers.ex;
3039
- return this.mode === "insert" ? this.labelColorizers.insert : this.labelColorizers.normal;
3326
+ return this.labelColorizers?.[this.getActiveMode()] ?? null;
3040
3327
  }
3041
3328
 
3042
3329
  private getModeLabel(): string {
3043
- if (this.mode === "insert") return " INSERT ";
3330
+ if ("insert" === this.mode) return " INSERT ";
3044
3331
  if (this.pendingExCommand !== null) return ` EX ${this.pendingExCommand}_ `;
3045
3332
 
3046
3333
  const prefixCount = this.prefixCount;
@@ -3073,21 +3360,29 @@ export default function (pi: ExtensionAPI) {
3073
3360
 
3074
3361
  pi.on("session_start", (_event, ctx) => {
3075
3362
  const piVimSettings = readPiVimSettings(ctx.cwd);
3076
- const clipboardMirrorPolicy = resolveClipboardMirrorPolicy(piVimSettings.clipboardMirror);
3363
+ const clipboardMirrorPolicy = resolveClipboardMirrorPolicy(
3364
+ piVimSettings.clipboardMirror,
3365
+ );
3077
3366
  if (clipboardMirrorPolicy.warning && ctx.hasUI) {
3078
3367
  ctx.ui.notify(clipboardMirrorPolicy.warning, "warning");
3079
3368
  }
3080
3369
 
3081
3370
  const t = ctx.ui.theme;
3371
+ const modeColors = resolveModeColors(piVimSettings.modeColors);
3082
3372
  const reverseVideo = (s: string) => `\x1b[7m${s}\x1b[27m`;
3083
- const colorizers = t ? {
3084
- insert: (s: string) => t.fg("borderMuted", reverseVideo(s)),
3085
- normal: (s: string) => t.fg("borderAccent", reverseVideo(s)),
3086
- ex: (s: string) => t.fg("warning", reverseVideo(s)),
3087
- } : null;
3373
+ const labelColorizers = t
3374
+ ? buildModeColorizers(t, modeColors, reverseVideo)
3375
+ : null;
3376
+ const borderColorizers =
3377
+ t && piVimSettings.syncBorderColorWithMode === true
3378
+ ? buildModeColorizers(t, modeColors)
3379
+ : null;
3088
3380
  ctx.ui.setEditorComponent((tui, theme, kb) => {
3089
3381
  cursorShapeCleanup = enableCursorShapeSupport(tui);
3090
- const editor = new ModalEditor(tui, theme, kb, colorizers);
3382
+ const editor = new ModalEditor(tui, theme, kb, {
3383
+ labelColorizers,
3384
+ borderColorizers,
3385
+ });
3091
3386
  editor.setClipboardMirrorPolicy(clipboardMirrorPolicy.policy);
3092
3387
  editor.setQuitFn(() => ctx.shutdown());
3093
3388
  editor.setNotifyFn((message) => ctx.ui.notify(message, "warning"));