Repository navigation
Expand file tree
/
Copy pathindex.ts
More file actions
4240 lines (3742 loc) · 127 KB
/
Copy pathindex.ts
File metadata and controls
4240 lines (3742 loc) · 127 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import {
CustomEditor,
type ExtensionAPI,
} from "@earendil-works/pi-coding-agent";
import {
Key,
matchesKey,
truncateToWidth,
visibleWidth,
} from "@earendil-works/pi-tui";
import {
ClipboardMirror,
type ClipboardReadFn,
readClipboardInChildProcess,
writeClipboardInChildProcess,
} from "./clipboard-mirror.js";
import {
type ClipboardMirrorPolicy,
DEFAULT_CLIPBOARD_MIRROR_POLICY,
type RegisterWriteSource,
resolveClipboardMirrorPolicy,
} from "./clipboard-policy.js";
import {
BLOCK_CURSOR_SHAPE,
type CursorShapeCleanup,
type CursorShapeRuntime,
type CursorShapeSequence,
enableCursorShapeSupport,
getCursorShapeRuntime,
hasPromptCursorMarker,
INSERT_CURSOR_SHAPE,
stripSoftwareCursorWhenHardwareCursorIsUsed,
} from "./cursor-shape.js";
import {
isBackspaceLikeInput,
isCountStarter,
isDigit,
isEnterLikeInput,
isEscapeLikeInput,
isPrintableChunk,
isPrintableInput,
} from "./input-keys.js";
import {
cancelModeChangeCommands,
createModeChangeHandler,
setModeChangeCommandRunnerForTests,
} from "./mode-change-command.js";
import {
buildModeColorizers,
buildOffBorderColor,
isNeutralBorder,
type ModeColorizers,
type ModeColorKey,
resolveModeColors,
} from "./mode-colors.js";
import { fitModeLabel } from "./mode-label.js";
import {
findCharMotionTarget,
findFirstNonWhitespaceColumn,
findParagraphMotionTarget,
getLineGraphemes,
reverseCharMotion,
type WordMotionClass,
} from "./motions.js";
import {
DEFAULT_EX_COMMAND_SETTINGS,
type ExCommandSettings,
readPiVimSettings,
resolveExCommandSettings,
resolveSurfaceSyncMaps,
type SurfaceSyncMap,
} from "./settings.js";
import {
resolveDelimitedTextObjectRange,
resolveMatchingPairMotionTarget,
resolveWordTextObjectRange,
type TextObjectKind,
type TextObjectRange,
type WordTextObjectClass,
} from "./text-objects.js";
import type {
CharMotion,
LastCharMotion,
Mode,
PendingMotion,
PendingOperator,
} from "./types.js";
import {
CHAR_MOTION_KEYS,
CTRL_A,
CTRL_E,
CTRL_K,
CTRL_R,
CTRL_UNDERSCORE,
ESC_DOWN,
ESC_LEFT,
ESC_RIGHT,
ESC_UP,
NEWLINE,
NORMAL_KEYS,
} from "./types.js";
import {
clampVisualPosition,
getInclusiveEndColumn,
getVisualLineRange,
isVisualMode,
orderVisualEndpoints,
type VisualMode,
type VisualPosition,
} from "./visual.js";
import {
WordBoundaryCache,
type WordMotionDirection,
type WordMotionTarget,
} from "./word-boundary-cache.js";
export { setModeChangeCommandRunnerForTests };
const MODE_COLOR_KEYS: readonly ModeColorKey[] = [
"insert",
"normal",
"visual",
"ex",
];
const BRACKETED_PASTE_START = "\x1b[200~";
const BRACKETED_PASTE_END = "\x1b[201~";
const BRACKETED_PASTE_END_TAIL = BRACKETED_PASTE_END.slice(1);
const MAX_COUNT = 9999;
const TEXT_INSERT_REPEAT_KEYS = new Set(["i", "a", "A", "I"]);
const OPEN_LINE_REPEAT_KEYS = new Set(["o", "O"]);
const REPEATABLE_COMMAND_START_KEYS = new Set([
"d",
"c",
"r",
"p",
"P",
"J",
"g",
...TEXT_INSERT_REPEAT_KEYS,
...OPEN_LINE_REPEAT_KEYS,
"D",
"C",
"S",
"s",
"x",
"X",
]);
// Normal-mode commands that must never run while a visual selection is live.
// They are swallowed instead of falling through to the normal-mode dispatch.
const VISUAL_IGNORED_KEYS = new Set([
"p",
"P",
"r",
"J",
"u",
"U",
"~",
">",
"<",
".",
":",
"i",
"a",
"A",
"I",
"R",
CTRL_R,
CTRL_UNDERSCORE,
]);
// Pi's builtin slash commands. `pi.getCommands()` reports only extension,
// prompt and skill commands; the builtin list is not re-exported from the
// package's public entry point, so it is mirrored here. A name missing from
// this mirror simply falls through to "Unsupported ex command".
const EX_BUILTIN_COMMAND_NAMES: ReadonlySet<string> = new Set([
"settings",
"model",
"scoped-models",
"export",
"import",
"share",
"copy",
"name",
"session",
"changelog",
"hotkeys",
"fork",
"clone",
"tree",
"login",
"logout",
"new",
"compact",
"resume",
"reload",
"quit",
"trust",
]);
type EditorSnapshot = {
text: string;
cursor: { line: number; col: number };
};
type CommandDispatchResult = void | PromiseLike<void>;
type DispatchState = {
snapshot: EditorSnapshot;
restore: () => void;
};
type RepeatRecording = {
/** Raw key inputs that reconstruct one repeatable normal-mode change. */
keys: string[];
/** Replay keys without command counts, used when `{count}.` overrides them. */
countOverrideKeys: string[];
/** Buffer mutation version before the command started. */
startChangeVersion: number;
/** True while the command is collecting insert-mode input until Escape. */
captureInsert: boolean;
/** True when a command completed even if it emitted no mutation event. */
forceCommit: boolean;
};
type RepeatableCommand = Pick<RepeatRecording, "keys" | "countOverrideKeys">;
type TransitionState = "none" | "undo" | "redo" | "restore";
type ModalEditorInternals = {
state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
preferredVisualCol?: number | null;
lastAction?: string | null;
historyIndex?: number;
onChange?: (text: string) => void;
tui?: { requestRender?: () => void };
pushUndoSnapshot?: () => void;
setCursorCol?: (col: number) => void;
undoStack?: { readonly length: number; pop(): unknown; stack: unknown[] };
};
type CustomEditorConstructorArgs = ConstructorParameters<typeof CustomEditor>;
type ModalEditorOptions = {
labelColorizers?: ModeColorizers | null;
borderColorizers?: ModeColorizers | null;
// Per-mode paint policy for each surface. "mode" paints the mode color;
// "host" shows the host's current border color; "thinking" shows the host
// color only while the host border is away from its neutral resting default.
// Absent → every mode "mode" (paint the colorizers this editor was given).
borderSync?: SurfaceSyncMap | null;
labelSync?: SurfaceSyncMap | null;
// Renders Pi's neutral "thinking off" border color. Lets "thinking" tell the
// resting border apart from an active thinking level or another extension's
// highlight. When omitted, "thinking" treats every host border as neutral and
// always paints the mode color.
offBorderColor?: ((s: string) => string) | null;
// Reverse-video transform applied to the label. When the label defers to the
// host color it is wrapped with this so it keeps its block styling.
labelTransform?: ((s: string) => string) | null;
};
export class ModalEditor extends CustomEditor {
private mode: Mode = "insert";
private pendingMotion: PendingMotion = null;
private pendingTextObject: TextObjectKind | null = null;
private pendingOperator: PendingOperator = null;
private prefixCount: string = "";
private operatorCount: string = "";
private pendingG: boolean = false;
private pendingGCount: string = "";
private pendingReplace: boolean = false;
private visualAnchor: VisualPosition | null = null;
private pendingExCommand: string | null = null;
private acceptingBracketedPasteInExCommand: boolean = false;
private pendingEscWhileAcceptingBracketedPasteInExCommand: boolean = false;
private discardingPasteAfterNewlineInExCommand: boolean = false;
private lastCharMotion: LastCharMotion | null = null;
private discardingBracketedPasteInNormalMode: boolean = false;
private pendingEscWhileDiscardingBracketedPasteInNormalMode: boolean = false;
private wordBoundaryCache = new WordBoundaryCache();
private readonly redoStack: EditorSnapshot[] = [];
private lastRepeatableCommand: RepeatableCommand | null = null;
private repeatRecording: RepeatRecording | null = null;
private implicitInsertSuppressed: boolean = false;
private replayingRepeat: boolean = false;
private repeatReplayFailed: boolean = false;
private bufferChangeVersion: number = 0;
/**
* One undo window per vim change, opened on the recorder's change boundaries
* (or a visual mutating operator). `floor` is the host `undoStack.length` at
* open; `openVersion` the `bufferChangeVersion`. On close the host stack is
* collapsed to `floor + 1`, so one host snapshot equals one vim change.
*/
private undoWindow: { floor: number; openVersion: number } | null = null;
private currentTransition: TransitionState = "none";
private onChangeHooked: boolean = false;
private readonly labelColorizers: ModeColorizers | null;
private readonly borderColorizers: ModeColorizers | null;
private readonly offBorderColor: ((s: string) => string) | null;
// Latest host-assigned border color and whether it is the neutral resting
// default. Kept as mutable instance fields (not closure locals) so the label
// resolver can read the same thinking state the border uses.
private borderBase: ((s: string) => string) | null = null;
private borderBaseIsNeutral = true;
// Per-mode paint policy for each surface (border and label). Null → every
// mode defaults to "mode" (paint the mode color).
private readonly borderSync: SurfaceSyncMap | null;
private readonly labelSync: SurfaceSyncMap | null;
// Reverse-video transform used by the label, so its defer-to-host branch
// matches the label's usual background-block styling.
private labelTransform: ((s: string) => string) | null = null;
private readonly cursorShapeRuntime: CursorShapeRuntime | null;
private lastCursorShapeSequence: CursorShapeSequence | null = null;
private lastLineCache = { l: "", w: 0, label: "", result: "" };
private unnamedRegister: string = "";
private preferRegisterForPut = false;
private clipboardMirrorPolicy: ClipboardMirrorPolicy =
DEFAULT_CLIPBOARD_MIRROR_POLICY;
private readonly clipboardMirror = new ClipboardMirror(
writeClipboardInChildProcess,
);
private clipboardReadFn: ClipboardReadFn = readClipboardInChildProcess;
private quitFn: () => void = () => {};
private notifyFn: (message: string) => void = () => {};
private modeChangeFn: (mode: Mode, prevMode: Mode) => void = () => {};
private exCommandSettings: ExCommandSettings = DEFAULT_EX_COMMAND_SETTINGS;
private commandNamesFn: () => ReadonlySet<string> = () =>
EX_BUILTIN_COMMAND_NAMES;
// Pi has no public command-dispatch API. The editor's own submit path is the
// one mechanism that reaches every command kind (builtin, extension, skill,
// prompt), so dispatching `:name` is literally the user typing `/name` and
// pressing Enter. Injectable so tests need no live pi runtime.
private runCommandFn: (commandLine: string) => CommandDispatchResult = (
commandLine,
) => {
const submit = this.onSubmit as
| ((text: string) => CommandDispatchResult)
| undefined;
if (!submit) return;
this.setText(commandLine);
return submit(commandLine);
};
private pendingAsyncDispatches: number = 0;
private restoreLatestDispatchStateFn: (() => void) | null = null;
constructor(
tui: CustomEditorConstructorArgs[0],
theme: CustomEditorConstructorArgs[1],
kb: CustomEditorConstructorArgs[2],
opts?: ModalEditorOptions,
) {
super(tui, theme, kb);
this.cursorShapeRuntime = getCursorShapeRuntime(tui);
this.labelColorizers = opts?.labelColorizers ?? null;
this.borderColorizers = opts?.borderColorizers ?? null;
this.offBorderColor = opts?.offBorderColor ?? null;
this.borderSync = opts?.borderSync ?? null;
this.labelSync = opts?.labelSync ?? null;
this.labelTransform = opts?.labelTransform ?? null;
this.installModeBorderColorizer();
}
setClipboardFn(fn: (text: string, signal?: AbortSignal) => unknown): void {
this.clipboardMirror.setWriteFn(
async (text: string, signal: AbortSignal) => {
await fn(text, signal);
},
);
}
setClipboardWriteTimeoutMs(timeoutMs: number): void {
this.clipboardMirror.setTimeoutMs(timeoutMs);
}
setClipboardReadFn(fn: ClipboardReadFn): void {
this.clipboardReadFn = fn;
}
setClipboardMirrorPolicy(policy: ClipboardMirrorPolicy): void {
this.clipboardMirrorPolicy = policy;
}
getClipboardMirrorPolicy(): ClipboardMirrorPolicy {
return this.clipboardMirrorPolicy;
}
setQuitFn(fn: () => void): void {
this.quitFn = fn;
}
setNotifyFn(fn: (message: string) => void): void {
this.notifyFn = fn;
}
setModeChangeFn(fn: (mode: Mode, prevMode: Mode) => void): void {
this.modeChangeFn = fn;
}
setRunCommandFn(fn: (commandLine: string) => CommandDispatchResult): void {
this.runCommandFn = fn;
}
setCommandNamesFn(fn: () => ReadonlySet<string>): void {
this.commandNamesFn = fn;
}
setExCommandSettings(settings: ExCommandSettings): void {
this.exCommandSettings = settings;
}
getRegister(): string {
return this.unnamedRegister;
}
setRegister(text: string): void {
this.unnamedRegister = text;
this.preferRegisterForPut = false;
}
getMode(): Mode {
return this.mode;
}
getText(): string {
return this.getLines().join("\n");
}
private getActiveMode(): ModeColorKey {
if (this.pendingExCommand !== null) return "ex";
if (this.mode === "insert") return "insert";
if (isVisualMode(this.mode)) return "visual";
return "normal";
}
/**
* Resolve a surface (border or label) color for the active mode under its
* per-mode paint policy:
* - "mode" always paints the mode color;
* - "host" always shows the host's current border color;
* - "thinking" shows the host color while the host border is away from its
* neutral resting default, otherwise paints the mode color.
* A missing policy map defaults every mode to "mode". `deferWrap` is identity
* for the border and reverse-video for the label, so a deferred host color
* inherits the surface's normal styling.
*/
private resolveSurfaceColor(
colorizers: ModeColorizers,
syncMap: SurfaceSyncMap | null,
text: string,
deferWrap: (s: string) => string,
): string {
const mode = this.getActiveMode();
const modeColorFn = colorizers[mode] ?? ((s: string) => s);
const policy = syncMap ? syncMap[mode] : "mode";
if (
policy === "mode" ||
(policy === "thinking" && this.borderBaseIsNeutral)
) {
return modeColorFn(text);
}
// Defer to the host border color ("host" always; "thinking" only while the
// host border is non-neutral), wrapped the way this surface styles its text.
const host = this.borderBase ?? modeColorFn;
return deferWrap(host(text));
}
// The border trap recolors the border getter and tracks the host's assigned
// border base for both surfaces. It is unnecessary — and skipped, so the
// border reference stays exactly what the host set — only when every border
// mode is "host" and every label mode is "mode" (the released-`false`
// defaults): the border shows the host color untouched and the label needs no
// host tracking.
private borderTrapNeeded(): boolean {
const borderAllHost = MODE_COLOR_KEYS.every(
(mode) => (this.borderSync ? this.borderSync[mode] : "mode") === "host",
);
const labelAllMode = MODE_COLOR_KEYS.every(
(mode) => (this.labelSync ? this.labelSync[mode] : "mode") === "mode",
);
return !(borderAllHost && labelAllMode);
}
private installModeBorderColorizer(): void {
if (!this.borderColorizers) return;
if (!this.borderTrapNeeded()) return;
// Capture the values the getter/setter close over so the traps never
// depend on late `this` rebinding. The accessors themselves are arrow
// functions, so their `this` is this editor instance.
const borderColorizers = this.borderColorizers;
const offBorderColor = this.offBorderColor;
// Seed the instance fields from whatever the host assigned before the trap
// was installed (usually the inherited default border).
this.borderBase = this.borderColor;
this.borderBaseIsNeutral = isNeutralBorder(this.borderBase, offBorderColor);
const resolvedBorderColor = (text: string) =>
this.resolveSurfaceColor(
borderColorizers,
this.borderSync,
text,
(s) => s,
);
// Pi assigns its default border color after extension editor construction.
// Keep a mode-aware getter installed and treat later assignments as the
// host base color, otherwise the border sync is overwritten in real
// sessions even though direct editor tests pass. Reclassify on every host
// assignment so a thinking-level cycle off -> on -> off is honored.
Object.defineProperty(this, "borderColor", {
get: () => resolvedBorderColor,
set: (next: unknown) => {
if (typeof next === "function") {
this.borderBase = next as (s: string) => string;
this.borderBaseIsNeutral = isNeutralBorder(
this.borderBase,
offBorderColor,
);
}
},
});
}
private setMode(mode: Mode = "insert"): void {
const prev = this.mode;
this.mode = mode;
// Leaving insert ends any host-tainted session, so the next implicit
// insert (post-submit or a re-entry) records normally again.
if (prev === "insert" && mode !== "insert") {
this.implicitInsertSuppressed = false;
}
if (prev !== mode) {
try {
this.modeChangeFn(mode, prev);
} catch {
// mode-change side effects must never break editing
}
}
}
override setText(text: string): void {
this.discardUndoWindow();
this.clearRedoStack();
this.clearRepeatState();
// A full host buffer reset starts a fresh session: later typing is a new
// implicit insert, not a continuation of a host-tainted one.
this.implicitInsertSuppressed = false;
super.setText(text);
this.refreshPendingDispatchRestore(true);
}
override insertTextAtCursor(text: string): void {
this.discardUndoWindow();
this.cancelRepeatableCommand();
// A host injection mid-insert taints the session: the injected text is not
// ours to replay, and neither is any typing that continues around it.
this.implicitInsertSuppressed = true;
super.insertTextAtCursor(text);
this.refreshPendingDispatchRestore(true);
}
/**
* While an async EX dispatch is pending, the prompt the settle handler will
* reapply must track the *latest* buffer, not only what `handleInput`
* produced. A wrapper mutating the prompt out of band through the public
* setters refreshes the snapshot here, so a late-settling dispatch that
* clears the prompt restores the wrapper's newer text instead of the stale
* pre-dispatch state.
*
* A public setter that empties the buffer is skipped: that is the async
* clear the restore exists to compensate for (or a wrapper clearing), so
* capturing it would defeat the reapply. `handleInput` still refreshes on any
* result, including an intentional emptying edit like `dd`.
*/
private refreshPendingDispatchRestore(fromPublicSetter = false): void {
if (this.pendingAsyncDispatches === 0) return;
if (fromPublicSetter && this.getText() === "") return;
this.restoreLatestDispatchStateFn = this.captureDispatchState().restore;
}
private captureSnapshot(): EditorSnapshot {
const cursor = this.getCursor();
return {
text: this.getText(),
cursor: { line: cursor.line, col: cursor.col },
};
}
private requireRedoRestoreState(editor: ModalEditorInternals): {
lines: string[];
cursorLine?: number;
cursorCol?: number;
} {
const state = editor.state;
if (!state || !Array.isArray(state.lines)) {
throw new Error("Redo restore prerequisite: editor state unavailable");
}
return state as {
lines: string[];
cursorLine?: number;
cursorCol?: number;
};
}
private restoreSnapshot(snapshot: EditorSnapshot): void {
const editor = this as unknown as ModalEditorInternals;
const state = this.requireRedoRestoreState(editor);
const lines = snapshot.text.split("\n");
state.lines = lines.length > 0 ? lines : [""];
const maxLine = Math.max(0, state.lines.length - 1);
const cursorLine = Math.max(0, Math.min(snapshot.cursor.line, maxLine));
const line = state.lines[cursorLine] ?? "";
const cursorCol = Math.max(0, Math.min(snapshot.cursor.col, line.length));
state.cursorLine = cursorLine;
if (typeof editor.setCursorCol === "function") {
editor.setCursorCol(cursorCol);
} else {
state.cursorCol = cursorCol;
editor.preferredVisualCol = null;
}
this.invalidateWordBoundaryCache();
editor.historyIndex = -1;
editor.lastAction = null;
editor.onChange?.(this.getText());
editor.tui?.requestRender?.();
}
private snapshotChanged(a: EditorSnapshot, b: EditorSnapshot): boolean {
return (
a.text !== b.text ||
a.cursor.line !== b.cursor.line ||
a.cursor.col !== b.cursor.col
);
}
private withTransition<T>(
transition: Exclude<TransitionState, "none">,
action: () => T,
): T {
const previousTransition = this.currentTransition;
this.currentTransition = transition;
try {
return action();
} finally {
this.currentTransition = previousTransition;
}
}
private performUndo(count: number = this.takeTotalCount(1)): void {
this.discardUndoWindow();
const maxSteps = Math.max(1, Math.min(MAX_COUNT, count));
for (let i = 0; i < maxSteps; i++) {
let changed = false;
this.withTransition("undo", () => {
const beforeUndo = this.captureSnapshot();
super.handleInput(CTRL_UNDERSCORE);
const afterUndo = this.captureSnapshot();
if (this.snapshotChanged(beforeUndo, afterUndo)) {
this.redoStack.push(beforeUndo);
changed = true;
}
});
if (!changed) break;
}
}
private performRedo(count: number = this.takeTotalCount(1)): void {
this.discardUndoWindow();
const maxSteps = Math.max(1, Math.min(MAX_COUNT, count));
const editor = this as unknown as ModalEditorInternals;
for (let i = 0; i < maxSteps; i++) {
const snapshot = this.redoStack[this.redoStack.length - 1];
if (!snapshot) break;
this.withTransition("redo", () => {
this.requireRedoRestoreState(editor);
if (typeof editor.pushUndoSnapshot !== "function") {
throw new Error(
"Redo restore prerequisite: pushUndoSnapshot unavailable",
);
}
editor.pushUndoSnapshot();
this.restoreSnapshot(snapshot);
this.redoStack.pop();
});
}
}
private clearRedoStack(): void {
this.redoStack.length = 0;
}
private invalidateWordBoundaryCache(): void {
this.wordBoundaryCache = new WordBoundaryCache();
}
private ensureOnChangeHook(): void {
if (this.onChangeHooked) return;
const editor = this as unknown as ModalEditorInternals;
const originalOnChange = editor.onChange;
editor.onChange = (text: string) => {
originalOnChange?.(text);
this.bufferChangeVersion++;
this.centralInvalidationCheck();
};
this.onChangeHooked = true;
}
private centralInvalidationCheck(): void {
if (this.redoStack.length === 0) return;
if (this.currentTransition !== "none") return;
this.clearRedoStack();
}
private isRepeatRecordingInProgress(): boolean {
return (
this.pendingMotion !== null ||
this.pendingTextObject !== null ||
this.pendingOperator !== null ||
this.pendingG ||
this.pendingReplace
);
}
private isRepeatableCommandStart(key: string): boolean {
return REPEATABLE_COMMAND_START_KEYS.has(key);
}
private isOperatorCountDigit(key: string): boolean {
return isDigit(key) && (key !== "0" || this.operatorCount.length > 0);
}
private shouldStripKeyFromCountOverride(key: string): boolean {
if (this.mode === "insert") return false;
if (this.pendingMotion || this.pendingTextObject || this.pendingReplace) {
return false;
}
if (!this.pendingOperator) return false;
return this.isOperatorCountDigit(key);
}
/**
* Every Enter encoding Pi submits on: legacy `\r` plus the Kitty keyboard
* protocol's CSI-u forms (`\x1b[13u`, numpad Enter, the modifier-0 variants).
* `\n` is deliberately excluded: pi-vim treats it as the shift+enter newline
* (see the implicit-insert newline dot-repeat test), so it stays captured as
* typed text rather than cancelling the recording.
*/
private isSubmitEnterInput(key: string): boolean {
return key !== "\n" && matchesKey(key, "enter");
}
private shouldCancelInsertRepeatInput(key: string): boolean {
// A submit must cancel the recording, not be captured into it. A bare `\r`
// check missed the Kitty CSI-u Enter (`\x1b[13u`), letting it be recorded
// and then re-submitted on replay.
if (this.isSubmitEnterInput(key)) return true;
return key === "\t" && this.isShowingAutocomplete();
}
/**
* Dot-repeat is recorded by watching the key stream for one normal-mode
* change. The raw key stream is kept for plain `.`, while a second stream
* strips normal-mode command counts so `{count}.` can replace the stored
* count like Vim instead of multiplying it by repeated replay.
*/
private prepareRepeatRecordingForInput(key: string): void {
if (this.replayingRepeat || this.pendingExCommand !== null) return;
// Visual-mode operators are not dot-repeatable; they clear the stored
// command instead of recording a normal-mode key stream that would replay
// as something else entirely.
if (isVisualMode(this.mode)) return;
if (this.mode === "insert") {
if (this.shouldCancelInsertRepeatInput(key)) {
this.clearRepeatState();
// A submit clears the host undo stack and an autocomplete accept injects
// host text; either way `floor` no longer indexes a valid snapshot, so
// discard the window. There is no cross-submit undo.
this.discardUndoWindow();
// Enter submits and opens a fresh prompt, so the next implicit insert
// is repeatable again. Tab-completion is a host edit inside the
// current session, so it taints the rest of that session — continued
// typing must not resurrect a recording.
this.implicitInsertSuppressed = !this.isSubmitEnterInput(key);
return;
}
// Implicit insert (startup / post-submit): the editor is already in
// insert mode with no recording open, so a vim `i`/`a`/… entry never
// ran to seed one. Synthesize an `i` entry on the first real keystroke
// so the typed run becomes dot-repeatable, exactly as if the user had
// pressed `i` in normal mode. Escape finalizes it as the last change;
// Enter and Tab-completion stay excluded via the cancel check above, so
// a later replay can never submit. A session tainted by a mid-insert
// host edit stays suppressed until Escape leaves insert mode.
if (
!this.repeatRecording &&
!this.implicitInsertSuppressed &&
!isEscapeLikeInput(key)
) {
this.beginImplicitInsertRecording();
}
if (this.repeatRecording?.captureInsert) {
this.repeatRecording.keys.push(key);
this.repeatRecording.countOverrideKeys.push(key);
}
return;
}
if (this.repeatRecording && !this.isRepeatRecordingInProgress()) {
this.cancelRepeatableCommand();
}
if (this.repeatRecording) {
this.repeatRecording.keys.push(key);
if (!this.shouldStripKeyFromCountOverride(key)) {
this.repeatRecording.countOverrideKeys.push(key);
}
return;
}
if (this.isRepeatRecordingInProgress()) return;
if (!this.isRepeatableCommandStart(key)) return;
this.repeatRecording = {
keys: [...this.prefixCount, key],
countOverrideKeys: [key],
startChangeVersion: this.bufferChangeVersion,
captureInsert: false,
forceCommit: false,
};
// A repeatable command starts a vim change; bracket it so every host push
// it produces (the delete of `cw`, the per-character pushes of `p`)
// collapses to one unit. This runs before the command body, so `floor`
// precedes the mutation.
this.openUndoWindow();
}
/**
* Open a recording for an implicit insert session as if `i` had been pressed.
* `captureInsert` starts true so the triggering keystroke and everything up
* to Escape is captured; replaying the stored `["i", …, Esc]` re-enters
* insert mode and re-types the run without ever submitting.
*/
private beginImplicitInsertRecording(): void {
this.repeatRecording = {
keys: ["i"],
countOverrideKeys: ["i"],
startChangeVersion: this.bufferChangeVersion,
captureInsert: true,
forceCommit: false,
};
this.openUndoWindow();
}
private clearRepeatState(): void {
if (this.replayingRepeat) return;
this.repeatRecording = null;
this.lastRepeatableCommand = null;
}
private cancelRepeatableCommand(): void {
if (this.replayingRepeat) {
this.repeatReplayFailed = true;
return;
}
this.repeatRecording = null;
}
private forceCommitRepeatRecording(): void {
if (this.replayingRepeat || !this.repeatRecording) return;
this.repeatRecording.forceCommit = true;
}
/**
* Commit only after the watched command has completed and changed the buffer.
* This naturally ignores motions, yanks, failed edits, and aborted pending
* commands while keeping insert-mode changes open until Escape returns to
* normal mode.
*/
private finishRepeatRecordingAfterInput(): void {
if (this.replayingRepeat) return;
const recording = this.repeatRecording;
if (!recording) return;
if (this.mode === "insert") {
recording.captureInsert = true;
return;
}
if (this.isRepeatRecordingInProgress()) return;
this.repeatRecording = null;
if (recording.keys.length === 0) return;
if (
recording.forceCommit ||
this.bufferChangeVersion !== recording.startChangeVersion
) {
this.lastRepeatableCommand = {
keys: [...recording.keys],
countOverrideKeys: [...recording.countOverrideKeys],
};
}
}
/**
* Open a window for the starting change, unless one is already open (a
* multi-key change reuses its first key's window) or a replay is in flight
* (the outer replay window owns the span). `floor` is captured before this
* turn's mutation, so `undoStack[floor]` becomes the change's first (pre-
* change) host push. Resetting `lastAction` forces a pure insert's first
* character to push that anchor despite leftover fish-style coalescing.
*/
private openUndoWindow(): void {
if (this.replayingRepeat) return;
if (this.undoWindow !== null) return;
const editor = this as unknown as ModalEditorInternals;
const stack = editor.undoStack?.stack;
if (!stack) return;
this.undoWindow = {
floor: stack.length,
openVersion: this.bufferChangeVersion,
};
editor.lastAction = null;
}
/**
* Close the window at a change boundary. If the buffer mutated, collapse to
* `floor + 1` (keep only the pre-change snapshot); a no-op change trims any
* stray anchor back to `floor`, leaving prior history untouched.
*/
private closeUndoWindow(): void {
const window = this.undoWindow;
this.undoWindow = null;
if (!window) return;
if (this.bufferChangeVersion === window.openVersion) {
this.collapseUndoStackTo(window.floor);
return;
}
this.collapseUndoStackTo(window.floor + 1);
}
/** Drop the open window without collapsing (undo/redo/reset/dispatch/submit). */
private discardUndoWindow(): void {
this.undoWindow = null;
}
/**
* Trim the host undo stack to `target` entries. A direct length trim (no
* `setText`/`onChange`) so `centralInvalidationCheck` is not tripped.
*/
private collapseUndoStackTo(target: number): void {
const stack = (this as unknown as ModalEditorInternals).undoStack?.stack;
if (!stack) return;
if (stack.length > target) stack.length = target;
}
/**
* Settle the window in the input `finally`, mirroring the recorder: hold
* while in insert mode or while an operator/motion/text-object is pending;
* otherwise close. Suppressed during a replay (the outer window collapses).
*/
private settleUndoWindowAfterInput(): void {
if (this.replayingRepeat) return;
if (this.undoWindow === null) return;
if (this.mode === "insert") return;
if (this.isRepeatRecordingInProgress()) return;
this.closeUndoWindow();
}
private isTextInsertRepeatCommand(command: RepeatableCommand): boolean {
return TEXT_INSERT_REPEAT_KEYS.has(command.countOverrideKeys[0] ?? "");
}
private isOpenLineRepeatCommand(command: RepeatableCommand): boolean {
return OPEN_LINE_REPEAT_KEYS.has(command.countOverrideKeys[0] ?? "");
}
private getOpenLineRepeatReplayKeys(
command: RepeatableCommand,
overrideCount: number,
): string[] {
if (command.countOverrideKeys[0] !== "O" || overrideCount <= 1) {
return Array.from(
{ length: overrideCount },
() => command.countOverrideKeys,
).flat();
}
return Array.from({ length: overrideCount }, (_, index) =>
index === 0
? command.countOverrideKeys
: ["j", ...command.countOverrideKeys],
).flat();
}
private getRepeatReplayKeys(
command: RepeatableCommand,
hasOverrideCount: boolean,
overrideCount: number,
): string[] {
if (!hasOverrideCount) return command.keys;