pi-vim 0.9.0 → 0.10.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,54 @@ 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
+ resolveWordTextObjectRange,
29
+ type TextObjectKind,
30
+ type TextObjectRange,
31
+ type WordTextObjectClass,
32
+ } from "./text-objects.js";
15
33
  import type {
16
- Mode,
17
34
  CharMotion,
35
+ LastCharMotion,
36
+ Mode,
18
37
  PendingMotion,
19
38
  PendingOperator,
20
- LastCharMotion,
21
39
  } from "./types.js";
22
40
  import {
23
- NORMAL_KEYS,
24
41
  CHAR_MOTION_KEYS,
25
- ESC_LEFT,
26
- ESC_RIGHT,
27
- ESC_UP,
28
42
  CTRL_A,
29
43
  CTRL_E,
30
44
  CTRL_K,
31
45
  CTRL_R,
32
46
  CTRL_UNDERSCORE,
33
- NEWLINE,
34
47
  ESC_DOWN,
48
+ ESC_LEFT,
49
+ ESC_RIGHT,
50
+ ESC_UP,
51
+ NEWLINE,
52
+ NORMAL_KEYS,
35
53
  } from "./types.js";
36
- import {
37
- reverseCharMotion,
38
- findCharMotionTarget,
39
- findParagraphMotionTarget,
40
- findFirstNonWhitespaceColumn,
41
- getLineGraphemes,
42
- type WordMotionClass,
43
- } from "./motions.js";
44
54
  import {
45
55
  WordBoundaryCache,
46
56
  type WordMotionDirection,
47
57
  type WordMotionTarget,
48
58
  } 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
59
 
64
60
  const BRACKETED_PASTE_START = "\x1b[200~";
65
61
  const BRACKETED_PASTE_END = "\x1b[201~";
@@ -71,12 +67,16 @@ const SOFTWARE_CURSOR_RESETS = ["\x1b[0m", "\x1b[27m"] as const;
71
67
  const INSERT_CURSOR_SHAPE = "\x1b[5 q";
72
68
  const BLOCK_CURSOR_SHAPE = "\x1b[1 q";
73
69
  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
70
  const CLIPBOARD_WRITE_TIMEOUT_MS = PI_NATIVE_CLIPBOARD_TIMEOUT_MS + 500;
77
71
  const CLIPBOARD_SPAWN_FAILURE_LIMIT = 3;
78
72
  const CLIPBOARD_READ_TIMEOUT_MS = 750;
79
73
  const CLIPBOARD_READ_MAX_BUFFER_BYTES = 1024 * 1024;
74
+ const MODE_COLORS = {
75
+ insert: "borderMuted",
76
+ normal: "borderAccent",
77
+ ex: "warning",
78
+ } as const;
79
+ const TOKEN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
80
80
 
81
81
  type EditorSnapshot = {
82
82
  text: string;
@@ -101,11 +101,13 @@ type ClipboardWriteFn = (text: string, signal: AbortSignal) => Promise<void>;
101
101
  type ClipboardReadFn = () => string | null;
102
102
  type ClipboardProcess = ReturnType<typeof spawn>;
103
103
 
104
- type ModeLabelColorizers = {
105
- insert: (s: string) => string;
106
- normal: (s: string) => string;
107
- ex: (s: string) => string;
104
+ type ModeColorKey = keyof typeof MODE_COLORS;
105
+ type ModeColorizers = Record<ModeColorKey, (s: string) => string>;
106
+ type ModalEditorOptions = {
107
+ labelColorizers?: ModeColorizers | null;
108
+ borderColorizers?: ModeColorizers | null;
108
109
  };
110
+ type ThemeLike = { fg(token: string, text: string): string };
109
111
 
110
112
  type CursorShapeSequence =
111
113
  | typeof INSERT_CURSOR_SHAPE
@@ -120,6 +122,45 @@ type CursorShapeRuntime = {
120
122
 
121
123
  type CursorShapeCleanup = () => void;
122
124
 
125
+ function resolveModeColors(
126
+ colors?: ModeColorSettings,
127
+ ): Required<ModeColorSettings> {
128
+ return {
129
+ insert: colors?.insert ?? MODE_COLORS.insert,
130
+ normal: colors?.normal ?? MODE_COLORS.normal,
131
+ ex: colors?.ex ?? MODE_COLORS.ex,
132
+ };
133
+ }
134
+ function colorizeWithTheme(
135
+ theme: ThemeLike,
136
+ token: string,
137
+ fallback: string,
138
+ text: string,
139
+ ): string {
140
+ const trimmedToken = token.trim();
141
+ if (TOKEN.test(trimmedToken)) {
142
+ try {
143
+ return theme.fg(trimmedToken, text);
144
+ } catch {
145
+ return theme.fg(fallback, text);
146
+ }
147
+ }
148
+ return theme.fg(fallback, text);
149
+ }
150
+ function buildModeColorizers(
151
+ theme: ThemeLike,
152
+ colors: Required<ModeColorSettings>,
153
+ transform: (text: string) => string = (text) => text,
154
+ ): ModeColorizers {
155
+ const colorizer = (mode: ModeColorKey) => (text: string) =>
156
+ colorizeWithTheme(theme, colors[mode], MODE_COLORS[mode], transform(text));
157
+ return {
158
+ insert: colorizer("insert"),
159
+ normal: colorizer("normal"),
160
+ ex: colorizer("ex"),
161
+ };
162
+ }
163
+
123
164
  type CursorShapeTuiCandidate = {
124
165
  terminal?: { write?: unknown };
125
166
  setShowHardwareCursor?: unknown;
@@ -135,7 +176,10 @@ function getCursorShapeRuntime(tui: unknown): CursorShapeRuntime | null {
135
176
 
136
177
  const write = terminal.write;
137
178
  const setShowHardwareCursor = candidate.setShowHardwareCursor;
138
- if (typeof write !== "function" || typeof setShowHardwareCursor !== "function") {
179
+ if (
180
+ typeof write !== "function" ||
181
+ typeof setShowHardwareCursor !== "function"
182
+ ) {
139
183
  return null;
140
184
  }
141
185
 
@@ -178,7 +222,10 @@ function findSoftwareCursorReset(
178
222
  line: string,
179
223
  startIndex: number,
180
224
  ): { index: number; sequence: (typeof SOFTWARE_CURSOR_RESETS)[number] } | null {
181
- let firstReset: { index: number; sequence: (typeof SOFTWARE_CURSOR_RESETS)[number] } | null = null;
225
+ let firstReset: {
226
+ index: number;
227
+ sequence: (typeof SOFTWARE_CURSOR_RESETS)[number];
228
+ } | null = null;
182
229
 
183
230
  for (const sequence of SOFTWARE_CURSOR_RESETS) {
184
231
  const index = line.indexOf(sequence, startIndex);
@@ -203,9 +250,11 @@ function stripSoftwareCursorAfterMarker(line: string): string {
203
250
  const reset = findSoftwareCursorReset(line, cursorContentStart);
204
251
  if (!reset) return line;
205
252
 
206
- return line.slice(0, cursorStart)
207
- + line.slice(cursorContentStart, reset.index)
208
- + line.slice(reset.index + reset.sequence.length);
253
+ return (
254
+ line.slice(0, cursorStart) +
255
+ line.slice(cursorContentStart, reset.index) +
256
+ line.slice(reset.index + reset.sequence.length)
257
+ );
209
258
  }
210
259
 
211
260
  type ClipboardCircuitBreaker = {
@@ -236,17 +285,21 @@ function isNodeSpawnErrno(error: unknown): boolean {
236
285
  if (!(error instanceof Error)) return false;
237
286
 
238
287
  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");
288
+ return (
289
+ typeof candidate.code === "string" &&
290
+ candidate.code.length > 0 &&
291
+ typeof candidate.syscall === "string" &&
292
+ candidate.syscall.startsWith("spawn")
293
+ );
243
294
  }
244
295
 
245
296
  function isClipboardEnvironmentFailure(error: unknown): boolean {
246
297
  return error instanceof ClipboardSpawnError || isNodeSpawnErrno(error);
247
298
  }
248
299
 
249
- const PI_CODING_AGENT_MODULE_URL = import.meta.resolve("@mariozechner/pi-coding-agent");
300
+ const PI_CODING_AGENT_MODULE_URL = import.meta.resolve(
301
+ "@mariozechner/pi-coding-agent",
302
+ );
250
303
  const CLIPBOARD_HELPER_SOURCE = `
251
304
  import { copyToClipboard } from ${JSON.stringify(PI_CODING_AGENT_MODULE_URL)};
252
305
 
@@ -257,10 +310,7 @@ for await (const chunk of process.stdin) {
257
310
 
258
311
  try {
259
312
  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
- }
313
+ } catch {}
264
314
  `;
265
315
 
266
316
  const CLIPBOARD_READ_HELPER_SOURCE = `
@@ -316,11 +366,14 @@ function killClipboardProcess(child: ClipboardProcess): void {
316
366
  try {
317
367
  child.kill("SIGKILL");
318
368
  } catch {
319
- // Best effort only; clipboard mirroring must not affect editing.
369
+ return;
320
370
  }
321
371
  }
322
372
 
323
- function writeClipboardInChildProcess(text: string, signal: AbortSignal): Promise<void> {
373
+ function writeClipboardInChildProcess(
374
+ text: string,
375
+ signal: AbortSignal,
376
+ ): Promise<void> {
324
377
  return new Promise<void>((resolve, reject) => {
325
378
  if (signal.aborted) {
326
379
  reject(getAbortError(signal));
@@ -350,12 +403,20 @@ function writeClipboardInChildProcess(text: string, signal: AbortSignal): Promis
350
403
  }
351
404
 
352
405
  try {
353
- child = spawn(process.execPath, ["--input-type=module", "-e", CLIPBOARD_HELPER_SOURCE], {
354
- stdio: ["pipe", "pipe", "ignore"],
355
- windowsHide: true,
356
- });
406
+ child = spawn(
407
+ process.execPath,
408
+ ["--input-type=module", "-e", CLIPBOARD_HELPER_SOURCE],
409
+ {
410
+ stdio: ["pipe", "pipe", "ignore"],
411
+ windowsHide: true,
412
+ },
413
+ );
357
414
  } catch (error) {
358
- finish(new ClipboardSpawnError("clipboard helper spawn failed", { cause: error }));
415
+ finish(
416
+ new ClipboardSpawnError("clipboard helper spawn failed", {
417
+ cause: error,
418
+ }),
419
+ );
359
420
  return;
360
421
  }
361
422
 
@@ -369,7 +430,11 @@ function writeClipboardInChildProcess(text: string, signal: AbortSignal): Promis
369
430
  });
370
431
 
371
432
  child.once("error", (error) => {
372
- finish(new ClipboardSpawnError("clipboard helper spawn failed", { cause: error }));
433
+ finish(
434
+ new ClipboardSpawnError("clipboard helper spawn failed", {
435
+ cause: error,
436
+ }),
437
+ );
373
438
  });
374
439
 
375
440
  child.once("close", (code) => {
@@ -393,7 +458,11 @@ function writeClipboardInChildProcess(text: string, signal: AbortSignal): Promis
393
458
  return;
394
459
  }
395
460
 
396
- finish(new ClipboardSpawnError(`clipboard helper failed with exit code ${code ?? "null"}`));
461
+ finish(
462
+ new ClipboardSpawnError(
463
+ `clipboard helper failed with exit code ${code ?? "null"}`,
464
+ ),
465
+ );
397
466
  });
398
467
 
399
468
  if (!child.stdin) {
@@ -436,7 +505,9 @@ class ClipboardMirror {
436
505
  ) {}
437
506
 
438
507
  setWriteFn(writeFn: ClipboardWriteFn): void {
439
- this.activeController?.abort(createClipboardAbortError("clipboard writer replaced"));
508
+ this.activeController?.abort(
509
+ createClipboardAbortError("clipboard writer replaced"),
510
+ );
440
511
  this.writeFn = writeFn;
441
512
  resetClipboardCircuitBreaker();
442
513
  }
@@ -446,7 +517,9 @@ class ClipboardMirror {
446
517
  }
447
518
 
448
519
  hasPendingWrite(): boolean {
449
- return this.activeText !== null || this.pendingText !== null || this.draining;
520
+ return (
521
+ this.activeText !== null || this.pendingText !== null || this.draining
522
+ );
450
523
  }
451
524
 
452
525
  mirror(text: string): void {
@@ -476,7 +549,6 @@ class ClipboardMirror {
476
549
  this.circuitBreaker.consecutiveEnvironmentFailures = 0;
477
550
  } catch (error) {
478
551
  this.recordWriteFailure(error);
479
- // Clipboard mirroring is best-effort; the register is authoritative.
480
552
  } finally {
481
553
  if (this.activeController === controller) {
482
554
  this.activeController = null;
@@ -503,13 +575,19 @@ class ClipboardMirror {
503
575
  }
504
576
 
505
577
  this.circuitBreaker.consecutiveEnvironmentFailures += 1;
506
- if (this.circuitBreaker.consecutiveEnvironmentFailures >= CLIPBOARD_SPAWN_FAILURE_LIMIT) {
578
+ if (
579
+ this.circuitBreaker.consecutiveEnvironmentFailures >=
580
+ CLIPBOARD_SPAWN_FAILURE_LIMIT
581
+ ) {
507
582
  this.circuitBreaker.disabled = true;
508
583
  this.pendingText = null;
509
584
  }
510
585
  }
511
586
 
512
- private async writeWithTimeout(text: string, controller: AbortController): Promise<void> {
587
+ private async writeWithTimeout(
588
+ text: string,
589
+ controller: AbortController,
590
+ ): Promise<void> {
513
591
  const timeoutError = createClipboardAbortError("clipboard write timed out");
514
592
  const timeoutId = setTimeout(() => {
515
593
  controller.abort(timeoutError);
@@ -548,15 +626,18 @@ export class ModalEditor extends CustomEditor {
548
626
  private readonly redoStack: EditorSnapshot[] = [];
549
627
  private currentTransition: TransitionState = "none";
550
628
  private onChangeHooked: boolean = false;
551
- private readonly labelColorizers: ModeLabelColorizers | null;
629
+ private readonly labelColorizers: ModeColorizers | null;
630
+ private readonly borderColorizers: ModeColorizers | null;
552
631
  private readonly cursorShapeRuntime: CursorShapeRuntime | null;
553
632
  private lastCursorShapeSequence: CursorShapeSequence | null = null;
554
633
 
555
- // Unnamed register
556
634
  private unnamedRegister: string = "";
557
635
  private preferRegisterForPut = false;
558
- private clipboardMirrorPolicy: ClipboardMirrorPolicy = DEFAULT_CLIPBOARD_MIRROR_POLICY;
559
- private readonly clipboardMirror = new ClipboardMirror(writeClipboardInChildProcess);
636
+ private clipboardMirrorPolicy: ClipboardMirrorPolicy =
637
+ DEFAULT_CLIPBOARD_MIRROR_POLICY;
638
+ private readonly clipboardMirror = new ClipboardMirror(
639
+ writeClipboardInChildProcess,
640
+ );
560
641
  private clipboardReadFn: ClipboardReadFn = readClipboardInChildProcess;
561
642
  private quitFn: () => void = () => {};
562
643
  private notifyFn: (message: string) => void = () => {};
@@ -565,18 +646,21 @@ export class ModalEditor extends CustomEditor {
565
646
  tui: CustomEditorConstructorArgs[0],
566
647
  theme: CustomEditorConstructorArgs[1],
567
648
  kb: CustomEditorConstructorArgs[2],
568
- labelColorizers?: ModeLabelColorizers | null,
649
+ opts?: ModalEditorOptions,
569
650
  ) {
570
651
  super(tui, theme, kb);
571
652
  this.cursorShapeRuntime = getCursorShapeRuntime(tui);
572
- this.labelColorizers = labelColorizers ?? null;
653
+ this.labelColorizers = opts?.labelColorizers ?? null;
654
+ this.borderColorizers = opts?.borderColorizers ?? null;
655
+ this.installModeBorderColorizer();
573
656
  }
574
657
 
575
- // Test seams
576
658
  setClipboardFn(fn: (text: string, signal?: AbortSignal) => unknown): void {
577
- this.clipboardMirror.setWriteFn(async (text: string, signal: AbortSignal) => {
578
- await fn(text, signal);
579
- });
659
+ this.clipboardMirror.setWriteFn(
660
+ async (text: string, signal: AbortSignal) => {
661
+ await fn(text, signal);
662
+ },
663
+ );
580
664
  }
581
665
  setClipboardWriteTimeoutMs(timeoutMs: number): void {
582
666
  this.clipboardMirror.setTimeoutMs(timeoutMs);
@@ -590,15 +674,51 @@ export class ModalEditor extends CustomEditor {
590
674
  getClipboardMirrorPolicy(): ClipboardMirrorPolicy {
591
675
  return this.clipboardMirrorPolicy;
592
676
  }
593
- setQuitFn(fn: () => void): void { this.quitFn = fn; }
594
- setNotifyFn(fn: (message: string) => void): void { this.notifyFn = fn; }
595
- getRegister(): string { return this.unnamedRegister; }
677
+ setQuitFn(fn: () => void): void {
678
+ this.quitFn = fn;
679
+ }
680
+ setNotifyFn(fn: (message: string) => void): void {
681
+ this.notifyFn = fn;
682
+ }
683
+ getRegister(): string {
684
+ return this.unnamedRegister;
685
+ }
596
686
  setRegister(text: string): void {
597
687
  this.unnamedRegister = text;
598
688
  this.preferRegisterForPut = false;
599
689
  }
600
- getMode(): Mode { return this.mode; }
601
- getText(): string { return this.getLines().join("\n"); }
690
+ getMode(): Mode {
691
+ return this.mode;
692
+ }
693
+ getText(): string {
694
+ return this.getLines().join("\n");
695
+ }
696
+
697
+ private getActiveMode(): Mode | "ex" {
698
+ if (this.pendingExCommand !== null) return "ex";
699
+ return this.mode;
700
+ }
701
+
702
+ private installModeBorderColorizer(): void {
703
+ if (!this.borderColorizers) return;
704
+ let base = this.borderColor;
705
+ const modeBorderColor = (text: string) =>
706
+ (this.borderColorizers?.[this.getActiveMode()] ?? base)(text);
707
+ // Pi assigns its default border color after extension editor construction.
708
+ // Keep a mode-aware getter installed and treat later assignments as the
709
+ // fallback/base color, otherwise syncBorderColorWithMode is overwritten in
710
+ // real sessions even though direct editor tests pass.
711
+ Object.defineProperty(this, "borderColor", {
712
+ get: () => modeBorderColor,
713
+ set(next: unknown) {
714
+ if (typeof next === "function") base = next as typeof base;
715
+ },
716
+ });
717
+ }
718
+
719
+ private setMode(mode: Mode = "insert"): void {
720
+ this.mode = mode;
721
+ }
602
722
 
603
723
  override setText(text: string): void {
604
724
  this.clearRedoStack();
@@ -613,14 +733,20 @@ export class ModalEditor extends CustomEditor {
613
733
  };
614
734
  }
615
735
 
616
- private requireRedoRestoreState(
617
- editor: ModalEditorInternals,
618
- ): { lines: string[]; cursorLine?: number; cursorCol?: number } {
736
+ private requireRedoRestoreState(editor: ModalEditorInternals): {
737
+ lines: string[];
738
+ cursorLine?: number;
739
+ cursorCol?: number;
740
+ } {
619
741
  const state = editor.state;
620
742
  if (!state || !Array.isArray(state.lines)) {
621
743
  throw new Error("Redo restore prerequisite: editor state unavailable");
622
744
  }
623
- return state as { lines: string[]; cursorLine?: number; cursorCol?: number };
745
+ return state as {
746
+ lines: string[];
747
+ cursorLine?: number;
748
+ cursorCol?: number;
749
+ };
624
750
  }
625
751
 
626
752
  private restoreSnapshot(snapshot: EditorSnapshot): void {
@@ -652,9 +778,11 @@ export class ModalEditor extends CustomEditor {
652
778
  }
653
779
 
654
780
  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;
781
+ return (
782
+ a.text !== b.text ||
783
+ a.cursor.line !== b.cursor.line ||
784
+ a.cursor.col !== b.cursor.col
785
+ );
658
786
  }
659
787
 
660
788
  private withTransition<T>(
@@ -741,9 +869,7 @@ export class ModalEditor extends CustomEditor {
741
869
  private applySyntheticEdit(mutation: () => void): void {
742
870
  const editor = this as unknown as ModalEditorInternals;
743
871
  if (!editor.state || !Array.isArray(editor.state.lines)) {
744
- throw new Error(
745
- "Synthetic edit prerequisite: editor state unavailable",
746
- );
872
+ throw new Error("Synthetic edit prerequisite: editor state unavailable");
747
873
  }
748
874
 
749
875
  if (typeof editor.pushUndoSnapshot !== "function") {
@@ -760,9 +886,6 @@ export class ModalEditor extends CustomEditor {
760
886
 
761
887
  if (this.getText() === textBefore) return;
762
888
 
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
889
  const postLines = editor.state.lines.slice();
767
890
  const postCursorLine = editor.state.cursorLine;
768
891
  const postCursorCol = editor.state.cursorCol;
@@ -790,8 +913,9 @@ export class ModalEditor extends CustomEditor {
790
913
  }
791
914
 
792
915
  private clearPendingExCommand(): void {
793
- const shouldDiscardBracketedPasteTail = this.acceptingBracketedPasteInExCommand
794
- || this.pendingEscWhileAcceptingBracketedPasteInExCommand;
916
+ const shouldDiscardBracketedPasteTail =
917
+ this.acceptingBracketedPasteInExCommand ||
918
+ this.pendingEscWhileAcceptingBracketedPasteInExCommand;
795
919
 
796
920
  this.pendingExCommand = null;
797
921
  this.acceptingBracketedPasteInExCommand = false;
@@ -875,7 +999,10 @@ export class ModalEditor extends CustomEditor {
875
999
  }
876
1000
  }
877
1001
 
878
- private stripBracketedPasteInNormalMode(data: string): { filtered: string | null; stripped: boolean } {
1002
+ private stripBracketedPasteInNormalMode(data: string): {
1003
+ filtered: string | null;
1004
+ stripped: boolean;
1005
+ } {
879
1006
  let chunk = data;
880
1007
  let stripped = false;
881
1008
 
@@ -898,14 +1025,18 @@ export class ModalEditor extends CustomEditor {
898
1025
  }
899
1026
 
900
1027
  stripped = true;
901
- const end = chunk.indexOf(BRACKETED_PASTE_END, start + BRACKETED_PASTE_START.length);
1028
+ const end = chunk.indexOf(
1029
+ BRACKETED_PASTE_END,
1030
+ start + BRACKETED_PASTE_START.length,
1031
+ );
902
1032
  if (end === -1) {
903
1033
  this.discardingBracketedPasteInNormalMode = true;
904
1034
  const leading = chunk.slice(0, start);
905
1035
  return { filtered: leading.length > 0 ? leading : null, stripped };
906
1036
  }
907
1037
 
908
- chunk = chunk.slice(0, start) + chunk.slice(end + BRACKETED_PASTE_END.length);
1038
+ chunk =
1039
+ chunk.slice(0, start) + chunk.slice(end + BRACKETED_PASTE_END.length);
909
1040
  if (!chunk) return { filtered: null, stripped };
910
1041
  }
911
1042
  }
@@ -958,24 +1089,19 @@ export class ModalEditor extends CustomEditor {
958
1089
  return;
959
1090
  }
960
1091
 
961
- if (this.mode === "insert") {
962
- // Shift+Alt+A: go to end of line (like Esc -> A but stay in insert)
1092
+ if ("insert" === this.mode) {
963
1093
  if (matchesKey(data, Key.shiftAlt("a")) || data === "\x1bA") {
964
1094
  super.handleInput(CTRL_E);
965
1095
  return;
966
1096
  }
967
- // Shift+Alt+I: go to start of line (like Esc -> I but stay in insert)
968
1097
  if (matchesKey(data, Key.shiftAlt("i")) || data === "\x1bI") {
969
1098
  super.handleInput(CTRL_A);
970
1099
  return;
971
1100
  }
972
- // Alt+o: open new line below (stay in insert mode)
973
1101
  if (matchesKey(data, Key.alt("o")) || data === "\x1bo") {
974
1102
  this.openLineBelow();
975
1103
  return;
976
1104
  }
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
1105
  if (matchesKey(data, Key.shiftAlt("o")) || data === "\x1bO") {
980
1106
  this.openLineAbove();
981
1107
  return;
@@ -1003,9 +1129,14 @@ export class ModalEditor extends CustomEditor {
1003
1129
  const replacement = data.repeat(count);
1004
1130
  const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
1005
1131
  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);
1132
+ const newText =
1133
+ text.slice(0, lineStartAbs) +
1134
+ before +
1135
+ replacement +
1136
+ after +
1137
+ text.slice(lineStartAbs + line.length);
1138
+ const newCursorAbs =
1139
+ lineStartAbs + before.length + data.length * (count - 1);
1009
1140
  this.replaceTextInBuffer(newText, newCursorAbs);
1010
1141
  return;
1011
1142
  }
@@ -1068,32 +1199,42 @@ export class ModalEditor extends CustomEditor {
1068
1199
  }
1069
1200
 
1070
1201
  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
1202
+ this.pendingMotion ||
1203
+ this.pendingTextObject ||
1204
+ this.pendingOperator ||
1205
+ this.prefixCount ||
1206
+ this.operatorCount ||
1207
+ this.pendingG ||
1208
+ this.pendingGCount ||
1209
+ this.pendingReplace
1079
1210
  ) {
1080
1211
  this.clearPendingState();
1081
1212
  return;
1082
1213
  }
1083
- if (this.mode === "insert") {
1214
+ if ("insert" === this.mode) {
1084
1215
  this.clearUnderlyingPasteStateIfActive();
1085
- this.mode = "normal";
1216
+ this.setMode("normal");
1086
1217
  } else {
1087
1218
  super.handleInput("\x1b"); // pass escape to abort agent
1088
1219
  }
1089
1220
  }
1090
1221
 
1091
1222
  private isEnterLikeInput(data: string): boolean {
1092
- return data === "\r" || data === "\n" || matchesKey(data, "enter") || matchesKey(data, "return");
1223
+ return (
1224
+ data === "\r" ||
1225
+ data === "\n" ||
1226
+ matchesKey(data, "enter") ||
1227
+ matchesKey(data, "return")
1228
+ );
1093
1229
  }
1094
1230
 
1095
1231
  private isBackspaceLikeInput(data: string): boolean {
1096
- return data === "\x7f" || data === "\x08" || matchesKey(data, "backspace") || matchesKey(data, "ctrl+h");
1232
+ return (
1233
+ data === "\x7f" ||
1234
+ data === "\x08" ||
1235
+ matchesKey(data, "backspace") ||
1236
+ matchesKey(data, "ctrl+h")
1237
+ );
1097
1238
  }
1098
1239
 
1099
1240
  private deleteLastPendingExCommandGrapheme(): void {
@@ -1116,10 +1257,10 @@ export class ModalEditor extends CustomEditor {
1116
1257
 
1117
1258
  private handlePendingExCommandControlChunk(data: string): boolean {
1118
1259
  if (
1119
- !data.includes("\r")
1120
- && !data.includes("\n")
1121
- && !data.includes("\x7f")
1122
- && !data.includes("\x08")
1260
+ !data.includes("\r") &&
1261
+ !data.includes("\n") &&
1262
+ !data.includes("\x7f") &&
1263
+ !data.includes("\x08")
1123
1264
  ) {
1124
1265
  return false;
1125
1266
  }
@@ -1216,7 +1357,8 @@ export class ModalEditor extends CustomEditor {
1216
1357
  if (data.length === 0) return false;
1217
1358
  for (const char of data) {
1218
1359
  const codePoint = char.codePointAt(0);
1219
- if (codePoint === undefined || codePoint < 32 || codePoint === 127) return false;
1360
+ if (codePoint === undefined || codePoint < 32 || codePoint === 127)
1361
+ return false;
1220
1362
  }
1221
1363
  return true;
1222
1364
  }
@@ -1253,9 +1395,10 @@ export class ModalEditor extends CustomEditor {
1253
1395
 
1254
1396
  if (prefix === null && operator === null) return defaultValue;
1255
1397
 
1256
- const total = prefix !== null && operator !== null
1257
- ? prefix * operator
1258
- : prefix ?? operator ?? defaultValue;
1398
+ const total =
1399
+ prefix !== null && operator !== null
1400
+ ? prefix * operator
1401
+ : (prefix ?? operator ?? defaultValue);
1259
1402
 
1260
1403
  if (!Number.isFinite(total) || total <= 0) return defaultValue;
1261
1404
  return Math.min(MAX_COUNT, total);
@@ -1290,7 +1433,7 @@ export class ModalEditor extends CustomEditor {
1290
1433
  } else if (this.pendingOperator === "c") {
1291
1434
  this.deleteWithCharMotion(pendingMotion, data);
1292
1435
  this.pendingOperator = null;
1293
- this.mode = "insert";
1436
+ this.setMode();
1294
1437
  } else if (this.pendingOperator === "y") {
1295
1438
  this.yankWithCharMotion(pendingMotion, data);
1296
1439
  this.pendingOperator = null;
@@ -1319,7 +1462,11 @@ export class ModalEditor extends CustomEditor {
1319
1462
  if (data === "w" || data === "W") {
1320
1463
  const semanticClass: WordTextObjectClass = data === "W" ? "WORD" : "word";
1321
1464
  const count = this.takeTotalCount(1);
1322
- const range = this.getWordObjectRange(pendingTextObject, count, semanticClass);
1465
+ const range = this.getWordObjectRange(
1466
+ pendingTextObject,
1467
+ count,
1468
+ semanticClass,
1469
+ );
1323
1470
  if (!range || !this.pendingOperator) {
1324
1471
  this.pendingOperator = null;
1325
1472
  return;
@@ -1357,7 +1504,7 @@ export class ModalEditor extends CustomEditor {
1357
1504
  if (range.endAbs === range.startAbs) {
1358
1505
  if (pendingOperator === "c") {
1359
1506
  this.moveCursorToAbsoluteIndex(range.startAbs);
1360
- this.mode = "insert";
1507
+ this.setMode();
1361
1508
  }
1362
1509
  return;
1363
1510
  }
@@ -1369,7 +1516,7 @@ export class ModalEditor extends CustomEditor {
1369
1516
 
1370
1517
  if (pendingOperator === "c") {
1371
1518
  this.deleteRangeByAbsolute(range.startAbs, range.endAbs);
1372
- this.mode = "insert";
1519
+ this.setMode();
1373
1520
  return;
1374
1521
  }
1375
1522
 
@@ -1399,7 +1546,8 @@ export class ModalEditor extends CustomEditor {
1399
1546
  }
1400
1547
 
1401
1548
  if (data === "j" || data === "k") {
1402
- const hasDualCount = this.prefixCount.length > 0 && this.operatorCount.length > 0;
1549
+ const hasDualCount =
1550
+ this.prefixCount.length > 0 && this.operatorCount.length > 0;
1403
1551
  const count = this.takeTotalCount(1);
1404
1552
  const delta = hasDualCount ? Math.max(0, count - 1) : count;
1405
1553
  this.deleteLinewiseByDelta(data === "j" ? delta : -delta);
@@ -1430,20 +1578,18 @@ export class ModalEditor extends CustomEditor {
1430
1578
  return;
1431
1579
  }
1432
1580
 
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
- );
1581
+ const hasCount =
1582
+ this.prefixCount.length > 0 || this.operatorCount.length > 0;
1583
+ const supportsCountedWordMotion =
1584
+ data === "w" ||
1585
+ data === "e" ||
1586
+ data === "b" ||
1587
+ data === "W" ||
1588
+ data === "E" ||
1589
+ data === "B";
1442
1590
  const supportsCountedTextObject = data === "i" || data === "a";
1443
1591
 
1444
1592
  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
1593
  this.cancelPendingOperator(data);
1448
1594
  return;
1449
1595
  }
@@ -1459,7 +1605,6 @@ export class ModalEditor extends CustomEditor {
1459
1605
  return;
1460
1606
  }
1461
1607
 
1462
- // Invalid motion: cancel operator to avoid sticky surprising deletes.
1463
1608
  this.cancelPendingOperator(data);
1464
1609
  }
1465
1610
 
@@ -1484,7 +1629,7 @@ export class ModalEditor extends CustomEditor {
1484
1629
 
1485
1630
  this.cutLine();
1486
1631
  this.pendingOperator = null;
1487
- this.mode = "insert";
1632
+ this.setMode();
1488
1633
  return;
1489
1634
  }
1490
1635
 
@@ -1505,7 +1650,7 @@ export class ModalEditor extends CustomEditor {
1505
1650
  this.replaceTextInBuffer(newText, cursorAbs);
1506
1651
  }
1507
1652
  this.pendingOperator = null;
1508
- this.mode = "insert";
1653
+ this.setMode();
1509
1654
  return;
1510
1655
  }
1511
1656
 
@@ -1514,15 +1659,15 @@ export class ModalEditor extends CustomEditor {
1514
1659
  return;
1515
1660
  }
1516
1661
 
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
- );
1662
+ const hasCount =
1663
+ this.prefixCount.length > 0 || this.operatorCount.length > 0;
1664
+ const supportsCountedWordMotion =
1665
+ data === "w" ||
1666
+ data === "e" ||
1667
+ data === "b" ||
1668
+ data === "W" ||
1669
+ data === "E" ||
1670
+ data === "B";
1526
1671
  const supportsCountedTextObject = data === "i" || data === "a";
1527
1672
 
1528
1673
  if (hasCount && !supportsCountedWordMotion && !supportsCountedTextObject) {
@@ -1536,16 +1681,14 @@ export class ModalEditor extends CustomEditor {
1536
1681
  }
1537
1682
 
1538
1683
  const motionCount = supportsCountedWordMotion ? this.takeTotalCount(1) : 1;
1539
- const effectiveMotion = data === "W" && this.isCursorOnNonWhitespace()
1540
- ? "E"
1541
- : data;
1684
+ const effectiveMotion =
1685
+ data === "W" && this.isCursorOnNonWhitespace() ? "E" : data;
1542
1686
  if (this.deleteWithMotion(effectiveMotion, motionCount)) {
1543
1687
  this.pendingOperator = null;
1544
- this.mode = "insert";
1688
+ this.setMode();
1545
1689
  return;
1546
1690
  }
1547
1691
 
1548
- // Invalid motion: cancel operator to avoid sticky surprising changes.
1549
1692
  this.cancelPendingOperator(data);
1550
1693
  }
1551
1694
 
@@ -1605,43 +1748,34 @@ export class ModalEditor extends CustomEditor {
1605
1748
  return;
1606
1749
  }
1607
1750
 
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
- );
1751
+ const supportsCountedStandaloneEdit =
1752
+ data === "x" ||
1753
+ data === "r" ||
1754
+ data === "s" ||
1755
+ data === "S" ||
1756
+ data === "D" ||
1757
+ data === "C" ||
1758
+ data === "p" ||
1759
+ data === "P" ||
1760
+ data === "Y" ||
1761
+ data === "J" ||
1762
+ data === "u" ||
1763
+ data === CTRL_UNDERSCORE ||
1764
+ matchesKey(data, "ctrl+_") ||
1765
+ data === CTRL_R ||
1766
+ matchesKey(data, "ctrl+r");
1767
+ const supportsCountedCharMotion =
1768
+ CHAR_MOTION_KEYS.has(data) || data === ";" || data === ",";
1769
+ const supportsCountedWordMotion =
1770
+ data === "w" ||
1771
+ data === "e" ||
1772
+ data === "b" ||
1773
+ data === "W" ||
1774
+ data === "E" ||
1775
+ data === "B";
1638
1776
  const supportsCountedParagraphMotion = data === "{" || data === "}";
1639
- const supportsCountedNav = (
1640
- data === "h"
1641
- || data === "j"
1642
- || data === "k"
1643
- || data === "l"
1644
- );
1777
+ const supportsCountedNav =
1778
+ data === "h" || data === "j" || data === "k" || data === "l";
1645
1779
  const supportsCountedUnderscore = data === "_";
1646
1780
 
1647
1781
  if (supportsCountedNav) {
@@ -1664,13 +1798,12 @@ export class ModalEditor extends CustomEditor {
1664
1798
  }
1665
1799
 
1666
1800
  if (
1667
- !supportsCountedStandaloneEdit
1668
- && !supportsCountedCharMotion
1669
- && !supportsCountedWordMotion
1670
- && !supportsCountedParagraphMotion
1671
- && !supportsCountedUnderscore
1801
+ !supportsCountedStandaloneEdit &&
1802
+ !supportsCountedCharMotion &&
1803
+ !supportsCountedWordMotion &&
1804
+ !supportsCountedParagraphMotion &&
1805
+ !supportsCountedUnderscore
1672
1806
  ) {
1673
- // Unsupported prefixed forms: drop count and keep processing this key.
1674
1807
  this.prefixCount = "";
1675
1808
  this.operatorCount = "";
1676
1809
  }
@@ -1742,7 +1875,11 @@ export class ModalEditor extends CustomEditor {
1742
1875
  }
1743
1876
 
1744
1877
  if (data === ";" && this.lastCharMotion) {
1745
- this.executeCharMotion(this.lastCharMotion.motion, this.lastCharMotion.char, false);
1878
+ this.executeCharMotion(
1879
+ this.lastCharMotion.motion,
1880
+ this.lastCharMotion.char,
1881
+ false,
1882
+ );
1746
1883
  return;
1747
1884
  }
1748
1885
  if (data === "," && this.lastCharMotion) {
@@ -1754,7 +1891,11 @@ export class ModalEditor extends CustomEditor {
1754
1891
  return;
1755
1892
  }
1756
1893
 
1757
- if (data === "u" || data === CTRL_UNDERSCORE || matchesKey(data, "ctrl+_")) {
1894
+ if (
1895
+ data === "u" ||
1896
+ data === CTRL_UNDERSCORE ||
1897
+ matchesKey(data, "ctrl+_")
1898
+ ) {
1758
1899
  this.performUndo();
1759
1900
  return;
1760
1901
  }
@@ -1814,7 +1955,6 @@ export class ModalEditor extends CustomEditor {
1814
1955
  return;
1815
1956
  }
1816
1957
 
1817
- // Pass control sequences (ctrl+c, etc.) to super, ignore printable chars
1818
1958
  if (this.isPrintableChunk(data)) return;
1819
1959
  super.handleInput(data);
1820
1960
  }
@@ -1834,29 +1974,29 @@ export class ModalEditor extends CustomEditor {
1834
1974
  const seq = NORMAL_KEYS[key];
1835
1975
  switch (key) {
1836
1976
  case "i":
1837
- this.mode = "insert";
1977
+ this.setMode();
1838
1978
  break;
1839
1979
  case "a":
1840
- this.mode = "insert";
1980
+ this.setMode();
1841
1981
  if (!this.isCursorAtOrPastEol()) {
1842
1982
  super.handleInput(ESC_RIGHT);
1843
1983
  }
1844
1984
  break;
1845
1985
  case "A":
1846
- this.mode = "insert";
1986
+ this.setMode();
1847
1987
  super.handleInput(CTRL_E);
1848
1988
  break;
1849
1989
  case "I":
1850
- this.mode = "insert";
1990
+ this.setMode();
1851
1991
  this.moveCursorToFirstNonWhitespace();
1852
1992
  break;
1853
1993
  case "o":
1854
1994
  this.openLineBelow();
1855
- this.mode = "insert";
1995
+ this.setMode();
1856
1996
  break;
1857
1997
  case "O":
1858
1998
  this.openLineAbove();
1859
- this.mode = "insert";
1999
+ this.setMode();
1860
2000
  break;
1861
2001
  case "D":
1862
2002
  this.takeTotalCount(1);
@@ -1865,16 +2005,16 @@ export class ModalEditor extends CustomEditor {
1865
2005
  case "C":
1866
2006
  this.takeTotalCount(1);
1867
2007
  this.cutToEndOfLine();
1868
- this.mode = "insert";
2008
+ this.setMode();
1869
2009
  break;
1870
2010
  case "S":
1871
2011
  this.takeTotalCount(1);
1872
2012
  this.cutCurrentLineContent();
1873
- this.mode = "insert";
2013
+ this.setMode();
1874
2014
  break;
1875
2015
  case "s":
1876
2016
  this.cutCharUnderCursor();
1877
- this.mode = "insert";
2017
+ this.setMode();
1878
2018
  break;
1879
2019
  case "x":
1880
2020
  this.cutCharUnderCursor();
@@ -1890,11 +2030,22 @@ export class ModalEditor extends CustomEditor {
1890
2030
  }
1891
2031
  }
1892
2032
 
1893
- private executeCharMotion(motion: CharMotion, targetChar: string, saveMotion: boolean = true): void {
2033
+ private executeCharMotion(
2034
+ motion: CharMotion,
2035
+ targetChar: string,
2036
+ saveMotion: boolean = true,
2037
+ ): void {
1894
2038
  const line = this.getLines()[this.getCursor().line] ?? "";
1895
2039
  const col = this.getCursor().col;
1896
2040
  const count = this.takeTotalCount(1);
1897
- const targetCol = findCharMotionTarget(line, col, motion, targetChar, !saveMotion, count);
2041
+ const targetCol = findCharMotionTarget(
2042
+ line,
2043
+ col,
2044
+ motion,
2045
+ targetChar,
2046
+ !saveMotion,
2047
+ count,
2048
+ );
1898
2049
 
1899
2050
  if (targetCol !== null && saveMotion) {
1900
2051
  this.lastCharMotion = { motion, char: targetChar };
@@ -1909,7 +2060,12 @@ export class ModalEditor extends CustomEditor {
1909
2060
  const lines = this.getLines();
1910
2061
  const fromLine = this.getCursor().line;
1911
2062
  const count = this.takeTotalCount(1);
1912
- const targetLine = findParagraphMotionTarget(lines, fromLine, direction, count);
2063
+ const targetLine = findParagraphMotionTarget(
2064
+ lines,
2065
+ fromLine,
2066
+ direction,
2067
+ count,
2068
+ );
1913
2069
  this.moveCursorToLineStart(targetLine);
1914
2070
  }
1915
2071
 
@@ -1924,7 +2080,11 @@ export class ModalEditor extends CustomEditor {
1924
2080
 
1925
2081
  const state = editor.state;
1926
2082
  if (!state || !Array.isArray(state.lines)) return false;
1927
- if (!Number.isInteger(state.cursorLine) || !Number.isInteger(state.cursorCol)) return false;
2083
+ if (
2084
+ !Number.isInteger(state.cursorLine) ||
2085
+ !Number.isInteger(state.cursorCol)
2086
+ )
2087
+ return false;
1928
2088
 
1929
2089
  const cursorLine = state.cursorLine as number;
1930
2090
  const cursorCol = state.cursorCol as number;
@@ -1933,8 +2093,6 @@ export class ModalEditor extends CustomEditor {
1933
2093
 
1934
2094
  const target = cursorCol + delta;
1935
2095
 
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
2096
  if (target < 0 || target > line.length) return false;
1939
2097
 
1940
2098
  state.cursorCol = target;
@@ -1974,7 +2132,10 @@ export class ModalEditor extends CustomEditor {
1974
2132
  }
1975
2133
 
1976
2134
  const currentLine = state.cursorLine ?? 0;
1977
- const targetLine = Math.max(0, Math.min(currentLine + delta, state.lines.length - 1));
2135
+ const targetLine = Math.max(
2136
+ 0,
2137
+ Math.min(currentLine + delta, state.lines.length - 1),
2138
+ );
1978
2139
  if (targetLine === currentLine) return;
1979
2140
 
1980
2141
  const preferredCol = editor.preferredVisualCol ?? state.cursorCol ?? 0;
@@ -2078,9 +2239,12 @@ export class ModalEditor extends CustomEditor {
2078
2239
  if (normalize) {
2079
2240
  const trimmedRight = right.trimStart();
2080
2241
  const leftLastChar = left[left.length - 1];
2081
- const leftEndsWithSpace = leftLastChar !== undefined && /\s/.test(leftLastChar);
2242
+ const leftEndsWithSpace =
2243
+ leftLastChar !== undefined && /\s/.test(leftLastChar);
2082
2244
  const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
2083
- joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
2245
+ joined = needsSeparator
2246
+ ? `${left} ${trimmedRight}`
2247
+ : left + trimmedRight;
2084
2248
  joinPoint = left.length;
2085
2249
  } else {
2086
2250
  joined = left + right;
@@ -2174,25 +2338,43 @@ export class ModalEditor extends CustomEditor {
2174
2338
  } else if (target === "start") {
2175
2339
  const startType = this.charType(text[next], semanticClass);
2176
2340
  if (startType !== "space") {
2177
- while (next < len && this.charType(text[next], semanticClass) === startType) next++;
2341
+ while (
2342
+ next < len &&
2343
+ this.charType(text[next], semanticClass) === startType
2344
+ )
2345
+ next++;
2178
2346
  }
2179
- while (next < len && this.charType(text[next], semanticClass) === "space") next++;
2347
+ while (
2348
+ next < len &&
2349
+ this.charType(text[next], semanticClass) === "space"
2350
+ )
2351
+ next++;
2180
2352
  } else {
2181
2353
  if (next < len - 1) next++;
2182
- while (next < len && this.charType(text[next], semanticClass) === "space") next++;
2354
+ while (
2355
+ next < len &&
2356
+ this.charType(text[next], semanticClass) === "space"
2357
+ )
2358
+ next++;
2183
2359
  if (next >= len) {
2184
2360
  next = len;
2185
2361
  } else {
2186
2362
  const t = this.charType(text[next], semanticClass);
2187
- while (next < len - 1 && this.charType(text[next + 1], semanticClass) === t) next++;
2363
+ while (
2364
+ next < len - 1 &&
2365
+ this.charType(text[next + 1], semanticClass) === t
2366
+ )
2367
+ next++;
2188
2368
  }
2189
2369
  }
2190
2370
  } else {
2191
2371
  if (next >= len) next = len - 1;
2192
2372
  if (next > 0) next--;
2193
- while (next > 0 && this.charType(text[next], semanticClass) === "space") next--;
2373
+ while (next > 0 && this.charType(text[next], semanticClass) === "space")
2374
+ next--;
2194
2375
  const t = this.charType(text[next], semanticClass);
2195
- while (next > 0 && this.charType(text[next - 1], semanticClass) === t) next--;
2376
+ while (next > 0 && this.charType(text[next - 1], semanticClass) === t)
2377
+ next--;
2196
2378
  }
2197
2379
 
2198
2380
  if (next === i) break;
@@ -2281,7 +2463,11 @@ export class ModalEditor extends CustomEditor {
2281
2463
  semanticClass: WordMotionClass = "word",
2282
2464
  ): boolean {
2283
2465
  const col = this.getCursor().col;
2284
- const targetCol = this.tryFindWordTargetLineLocal(direction, target, semanticClass);
2466
+ const targetCol = this.tryFindWordTargetLineLocal(
2467
+ direction,
2468
+ target,
2469
+ semanticClass,
2470
+ );
2285
2471
  if (targetCol === null || targetCol === col) return false;
2286
2472
 
2287
2473
  this.moveCursorToCol(targetCol);
@@ -2297,7 +2483,8 @@ export class ModalEditor extends CustomEditor {
2297
2483
  const lineIndex = cursor.line;
2298
2484
  const col = cursor.col;
2299
2485
  const lineSnapshot = this.getLines()[lineIndex] ?? "";
2300
- const direction: WordMotionDirection = motion === "b" ? "backward" : "forward";
2486
+ const direction: WordMotionDirection =
2487
+ motion === "b" ? "backward" : "forward";
2301
2488
  const target: WordMotionTarget = motion === "e" ? "end" : "start";
2302
2489
  const steps = Math.max(1, Math.min(MAX_COUNT, count));
2303
2490
 
@@ -2364,7 +2551,10 @@ export class ModalEditor extends CustomEditor {
2364
2551
  return true;
2365
2552
  }
2366
2553
 
2367
- private writeToRegister(text: string, source: RegisterWriteSource = "mutation"): void {
2554
+ private writeToRegister(
2555
+ text: string,
2556
+ source: RegisterWriteSource = "mutation",
2557
+ ): void {
2368
2558
  this.unnamedRegister = text;
2369
2559
  const shouldMirror = text !== "" && this.shouldMirrorRegisterWrite(source);
2370
2560
  this.preferRegisterForPut = text !== "" && !shouldMirror;
@@ -2380,7 +2570,9 @@ export class ModalEditor extends CustomEditor {
2380
2570
  }
2381
2571
 
2382
2572
  private hasMultiCodeUnitGraphemes(line: string): boolean {
2383
- return getLineGraphemes(line).some((segment) => segment.end - segment.start > 1);
2573
+ return getLineGraphemes(line).some(
2574
+ (segment) => segment.end - segment.start > 1,
2575
+ );
2384
2576
  }
2385
2577
 
2386
2578
  private getGraphemeRangeAtCol(
@@ -2391,7 +2583,9 @@ export class ModalEditor extends CustomEditor {
2391
2583
  ): { start: number; end: number } | null {
2392
2584
  const clampedCol = Math.max(0, Math.min(col, line.length));
2393
2585
  const segments = getLineGraphemes(line);
2394
- const startIndex = segments.findIndex((segment) => clampedCol < segment.end);
2586
+ const startIndex = segments.findIndex(
2587
+ (segment) => clampedCol < segment.end,
2588
+ );
2395
2589
  if (startIndex === -1) return null;
2396
2590
 
2397
2591
  let endIndex = startIndex + Math.max(1, count) - 1;
@@ -2432,7 +2626,8 @@ export class ModalEditor extends CustomEditor {
2432
2626
  const text = this.getText();
2433
2627
  this.writeToRegister(line.slice(range.start, range.end));
2434
2628
  this.replaceTextInBuffer(
2435
- text.slice(0, lineStartAbs + range.start) + text.slice(lineStartAbs + range.end),
2629
+ text.slice(0, lineStartAbs + range.start) +
2630
+ text.slice(lineStartAbs + range.end),
2436
2631
  lineStartAbs + range.start,
2437
2632
  );
2438
2633
  }
@@ -2443,7 +2638,8 @@ export class ModalEditor extends CustomEditor {
2443
2638
  const { line, col } = this.getCurrentLineAndCol();
2444
2639
 
2445
2640
  const hasNextLine = cursorLine < lines.length - 1;
2446
- const deleted = col < line.length ? line.slice(col) : hasNextLine ? "\n" : "";
2641
+ const deleted =
2642
+ col < line.length ? line.slice(col) : hasNextLine ? "\n" : "";
2447
2643
 
2448
2644
  this.writeToRegister(deleted);
2449
2645
  super.handleInput(CTRL_K);
@@ -2466,7 +2662,10 @@ export class ModalEditor extends CustomEditor {
2466
2662
  this.cutCurrentLineContent();
2467
2663
  }
2468
2664
 
2469
- private getNormalizedLineRange(startLine: number, endLine: number): { start: number; end: number } {
2665
+ private getNormalizedLineRange(
2666
+ startLine: number,
2667
+ endLine: number,
2668
+ ): { start: number; end: number } {
2470
2669
  const lines = this.getLines();
2471
2670
  const last = Math.max(0, lines.length - 1);
2472
2671
  const clampedStart = Math.max(0, Math.min(startLine, last));
@@ -2483,7 +2682,10 @@ export class ModalEditor extends CustomEditor {
2483
2682
  return `${lines.slice(start, end + 1).join("\n")}\n`;
2484
2683
  }
2485
2684
 
2486
- private getLineDeleteAbsoluteRange(startLine: number, endLine: number): { startAbs: number; endAbs: number } {
2685
+ private getLineDeleteAbsoluteRange(
2686
+ startLine: number,
2687
+ endLine: number,
2688
+ ): { startAbs: number; endAbs: number } {
2487
2689
  const lines = this.getLines();
2488
2690
  const text = this.getText();
2489
2691
  const { start, end } = this.getNormalizedLineRange(startLine, endLine);
@@ -2510,7 +2712,10 @@ export class ModalEditor extends CustomEditor {
2510
2712
  if (lines.length === 0) return;
2511
2713
 
2512
2714
  const payload = this.getLinewisePayload(startLine, endLine);
2513
- const { startAbs, endAbs } = this.getLineDeleteAbsoluteRange(startLine, endLine);
2715
+ const { startAbs, endAbs } = this.getLineDeleteAbsoluteRange(
2716
+ startLine,
2717
+ endLine,
2718
+ );
2514
2719
 
2515
2720
  this.writeToRegister(payload);
2516
2721
 
@@ -2519,7 +2724,6 @@ export class ModalEditor extends CustomEditor {
2519
2724
  const newText = text.slice(0, startAbs) + text.slice(endAbs);
2520
2725
  this.replaceTextInBuffer(newText, startAbs);
2521
2726
 
2522
- // Ensure cursor is at column 0 of the landing line
2523
2727
  super.handleInput(CTRL_A);
2524
2728
  }
2525
2729
  }
@@ -2552,7 +2756,6 @@ export class ModalEditor extends CustomEditor {
2552
2756
  const col = cursor.col;
2553
2757
 
2554
2758
  if (motion === "$") {
2555
- // Match D/C behavior exactly, including newline kill at EOL.
2556
2759
  this.cutToEndOfLine();
2557
2760
  return true;
2558
2761
  }
@@ -2563,7 +2766,11 @@ export class ModalEditor extends CustomEditor {
2563
2766
  }
2564
2767
 
2565
2768
  if (motion === "^") {
2566
- this.deleteRange(col, findFirstNonWhitespaceColumn(this.getLines()[cursor.line] ?? ""), false);
2769
+ this.deleteRange(
2770
+ col,
2771
+ findFirstNonWhitespaceColumn(this.getLines()[cursor.line] ?? ""),
2772
+ false,
2773
+ );
2567
2774
  return true;
2568
2775
  }
2569
2776
 
@@ -2593,7 +2800,11 @@ export class ModalEditor extends CustomEditor {
2593
2800
  count,
2594
2801
  wordMotion.semanticClass,
2595
2802
  );
2596
- this.deleteRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e");
2803
+ this.deleteRangeByAbsolute(
2804
+ currentAbs,
2805
+ targetAbs,
2806
+ wordMotion.motion === "e",
2807
+ );
2597
2808
  return true;
2598
2809
  }
2599
2810
 
@@ -2604,7 +2815,14 @@ export class ModalEditor extends CustomEditor {
2604
2815
  const line = this.getLines()[this.getCursor().line] ?? "";
2605
2816
  const col = this.getCursor().col;
2606
2817
  const count = this.takeTotalCount(1);
2607
- const targetCol = findCharMotionTarget(line, col, motion, targetChar, false, count);
2818
+ const targetCol = findCharMotionTarget(
2819
+ line,
2820
+ col,
2821
+ motion,
2822
+ targetChar,
2823
+ false,
2824
+ count,
2825
+ );
2608
2826
 
2609
2827
  if (targetCol === null) return;
2610
2828
 
@@ -2633,7 +2851,8 @@ export class ModalEditor extends CustomEditor {
2633
2851
  }
2634
2852
 
2635
2853
  if (data === "j" || data === "k") {
2636
- const hasDualCount = this.prefixCount.length > 0 && this.operatorCount.length > 0;
2854
+ const hasDualCount =
2855
+ this.prefixCount.length > 0 && this.operatorCount.length > 0;
2637
2856
  const count = this.takeTotalCount(1);
2638
2857
  const delta = hasDualCount ? Math.max(0, count - 1) : count;
2639
2858
  this.yankLinewiseByDelta(data === "j" ? delta : -delta);
@@ -2670,7 +2889,6 @@ export class ModalEditor extends CustomEditor {
2670
2889
  }
2671
2890
 
2672
2891
  if (this.hasPendingCount()) {
2673
- // Counted forms beyond yy, y{count}j/k, and y{count}{f/F/t/T} are out of scope.
2674
2892
  this.cancelPendingOperator(data);
2675
2893
  return;
2676
2894
  }
@@ -2728,7 +2946,11 @@ export class ModalEditor extends CustomEditor {
2728
2946
  1,
2729
2947
  wordMotion.semanticClass,
2730
2948
  );
2731
- this.yankRangeByAbsolute(currentAbs, targetAbs, wordMotion.motion === "e");
2949
+ this.yankRangeByAbsolute(
2950
+ currentAbs,
2951
+ targetAbs,
2952
+ wordMotion.motion === "e",
2953
+ );
2732
2954
  return true;
2733
2955
  }
2734
2956
 
@@ -2739,7 +2961,14 @@ export class ModalEditor extends CustomEditor {
2739
2961
  const line = this.getLines()[this.getCursor().line] ?? "";
2740
2962
  const col = this.getCursor().col;
2741
2963
  const count = this.takeTotalCount(1);
2742
- const targetCol = findCharMotionTarget(line, col, motion, targetChar, false, count);
2964
+ const targetCol = findCharMotionTarget(
2965
+ line,
2966
+ col,
2967
+ motion,
2968
+ targetChar,
2969
+ false,
2970
+ count,
2971
+ );
2743
2972
 
2744
2973
  if (targetCol === null) return;
2745
2974
 
@@ -2754,17 +2983,24 @@ export class ModalEditor extends CustomEditor {
2754
2983
  let end = Math.min(rawEnd, line.length);
2755
2984
 
2756
2985
  if (inclusive) {
2757
- const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1);
2986
+ const targetRange = this.getGraphemeRangeAtCol(
2987
+ line,
2988
+ Math.max(col, targetCol),
2989
+ 1,
2990
+ );
2758
2991
  end = targetRange?.end ?? end;
2759
2992
  }
2760
2993
 
2761
2994
  if (end <= start) return;
2762
2995
 
2763
- // Yank only — no cursor movement, no text mutation
2764
2996
  this.writeToRegister(line.slice(start, end), "yank");
2765
2997
  }
2766
2998
 
2767
- private yankRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean = false): void {
2999
+ private yankRangeByAbsolute(
3000
+ currentAbs: number,
3001
+ targetAbs: number,
3002
+ inclusive: boolean = false,
3003
+ ): void {
2768
3004
  const text = this.getText();
2769
3005
  const start = Math.min(currentAbs, targetAbs);
2770
3006
  const rawEnd = Math.max(currentAbs, targetAbs) + (inclusive ? 1 : 0);
@@ -2773,7 +3009,10 @@ export class ModalEditor extends CustomEditor {
2773
3009
  this.writeToRegister(text.slice(start, end), "yank");
2774
3010
  }
2775
3011
 
2776
- private getCursorFromAbsoluteIndex(text: string, abs: number): { line: number; col: number } {
3012
+ private getCursorFromAbsoluteIndex(
3013
+ text: string,
3014
+ abs: number,
3015
+ ): { line: number; col: number } {
2777
3016
  const lines = text.length === 0 ? [""] : text.split("\n");
2778
3017
  let remaining = Math.max(0, Math.min(abs, text.length));
2779
3018
  for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
@@ -2814,7 +3053,11 @@ export class ModalEditor extends CustomEditor {
2814
3053
  editor.tui?.requestRender?.();
2815
3054
  }
2816
3055
 
2817
- private deleteRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean = false): void {
3056
+ private deleteRangeByAbsolute(
3057
+ currentAbs: number,
3058
+ targetAbs: number,
3059
+ inclusive: boolean = false,
3060
+ ): void {
2818
3061
  const text = this.getText();
2819
3062
  const start = Math.min(currentAbs, targetAbs);
2820
3063
  const rawEnd = Math.max(currentAbs, targetAbs) + (inclusive ? 1 : 0);
@@ -2866,12 +3109,14 @@ export class ModalEditor extends CustomEditor {
2866
3109
  const count = this.takeTotalCount(1);
2867
3110
  const text = this.getPasteRegisterText();
2868
3111
  if (!text) return;
2869
- const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
3112
+ const safeCount = Math.min(
3113
+ count,
3114
+ Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)),
3115
+ );
2870
3116
 
2871
3117
  if (text.endsWith("\n")) {
2872
3118
  const content = text.slice(0, -1);
2873
3119
  for (let i = 0; i < safeCount; i++) {
2874
- // Line-wise: insert new line below and fill it
2875
3120
  super.handleInput(CTRL_E);
2876
3121
  super.handleInput(NEWLINE);
2877
3122
  for (const char of content) {
@@ -2881,7 +3126,6 @@ export class ModalEditor extends CustomEditor {
2881
3126
  return;
2882
3127
  }
2883
3128
 
2884
- // Character-wise: insert after cursor
2885
3129
  if (!this.isCursorAtOrPastEol()) {
2886
3130
  super.handleInput(ESC_RIGHT);
2887
3131
  }
@@ -2896,12 +3140,14 @@ export class ModalEditor extends CustomEditor {
2896
3140
  const count = this.takeTotalCount(1);
2897
3141
  const text = this.getPasteRegisterText();
2898
3142
  if (!text) return;
2899
- const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
3143
+ const safeCount = Math.min(
3144
+ count,
3145
+ Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)),
3146
+ );
2900
3147
 
2901
3148
  if (text.endsWith("\n")) {
2902
3149
  const content = text.slice(0, -1);
2903
3150
  for (let i = 0; i < safeCount; i++) {
2904
- // Line-wise: insert new line above and fill it
2905
3151
  super.handleInput(CTRL_A);
2906
3152
  super.handleInput(NEWLINE);
2907
3153
  super.handleInput(ESC_UP);
@@ -2912,7 +3158,6 @@ export class ModalEditor extends CustomEditor {
2912
3158
  return;
2913
3159
  }
2914
3160
 
2915
- // Character-wise: insert before cursor (just type it)
2916
3161
  for (let i = 0; i < safeCount; i++) {
2917
3162
  for (const char of text) {
2918
3163
  super.handleInput(char === "\n" ? NEWLINE : char);
@@ -2920,7 +3165,11 @@ export class ModalEditor extends CustomEditor {
2920
3165
  }
2921
3166
  }
2922
3167
 
2923
- private deleteRange(col: number, targetCol: number, inclusive: boolean): void {
3168
+ private deleteRange(
3169
+ col: number,
3170
+ targetCol: number,
3171
+ inclusive: boolean,
3172
+ ): void {
2924
3173
  const cursor = this.getCursor();
2925
3174
  const line = this.getLines()[cursor.line] ?? "";
2926
3175
  const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
@@ -2929,7 +3178,11 @@ export class ModalEditor extends CustomEditor {
2929
3178
  let end = Math.min(rawEnd, line.length);
2930
3179
 
2931
3180
  if (inclusive) {
2932
- const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1);
3181
+ const targetRange = this.getGraphemeRangeAtCol(
3182
+ line,
3183
+ Math.max(col, targetCol),
3184
+ 1,
3185
+ );
2933
3186
  end = targetRange?.end ?? end;
2934
3187
  }
2935
3188
 
@@ -2978,7 +3231,7 @@ export class ModalEditor extends CustomEditor {
2978
3231
  }
2979
3232
 
2980
3233
  private getDesiredCursorShapeSequence(): CursorShapeSequence {
2981
- return this.mode === "insert" && this.pendingExCommand === null
3234
+ return "insert" === this.mode && this.pendingExCommand === null
2982
3235
  ? INSERT_CURSOR_SHAPE
2983
3236
  : BLOCK_CURSOR_SHAPE;
2984
3237
  }
@@ -3026,7 +3279,8 @@ export class ModalEditor extends CustomEditor {
3026
3279
  const last = lines.length - 1;
3027
3280
  const lastLine = lines[last];
3028
3281
  if (lastLine && visibleWidth(lastLine) >= visibleWidth(rawLabel)) {
3029
- lines[last] = truncateToWidth(lastLine, width - visibleWidth(rawLabel), "") + label;
3282
+ lines[last] =
3283
+ truncateToWidth(lastLine, width - visibleWidth(rawLabel), "") + label;
3030
3284
  } else {
3031
3285
  lines[last] = label;
3032
3286
  }
@@ -3034,13 +3288,11 @@ export class ModalEditor extends CustomEditor {
3034
3288
  }
3035
3289
 
3036
3290
  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;
3291
+ return this.labelColorizers?.[this.getActiveMode()] ?? null;
3040
3292
  }
3041
3293
 
3042
3294
  private getModeLabel(): string {
3043
- if (this.mode === "insert") return " INSERT ";
3295
+ if ("insert" === this.mode) return " INSERT ";
3044
3296
  if (this.pendingExCommand !== null) return ` EX ${this.pendingExCommand}_ `;
3045
3297
 
3046
3298
  const prefixCount = this.prefixCount;
@@ -3073,21 +3325,29 @@ export default function (pi: ExtensionAPI) {
3073
3325
 
3074
3326
  pi.on("session_start", (_event, ctx) => {
3075
3327
  const piVimSettings = readPiVimSettings(ctx.cwd);
3076
- const clipboardMirrorPolicy = resolveClipboardMirrorPolicy(piVimSettings.clipboardMirror);
3328
+ const clipboardMirrorPolicy = resolveClipboardMirrorPolicy(
3329
+ piVimSettings.clipboardMirror,
3330
+ );
3077
3331
  if (clipboardMirrorPolicy.warning && ctx.hasUI) {
3078
3332
  ctx.ui.notify(clipboardMirrorPolicy.warning, "warning");
3079
3333
  }
3080
3334
 
3081
3335
  const t = ctx.ui.theme;
3336
+ const modeColors = resolveModeColors(piVimSettings.modeColors);
3082
3337
  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;
3338
+ const labelColorizers = t
3339
+ ? buildModeColorizers(t, modeColors, reverseVideo)
3340
+ : null;
3341
+ const borderColorizers =
3342
+ t && piVimSettings.syncBorderColorWithMode === true
3343
+ ? buildModeColorizers(t, modeColors)
3344
+ : null;
3088
3345
  ctx.ui.setEditorComponent((tui, theme, kb) => {
3089
3346
  cursorShapeCleanup = enableCursorShapeSupport(tui);
3090
- const editor = new ModalEditor(tui, theme, kb, colorizers);
3347
+ const editor = new ModalEditor(tui, theme, kb, {
3348
+ labelColorizers,
3349
+ borderColorizers,
3350
+ });
3091
3351
  editor.setClipboardMirrorPolicy(clipboardMirrorPolicy.policy);
3092
3352
  editor.setQuitFn(() => ctx.shutdown());
3093
3353
  editor.setNotifyFn((message) => ctx.ui.notify(message, "warning"));