pi-vim 0.3.2 → 0.9.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/README.md +182 -169
- package/clipboard-policy.ts +73 -0
- package/index.ts +1019 -185
- package/motions.ts +14 -4
- package/package.json +7 -3
- package/text-objects.ts +303 -0
- package/word-boundary-cache.ts +7 -7
package/index.ts
CHANGED
|
@@ -1,60 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
* Modal Editor - vim-like modal editing extension
|
|
3
|
-
*
|
|
4
|
-
* Usage: pi --extension ./index.ts
|
|
5
|
-
*
|
|
6
|
-
* - Escape / ctrl+[: insert → normal mode (in normal mode, aborts agent)
|
|
7
|
-
* - i: normal → insert mode (at cursor)
|
|
8
|
-
* - a: insert after cursor
|
|
9
|
-
* - A: insert at end of line
|
|
10
|
-
* - I: insert at start of line
|
|
11
|
-
* - o: open new line below (insert mode)
|
|
12
|
-
* - O: open new line above (insert mode)
|
|
13
|
-
* - hjkl: navigation in normal mode
|
|
14
|
-
* - 0/$: line start/end
|
|
15
|
-
* - ^: first non-whitespace char of line
|
|
16
|
-
* - _: first non-whitespace (with count: down count-1 lines first); linewise with d/c/y
|
|
17
|
-
* - x: delete char under cursor
|
|
18
|
-
* - D: delete to end of line
|
|
19
|
-
* - S: substitute line (delete line content + insert mode)
|
|
20
|
-
* - s: substitute char (delete char + insert mode)
|
|
21
|
-
* - d{motion}: delete with motion (`w/b/e` + `W/B/E`, `$`, `0`, `^`, `dd`/`d_`, `f/t/F/T{char}`)
|
|
22
|
-
* - c{motion}: change with same motion set as `d` (then enter insert mode)
|
|
23
|
-
* - y{motion}: yank with same motion set as `d` (no text mutation)
|
|
24
|
-
* - f{char}: jump to next {char} on line
|
|
25
|
-
* - F{char}: jump to previous {char} on line
|
|
26
|
-
* - t{char}: jump to just before next {char} on line
|
|
27
|
-
* - T{char}: jump to just after previous {char} on line
|
|
28
|
-
* - ;: repeat last f/F/t/T motion (same direction)
|
|
29
|
-
* - ,: repeat last f/F/t/T motion (reverse direction)
|
|
30
|
-
* - w/b/e: `word` motions (keyword/punctuation aware)
|
|
31
|
-
* - W/B/E: `WORD` motions (whitespace-delimited non-space runs)
|
|
32
|
-
* - {/}: paragraph motions to previous/next paragraph start (line start col 0)
|
|
33
|
-
* - `{count}` prefixes supported for navigation, paragraph motions, and `d/c` word/WORD motions
|
|
34
|
-
* - operator forms with braces (`d{`, `d}`, `c{`, `c}`, `y{`, `y}`) are out of scope
|
|
35
|
-
* - counted yank caveat: `y2w`, `2yw`, `y2W`, `2yW` cancel (linewise counts still supported)
|
|
36
|
-
* - Shift+Alt+A: go to end of line (insert mode shortcut)
|
|
37
|
-
* - Shift+Alt+I: go to start of line (insert mode shortcut)
|
|
38
|
-
* - Alt+o: open new line below (insert mode shortcut)
|
|
39
|
-
* - Alt+Shift+o: open new line above (insert mode shortcut)
|
|
40
|
-
* - u: undo (normal mode, sends ctrl+_ to underlying readline editor)
|
|
41
|
-
* - ctrl+c, ctrl+d, etc. work in both modes
|
|
42
|
-
*
|
|
43
|
-
* Inspired by original repo:
|
|
44
|
-
* - https://github.com/badlogic/pi-mono
|
|
45
|
-
* (packages/coding-agent/examples/extensions/modal-editor.ts)
|
|
46
|
-
*
|
|
47
|
-
* Additional ideas adapted from:
|
|
48
|
-
* - https://github.com/l-lin/dotfiles
|
|
49
|
-
* (home-manager/modules/share/ai/pi/.pi/agent/extensions/vim-mode)
|
|
50
|
-
*/
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
51
2
|
|
|
52
3
|
import {
|
|
53
|
-
copyToClipboard,
|
|
54
4
|
CustomEditor,
|
|
55
5
|
type ExtensionAPI,
|
|
56
6
|
} from "@mariozechner/pi-coding-agent";
|
|
57
7
|
import {
|
|
8
|
+
CURSOR_MARKER,
|
|
58
9
|
Key,
|
|
59
10
|
matchesKey,
|
|
60
11
|
truncateToWidth,
|
|
@@ -95,11 +46,37 @@ import {
|
|
|
95
46
|
type WordMotionDirection,
|
|
96
47
|
type WordMotionTarget,
|
|
97
48
|
} from "./word-boundary-cache.js";
|
|
49
|
+
import {
|
|
50
|
+
DEFAULT_CLIPBOARD_MIRROR_POLICY,
|
|
51
|
+
readPiVimSettings,
|
|
52
|
+
resolveClipboardMirrorPolicy,
|
|
53
|
+
type ClipboardMirrorPolicy,
|
|
54
|
+
type RegisterWriteSource,
|
|
55
|
+
} from "./clipboard-policy.js";
|
|
56
|
+
import {
|
|
57
|
+
resolveDelimitedTextObjectRange,
|
|
58
|
+
resolveWordTextObjectRange,
|
|
59
|
+
type TextObjectKind,
|
|
60
|
+
type TextObjectRange,
|
|
61
|
+
type WordTextObjectClass,
|
|
62
|
+
} from "./text-objects.js";
|
|
98
63
|
|
|
99
64
|
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
100
65
|
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
101
66
|
const BRACKETED_PASTE_END_TAIL = BRACKETED_PASTE_END.slice(1);
|
|
102
67
|
const MAX_COUNT = 9999;
|
|
68
|
+
const PI_NATIVE_CLIPBOARD_TIMEOUT_MS = 5000;
|
|
69
|
+
const SOFTWARE_CURSOR_START = "\x1b[7m";
|
|
70
|
+
const SOFTWARE_CURSOR_RESETS = ["\x1b[0m", "\x1b[27m"] as const;
|
|
71
|
+
const INSERT_CURSOR_SHAPE = "\x1b[5 q";
|
|
72
|
+
const BLOCK_CURSOR_SHAPE = "\x1b[1 q";
|
|
73
|
+
const RESET_CURSOR_SHAPE = "\x1b[0 q";
|
|
74
|
+
// Pi emits OSC52 before its native clipboard fallback. Give that 5s fallback
|
|
75
|
+
// a small grace so the parent does not kill the helper and discard stdout.
|
|
76
|
+
const CLIPBOARD_WRITE_TIMEOUT_MS = PI_NATIVE_CLIPBOARD_TIMEOUT_MS + 500;
|
|
77
|
+
const CLIPBOARD_SPAWN_FAILURE_LIMIT = 3;
|
|
78
|
+
const CLIPBOARD_READ_TIMEOUT_MS = 750;
|
|
79
|
+
const CLIPBOARD_READ_MAX_BUFFER_BYTES = 1024 * 1024;
|
|
103
80
|
|
|
104
81
|
type EditorSnapshot = {
|
|
105
82
|
text: string;
|
|
@@ -119,16 +96,451 @@ type ModalEditorInternals = {
|
|
|
119
96
|
setCursorCol?: (col: number) => void;
|
|
120
97
|
};
|
|
121
98
|
|
|
99
|
+
type CustomEditorConstructorArgs = ConstructorParameters<typeof CustomEditor>;
|
|
100
|
+
type ClipboardWriteFn = (text: string, signal: AbortSignal) => Promise<void>;
|
|
101
|
+
type ClipboardReadFn = () => string | null;
|
|
102
|
+
type ClipboardProcess = ReturnType<typeof spawn>;
|
|
103
|
+
|
|
104
|
+
type ModeLabelColorizers = {
|
|
105
|
+
insert: (s: string) => string;
|
|
106
|
+
normal: (s: string) => string;
|
|
107
|
+
ex: (s: string) => string;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
type CursorShapeSequence =
|
|
111
|
+
| typeof INSERT_CURSOR_SHAPE
|
|
112
|
+
| typeof BLOCK_CURSOR_SHAPE
|
|
113
|
+
| typeof RESET_CURSOR_SHAPE;
|
|
114
|
+
|
|
115
|
+
type CursorShapeRuntime = {
|
|
116
|
+
writeCursorShape: (sequence: CursorShapeSequence) => void;
|
|
117
|
+
setShowHardwareCursor: (show: boolean) => void;
|
|
118
|
+
getShowHardwareCursor?: () => boolean | undefined;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
type CursorShapeCleanup = () => void;
|
|
122
|
+
|
|
123
|
+
type CursorShapeTuiCandidate = {
|
|
124
|
+
terminal?: { write?: unknown };
|
|
125
|
+
setShowHardwareCursor?: unknown;
|
|
126
|
+
getShowHardwareCursor?: unknown;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
function getCursorShapeRuntime(tui: unknown): CursorShapeRuntime | null {
|
|
130
|
+
if (typeof tui !== "object" || tui === null) return null;
|
|
131
|
+
|
|
132
|
+
const candidate = tui as CursorShapeTuiCandidate;
|
|
133
|
+
const terminal = candidate.terminal;
|
|
134
|
+
if (typeof terminal !== "object" || terminal === null) return null;
|
|
135
|
+
|
|
136
|
+
const write = terminal.write;
|
|
137
|
+
const setShowHardwareCursor = candidate.setShowHardwareCursor;
|
|
138
|
+
if (typeof write !== "function" || typeof setShowHardwareCursor !== "function") {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const runtime: CursorShapeRuntime = {
|
|
143
|
+
writeCursorShape(sequence: CursorShapeSequence): void {
|
|
144
|
+
write.call(terminal, sequence);
|
|
145
|
+
},
|
|
146
|
+
setShowHardwareCursor(show: boolean): void {
|
|
147
|
+
setShowHardwareCursor.call(candidate, show);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (typeof candidate.getShowHardwareCursor === "function") {
|
|
152
|
+
const getShowHardwareCursor = candidate.getShowHardwareCursor;
|
|
153
|
+
runtime.getShowHardwareCursor = () => {
|
|
154
|
+
const value = getShowHardwareCursor.call(candidate);
|
|
155
|
+
return typeof value === "boolean" ? value : undefined;
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return runtime;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function enableCursorShapeSupport(tui: unknown): CursorShapeCleanup | null {
|
|
163
|
+
const runtime = getCursorShapeRuntime(tui);
|
|
164
|
+
if (!runtime) return null;
|
|
165
|
+
|
|
166
|
+
const previousShowHardwareCursor = runtime.getShowHardwareCursor?.();
|
|
167
|
+
runtime.setShowHardwareCursor(true);
|
|
168
|
+
|
|
169
|
+
return () => {
|
|
170
|
+
runtime.writeCursorShape(RESET_CURSOR_SHAPE);
|
|
171
|
+
if (previousShowHardwareCursor !== undefined) {
|
|
172
|
+
runtime.setShowHardwareCursor(previousShowHardwareCursor);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function findSoftwareCursorReset(
|
|
178
|
+
line: string,
|
|
179
|
+
startIndex: number,
|
|
180
|
+
): { index: number; sequence: (typeof SOFTWARE_CURSOR_RESETS)[number] } | null {
|
|
181
|
+
let firstReset: { index: number; sequence: (typeof SOFTWARE_CURSOR_RESETS)[number] } | null = null;
|
|
182
|
+
|
|
183
|
+
for (const sequence of SOFTWARE_CURSOR_RESETS) {
|
|
184
|
+
const index = line.indexOf(sequence, startIndex);
|
|
185
|
+
if (index === -1) continue;
|
|
186
|
+
if (!firstReset || index < firstReset.index) {
|
|
187
|
+
firstReset = { index, sequence };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return firstReset;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function stripSoftwareCursorAfterMarker(line: string): string {
|
|
195
|
+
const markerIndex = line.indexOf(CURSOR_MARKER);
|
|
196
|
+
if (markerIndex === -1) return line;
|
|
197
|
+
|
|
198
|
+
const searchStart = markerIndex + CURSOR_MARKER.length;
|
|
199
|
+
const cursorStart = line.indexOf(SOFTWARE_CURSOR_START, searchStart);
|
|
200
|
+
if (cursorStart === -1) return line;
|
|
201
|
+
|
|
202
|
+
const cursorContentStart = cursorStart + SOFTWARE_CURSOR_START.length;
|
|
203
|
+
const reset = findSoftwareCursorReset(line, cursorContentStart);
|
|
204
|
+
if (!reset) return line;
|
|
205
|
+
|
|
206
|
+
return line.slice(0, cursorStart)
|
|
207
|
+
+ line.slice(cursorContentStart, reset.index)
|
|
208
|
+
+ line.slice(reset.index + reset.sequence.length);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
type ClipboardCircuitBreaker = {
|
|
212
|
+
consecutiveEnvironmentFailures: number;
|
|
213
|
+
disabled: boolean;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const processClipboardCircuitBreaker: ClipboardCircuitBreaker = {
|
|
217
|
+
consecutiveEnvironmentFailures: 0,
|
|
218
|
+
disabled: false,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
function resetClipboardCircuitBreaker(): void {
|
|
222
|
+
processClipboardCircuitBreaker.consecutiveEnvironmentFailures = 0;
|
|
223
|
+
processClipboardCircuitBreaker.disabled = false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
class ClipboardSpawnError extends Error {
|
|
227
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
228
|
+
super(message, options);
|
|
229
|
+
this.name = "ClipboardSpawnError";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
type SpawnErrnoLike = Error & { code?: unknown; syscall?: unknown };
|
|
234
|
+
|
|
235
|
+
function isNodeSpawnErrno(error: unknown): boolean {
|
|
236
|
+
if (!(error instanceof Error)) return false;
|
|
237
|
+
|
|
238
|
+
const candidate = error as SpawnErrnoLike;
|
|
239
|
+
return typeof candidate.code === "string"
|
|
240
|
+
&& candidate.code.length > 0
|
|
241
|
+
&& typeof candidate.syscall === "string"
|
|
242
|
+
&& candidate.syscall.startsWith("spawn");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isClipboardEnvironmentFailure(error: unknown): boolean {
|
|
246
|
+
return error instanceof ClipboardSpawnError || isNodeSpawnErrno(error);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const PI_CODING_AGENT_MODULE_URL = import.meta.resolve("@mariozechner/pi-coding-agent");
|
|
250
|
+
const CLIPBOARD_HELPER_SOURCE = `
|
|
251
|
+
import { copyToClipboard } from ${JSON.stringify(PI_CODING_AGENT_MODULE_URL)};
|
|
252
|
+
|
|
253
|
+
const chunks = [];
|
|
254
|
+
for await (const chunk of process.stdin) {
|
|
255
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
await Promise.resolve(copyToClipboard(Buffer.concat(chunks).toString("utf8")));
|
|
260
|
+
} catch {
|
|
261
|
+
// Pi clipboard writes are best-effort. Backend failures must not make the
|
|
262
|
+
// helper exit non-zero and trip the parent spawn/environment breaker.
|
|
263
|
+
}
|
|
264
|
+
`;
|
|
265
|
+
|
|
266
|
+
const CLIPBOARD_READ_HELPER_SOURCE = `
|
|
267
|
+
import { createRequire } from "node:module";
|
|
268
|
+
|
|
269
|
+
const require = createRequire(${JSON.stringify(PI_CODING_AGENT_MODULE_URL)});
|
|
270
|
+
const clipboard = require("@mariozechner/clipboard");
|
|
271
|
+
if (!await clipboard.hasText()) {
|
|
272
|
+
process.exit(0);
|
|
273
|
+
}
|
|
274
|
+
const text = await clipboard.getText();
|
|
275
|
+
if (typeof text === "string") {
|
|
276
|
+
process.stdout.write(text);
|
|
277
|
+
}
|
|
278
|
+
`;
|
|
279
|
+
|
|
280
|
+
function readClipboardInChildProcess(): string | null {
|
|
281
|
+
try {
|
|
282
|
+
const result = spawnSync(
|
|
283
|
+
process.execPath,
|
|
284
|
+
["--input-type=module", "-e", CLIPBOARD_READ_HELPER_SOURCE],
|
|
285
|
+
{
|
|
286
|
+
encoding: "utf8",
|
|
287
|
+
maxBuffer: CLIPBOARD_READ_MAX_BUFFER_BYTES,
|
|
288
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
289
|
+
timeout: CLIPBOARD_READ_TIMEOUT_MS,
|
|
290
|
+
windowsHide: true,
|
|
291
|
+
},
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
if (result.error || result.status !== 0 || result.signal) return null;
|
|
295
|
+
return result.stdout ?? "";
|
|
296
|
+
} catch {
|
|
297
|
+
return null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function createClipboardAbortError(message: string): Error {
|
|
302
|
+
const error = new Error(message);
|
|
303
|
+
error.name = "AbortError";
|
|
304
|
+
return error;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function getAbortError(signal: AbortSignal): Error {
|
|
308
|
+
return signal.reason instanceof Error
|
|
309
|
+
? signal.reason
|
|
310
|
+
: createClipboardAbortError("clipboard write aborted");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function killClipboardProcess(child: ClipboardProcess): void {
|
|
314
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
child.kill("SIGKILL");
|
|
318
|
+
} catch {
|
|
319
|
+
// Best effort only; clipboard mirroring must not affect editing.
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function writeClipboardInChildProcess(text: string, signal: AbortSignal): Promise<void> {
|
|
324
|
+
return new Promise<void>((resolve, reject) => {
|
|
325
|
+
if (signal.aborted) {
|
|
326
|
+
reject(getAbortError(signal));
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let child: ClipboardProcess | null = null;
|
|
331
|
+
let settled = false;
|
|
332
|
+
const stdoutChunks: Buffer[] = [];
|
|
333
|
+
|
|
334
|
+
function finish(error?: unknown): void {
|
|
335
|
+
if (settled) return;
|
|
336
|
+
settled = true;
|
|
337
|
+
signal.removeEventListener("abort", onAbort);
|
|
338
|
+
if (error) {
|
|
339
|
+
reject(error);
|
|
340
|
+
} else {
|
|
341
|
+
resolve();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function onAbort(): void {
|
|
346
|
+
if (child) {
|
|
347
|
+
killClipboardProcess(child);
|
|
348
|
+
}
|
|
349
|
+
finish(getAbortError(signal));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
child = spawn(process.execPath, ["--input-type=module", "-e", CLIPBOARD_HELPER_SOURCE], {
|
|
354
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
355
|
+
windowsHide: true,
|
|
356
|
+
});
|
|
357
|
+
} catch (error) {
|
|
358
|
+
finish(new ClipboardSpawnError("clipboard helper spawn failed", { cause: error }));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
363
|
+
|
|
364
|
+
child.stdout?.on("data", (chunk: Buffer | string) => {
|
|
365
|
+
stdoutChunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
366
|
+
});
|
|
367
|
+
child.stdout?.on("error", (error) => {
|
|
368
|
+
finish(error);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
child.once("error", (error) => {
|
|
372
|
+
finish(new ClipboardSpawnError("clipboard helper spawn failed", { cause: error }));
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
child.once("close", (code) => {
|
|
376
|
+
if (settled) return;
|
|
377
|
+
|
|
378
|
+
if (signal.aborted) {
|
|
379
|
+
finish(getAbortError(signal));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (code === 0) {
|
|
384
|
+
try {
|
|
385
|
+
for (const chunk of stdoutChunks) {
|
|
386
|
+
process.stdout.write(chunk);
|
|
387
|
+
}
|
|
388
|
+
} catch (error) {
|
|
389
|
+
finish(error);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
finish();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
finish(new ClipboardSpawnError(`clipboard helper failed with exit code ${code ?? "null"}`));
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
if (!child.stdin) {
|
|
400
|
+
killClipboardProcess(child);
|
|
401
|
+
finish(new ClipboardSpawnError("clipboard helper stdin unavailable"));
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
child.stdin.on("error", (error: NodeJS.ErrnoException) => {
|
|
406
|
+
if (signal.aborted) {
|
|
407
|
+
finish(getAbortError(signal));
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (error.code === "EPIPE" || error.code === "ERR_STREAM_DESTROYED") {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
finish(error);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
child.stdin.end(text);
|
|
420
|
+
} catch (error) {
|
|
421
|
+
finish(error);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
class ClipboardMirror {
|
|
427
|
+
private activeController: AbortController | null = null;
|
|
428
|
+
private activeText: string | null = null;
|
|
429
|
+
private draining = false;
|
|
430
|
+
private pendingText: string | null = null;
|
|
431
|
+
|
|
432
|
+
constructor(
|
|
433
|
+
private writeFn: ClipboardWriteFn,
|
|
434
|
+
private timeoutMs: number = CLIPBOARD_WRITE_TIMEOUT_MS,
|
|
435
|
+
private readonly circuitBreaker: ClipboardCircuitBreaker = processClipboardCircuitBreaker,
|
|
436
|
+
) {}
|
|
437
|
+
|
|
438
|
+
setWriteFn(writeFn: ClipboardWriteFn): void {
|
|
439
|
+
this.activeController?.abort(createClipboardAbortError("clipboard writer replaced"));
|
|
440
|
+
this.writeFn = writeFn;
|
|
441
|
+
resetClipboardCircuitBreaker();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
setTimeoutMs(timeoutMs: number): void {
|
|
445
|
+
this.timeoutMs = Math.max(0, timeoutMs);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
hasPendingWrite(): boolean {
|
|
449
|
+
return this.activeText !== null || this.pendingText !== null || this.draining;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
mirror(text: string): void {
|
|
453
|
+
if (this.circuitBreaker.disabled) return;
|
|
454
|
+
|
|
455
|
+
this.pendingText = text;
|
|
456
|
+
|
|
457
|
+
if (!this.draining) {
|
|
458
|
+
void this.drain();
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private async drain(): Promise<void> {
|
|
463
|
+
if (this.draining) return;
|
|
464
|
+
this.draining = true;
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
while (this.pendingText !== null && !this.circuitBreaker.disabled) {
|
|
468
|
+
const text = this.pendingText;
|
|
469
|
+
this.pendingText = null;
|
|
470
|
+
const controller = new AbortController();
|
|
471
|
+
this.activeController = controller;
|
|
472
|
+
this.activeText = text;
|
|
473
|
+
|
|
474
|
+
try {
|
|
475
|
+
await this.writeWithTimeout(text, controller);
|
|
476
|
+
this.circuitBreaker.consecutiveEnvironmentFailures = 0;
|
|
477
|
+
} catch (error) {
|
|
478
|
+
this.recordWriteFailure(error);
|
|
479
|
+
// Clipboard mirroring is best-effort; the register is authoritative.
|
|
480
|
+
} finally {
|
|
481
|
+
if (this.activeController === controller) {
|
|
482
|
+
this.activeController = null;
|
|
483
|
+
}
|
|
484
|
+
this.activeText = null;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (this.circuitBreaker.disabled) {
|
|
489
|
+
this.pendingText = null;
|
|
490
|
+
}
|
|
491
|
+
} finally {
|
|
492
|
+
this.draining = false;
|
|
493
|
+
if (this.pendingText !== null && !this.circuitBreaker.disabled) {
|
|
494
|
+
void this.drain();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private recordWriteFailure(error: unknown): void {
|
|
500
|
+
if (!isClipboardEnvironmentFailure(error)) {
|
|
501
|
+
this.circuitBreaker.consecutiveEnvironmentFailures = 0;
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
this.circuitBreaker.consecutiveEnvironmentFailures += 1;
|
|
506
|
+
if (this.circuitBreaker.consecutiveEnvironmentFailures >= CLIPBOARD_SPAWN_FAILURE_LIMIT) {
|
|
507
|
+
this.circuitBreaker.disabled = true;
|
|
508
|
+
this.pendingText = null;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private async writeWithTimeout(text: string, controller: AbortController): Promise<void> {
|
|
513
|
+
const timeoutError = createClipboardAbortError("clipboard write timed out");
|
|
514
|
+
const timeoutId = setTimeout(() => {
|
|
515
|
+
controller.abort(timeoutError);
|
|
516
|
+
}, this.timeoutMs);
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
await this.writeFn(text, controller.signal);
|
|
520
|
+
} catch (error) {
|
|
521
|
+
if (controller.signal.aborted) {
|
|
522
|
+
throw getAbortError(controller.signal);
|
|
523
|
+
}
|
|
524
|
+
throw error;
|
|
525
|
+
} finally {
|
|
526
|
+
clearTimeout(timeoutId);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
122
531
|
export class ModalEditor extends CustomEditor {
|
|
123
532
|
private mode: Mode = "insert";
|
|
124
533
|
private pendingMotion: PendingMotion = null;
|
|
125
|
-
private pendingTextObject:
|
|
534
|
+
private pendingTextObject: TextObjectKind | null = null;
|
|
126
535
|
private pendingOperator: PendingOperator = null;
|
|
127
536
|
private prefixCount: string = "";
|
|
128
537
|
private operatorCount: string = "";
|
|
129
538
|
private pendingG: boolean = false;
|
|
130
539
|
private pendingGCount: string = "";
|
|
131
540
|
private pendingReplace: boolean = false;
|
|
541
|
+
private pendingExCommand: string | null = null;
|
|
542
|
+
private acceptingBracketedPasteInExCommand: boolean = false;
|
|
543
|
+
private pendingEscWhileAcceptingBracketedPasteInExCommand: boolean = false;
|
|
132
544
|
private lastCharMotion: LastCharMotion | null = null;
|
|
133
545
|
private discardingBracketedPasteInNormalMode: boolean = false;
|
|
134
546
|
private pendingEscWhileDiscardingBracketedPasteInNormalMode: boolean = false;
|
|
@@ -136,32 +548,55 @@ export class ModalEditor extends CustomEditor {
|
|
|
136
548
|
private readonly redoStack: EditorSnapshot[] = [];
|
|
137
549
|
private currentTransition: TransitionState = "none";
|
|
138
550
|
private onChangeHooked: boolean = false;
|
|
139
|
-
private readonly labelColorizers:
|
|
551
|
+
private readonly labelColorizers: ModeLabelColorizers | null;
|
|
552
|
+
private readonly cursorShapeRuntime: CursorShapeRuntime | null;
|
|
553
|
+
private lastCursorShapeSequence: CursorShapeSequence | null = null;
|
|
140
554
|
|
|
141
555
|
// Unnamed register
|
|
142
556
|
private unnamedRegister: string = "";
|
|
143
|
-
private
|
|
144
|
-
|
|
145
|
-
|
|
557
|
+
private preferRegisterForPut = false;
|
|
558
|
+
private clipboardMirrorPolicy: ClipboardMirrorPolicy = DEFAULT_CLIPBOARD_MIRROR_POLICY;
|
|
559
|
+
private readonly clipboardMirror = new ClipboardMirror(writeClipboardInChildProcess);
|
|
560
|
+
private clipboardReadFn: ClipboardReadFn = readClipboardInChildProcess;
|
|
561
|
+
private quitFn: () => void = () => {};
|
|
562
|
+
private notifyFn: (message: string) => void = () => {};
|
|
146
563
|
|
|
147
564
|
constructor(
|
|
148
|
-
tui:
|
|
149
|
-
theme:
|
|
150
|
-
kb:
|
|
151
|
-
labelColorizers?:
|
|
565
|
+
tui: CustomEditorConstructorArgs[0],
|
|
566
|
+
theme: CustomEditorConstructorArgs[1],
|
|
567
|
+
kb: CustomEditorConstructorArgs[2],
|
|
568
|
+
labelColorizers?: ModeLabelColorizers | null,
|
|
152
569
|
) {
|
|
153
570
|
super(tui, theme, kb);
|
|
571
|
+
this.cursorShapeRuntime = getCursorShapeRuntime(tui);
|
|
154
572
|
this.labelColorizers = labelColorizers ?? null;
|
|
155
573
|
}
|
|
156
574
|
|
|
157
575
|
// Test seams
|
|
158
|
-
setClipboardFn(fn: (text: string) => unknown): void {
|
|
159
|
-
this.
|
|
160
|
-
await fn(text);
|
|
161
|
-
};
|
|
576
|
+
setClipboardFn(fn: (text: string, signal?: AbortSignal) => unknown): void {
|
|
577
|
+
this.clipboardMirror.setWriteFn(async (text: string, signal: AbortSignal) => {
|
|
578
|
+
await fn(text, signal);
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
setClipboardWriteTimeoutMs(timeoutMs: number): void {
|
|
582
|
+
this.clipboardMirror.setTimeoutMs(timeoutMs);
|
|
162
583
|
}
|
|
584
|
+
setClipboardReadFn(fn: ClipboardReadFn): void {
|
|
585
|
+
this.clipboardReadFn = fn;
|
|
586
|
+
}
|
|
587
|
+
setClipboardMirrorPolicy(policy: ClipboardMirrorPolicy): void {
|
|
588
|
+
this.clipboardMirrorPolicy = policy;
|
|
589
|
+
}
|
|
590
|
+
getClipboardMirrorPolicy(): ClipboardMirrorPolicy {
|
|
591
|
+
return this.clipboardMirrorPolicy;
|
|
592
|
+
}
|
|
593
|
+
setQuitFn(fn: () => void): void { this.quitFn = fn; }
|
|
594
|
+
setNotifyFn(fn: (message: string) => void): void { this.notifyFn = fn; }
|
|
163
595
|
getRegister(): string { return this.unnamedRegister; }
|
|
164
|
-
setRegister(text: string): void {
|
|
596
|
+
setRegister(text: string): void {
|
|
597
|
+
this.unnamedRegister = text;
|
|
598
|
+
this.preferRegisterForPut = false;
|
|
599
|
+
}
|
|
165
600
|
getMode(): Mode { return this.mode; }
|
|
166
601
|
getText(): string { return this.getLines().join("\n"); }
|
|
167
602
|
|
|
@@ -348,6 +783,26 @@ export class ModalEditor extends CustomEditor {
|
|
|
348
783
|
editor.tui?.requestRender?.();
|
|
349
784
|
}
|
|
350
785
|
|
|
786
|
+
private startPendingExCommand(): void {
|
|
787
|
+
this.pendingExCommand = ":";
|
|
788
|
+
this.acceptingBracketedPasteInExCommand = false;
|
|
789
|
+
this.pendingEscWhileAcceptingBracketedPasteInExCommand = false;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
private clearPendingExCommand(): void {
|
|
793
|
+
const shouldDiscardBracketedPasteTail = this.acceptingBracketedPasteInExCommand
|
|
794
|
+
|| this.pendingEscWhileAcceptingBracketedPasteInExCommand;
|
|
795
|
+
|
|
796
|
+
this.pendingExCommand = null;
|
|
797
|
+
this.acceptingBracketedPasteInExCommand = false;
|
|
798
|
+
this.pendingEscWhileAcceptingBracketedPasteInExCommand = false;
|
|
799
|
+
|
|
800
|
+
if (shouldDiscardBracketedPasteTail) {
|
|
801
|
+
this.discardingBracketedPasteInNormalMode = true;
|
|
802
|
+
this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
351
806
|
private clearPendingState(): void {
|
|
352
807
|
this.pendingMotion = null;
|
|
353
808
|
this.pendingTextObject = null;
|
|
@@ -357,12 +812,69 @@ export class ModalEditor extends CustomEditor {
|
|
|
357
812
|
this.pendingG = false;
|
|
358
813
|
this.pendingGCount = "";
|
|
359
814
|
this.pendingReplace = false;
|
|
815
|
+
this.clearPendingExCommand();
|
|
360
816
|
}
|
|
361
817
|
|
|
362
818
|
private isEscapeLikeInput(data: string): boolean {
|
|
363
819
|
return matchesKey(data, "escape") || matchesKey(data, "ctrl+[");
|
|
364
820
|
}
|
|
365
821
|
|
|
822
|
+
private normalizePendingExCommandInput(data: string): string | null {
|
|
823
|
+
let chunk = data;
|
|
824
|
+
let normalized = "";
|
|
825
|
+
|
|
826
|
+
while (true) {
|
|
827
|
+
if (this.acceptingBracketedPasteInExCommand) {
|
|
828
|
+
if (this.pendingEscWhileAcceptingBracketedPasteInExCommand) {
|
|
829
|
+
if (chunk.startsWith(BRACKETED_PASTE_END_TAIL)) {
|
|
830
|
+
this.pendingEscWhileAcceptingBracketedPasteInExCommand = false;
|
|
831
|
+
this.acceptingBracketedPasteInExCommand = false;
|
|
832
|
+
chunk = chunk.slice(BRACKETED_PASTE_END_TAIL.length);
|
|
833
|
+
if (chunk.length === 0) {
|
|
834
|
+
return normalized.length > 0 ? normalized : null;
|
|
835
|
+
}
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
normalized += "\x1b";
|
|
840
|
+
this.pendingEscWhileAcceptingBracketedPasteInExCommand = false;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const end = chunk.indexOf(BRACKETED_PASTE_END);
|
|
844
|
+
if (end !== -1) {
|
|
845
|
+
normalized += chunk.slice(0, end);
|
|
846
|
+
this.acceptingBracketedPasteInExCommand = false;
|
|
847
|
+
chunk = chunk.slice(end + BRACKETED_PASTE_END.length);
|
|
848
|
+
if (chunk.length === 0) {
|
|
849
|
+
return normalized.length > 0 ? normalized : null;
|
|
850
|
+
}
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (this.isEscapeLikeInput(chunk)) {
|
|
855
|
+
this.pendingEscWhileAcceptingBracketedPasteInExCommand = true;
|
|
856
|
+
return normalized.length > 0 ? normalized : null;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
normalized += chunk;
|
|
860
|
+
return normalized.length > 0 ? normalized : null;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const start = chunk.indexOf(BRACKETED_PASTE_START);
|
|
864
|
+
if (start === -1) {
|
|
865
|
+
normalized += chunk;
|
|
866
|
+
return normalized.length > 0 ? normalized : null;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
normalized += chunk.slice(0, start);
|
|
870
|
+
chunk = chunk.slice(start + BRACKETED_PASTE_START.length);
|
|
871
|
+
this.acceptingBracketedPasteInExCommand = true;
|
|
872
|
+
if (chunk.length === 0) {
|
|
873
|
+
return normalized.length > 0 ? normalized : null;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
366
878
|
private stripBracketedPasteInNormalMode(data: string): { filtered: string | null; stripped: boolean } {
|
|
367
879
|
let chunk = data;
|
|
368
880
|
let stripped = false;
|
|
@@ -401,7 +913,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
401
913
|
handleInput(data: string): void {
|
|
402
914
|
this.ensureOnChangeHook();
|
|
403
915
|
|
|
404
|
-
if (this.
|
|
916
|
+
if (this.pendingExCommand !== null) {
|
|
917
|
+
const normalized = this.normalizePendingExCommandInput(data);
|
|
918
|
+
if (normalized === null) return;
|
|
919
|
+
data = normalized;
|
|
920
|
+
} else if (this.mode !== "insert") {
|
|
405
921
|
if (this.discardingBracketedPasteInNormalMode) {
|
|
406
922
|
if (this.isEscapeLikeInput(data)) {
|
|
407
923
|
if (this.pendingEscWhileDiscardingBracketedPasteInNormalMode) {
|
|
@@ -438,17 +954,20 @@ export class ModalEditor extends CustomEditor {
|
|
|
438
954
|
}
|
|
439
955
|
|
|
440
956
|
if (this.isEscapeLikeInput(data)) {
|
|
441
|
-
|
|
957
|
+
this.handleEscape();
|
|
958
|
+
return;
|
|
442
959
|
}
|
|
443
960
|
|
|
444
961
|
if (this.mode === "insert") {
|
|
445
962
|
// Shift+Alt+A: go to end of line (like Esc -> A but stay in insert)
|
|
446
963
|
if (matchesKey(data, Key.shiftAlt("a")) || data === "\x1bA") {
|
|
447
|
-
|
|
964
|
+
super.handleInput(CTRL_E);
|
|
965
|
+
return;
|
|
448
966
|
}
|
|
449
967
|
// Shift+Alt+I: go to start of line (like Esc -> I but stay in insert)
|
|
450
968
|
if (matchesKey(data, Key.shiftAlt("i")) || data === "\x1bI") {
|
|
451
|
-
|
|
969
|
+
super.handleInput(CTRL_A);
|
|
970
|
+
return;
|
|
452
971
|
}
|
|
453
972
|
// Alt+o: open new line below (stay in insert mode)
|
|
454
973
|
if (matchesKey(data, Key.alt("o")) || data === "\x1bo") {
|
|
@@ -491,24 +1010,34 @@ export class ModalEditor extends CustomEditor {
|
|
|
491
1010
|
return;
|
|
492
1011
|
}
|
|
493
1012
|
|
|
1013
|
+
if (this.pendingExCommand !== null) {
|
|
1014
|
+
this.handlePendingExCommand(data);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
494
1018
|
if (this.pendingTextObject) {
|
|
495
|
-
|
|
1019
|
+
this.handlePendingTextObject(data);
|
|
1020
|
+
return;
|
|
496
1021
|
}
|
|
497
1022
|
|
|
498
1023
|
if (this.pendingMotion) {
|
|
499
|
-
|
|
1024
|
+
this.handlePendingMotion(data);
|
|
1025
|
+
return;
|
|
500
1026
|
}
|
|
501
1027
|
|
|
502
1028
|
if (this.pendingOperator === "d") {
|
|
503
|
-
|
|
1029
|
+
this.handlePendingDelete(data);
|
|
1030
|
+
return;
|
|
504
1031
|
}
|
|
505
1032
|
|
|
506
1033
|
if (this.pendingOperator === "c") {
|
|
507
|
-
|
|
1034
|
+
this.handlePendingChange(data);
|
|
1035
|
+
return;
|
|
508
1036
|
}
|
|
509
1037
|
|
|
510
1038
|
if (this.pendingOperator === "y") {
|
|
511
|
-
|
|
1039
|
+
this.handlePendingYank(data);
|
|
1040
|
+
return;
|
|
512
1041
|
}
|
|
513
1042
|
|
|
514
1043
|
this.handleNormalMode(data);
|
|
@@ -533,6 +1062,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
533
1062
|
}
|
|
534
1063
|
|
|
535
1064
|
private handleEscape(): void {
|
|
1065
|
+
if (this.pendingExCommand !== null) {
|
|
1066
|
+
this.clearPendingExCommand();
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
536
1070
|
if (
|
|
537
1071
|
this.pendingMotion
|
|
538
1072
|
|| this.pendingTextObject
|
|
@@ -554,11 +1088,135 @@ export class ModalEditor extends CustomEditor {
|
|
|
554
1088
|
}
|
|
555
1089
|
}
|
|
556
1090
|
|
|
1091
|
+
private isEnterLikeInput(data: string): boolean {
|
|
1092
|
+
return data === "\r" || data === "\n" || matchesKey(data, "enter") || matchesKey(data, "return");
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
private isBackspaceLikeInput(data: string): boolean {
|
|
1096
|
+
return data === "\x7f" || data === "\x08" || matchesKey(data, "backspace") || matchesKey(data, "ctrl+h");
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
private deleteLastPendingExCommandGrapheme(): void {
|
|
1100
|
+
const current = this.pendingExCommand ?? "";
|
|
1101
|
+
const graphemes = getLineGraphemes(current);
|
|
1102
|
+
|
|
1103
|
+
if (graphemes.length <= 1) {
|
|
1104
|
+
this.clearPendingExCommand();
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
const previousGrapheme = graphemes[graphemes.length - 2];
|
|
1109
|
+
if (!previousGrapheme) {
|
|
1110
|
+
this.clearPendingExCommand();
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
this.pendingExCommand = current.slice(0, previousGrapheme.end);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
private handlePendingExCommandControlChunk(data: string): boolean {
|
|
1118
|
+
if (
|
|
1119
|
+
!data.includes("\r")
|
|
1120
|
+
&& !data.includes("\n")
|
|
1121
|
+
&& !data.includes("\x7f")
|
|
1122
|
+
&& !data.includes("\x08")
|
|
1123
|
+
) {
|
|
1124
|
+
return false;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
let printable = "";
|
|
1128
|
+
const flushPrintable = () => {
|
|
1129
|
+
if (!printable) return;
|
|
1130
|
+
this.pendingExCommand += printable;
|
|
1131
|
+
printable = "";
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
for (const char of data) {
|
|
1135
|
+
if (char === "\r" || char === "\n") {
|
|
1136
|
+
flushPrintable();
|
|
1137
|
+
this.submitPendingExCommand();
|
|
1138
|
+
return true;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (char === "\x7f" || char === "\x08") {
|
|
1142
|
+
flushPrintable();
|
|
1143
|
+
this.deleteLastPendingExCommandGrapheme();
|
|
1144
|
+
if (this.pendingExCommand === null) {
|
|
1145
|
+
return true;
|
|
1146
|
+
}
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const codePoint = char.codePointAt(0);
|
|
1151
|
+
if (codePoint === undefined || codePoint < 32 || codePoint === 127) {
|
|
1152
|
+
this.clearPendingExCommand();
|
|
1153
|
+
return true;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
printable += char;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
flushPrintable();
|
|
1160
|
+
return true;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
private handlePendingExCommand(data: string): void {
|
|
1164
|
+
if (this.isEnterLikeInput(data)) {
|
|
1165
|
+
this.submitPendingExCommand();
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (this.isBackspaceLikeInput(data)) {
|
|
1170
|
+
this.deleteLastPendingExCommandGrapheme();
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
if (this.handlePendingExCommandControlChunk(data)) {
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (!this.isPrintableChunk(data)) {
|
|
1179
|
+
this.clearPendingExCommand();
|
|
1180
|
+
this.handleInput(data);
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
this.pendingExCommand += data;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
private hasNonEmptyPrompt(): boolean {
|
|
1188
|
+
return this.getText().trim().length > 0;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private submitPendingExCommand(): void {
|
|
1192
|
+
const command = this.pendingExCommand?.slice(1).trim() ?? "";
|
|
1193
|
+
this.clearPendingExCommand();
|
|
1194
|
+
|
|
1195
|
+
if (command === "q" || command === "qa") {
|
|
1196
|
+
if (this.hasNonEmptyPrompt()) {
|
|
1197
|
+
this.notifyFn(`Prompt is not empty; use :${command}! to quit anyway`);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
this.quitFn();
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
if (command === "q!" || command === "qa!") {
|
|
1206
|
+
this.quitFn();
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
if (command) {
|
|
1211
|
+
this.notifyFn(`Unsupported ex command: :${command}`);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
557
1215
|
private isPrintableChunk(data: string): boolean {
|
|
558
1216
|
if (data.length === 0) return false;
|
|
559
1217
|
for (const char of data) {
|
|
560
|
-
const codePoint = char.codePointAt(0)
|
|
561
|
-
if (codePoint < 32 || codePoint === 127) return false;
|
|
1218
|
+
const codePoint = char.codePointAt(0);
|
|
1219
|
+
if (codePoint === undefined || codePoint < 32 || codePoint === 127) return false;
|
|
562
1220
|
}
|
|
563
1221
|
return true;
|
|
564
1222
|
}
|
|
@@ -603,6 +1261,10 @@ export class ModalEditor extends CustomEditor {
|
|
|
603
1261
|
return Math.min(MAX_COUNT, total);
|
|
604
1262
|
}
|
|
605
1263
|
|
|
1264
|
+
private hasPendingCount(): boolean {
|
|
1265
|
+
return this.prefixCount.length > 0 || this.operatorCount.length > 0;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
606
1268
|
private cancelPendingOperator(data: string): void {
|
|
607
1269
|
this.pendingOperator = null;
|
|
608
1270
|
this.prefixCount = "";
|
|
@@ -619,59 +1281,101 @@ export class ModalEditor extends CustomEditor {
|
|
|
619
1281
|
return;
|
|
620
1282
|
}
|
|
621
1283
|
|
|
1284
|
+
const pendingMotion = this.pendingMotion;
|
|
1285
|
+
if (!pendingMotion) return;
|
|
1286
|
+
|
|
622
1287
|
if (this.pendingOperator === "d") {
|
|
623
|
-
this.deleteWithCharMotion(
|
|
1288
|
+
this.deleteWithCharMotion(pendingMotion, data);
|
|
624
1289
|
this.pendingOperator = null;
|
|
625
1290
|
} else if (this.pendingOperator === "c") {
|
|
626
|
-
this.deleteWithCharMotion(
|
|
1291
|
+
this.deleteWithCharMotion(pendingMotion, data);
|
|
627
1292
|
this.pendingOperator = null;
|
|
628
1293
|
this.mode = "insert";
|
|
629
1294
|
} else if (this.pendingOperator === "y") {
|
|
630
|
-
this.yankWithCharMotion(
|
|
1295
|
+
this.yankWithCharMotion(pendingMotion, data);
|
|
631
1296
|
this.pendingOperator = null;
|
|
632
1297
|
} else {
|
|
633
|
-
this.executeCharMotion(
|
|
1298
|
+
this.executeCharMotion(pendingMotion, data);
|
|
634
1299
|
}
|
|
635
1300
|
|
|
636
1301
|
this.pendingMotion = null;
|
|
637
1302
|
}
|
|
638
1303
|
|
|
639
1304
|
private handlePendingTextObject(data: string): void {
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
1305
|
+
const pendingTextObject = this.pendingTextObject;
|
|
1306
|
+
this.pendingTextObject = null;
|
|
1307
|
+
if (!pendingTextObject) {
|
|
1308
|
+
this.pendingOperator = null;
|
|
643
1309
|
return;
|
|
644
1310
|
}
|
|
645
1311
|
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
this.
|
|
649
|
-
|
|
650
|
-
this.pendingOperator = null;
|
|
1312
|
+
const hasCount = this.hasPendingCount();
|
|
1313
|
+
|
|
1314
|
+
if (this.pendingOperator === "y" && hasCount) {
|
|
1315
|
+
this.cancelPendingOperator(data);
|
|
651
1316
|
return;
|
|
652
1317
|
}
|
|
653
1318
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
this.
|
|
657
|
-
this.
|
|
1319
|
+
if (data === "w" || data === "W") {
|
|
1320
|
+
const semanticClass: WordTextObjectClass = data === "W" ? "WORD" : "word";
|
|
1321
|
+
const count = this.takeTotalCount(1);
|
|
1322
|
+
const range = this.getWordObjectRange(pendingTextObject, count, semanticClass);
|
|
1323
|
+
if (!range || !this.pendingOperator) {
|
|
1324
|
+
this.pendingOperator = null;
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
this.applyResolvedTextObjectRange(range);
|
|
658
1329
|
return;
|
|
659
1330
|
}
|
|
660
1331
|
|
|
661
|
-
if (
|
|
662
|
-
this.
|
|
663
|
-
this.pendingOperator = null;
|
|
664
|
-
this.mode = "insert";
|
|
1332
|
+
if (hasCount) {
|
|
1333
|
+
this.cancelPendingOperator(data);
|
|
665
1334
|
return;
|
|
666
1335
|
}
|
|
667
1336
|
|
|
668
|
-
|
|
669
|
-
this.
|
|
670
|
-
this.
|
|
1337
|
+
const range = resolveDelimitedTextObjectRange(
|
|
1338
|
+
this.getText(),
|
|
1339
|
+
this.getDelimitedTextObjectCursorAbs(),
|
|
1340
|
+
pendingTextObject,
|
|
1341
|
+
data,
|
|
1342
|
+
);
|
|
1343
|
+
if (!range) {
|
|
1344
|
+
this.cancelPendingOperator(data);
|
|
671
1345
|
return;
|
|
672
1346
|
}
|
|
673
1347
|
|
|
1348
|
+
this.applyResolvedTextObjectRange(range);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
private applyResolvedTextObjectRange(range: TextObjectRange): void {
|
|
1352
|
+
const pendingOperator = this.pendingOperator;
|
|
674
1353
|
this.pendingOperator = null;
|
|
1354
|
+
|
|
1355
|
+
if (!pendingOperator || range.endAbs < range.startAbs) return;
|
|
1356
|
+
|
|
1357
|
+
if (range.endAbs === range.startAbs) {
|
|
1358
|
+
if (pendingOperator === "c") {
|
|
1359
|
+
this.moveCursorToAbsoluteIndex(range.startAbs);
|
|
1360
|
+
this.mode = "insert";
|
|
1361
|
+
}
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (pendingOperator === "d") {
|
|
1366
|
+
this.deleteRangeByAbsolute(range.startAbs, range.endAbs);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
if (pendingOperator === "c") {
|
|
1371
|
+
this.deleteRangeByAbsolute(range.startAbs, range.endAbs);
|
|
1372
|
+
this.mode = "insert";
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
if (pendingOperator === "y") {
|
|
1377
|
+
this.yankRangeByAbsolute(range.startAbs, range.endAbs);
|
|
1378
|
+
}
|
|
675
1379
|
}
|
|
676
1380
|
|
|
677
1381
|
private handlePendingDelete(data: string): void {
|
|
@@ -986,6 +1690,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
986
1690
|
return;
|
|
987
1691
|
}
|
|
988
1692
|
|
|
1693
|
+
if (data === ":") {
|
|
1694
|
+
this.startPendingExCommand();
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
989
1698
|
if (data === "G") {
|
|
990
1699
|
this.moveCursorToBufferEnd();
|
|
991
1700
|
return;
|
|
@@ -1076,16 +1785,33 @@ export class ModalEditor extends CustomEditor {
|
|
|
1076
1785
|
|
|
1077
1786
|
if (data === "w") {
|
|
1078
1787
|
const count = this.takeTotalCount(1);
|
|
1079
|
-
|
|
1788
|
+
this.moveWord("forward", "start", count, "word");
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
if (data === "b") {
|
|
1792
|
+
this.moveWord("backward", "start", this.takeTotalCount(1), "word");
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
if (data === "e") {
|
|
1796
|
+
this.moveWord("forward", "end", this.takeTotalCount(1), "word");
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
if (data === "W") {
|
|
1800
|
+
this.moveWord("forward", "start", this.takeTotalCount(1), "WORD");
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
if (data === "B") {
|
|
1804
|
+
this.moveWord("backward", "start", this.takeTotalCount(1), "WORD");
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
if (data === "E") {
|
|
1808
|
+
this.moveWord("forward", "end", this.takeTotalCount(1), "WORD");
|
|
1809
|
+
return;
|
|
1080
1810
|
}
|
|
1081
|
-
if (data === "b") return this.moveWord("backward", "start", this.takeTotalCount(1), "word");
|
|
1082
|
-
if (data === "e") return this.moveWord("forward", "end", this.takeTotalCount(1), "word");
|
|
1083
|
-
if (data === "W") return this.moveWord("forward", "start", this.takeTotalCount(1), "WORD");
|
|
1084
|
-
if (data === "B") return this.moveWord("backward", "start", this.takeTotalCount(1), "WORD");
|
|
1085
|
-
if (data === "E") return this.moveWord("forward", "end", this.takeTotalCount(1), "WORD");
|
|
1086
1811
|
|
|
1087
1812
|
if (Object.hasOwn(NORMAL_KEYS, data)) {
|
|
1088
|
-
|
|
1813
|
+
this.handleMappedKey(data);
|
|
1814
|
+
return;
|
|
1089
1815
|
}
|
|
1090
1816
|
|
|
1091
1817
|
// Pass control sequences (ctrl+c, etc.) to super, ignore printable chars
|
|
@@ -1345,13 +2071,14 @@ export class ModalEditor extends CustomEditor {
|
|
|
1345
2071
|
for (let i = 0; i < steps; i++) {
|
|
1346
2072
|
if (currentLine >= state.lines.length - 1) break;
|
|
1347
2073
|
|
|
1348
|
-
const left = state.lines[currentLine]
|
|
1349
|
-
const right = state.lines[currentLine + 1]
|
|
2074
|
+
const left = state.lines[currentLine] ?? "";
|
|
2075
|
+
const right = state.lines[currentLine + 1] ?? "";
|
|
1350
2076
|
let joined: string;
|
|
1351
2077
|
|
|
1352
2078
|
if (normalize) {
|
|
1353
2079
|
const trimmedRight = right.trimStart();
|
|
1354
|
-
const
|
|
2080
|
+
const leftLastChar = left[left.length - 1];
|
|
2081
|
+
const leftEndsWithSpace = leftLastChar !== undefined && /\s/.test(leftLastChar);
|
|
1355
2082
|
const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
|
|
1356
2083
|
joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
|
|
1357
2084
|
joinPoint = left.length;
|
|
@@ -1412,6 +2139,18 @@ export class ModalEditor extends CustomEditor {
|
|
|
1412
2139
|
return this.getAbsoluteIndex(cursor.line, cursor.col);
|
|
1413
2140
|
}
|
|
1414
2141
|
|
|
2142
|
+
private getDelimitedTextObjectCursorAbs(): number {
|
|
2143
|
+
const lines = this.getLines();
|
|
2144
|
+
const cursor = this.getCursor();
|
|
2145
|
+
const line = lines[cursor.line] ?? "";
|
|
2146
|
+
|
|
2147
|
+
if (line.length > 0 && cursor.col >= line.length) {
|
|
2148
|
+
return this.getAbsoluteIndex(cursor.line, line.length - 1);
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
return this.getAbsoluteIndex(cursor.line, cursor.col);
|
|
2152
|
+
}
|
|
2153
|
+
|
|
1415
2154
|
private findWordTargetInText(
|
|
1416
2155
|
text: string,
|
|
1417
2156
|
abs: number,
|
|
@@ -1619,11 +2358,19 @@ export class ModalEditor extends CustomEditor {
|
|
|
1619
2358
|
}
|
|
1620
2359
|
}
|
|
1621
2360
|
|
|
1622
|
-
private
|
|
2361
|
+
private shouldMirrorRegisterWrite(source: RegisterWriteSource): boolean {
|
|
2362
|
+
if (this.clipboardMirrorPolicy === "never") return false;
|
|
2363
|
+
if (this.clipboardMirrorPolicy === "yank") return source === "yank";
|
|
2364
|
+
return true;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
private writeToRegister(text: string, source: RegisterWriteSource = "mutation"): void {
|
|
1623
2368
|
this.unnamedRegister = text;
|
|
1624
|
-
|
|
2369
|
+
const shouldMirror = text !== "" && this.shouldMirrorRegisterWrite(source);
|
|
2370
|
+
this.preferRegisterForPut = text !== "" && !shouldMirror;
|
|
2371
|
+
if (!shouldMirror) return;
|
|
1625
2372
|
|
|
1626
|
-
|
|
2373
|
+
this.clipboardMirror.mirror(text);
|
|
1627
2374
|
}
|
|
1628
2375
|
|
|
1629
2376
|
private getCurrentLineAndCol(): { line: string; col: number } {
|
|
@@ -1653,9 +2400,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1653
2400
|
endIndex = segments.length - 1;
|
|
1654
2401
|
}
|
|
1655
2402
|
|
|
2403
|
+
const startSegment = segments[startIndex];
|
|
2404
|
+
const endSegment = segments[endIndex];
|
|
2405
|
+
if (!startSegment || !endSegment) return null;
|
|
2406
|
+
|
|
1656
2407
|
return {
|
|
1657
|
-
start:
|
|
1658
|
-
end:
|
|
2408
|
+
start: startSegment.start,
|
|
2409
|
+
end: endSegment.end,
|
|
1659
2410
|
};
|
|
1660
2411
|
}
|
|
1661
2412
|
|
|
@@ -1775,7 +2526,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1775
2526
|
|
|
1776
2527
|
private yankLineRange(startLine: number, endLine: number): void {
|
|
1777
2528
|
if (this.getLines().length === 0) return;
|
|
1778
|
-
this.writeToRegister(this.getLinewisePayload(startLine, endLine));
|
|
2529
|
+
this.writeToRegister(this.getLinewisePayload(startLine, endLine), "yank");
|
|
1779
2530
|
}
|
|
1780
2531
|
|
|
1781
2532
|
private deleteLinewiseByDelta(delta: number): void {
|
|
@@ -1913,14 +2664,14 @@ export class ModalEditor extends CustomEditor {
|
|
|
1913
2664
|
return;
|
|
1914
2665
|
}
|
|
1915
2666
|
|
|
1916
|
-
if (
|
|
1917
|
-
|
|
1918
|
-
this.cancelPendingOperator(data);
|
|
2667
|
+
if (data === "i" || data === "a") {
|
|
2668
|
+
this.pendingTextObject = data;
|
|
1919
2669
|
return;
|
|
1920
2670
|
}
|
|
1921
2671
|
|
|
1922
|
-
if (
|
|
1923
|
-
|
|
2672
|
+
if (this.hasPendingCount()) {
|
|
2673
|
+
// Counted forms beyond yy, y{count}j/k, and y{count}{f/F/t/T} are out of scope.
|
|
2674
|
+
this.cancelPendingOperator(data);
|
|
1924
2675
|
return;
|
|
1925
2676
|
}
|
|
1926
2677
|
|
|
@@ -2010,7 +2761,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
2010
2761
|
if (end <= start) return;
|
|
2011
2762
|
|
|
2012
2763
|
// Yank only — no cursor movement, no text mutation
|
|
2013
|
-
this.writeToRegister(line.slice(start, end));
|
|
2764
|
+
this.writeToRegister(line.slice(start, end), "yank");
|
|
2014
2765
|
}
|
|
2015
2766
|
|
|
2016
2767
|
private yankRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean = false): void {
|
|
@@ -2019,7 +2770,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
2019
2770
|
const rawEnd = Math.max(currentAbs, targetAbs) + (inclusive ? 1 : 0);
|
|
2020
2771
|
const end = Math.min(rawEnd, text.length);
|
|
2021
2772
|
if (end <= start) return;
|
|
2022
|
-
this.writeToRegister(text.slice(start, end));
|
|
2773
|
+
this.writeToRegister(text.slice(start, end), "yank");
|
|
2023
2774
|
}
|
|
2024
2775
|
|
|
2025
2776
|
private getCursorFromAbsoluteIndex(text: string, abs: number): { line: number; col: number } {
|
|
@@ -2077,73 +2828,43 @@ export class ModalEditor extends CustomEditor {
|
|
|
2077
2828
|
}
|
|
2078
2829
|
|
|
2079
2830
|
private getWordObjectRange(
|
|
2080
|
-
kind:
|
|
2831
|
+
kind: TextObjectKind,
|
|
2081
2832
|
count: number = 1,
|
|
2082
|
-
|
|
2833
|
+
semanticClass: WordTextObjectClass = "word",
|
|
2834
|
+
): TextObjectRange | null {
|
|
2083
2835
|
const lines = this.getLines();
|
|
2084
2836
|
const cursor = this.getCursor();
|
|
2085
2837
|
const line = lines[cursor.line] ?? "";
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
const steps = Math.max(1, Math.min(MAX_COUNT, count));
|
|
2089
|
-
const hasWordChar = (idx: number) => idx >= 0 && idx < line.length && this.isWordChar(line[idx]!);
|
|
2090
|
-
|
|
2091
|
-
let col = Math.min(cursor.col, Math.max(0, line.length - 1));
|
|
2092
|
-
|
|
2093
|
-
if (!hasWordChar(col)) {
|
|
2094
|
-
let right = col;
|
|
2095
|
-
while (right < line.length && !hasWordChar(right)) right++;
|
|
2096
|
-
if (right < line.length) {
|
|
2097
|
-
col = right;
|
|
2098
|
-
} else {
|
|
2099
|
-
let left = Math.min(col, line.length - 1);
|
|
2100
|
-
while (left >= 0 && !hasWordChar(left)) left--;
|
|
2101
|
-
if (left < 0) return null;
|
|
2102
|
-
col = left;
|
|
2103
|
-
}
|
|
2104
|
-
}
|
|
2105
|
-
|
|
2106
|
-
let start = col;
|
|
2107
|
-
while (start > 0 && hasWordChar(start - 1)) start--;
|
|
2108
|
-
|
|
2109
|
-
let end = col + 1;
|
|
2110
|
-
while (end < line.length && hasWordChar(end)) end++;
|
|
2838
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
2111
2839
|
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2840
|
+
return resolveWordTextObjectRange(
|
|
2841
|
+
line,
|
|
2842
|
+
lineStartAbs,
|
|
2843
|
+
cursor.col,
|
|
2844
|
+
kind,
|
|
2845
|
+
count,
|
|
2846
|
+
semanticClass,
|
|
2847
|
+
);
|
|
2848
|
+
}
|
|
2117
2849
|
|
|
2118
|
-
|
|
2119
|
-
while (nextWordEnd < line.length && hasWordChar(nextWordEnd)) nextWordEnd++;
|
|
2850
|
+
private static readonly PUT_SIZE_LIMIT = 512 * 1024; // 512 KB safety cap
|
|
2120
2851
|
|
|
2121
|
-
|
|
2122
|
-
|
|
2852
|
+
private getPasteRegisterText(): string {
|
|
2853
|
+
if (this.preferRegisterForPut || this.clipboardMirror.hasPendingWrite()) {
|
|
2854
|
+
return this.unnamedRegister;
|
|
2123
2855
|
}
|
|
2124
2856
|
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
end = aroundEnd;
|
|
2131
|
-
} else {
|
|
2132
|
-
while (start > 0 && /\s/.test(line[start - 1]!)) start--;
|
|
2133
|
-
}
|
|
2857
|
+
try {
|
|
2858
|
+
const clipboardText = this.clipboardReadFn();
|
|
2859
|
+
return clipboardText ?? this.unnamedRegister;
|
|
2860
|
+
} catch {
|
|
2861
|
+
return this.unnamedRegister;
|
|
2134
2862
|
}
|
|
2135
|
-
|
|
2136
|
-
return {
|
|
2137
|
-
startAbs: this.getAbsoluteIndex(cursor.line, start),
|
|
2138
|
-
endAbs: this.getAbsoluteIndex(cursor.line, end),
|
|
2139
|
-
};
|
|
2140
2863
|
}
|
|
2141
2864
|
|
|
2142
|
-
private static readonly PUT_SIZE_LIMIT = 512 * 1024; // 512 KB safety cap
|
|
2143
|
-
|
|
2144
2865
|
private putAfter(): void {
|
|
2145
2866
|
const count = this.takeTotalCount(1);
|
|
2146
|
-
const text = this.
|
|
2867
|
+
const text = this.getPasteRegisterText();
|
|
2147
2868
|
if (!text) return;
|
|
2148
2869
|
const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
|
|
2149
2870
|
|
|
@@ -2173,7 +2894,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
2173
2894
|
|
|
2174
2895
|
private putBefore(): void {
|
|
2175
2896
|
const count = this.takeTotalCount(1);
|
|
2176
|
-
const text = this.
|
|
2897
|
+
const text = this.getPasteRegisterText();
|
|
2177
2898
|
if (!text) return;
|
|
2178
2899
|
const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
|
|
2179
2900
|
|
|
@@ -2215,24 +2936,112 @@ export class ModalEditor extends CustomEditor {
|
|
|
2215
2936
|
this.deleteRangeByAbsolute(lineStartAbs + start, lineStartAbs + end);
|
|
2216
2937
|
}
|
|
2217
2938
|
|
|
2939
|
+
private takeModeLabelSuffix(rawLabel: string, width: number): string {
|
|
2940
|
+
if (width <= 0) return "";
|
|
2941
|
+
|
|
2942
|
+
const graphemes = getLineGraphemes(rawLabel);
|
|
2943
|
+
const suffix: string[] = [];
|
|
2944
|
+
let usedWidth = 0;
|
|
2945
|
+
|
|
2946
|
+
for (let i = graphemes.length - 1; i >= 0; i--) {
|
|
2947
|
+
const grapheme = graphemes[i];
|
|
2948
|
+
if (!grapheme) continue;
|
|
2949
|
+
|
|
2950
|
+
const segment = rawLabel.slice(grapheme.start, grapheme.end);
|
|
2951
|
+
const segmentWidth = visibleWidth(segment);
|
|
2952
|
+
if (usedWidth + segmentWidth > width) break;
|
|
2953
|
+
suffix.push(segment);
|
|
2954
|
+
usedWidth += segmentWidth;
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
return suffix.reverse().join("");
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
private fitModeLabel(rawLabel: string, width: number): string {
|
|
2961
|
+
if (visibleWidth(rawLabel) <= width) return rawLabel;
|
|
2962
|
+
|
|
2963
|
+
const prefix = rawLabel.startsWith(" INSERT ")
|
|
2964
|
+
? " INSERT "
|
|
2965
|
+
: rawLabel.startsWith(" NORMAL ")
|
|
2966
|
+
? " NORMAL "
|
|
2967
|
+
: rawLabel.startsWith(" EX ")
|
|
2968
|
+
? " EX "
|
|
2969
|
+
: "";
|
|
2970
|
+
|
|
2971
|
+
if (!prefix || visibleWidth(prefix) >= width) {
|
|
2972
|
+
return truncateToWidth(rawLabel, width, "");
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
const suffixWidth = width - visibleWidth(prefix) - 1;
|
|
2976
|
+
if (suffixWidth <= 0) return `${prefix}…`;
|
|
2977
|
+
return `${prefix}…${this.takeModeLabelSuffix(rawLabel, suffixWidth)}`;
|
|
2978
|
+
}
|
|
2979
|
+
|
|
2980
|
+
private getDesiredCursorShapeSequence(): CursorShapeSequence {
|
|
2981
|
+
return this.mode === "insert" && this.pendingExCommand === null
|
|
2982
|
+
? INSERT_CURSOR_SHAPE
|
|
2983
|
+
: BLOCK_CURSOR_SHAPE;
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
private hasPromptCursorMarker(lines: string[]): boolean {
|
|
2987
|
+
return lines.some((line) => line.includes(CURSOR_MARKER));
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
private stripSoftwareCursorWhenHardwareCursorIsUsed(lines: string[]): void {
|
|
2991
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
2992
|
+
const line = lines[i];
|
|
2993
|
+
if (!line?.includes(CURSOR_MARKER)) continue;
|
|
2994
|
+
|
|
2995
|
+
lines[i] = stripSoftwareCursorAfterMarker(line);
|
|
2996
|
+
return;
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
private syncCursorShapeForRender(lines: string[]): void {
|
|
3001
|
+
if (!this.cursorShapeRuntime) return;
|
|
3002
|
+
if (!this.hasPromptCursorMarker(lines)) return;
|
|
3003
|
+
|
|
3004
|
+
if (this.cursorShapeRuntime.getShowHardwareCursor?.() === false) {
|
|
3005
|
+
this.lastCursorShapeSequence = null;
|
|
3006
|
+
return;
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
this.stripSoftwareCursorWhenHardwareCursorIsUsed(lines);
|
|
3010
|
+
|
|
3011
|
+
const sequence = this.getDesiredCursorShapeSequence();
|
|
3012
|
+
if (sequence === this.lastCursorShapeSequence) return;
|
|
3013
|
+
|
|
3014
|
+
this.cursorShapeRuntime.writeCursorShape(sequence);
|
|
3015
|
+
this.lastCursorShapeSequence = sequence;
|
|
3016
|
+
}
|
|
3017
|
+
|
|
2218
3018
|
render(width: number): string[] {
|
|
2219
3019
|
const lines = super.render(width);
|
|
3020
|
+
this.syncCursorShapeForRender(lines);
|
|
2220
3021
|
if (lines.length === 0) return lines;
|
|
2221
3022
|
|
|
2222
|
-
const rawLabel = this.getModeLabel();
|
|
2223
|
-
const colorize = this.
|
|
2224
|
-
? (this.mode === "insert" ? this.labelColorizers.insert : this.labelColorizers.normal)
|
|
2225
|
-
: null;
|
|
3023
|
+
const rawLabel = this.fitModeLabel(this.getModeLabel(), width);
|
|
3024
|
+
const colorize = this.getModeLabelColorizer();
|
|
2226
3025
|
const label = colorize ? colorize(rawLabel) : rawLabel;
|
|
2227
3026
|
const last = lines.length - 1;
|
|
2228
|
-
|
|
2229
|
-
|
|
3027
|
+
const lastLine = lines[last];
|
|
3028
|
+
if (lastLine && visibleWidth(lastLine) >= visibleWidth(rawLabel)) {
|
|
3029
|
+
lines[last] = truncateToWidth(lastLine, width - visibleWidth(rawLabel), "") + label;
|
|
3030
|
+
} else {
|
|
3031
|
+
lines[last] = label;
|
|
2230
3032
|
}
|
|
2231
3033
|
return lines;
|
|
2232
3034
|
}
|
|
2233
3035
|
|
|
3036
|
+
private getModeLabelColorizer(): ((s: string) => string) | null {
|
|
3037
|
+
if (!this.labelColorizers) return null;
|
|
3038
|
+
if (this.pendingExCommand !== null) return this.labelColorizers.ex;
|
|
3039
|
+
return this.mode === "insert" ? this.labelColorizers.insert : this.labelColorizers.normal;
|
|
3040
|
+
}
|
|
3041
|
+
|
|
2234
3042
|
private getModeLabel(): string {
|
|
2235
3043
|
if (this.mode === "insert") return " INSERT ";
|
|
3044
|
+
if (this.pendingExCommand !== null) return ` EX ${this.pendingExCommand}_ `;
|
|
2236
3045
|
|
|
2237
3046
|
const prefixCount = this.prefixCount;
|
|
2238
3047
|
const operatorCount = this.operatorCount;
|
|
@@ -2260,12 +3069,37 @@ export class ModalEditor extends CustomEditor {
|
|
|
2260
3069
|
}
|
|
2261
3070
|
|
|
2262
3071
|
export default function (pi: ExtensionAPI) {
|
|
3072
|
+
let cursorShapeCleanup: CursorShapeCleanup | null = null;
|
|
3073
|
+
|
|
2263
3074
|
pi.on("session_start", (_event, ctx) => {
|
|
3075
|
+
const piVimSettings = readPiVimSettings(ctx.cwd);
|
|
3076
|
+
const clipboardMirrorPolicy = resolveClipboardMirrorPolicy(piVimSettings.clipboardMirror);
|
|
3077
|
+
if (clipboardMirrorPolicy.warning && ctx.hasUI) {
|
|
3078
|
+
ctx.ui.notify(clipboardMirrorPolicy.warning, "warning");
|
|
3079
|
+
}
|
|
3080
|
+
|
|
2264
3081
|
const t = ctx.ui.theme;
|
|
3082
|
+
const reverseVideo = (s: string) => `\x1b[7m${s}\x1b[27m`;
|
|
2265
3083
|
const colorizers = t ? {
|
|
2266
|
-
insert: (s: string) => t.fg("borderMuted",
|
|
2267
|
-
normal: (s: string) => t.fg("borderAccent",
|
|
3084
|
+
insert: (s: string) => t.fg("borderMuted", reverseVideo(s)),
|
|
3085
|
+
normal: (s: string) => t.fg("borderAccent", reverseVideo(s)),
|
|
3086
|
+
ex: (s: string) => t.fg("warning", reverseVideo(s)),
|
|
2268
3087
|
} : null;
|
|
2269
|
-
ctx.ui.setEditorComponent((tui, theme, kb) =>
|
|
3088
|
+
ctx.ui.setEditorComponent((tui, theme, kb) => {
|
|
3089
|
+
cursorShapeCleanup = enableCursorShapeSupport(tui);
|
|
3090
|
+
const editor = new ModalEditor(tui, theme, kb, colorizers);
|
|
3091
|
+
editor.setClipboardMirrorPolicy(clipboardMirrorPolicy.policy);
|
|
3092
|
+
editor.setQuitFn(() => ctx.shutdown());
|
|
3093
|
+
editor.setNotifyFn((message) => ctx.ui.notify(message, "warning"));
|
|
3094
|
+
return editor;
|
|
3095
|
+
});
|
|
3096
|
+
});
|
|
3097
|
+
|
|
3098
|
+
pi.on("session_shutdown", () => {
|
|
3099
|
+
try {
|
|
3100
|
+
cursorShapeCleanup?.();
|
|
3101
|
+
} finally {
|
|
3102
|
+
cursorShapeCleanup = null;
|
|
3103
|
+
}
|
|
2270
3104
|
});
|
|
2271
3105
|
}
|