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