pi-vim 0.3.1 → 0.8.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 +190 -171
- package/clipboard-policy.ts +73 -0
- package/index.ts +1017 -183
- package/motions.ts +16 -6
- package/package.json +17 -6
- 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,26 +548,49 @@ 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 clipboardMirrorPolicy: ClipboardMirrorPolicy = DEFAULT_CLIPBOARD_MIRROR_POLICY;
|
|
558
|
+
private readonly clipboardMirror = new ClipboardMirror(writeClipboardInChildProcess);
|
|
559
|
+
private clipboardReadFn: ClipboardReadFn = readClipboardInChildProcess;
|
|
560
|
+
private quitFn: () => void = () => {};
|
|
561
|
+
private notifyFn: (message: string) => void = () => {};
|
|
146
562
|
|
|
147
563
|
constructor(
|
|
148
|
-
tui:
|
|
149
|
-
theme:
|
|
150
|
-
kb:
|
|
151
|
-
labelColorizers?:
|
|
564
|
+
tui: CustomEditorConstructorArgs[0],
|
|
565
|
+
theme: CustomEditorConstructorArgs[1],
|
|
566
|
+
kb: CustomEditorConstructorArgs[2],
|
|
567
|
+
labelColorizers?: ModeLabelColorizers | null,
|
|
152
568
|
) {
|
|
153
569
|
super(tui, theme, kb);
|
|
570
|
+
this.cursorShapeRuntime = getCursorShapeRuntime(tui);
|
|
154
571
|
this.labelColorizers = labelColorizers ?? null;
|
|
155
572
|
}
|
|
156
573
|
|
|
157
574
|
// Test seams
|
|
158
|
-
setClipboardFn(fn: (text: string) =>
|
|
575
|
+
setClipboardFn(fn: (text: string, signal?: AbortSignal) => unknown): void {
|
|
576
|
+
this.clipboardMirror.setWriteFn(async (text: string, signal: AbortSignal) => {
|
|
577
|
+
await fn(text, signal);
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
setClipboardWriteTimeoutMs(timeoutMs: number): void {
|
|
581
|
+
this.clipboardMirror.setTimeoutMs(timeoutMs);
|
|
582
|
+
}
|
|
583
|
+
setClipboardReadFn(fn: ClipboardReadFn): void {
|
|
584
|
+
this.clipboardReadFn = fn;
|
|
585
|
+
}
|
|
586
|
+
setClipboardMirrorPolicy(policy: ClipboardMirrorPolicy): void {
|
|
587
|
+
this.clipboardMirrorPolicy = policy;
|
|
588
|
+
}
|
|
589
|
+
getClipboardMirrorPolicy(): ClipboardMirrorPolicy {
|
|
590
|
+
return this.clipboardMirrorPolicy;
|
|
591
|
+
}
|
|
592
|
+
setQuitFn(fn: () => void): void { this.quitFn = fn; }
|
|
593
|
+
setNotifyFn(fn: (message: string) => void): void { this.notifyFn = fn; }
|
|
159
594
|
getRegister(): string { return this.unnamedRegister; }
|
|
160
595
|
setRegister(text: string): void { this.unnamedRegister = text; }
|
|
161
596
|
getMode(): Mode { return this.mode; }
|
|
@@ -181,7 +616,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
181
616
|
if (!state || !Array.isArray(state.lines)) {
|
|
182
617
|
throw new Error("Redo restore prerequisite: editor state unavailable");
|
|
183
618
|
}
|
|
184
|
-
return state;
|
|
619
|
+
return state as { lines: string[]; cursorLine?: number; cursorCol?: number };
|
|
185
620
|
}
|
|
186
621
|
|
|
187
622
|
private restoreSnapshot(snapshot: EditorSnapshot): void {
|
|
@@ -344,6 +779,26 @@ export class ModalEditor extends CustomEditor {
|
|
|
344
779
|
editor.tui?.requestRender?.();
|
|
345
780
|
}
|
|
346
781
|
|
|
782
|
+
private startPendingExCommand(): void {
|
|
783
|
+
this.pendingExCommand = ":";
|
|
784
|
+
this.acceptingBracketedPasteInExCommand = false;
|
|
785
|
+
this.pendingEscWhileAcceptingBracketedPasteInExCommand = false;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private clearPendingExCommand(): void {
|
|
789
|
+
const shouldDiscardBracketedPasteTail = this.acceptingBracketedPasteInExCommand
|
|
790
|
+
|| this.pendingEscWhileAcceptingBracketedPasteInExCommand;
|
|
791
|
+
|
|
792
|
+
this.pendingExCommand = null;
|
|
793
|
+
this.acceptingBracketedPasteInExCommand = false;
|
|
794
|
+
this.pendingEscWhileAcceptingBracketedPasteInExCommand = false;
|
|
795
|
+
|
|
796
|
+
if (shouldDiscardBracketedPasteTail) {
|
|
797
|
+
this.discardingBracketedPasteInNormalMode = true;
|
|
798
|
+
this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
347
802
|
private clearPendingState(): void {
|
|
348
803
|
this.pendingMotion = null;
|
|
349
804
|
this.pendingTextObject = null;
|
|
@@ -353,12 +808,69 @@ export class ModalEditor extends CustomEditor {
|
|
|
353
808
|
this.pendingG = false;
|
|
354
809
|
this.pendingGCount = "";
|
|
355
810
|
this.pendingReplace = false;
|
|
811
|
+
this.clearPendingExCommand();
|
|
356
812
|
}
|
|
357
813
|
|
|
358
814
|
private isEscapeLikeInput(data: string): boolean {
|
|
359
815
|
return matchesKey(data, "escape") || matchesKey(data, "ctrl+[");
|
|
360
816
|
}
|
|
361
817
|
|
|
818
|
+
private normalizePendingExCommandInput(data: string): string | null {
|
|
819
|
+
let chunk = data;
|
|
820
|
+
let normalized = "";
|
|
821
|
+
|
|
822
|
+
while (true) {
|
|
823
|
+
if (this.acceptingBracketedPasteInExCommand) {
|
|
824
|
+
if (this.pendingEscWhileAcceptingBracketedPasteInExCommand) {
|
|
825
|
+
if (chunk.startsWith(BRACKETED_PASTE_END_TAIL)) {
|
|
826
|
+
this.pendingEscWhileAcceptingBracketedPasteInExCommand = false;
|
|
827
|
+
this.acceptingBracketedPasteInExCommand = false;
|
|
828
|
+
chunk = chunk.slice(BRACKETED_PASTE_END_TAIL.length);
|
|
829
|
+
if (chunk.length === 0) {
|
|
830
|
+
return normalized.length > 0 ? normalized : null;
|
|
831
|
+
}
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
normalized += "\x1b";
|
|
836
|
+
this.pendingEscWhileAcceptingBracketedPasteInExCommand = false;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const end = chunk.indexOf(BRACKETED_PASTE_END);
|
|
840
|
+
if (end !== -1) {
|
|
841
|
+
normalized += chunk.slice(0, end);
|
|
842
|
+
this.acceptingBracketedPasteInExCommand = false;
|
|
843
|
+
chunk = chunk.slice(end + BRACKETED_PASTE_END.length);
|
|
844
|
+
if (chunk.length === 0) {
|
|
845
|
+
return normalized.length > 0 ? normalized : null;
|
|
846
|
+
}
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (this.isEscapeLikeInput(chunk)) {
|
|
851
|
+
this.pendingEscWhileAcceptingBracketedPasteInExCommand = true;
|
|
852
|
+
return normalized.length > 0 ? normalized : null;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
normalized += chunk;
|
|
856
|
+
return normalized.length > 0 ? normalized : null;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const start = chunk.indexOf(BRACKETED_PASTE_START);
|
|
860
|
+
if (start === -1) {
|
|
861
|
+
normalized += chunk;
|
|
862
|
+
return normalized.length > 0 ? normalized : null;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
normalized += chunk.slice(0, start);
|
|
866
|
+
chunk = chunk.slice(start + BRACKETED_PASTE_START.length);
|
|
867
|
+
this.acceptingBracketedPasteInExCommand = true;
|
|
868
|
+
if (chunk.length === 0) {
|
|
869
|
+
return normalized.length > 0 ? normalized : null;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
362
874
|
private stripBracketedPasteInNormalMode(data: string): { filtered: string | null; stripped: boolean } {
|
|
363
875
|
let chunk = data;
|
|
364
876
|
let stripped = false;
|
|
@@ -397,7 +909,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
397
909
|
handleInput(data: string): void {
|
|
398
910
|
this.ensureOnChangeHook();
|
|
399
911
|
|
|
400
|
-
if (this.
|
|
912
|
+
if (this.pendingExCommand !== null) {
|
|
913
|
+
const normalized = this.normalizePendingExCommandInput(data);
|
|
914
|
+
if (normalized === null) return;
|
|
915
|
+
data = normalized;
|
|
916
|
+
} else if (this.mode !== "insert") {
|
|
401
917
|
if (this.discardingBracketedPasteInNormalMode) {
|
|
402
918
|
if (this.isEscapeLikeInput(data)) {
|
|
403
919
|
if (this.pendingEscWhileDiscardingBracketedPasteInNormalMode) {
|
|
@@ -434,17 +950,20 @@ export class ModalEditor extends CustomEditor {
|
|
|
434
950
|
}
|
|
435
951
|
|
|
436
952
|
if (this.isEscapeLikeInput(data)) {
|
|
437
|
-
|
|
953
|
+
this.handleEscape();
|
|
954
|
+
return;
|
|
438
955
|
}
|
|
439
956
|
|
|
440
957
|
if (this.mode === "insert") {
|
|
441
958
|
// Shift+Alt+A: go to end of line (like Esc -> A but stay in insert)
|
|
442
959
|
if (matchesKey(data, Key.shiftAlt("a")) || data === "\x1bA") {
|
|
443
|
-
|
|
960
|
+
super.handleInput(CTRL_E);
|
|
961
|
+
return;
|
|
444
962
|
}
|
|
445
963
|
// Shift+Alt+I: go to start of line (like Esc -> I but stay in insert)
|
|
446
964
|
if (matchesKey(data, Key.shiftAlt("i")) || data === "\x1bI") {
|
|
447
|
-
|
|
965
|
+
super.handleInput(CTRL_A);
|
|
966
|
+
return;
|
|
448
967
|
}
|
|
449
968
|
// Alt+o: open new line below (stay in insert mode)
|
|
450
969
|
if (matchesKey(data, Key.alt("o")) || data === "\x1bo") {
|
|
@@ -487,24 +1006,34 @@ export class ModalEditor extends CustomEditor {
|
|
|
487
1006
|
return;
|
|
488
1007
|
}
|
|
489
1008
|
|
|
1009
|
+
if (this.pendingExCommand !== null) {
|
|
1010
|
+
this.handlePendingExCommand(data);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
490
1014
|
if (this.pendingTextObject) {
|
|
491
|
-
|
|
1015
|
+
this.handlePendingTextObject(data);
|
|
1016
|
+
return;
|
|
492
1017
|
}
|
|
493
1018
|
|
|
494
1019
|
if (this.pendingMotion) {
|
|
495
|
-
|
|
1020
|
+
this.handlePendingMotion(data);
|
|
1021
|
+
return;
|
|
496
1022
|
}
|
|
497
1023
|
|
|
498
1024
|
if (this.pendingOperator === "d") {
|
|
499
|
-
|
|
1025
|
+
this.handlePendingDelete(data);
|
|
1026
|
+
return;
|
|
500
1027
|
}
|
|
501
1028
|
|
|
502
1029
|
if (this.pendingOperator === "c") {
|
|
503
|
-
|
|
1030
|
+
this.handlePendingChange(data);
|
|
1031
|
+
return;
|
|
504
1032
|
}
|
|
505
1033
|
|
|
506
1034
|
if (this.pendingOperator === "y") {
|
|
507
|
-
|
|
1035
|
+
this.handlePendingYank(data);
|
|
1036
|
+
return;
|
|
508
1037
|
}
|
|
509
1038
|
|
|
510
1039
|
this.handleNormalMode(data);
|
|
@@ -529,6 +1058,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
529
1058
|
}
|
|
530
1059
|
|
|
531
1060
|
private handleEscape(): void {
|
|
1061
|
+
if (this.pendingExCommand !== null) {
|
|
1062
|
+
this.clearPendingExCommand();
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
532
1066
|
if (
|
|
533
1067
|
this.pendingMotion
|
|
534
1068
|
|| this.pendingTextObject
|
|
@@ -550,11 +1084,135 @@ export class ModalEditor extends CustomEditor {
|
|
|
550
1084
|
}
|
|
551
1085
|
}
|
|
552
1086
|
|
|
1087
|
+
private isEnterLikeInput(data: string): boolean {
|
|
1088
|
+
return data === "\r" || data === "\n" || matchesKey(data, "enter") || matchesKey(data, "return");
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
private isBackspaceLikeInput(data: string): boolean {
|
|
1092
|
+
return data === "\x7f" || data === "\x08" || matchesKey(data, "backspace") || matchesKey(data, "ctrl+h");
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
private deleteLastPendingExCommandGrapheme(): void {
|
|
1096
|
+
const current = this.pendingExCommand ?? "";
|
|
1097
|
+
const graphemes = getLineGraphemes(current);
|
|
1098
|
+
|
|
1099
|
+
if (graphemes.length <= 1) {
|
|
1100
|
+
this.clearPendingExCommand();
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const previousGrapheme = graphemes[graphemes.length - 2];
|
|
1105
|
+
if (!previousGrapheme) {
|
|
1106
|
+
this.clearPendingExCommand();
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
this.pendingExCommand = current.slice(0, previousGrapheme.end);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
private handlePendingExCommandControlChunk(data: string): boolean {
|
|
1114
|
+
if (
|
|
1115
|
+
!data.includes("\r")
|
|
1116
|
+
&& !data.includes("\n")
|
|
1117
|
+
&& !data.includes("\x7f")
|
|
1118
|
+
&& !data.includes("\x08")
|
|
1119
|
+
) {
|
|
1120
|
+
return false;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
let printable = "";
|
|
1124
|
+
const flushPrintable = () => {
|
|
1125
|
+
if (!printable) return;
|
|
1126
|
+
this.pendingExCommand += printable;
|
|
1127
|
+
printable = "";
|
|
1128
|
+
};
|
|
1129
|
+
|
|
1130
|
+
for (const char of data) {
|
|
1131
|
+
if (char === "\r" || char === "\n") {
|
|
1132
|
+
flushPrintable();
|
|
1133
|
+
this.submitPendingExCommand();
|
|
1134
|
+
return true;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
if (char === "\x7f" || char === "\x08") {
|
|
1138
|
+
flushPrintable();
|
|
1139
|
+
this.deleteLastPendingExCommandGrapheme();
|
|
1140
|
+
if (this.pendingExCommand === null) {
|
|
1141
|
+
return true;
|
|
1142
|
+
}
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
const codePoint = char.codePointAt(0);
|
|
1147
|
+
if (codePoint === undefined || codePoint < 32 || codePoint === 127) {
|
|
1148
|
+
this.clearPendingExCommand();
|
|
1149
|
+
return true;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
printable += char;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
flushPrintable();
|
|
1156
|
+
return true;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
private handlePendingExCommand(data: string): void {
|
|
1160
|
+
if (this.isEnterLikeInput(data)) {
|
|
1161
|
+
this.submitPendingExCommand();
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if (this.isBackspaceLikeInput(data)) {
|
|
1166
|
+
this.deleteLastPendingExCommandGrapheme();
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (this.handlePendingExCommandControlChunk(data)) {
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
if (!this.isPrintableChunk(data)) {
|
|
1175
|
+
this.clearPendingExCommand();
|
|
1176
|
+
this.handleInput(data);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
this.pendingExCommand += data;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
private hasNonEmptyPrompt(): boolean {
|
|
1184
|
+
return this.getText().trim().length > 0;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
private submitPendingExCommand(): void {
|
|
1188
|
+
const command = this.pendingExCommand?.slice(1).trim() ?? "";
|
|
1189
|
+
this.clearPendingExCommand();
|
|
1190
|
+
|
|
1191
|
+
if (command === "q" || command === "qa") {
|
|
1192
|
+
if (this.hasNonEmptyPrompt()) {
|
|
1193
|
+
this.notifyFn(`Prompt is not empty; use :${command}! to quit anyway`);
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
this.quitFn();
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
if (command === "q!" || command === "qa!") {
|
|
1202
|
+
this.quitFn();
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
if (command) {
|
|
1207
|
+
this.notifyFn(`Unsupported ex command: :${command}`);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
553
1211
|
private isPrintableChunk(data: string): boolean {
|
|
554
1212
|
if (data.length === 0) return false;
|
|
555
1213
|
for (const char of data) {
|
|
556
|
-
const codePoint = char.codePointAt(0)
|
|
557
|
-
if (codePoint < 32 || codePoint === 127) return false;
|
|
1214
|
+
const codePoint = char.codePointAt(0);
|
|
1215
|
+
if (codePoint === undefined || codePoint < 32 || codePoint === 127) return false;
|
|
558
1216
|
}
|
|
559
1217
|
return true;
|
|
560
1218
|
}
|
|
@@ -599,6 +1257,10 @@ export class ModalEditor extends CustomEditor {
|
|
|
599
1257
|
return Math.min(MAX_COUNT, total);
|
|
600
1258
|
}
|
|
601
1259
|
|
|
1260
|
+
private hasPendingCount(): boolean {
|
|
1261
|
+
return this.prefixCount.length > 0 || this.operatorCount.length > 0;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
602
1264
|
private cancelPendingOperator(data: string): void {
|
|
603
1265
|
this.pendingOperator = null;
|
|
604
1266
|
this.prefixCount = "";
|
|
@@ -615,59 +1277,101 @@ export class ModalEditor extends CustomEditor {
|
|
|
615
1277
|
return;
|
|
616
1278
|
}
|
|
617
1279
|
|
|
1280
|
+
const pendingMotion = this.pendingMotion;
|
|
1281
|
+
if (!pendingMotion) return;
|
|
1282
|
+
|
|
618
1283
|
if (this.pendingOperator === "d") {
|
|
619
|
-
this.deleteWithCharMotion(
|
|
1284
|
+
this.deleteWithCharMotion(pendingMotion, data);
|
|
620
1285
|
this.pendingOperator = null;
|
|
621
1286
|
} else if (this.pendingOperator === "c") {
|
|
622
|
-
this.deleteWithCharMotion(
|
|
1287
|
+
this.deleteWithCharMotion(pendingMotion, data);
|
|
623
1288
|
this.pendingOperator = null;
|
|
624
1289
|
this.mode = "insert";
|
|
625
1290
|
} else if (this.pendingOperator === "y") {
|
|
626
|
-
this.yankWithCharMotion(
|
|
1291
|
+
this.yankWithCharMotion(pendingMotion, data);
|
|
627
1292
|
this.pendingOperator = null;
|
|
628
1293
|
} else {
|
|
629
|
-
this.executeCharMotion(
|
|
1294
|
+
this.executeCharMotion(pendingMotion, data);
|
|
630
1295
|
}
|
|
631
1296
|
|
|
632
1297
|
this.pendingMotion = null;
|
|
633
1298
|
}
|
|
634
1299
|
|
|
635
1300
|
private handlePendingTextObject(data: string): void {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
1301
|
+
const pendingTextObject = this.pendingTextObject;
|
|
1302
|
+
this.pendingTextObject = null;
|
|
1303
|
+
if (!pendingTextObject) {
|
|
1304
|
+
this.pendingOperator = null;
|
|
639
1305
|
return;
|
|
640
1306
|
}
|
|
641
1307
|
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
this.
|
|
645
|
-
|
|
646
|
-
this.pendingOperator = null;
|
|
1308
|
+
const hasCount = this.hasPendingCount();
|
|
1309
|
+
|
|
1310
|
+
if (this.pendingOperator === "y" && hasCount) {
|
|
1311
|
+
this.cancelPendingOperator(data);
|
|
647
1312
|
return;
|
|
648
1313
|
}
|
|
649
1314
|
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
this.
|
|
653
|
-
this.
|
|
1315
|
+
if (data === "w" || data === "W") {
|
|
1316
|
+
const semanticClass: WordTextObjectClass = data === "W" ? "WORD" : "word";
|
|
1317
|
+
const count = this.takeTotalCount(1);
|
|
1318
|
+
const range = this.getWordObjectRange(pendingTextObject, count, semanticClass);
|
|
1319
|
+
if (!range || !this.pendingOperator) {
|
|
1320
|
+
this.pendingOperator = null;
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
this.applyResolvedTextObjectRange(range);
|
|
654
1325
|
return;
|
|
655
1326
|
}
|
|
656
1327
|
|
|
657
|
-
if (
|
|
658
|
-
this.
|
|
659
|
-
this.pendingOperator = null;
|
|
660
|
-
this.mode = "insert";
|
|
1328
|
+
if (hasCount) {
|
|
1329
|
+
this.cancelPendingOperator(data);
|
|
661
1330
|
return;
|
|
662
1331
|
}
|
|
663
1332
|
|
|
664
|
-
|
|
665
|
-
this.
|
|
666
|
-
this.
|
|
1333
|
+
const range = resolveDelimitedTextObjectRange(
|
|
1334
|
+
this.getText(),
|
|
1335
|
+
this.getDelimitedTextObjectCursorAbs(),
|
|
1336
|
+
pendingTextObject,
|
|
1337
|
+
data,
|
|
1338
|
+
);
|
|
1339
|
+
if (!range) {
|
|
1340
|
+
this.cancelPendingOperator(data);
|
|
667
1341
|
return;
|
|
668
1342
|
}
|
|
669
1343
|
|
|
1344
|
+
this.applyResolvedTextObjectRange(range);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
private applyResolvedTextObjectRange(range: TextObjectRange): void {
|
|
1348
|
+
const pendingOperator = this.pendingOperator;
|
|
670
1349
|
this.pendingOperator = null;
|
|
1350
|
+
|
|
1351
|
+
if (!pendingOperator || range.endAbs < range.startAbs) return;
|
|
1352
|
+
|
|
1353
|
+
if (range.endAbs === range.startAbs) {
|
|
1354
|
+
if (pendingOperator === "c") {
|
|
1355
|
+
this.moveCursorToAbsoluteIndex(range.startAbs);
|
|
1356
|
+
this.mode = "insert";
|
|
1357
|
+
}
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
if (pendingOperator === "d") {
|
|
1362
|
+
this.deleteRangeByAbsolute(range.startAbs, range.endAbs);
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
if (pendingOperator === "c") {
|
|
1367
|
+
this.deleteRangeByAbsolute(range.startAbs, range.endAbs);
|
|
1368
|
+
this.mode = "insert";
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
if (pendingOperator === "y") {
|
|
1373
|
+
this.yankRangeByAbsolute(range.startAbs, range.endAbs);
|
|
1374
|
+
}
|
|
671
1375
|
}
|
|
672
1376
|
|
|
673
1377
|
private handlePendingDelete(data: string): void {
|
|
@@ -982,6 +1686,11 @@ export class ModalEditor extends CustomEditor {
|
|
|
982
1686
|
return;
|
|
983
1687
|
}
|
|
984
1688
|
|
|
1689
|
+
if (data === ":") {
|
|
1690
|
+
this.startPendingExCommand();
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
985
1694
|
if (data === "G") {
|
|
986
1695
|
this.moveCursorToBufferEnd();
|
|
987
1696
|
return;
|
|
@@ -1072,16 +1781,33 @@ export class ModalEditor extends CustomEditor {
|
|
|
1072
1781
|
|
|
1073
1782
|
if (data === "w") {
|
|
1074
1783
|
const count = this.takeTotalCount(1);
|
|
1075
|
-
|
|
1784
|
+
this.moveWord("forward", "start", count, "word");
|
|
1785
|
+
return;
|
|
1786
|
+
}
|
|
1787
|
+
if (data === "b") {
|
|
1788
|
+
this.moveWord("backward", "start", this.takeTotalCount(1), "word");
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
if (data === "e") {
|
|
1792
|
+
this.moveWord("forward", "end", this.takeTotalCount(1), "word");
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
if (data === "W") {
|
|
1796
|
+
this.moveWord("forward", "start", this.takeTotalCount(1), "WORD");
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
if (data === "B") {
|
|
1800
|
+
this.moveWord("backward", "start", this.takeTotalCount(1), "WORD");
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
if (data === "E") {
|
|
1804
|
+
this.moveWord("forward", "end", this.takeTotalCount(1), "WORD");
|
|
1805
|
+
return;
|
|
1076
1806
|
}
|
|
1077
|
-
if (data === "b") return this.moveWord("backward", "start", this.takeTotalCount(1), "word");
|
|
1078
|
-
if (data === "e") return this.moveWord("forward", "end", this.takeTotalCount(1), "word");
|
|
1079
|
-
if (data === "W") return this.moveWord("forward", "start", this.takeTotalCount(1), "WORD");
|
|
1080
|
-
if (data === "B") return this.moveWord("backward", "start", this.takeTotalCount(1), "WORD");
|
|
1081
|
-
if (data === "E") return this.moveWord("forward", "end", this.takeTotalCount(1), "WORD");
|
|
1082
1807
|
|
|
1083
1808
|
if (Object.hasOwn(NORMAL_KEYS, data)) {
|
|
1084
|
-
|
|
1809
|
+
this.handleMappedKey(data);
|
|
1810
|
+
return;
|
|
1085
1811
|
}
|
|
1086
1812
|
|
|
1087
1813
|
// Pass control sequences (ctrl+c, etc.) to super, ignore printable chars
|
|
@@ -1315,7 +2041,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1315
2041
|
}
|
|
1316
2042
|
|
|
1317
2043
|
private moveCursorToFirstNonWhitespace(): void {
|
|
1318
|
-
const { line
|
|
2044
|
+
const { line } = this.getCurrentLineAndCol();
|
|
1319
2045
|
const targetCol = findFirstNonWhitespaceColumn(line);
|
|
1320
2046
|
this.moveCursorToCol(targetCol);
|
|
1321
2047
|
}
|
|
@@ -1341,13 +2067,14 @@ export class ModalEditor extends CustomEditor {
|
|
|
1341
2067
|
for (let i = 0; i < steps; i++) {
|
|
1342
2068
|
if (currentLine >= state.lines.length - 1) break;
|
|
1343
2069
|
|
|
1344
|
-
const left = state.lines[currentLine]
|
|
1345
|
-
const right = state.lines[currentLine + 1]
|
|
2070
|
+
const left = state.lines[currentLine] ?? "";
|
|
2071
|
+
const right = state.lines[currentLine + 1] ?? "";
|
|
1346
2072
|
let joined: string;
|
|
1347
2073
|
|
|
1348
2074
|
if (normalize) {
|
|
1349
2075
|
const trimmedRight = right.trimStart();
|
|
1350
|
-
const
|
|
2076
|
+
const leftLastChar = left[left.length - 1];
|
|
2077
|
+
const leftEndsWithSpace = leftLastChar !== undefined && /\s/.test(leftLastChar);
|
|
1351
2078
|
const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
|
|
1352
2079
|
joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
|
|
1353
2080
|
joinPoint = left.length;
|
|
@@ -1408,6 +2135,18 @@ export class ModalEditor extends CustomEditor {
|
|
|
1408
2135
|
return this.getAbsoluteIndex(cursor.line, cursor.col);
|
|
1409
2136
|
}
|
|
1410
2137
|
|
|
2138
|
+
private getDelimitedTextObjectCursorAbs(): number {
|
|
2139
|
+
const lines = this.getLines();
|
|
2140
|
+
const cursor = this.getCursor();
|
|
2141
|
+
const line = lines[cursor.line] ?? "";
|
|
2142
|
+
|
|
2143
|
+
if (line.length > 0 && cursor.col >= line.length) {
|
|
2144
|
+
return this.getAbsoluteIndex(cursor.line, line.length - 1);
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
return this.getAbsoluteIndex(cursor.line, cursor.col);
|
|
2148
|
+
}
|
|
2149
|
+
|
|
1411
2150
|
private findWordTargetInText(
|
|
1412
2151
|
text: string,
|
|
1413
2152
|
abs: number,
|
|
@@ -1615,10 +2354,18 @@ export class ModalEditor extends CustomEditor {
|
|
|
1615
2354
|
}
|
|
1616
2355
|
}
|
|
1617
2356
|
|
|
1618
|
-
private
|
|
2357
|
+
private shouldMirrorRegisterWrite(source: RegisterWriteSource): boolean {
|
|
2358
|
+
if (this.clipboardMirrorPolicy === "never") return false;
|
|
2359
|
+
if (this.clipboardMirrorPolicy === "yank") return source === "yank";
|
|
2360
|
+
return true;
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
private writeToRegister(text: string, source: RegisterWriteSource = "mutation"): void {
|
|
1619
2364
|
this.unnamedRegister = text;
|
|
1620
2365
|
if (!text) return;
|
|
1621
|
-
this.
|
|
2366
|
+
if (!this.shouldMirrorRegisterWrite(source)) return;
|
|
2367
|
+
|
|
2368
|
+
this.clipboardMirror.mirror(text);
|
|
1622
2369
|
}
|
|
1623
2370
|
|
|
1624
2371
|
private getCurrentLineAndCol(): { line: string; col: number } {
|
|
@@ -1648,9 +2395,13 @@ export class ModalEditor extends CustomEditor {
|
|
|
1648
2395
|
endIndex = segments.length - 1;
|
|
1649
2396
|
}
|
|
1650
2397
|
|
|
2398
|
+
const startSegment = segments[startIndex];
|
|
2399
|
+
const endSegment = segments[endIndex];
|
|
2400
|
+
if (!startSegment || !endSegment) return null;
|
|
2401
|
+
|
|
1651
2402
|
return {
|
|
1652
|
-
start:
|
|
1653
|
-
end:
|
|
2403
|
+
start: startSegment.start,
|
|
2404
|
+
end: endSegment.end,
|
|
1654
2405
|
};
|
|
1655
2406
|
}
|
|
1656
2407
|
|
|
@@ -1770,7 +2521,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
1770
2521
|
|
|
1771
2522
|
private yankLineRange(startLine: number, endLine: number): void {
|
|
1772
2523
|
if (this.getLines().length === 0) return;
|
|
1773
|
-
this.writeToRegister(this.getLinewisePayload(startLine, endLine));
|
|
2524
|
+
this.writeToRegister(this.getLinewisePayload(startLine, endLine), "yank");
|
|
1774
2525
|
}
|
|
1775
2526
|
|
|
1776
2527
|
private deleteLinewiseByDelta(delta: number): void {
|
|
@@ -1908,14 +2659,14 @@ export class ModalEditor extends CustomEditor {
|
|
|
1908
2659
|
return;
|
|
1909
2660
|
}
|
|
1910
2661
|
|
|
1911
|
-
if (
|
|
1912
|
-
|
|
1913
|
-
this.cancelPendingOperator(data);
|
|
2662
|
+
if (data === "i" || data === "a") {
|
|
2663
|
+
this.pendingTextObject = data;
|
|
1914
2664
|
return;
|
|
1915
2665
|
}
|
|
1916
2666
|
|
|
1917
|
-
if (
|
|
1918
|
-
|
|
2667
|
+
if (this.hasPendingCount()) {
|
|
2668
|
+
// Counted forms beyond yy, y{count}j/k, and y{count}{f/F/t/T} are out of scope.
|
|
2669
|
+
this.cancelPendingOperator(data);
|
|
1919
2670
|
return;
|
|
1920
2671
|
}
|
|
1921
2672
|
|
|
@@ -2005,16 +2756,16 @@ export class ModalEditor extends CustomEditor {
|
|
|
2005
2756
|
if (end <= start) return;
|
|
2006
2757
|
|
|
2007
2758
|
// Yank only — no cursor movement, no text mutation
|
|
2008
|
-
this.writeToRegister(line.slice(start, end));
|
|
2759
|
+
this.writeToRegister(line.slice(start, end), "yank");
|
|
2009
2760
|
}
|
|
2010
2761
|
|
|
2011
|
-
private yankRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean): void {
|
|
2762
|
+
private yankRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean = false): void {
|
|
2012
2763
|
const text = this.getText();
|
|
2013
2764
|
const start = Math.min(currentAbs, targetAbs);
|
|
2014
2765
|
const rawEnd = Math.max(currentAbs, targetAbs) + (inclusive ? 1 : 0);
|
|
2015
2766
|
const end = Math.min(rawEnd, text.length);
|
|
2016
2767
|
if (end <= start) return;
|
|
2017
|
-
this.writeToRegister(text.slice(start, end));
|
|
2768
|
+
this.writeToRegister(text.slice(start, end), "yank");
|
|
2018
2769
|
}
|
|
2019
2770
|
|
|
2020
2771
|
private getCursorFromAbsoluteIndex(text: string, abs: number): { line: number; col: number } {
|
|
@@ -2072,73 +2823,43 @@ export class ModalEditor extends CustomEditor {
|
|
|
2072
2823
|
}
|
|
2073
2824
|
|
|
2074
2825
|
private getWordObjectRange(
|
|
2075
|
-
kind:
|
|
2826
|
+
kind: TextObjectKind,
|
|
2076
2827
|
count: number = 1,
|
|
2077
|
-
|
|
2828
|
+
semanticClass: WordTextObjectClass = "word",
|
|
2829
|
+
): TextObjectRange | null {
|
|
2078
2830
|
const lines = this.getLines();
|
|
2079
2831
|
const cursor = this.getCursor();
|
|
2080
2832
|
const line = lines[cursor.line] ?? "";
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
const steps = Math.max(1, Math.min(MAX_COUNT, count));
|
|
2084
|
-
const hasWordChar = (idx: number) => idx >= 0 && idx < line.length && this.isWordChar(line[idx]!);
|
|
2085
|
-
|
|
2086
|
-
let col = Math.min(cursor.col, Math.max(0, line.length - 1));
|
|
2087
|
-
|
|
2088
|
-
if (!hasWordChar(col)) {
|
|
2089
|
-
let right = col;
|
|
2090
|
-
while (right < line.length && !hasWordChar(right)) right++;
|
|
2091
|
-
if (right < line.length) {
|
|
2092
|
-
col = right;
|
|
2093
|
-
} else {
|
|
2094
|
-
let left = Math.min(col, line.length - 1);
|
|
2095
|
-
while (left >= 0 && !hasWordChar(left)) left--;
|
|
2096
|
-
if (left < 0) return null;
|
|
2097
|
-
col = left;
|
|
2098
|
-
}
|
|
2099
|
-
}
|
|
2100
|
-
|
|
2101
|
-
let start = col;
|
|
2102
|
-
while (start > 0 && hasWordChar(start - 1)) start--;
|
|
2103
|
-
|
|
2104
|
-
let end = col + 1;
|
|
2105
|
-
while (end < line.length && hasWordChar(end)) end++;
|
|
2833
|
+
const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
|
|
2106
2834
|
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2835
|
+
return resolveWordTextObjectRange(
|
|
2836
|
+
line,
|
|
2837
|
+
lineStartAbs,
|
|
2838
|
+
cursor.col,
|
|
2839
|
+
kind,
|
|
2840
|
+
count,
|
|
2841
|
+
semanticClass,
|
|
2842
|
+
);
|
|
2843
|
+
}
|
|
2112
2844
|
|
|
2113
|
-
|
|
2114
|
-
while (nextWordEnd < line.length && hasWordChar(nextWordEnd)) nextWordEnd++;
|
|
2845
|
+
private static readonly PUT_SIZE_LIMIT = 512 * 1024; // 512 KB safety cap
|
|
2115
2846
|
|
|
2116
|
-
|
|
2117
|
-
|
|
2847
|
+
private getPasteRegisterText(): string {
|
|
2848
|
+
if (this.clipboardMirror.hasPendingWrite()) {
|
|
2849
|
+
return this.unnamedRegister;
|
|
2118
2850
|
}
|
|
2119
2851
|
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
end = aroundEnd;
|
|
2126
|
-
} else {
|
|
2127
|
-
while (start > 0 && /\s/.test(line[start - 1]!)) start--;
|
|
2128
|
-
}
|
|
2852
|
+
try {
|
|
2853
|
+
const clipboardText = this.clipboardReadFn();
|
|
2854
|
+
return clipboardText ?? this.unnamedRegister;
|
|
2855
|
+
} catch {
|
|
2856
|
+
return this.unnamedRegister;
|
|
2129
2857
|
}
|
|
2130
|
-
|
|
2131
|
-
return {
|
|
2132
|
-
startAbs: this.getAbsoluteIndex(cursor.line, start),
|
|
2133
|
-
endAbs: this.getAbsoluteIndex(cursor.line, end),
|
|
2134
|
-
};
|
|
2135
2858
|
}
|
|
2136
2859
|
|
|
2137
|
-
private static readonly PUT_SIZE_LIMIT = 512 * 1024; // 512 KB safety cap
|
|
2138
|
-
|
|
2139
2860
|
private putAfter(): void {
|
|
2140
2861
|
const count = this.takeTotalCount(1);
|
|
2141
|
-
const text = this.
|
|
2862
|
+
const text = this.getPasteRegisterText();
|
|
2142
2863
|
if (!text) return;
|
|
2143
2864
|
const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
|
|
2144
2865
|
|
|
@@ -2168,7 +2889,7 @@ export class ModalEditor extends CustomEditor {
|
|
|
2168
2889
|
|
|
2169
2890
|
private putBefore(): void {
|
|
2170
2891
|
const count = this.takeTotalCount(1);
|
|
2171
|
-
const text = this.
|
|
2892
|
+
const text = this.getPasteRegisterText();
|
|
2172
2893
|
if (!text) return;
|
|
2173
2894
|
const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
|
|
2174
2895
|
|
|
@@ -2210,24 +2931,112 @@ export class ModalEditor extends CustomEditor {
|
|
|
2210
2931
|
this.deleteRangeByAbsolute(lineStartAbs + start, lineStartAbs + end);
|
|
2211
2932
|
}
|
|
2212
2933
|
|
|
2934
|
+
private takeModeLabelSuffix(rawLabel: string, width: number): string {
|
|
2935
|
+
if (width <= 0) return "";
|
|
2936
|
+
|
|
2937
|
+
const graphemes = getLineGraphemes(rawLabel);
|
|
2938
|
+
const suffix: string[] = [];
|
|
2939
|
+
let usedWidth = 0;
|
|
2940
|
+
|
|
2941
|
+
for (let i = graphemes.length - 1; i >= 0; i--) {
|
|
2942
|
+
const grapheme = graphemes[i];
|
|
2943
|
+
if (!grapheme) continue;
|
|
2944
|
+
|
|
2945
|
+
const segment = rawLabel.slice(grapheme.start, grapheme.end);
|
|
2946
|
+
const segmentWidth = visibleWidth(segment);
|
|
2947
|
+
if (usedWidth + segmentWidth > width) break;
|
|
2948
|
+
suffix.push(segment);
|
|
2949
|
+
usedWidth += segmentWidth;
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
return suffix.reverse().join("");
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
private fitModeLabel(rawLabel: string, width: number): string {
|
|
2956
|
+
if (visibleWidth(rawLabel) <= width) return rawLabel;
|
|
2957
|
+
|
|
2958
|
+
const prefix = rawLabel.startsWith(" INSERT ")
|
|
2959
|
+
? " INSERT "
|
|
2960
|
+
: rawLabel.startsWith(" NORMAL ")
|
|
2961
|
+
? " NORMAL "
|
|
2962
|
+
: rawLabel.startsWith(" EX ")
|
|
2963
|
+
? " EX "
|
|
2964
|
+
: "";
|
|
2965
|
+
|
|
2966
|
+
if (!prefix || visibleWidth(prefix) >= width) {
|
|
2967
|
+
return truncateToWidth(rawLabel, width, "");
|
|
2968
|
+
}
|
|
2969
|
+
|
|
2970
|
+
const suffixWidth = width - visibleWidth(prefix) - 1;
|
|
2971
|
+
if (suffixWidth <= 0) return `${prefix}…`;
|
|
2972
|
+
return `${prefix}…${this.takeModeLabelSuffix(rawLabel, suffixWidth)}`;
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
private getDesiredCursorShapeSequence(): CursorShapeSequence {
|
|
2976
|
+
return this.mode === "insert" && this.pendingExCommand === null
|
|
2977
|
+
? INSERT_CURSOR_SHAPE
|
|
2978
|
+
: BLOCK_CURSOR_SHAPE;
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
private hasPromptCursorMarker(lines: string[]): boolean {
|
|
2982
|
+
return lines.some((line) => line.includes(CURSOR_MARKER));
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
private stripSoftwareCursorWhenHardwareCursorIsUsed(lines: string[]): void {
|
|
2986
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
2987
|
+
const line = lines[i];
|
|
2988
|
+
if (!line?.includes(CURSOR_MARKER)) continue;
|
|
2989
|
+
|
|
2990
|
+
lines[i] = stripSoftwareCursorAfterMarker(line);
|
|
2991
|
+
return;
|
|
2992
|
+
}
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
private syncCursorShapeForRender(lines: string[]): void {
|
|
2996
|
+
if (!this.cursorShapeRuntime) return;
|
|
2997
|
+
if (!this.hasPromptCursorMarker(lines)) return;
|
|
2998
|
+
|
|
2999
|
+
if (this.cursorShapeRuntime.getShowHardwareCursor?.() === false) {
|
|
3000
|
+
this.lastCursorShapeSequence = null;
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
this.stripSoftwareCursorWhenHardwareCursorIsUsed(lines);
|
|
3005
|
+
|
|
3006
|
+
const sequence = this.getDesiredCursorShapeSequence();
|
|
3007
|
+
if (sequence === this.lastCursorShapeSequence) return;
|
|
3008
|
+
|
|
3009
|
+
this.cursorShapeRuntime.writeCursorShape(sequence);
|
|
3010
|
+
this.lastCursorShapeSequence = sequence;
|
|
3011
|
+
}
|
|
3012
|
+
|
|
2213
3013
|
render(width: number): string[] {
|
|
2214
3014
|
const lines = super.render(width);
|
|
3015
|
+
this.syncCursorShapeForRender(lines);
|
|
2215
3016
|
if (lines.length === 0) return lines;
|
|
2216
3017
|
|
|
2217
|
-
const rawLabel = this.getModeLabel();
|
|
2218
|
-
const colorize = this.
|
|
2219
|
-
? (this.mode === "insert" ? this.labelColorizers.insert : this.labelColorizers.normal)
|
|
2220
|
-
: null;
|
|
3018
|
+
const rawLabel = this.fitModeLabel(this.getModeLabel(), width);
|
|
3019
|
+
const colorize = this.getModeLabelColorizer();
|
|
2221
3020
|
const label = colorize ? colorize(rawLabel) : rawLabel;
|
|
2222
3021
|
const last = lines.length - 1;
|
|
2223
|
-
|
|
2224
|
-
|
|
3022
|
+
const lastLine = lines[last];
|
|
3023
|
+
if (lastLine && visibleWidth(lastLine) >= visibleWidth(rawLabel)) {
|
|
3024
|
+
lines[last] = truncateToWidth(lastLine, width - visibleWidth(rawLabel), "") + label;
|
|
3025
|
+
} else {
|
|
3026
|
+
lines[last] = label;
|
|
2225
3027
|
}
|
|
2226
3028
|
return lines;
|
|
2227
3029
|
}
|
|
2228
3030
|
|
|
3031
|
+
private getModeLabelColorizer(): ((s: string) => string) | null {
|
|
3032
|
+
if (!this.labelColorizers) return null;
|
|
3033
|
+
if (this.pendingExCommand !== null) return this.labelColorizers.ex;
|
|
3034
|
+
return this.mode === "insert" ? this.labelColorizers.insert : this.labelColorizers.normal;
|
|
3035
|
+
}
|
|
3036
|
+
|
|
2229
3037
|
private getModeLabel(): string {
|
|
2230
3038
|
if (this.mode === "insert") return " INSERT ";
|
|
3039
|
+
if (this.pendingExCommand !== null) return ` EX ${this.pendingExCommand}_ `;
|
|
2231
3040
|
|
|
2232
3041
|
const prefixCount = this.prefixCount;
|
|
2233
3042
|
const operatorCount = this.operatorCount;
|
|
@@ -2255,12 +3064,37 @@ export class ModalEditor extends CustomEditor {
|
|
|
2255
3064
|
}
|
|
2256
3065
|
|
|
2257
3066
|
export default function (pi: ExtensionAPI) {
|
|
3067
|
+
let cursorShapeCleanup: CursorShapeCleanup | null = null;
|
|
3068
|
+
|
|
2258
3069
|
pi.on("session_start", (_event, ctx) => {
|
|
3070
|
+
const piVimSettings = readPiVimSettings(ctx.cwd);
|
|
3071
|
+
const clipboardMirrorPolicy = resolveClipboardMirrorPolicy(piVimSettings.clipboardMirror);
|
|
3072
|
+
if (clipboardMirrorPolicy.warning && ctx.hasUI) {
|
|
3073
|
+
ctx.ui.notify(clipboardMirrorPolicy.warning, "warning");
|
|
3074
|
+
}
|
|
3075
|
+
|
|
2259
3076
|
const t = ctx.ui.theme;
|
|
3077
|
+
const reverseVideo = (s: string) => `\x1b[7m${s}\x1b[27m`;
|
|
2260
3078
|
const colorizers = t ? {
|
|
2261
|
-
insert: (s: string) => t.fg("borderMuted",
|
|
2262
|
-
normal: (s: string) => t.fg("borderAccent",
|
|
3079
|
+
insert: (s: string) => t.fg("borderMuted", reverseVideo(s)),
|
|
3080
|
+
normal: (s: string) => t.fg("borderAccent", reverseVideo(s)),
|
|
3081
|
+
ex: (s: string) => t.fg("warning", reverseVideo(s)),
|
|
2263
3082
|
} : null;
|
|
2264
|
-
ctx.ui.setEditorComponent((tui, theme, kb) =>
|
|
3083
|
+
ctx.ui.setEditorComponent((tui, theme, kb) => {
|
|
3084
|
+
cursorShapeCleanup = enableCursorShapeSupport(tui);
|
|
3085
|
+
const editor = new ModalEditor(tui, theme, kb, colorizers);
|
|
3086
|
+
editor.setClipboardMirrorPolicy(clipboardMirrorPolicy.policy);
|
|
3087
|
+
editor.setQuitFn(() => ctx.shutdown());
|
|
3088
|
+
editor.setNotifyFn((message) => ctx.ui.notify(message, "warning"));
|
|
3089
|
+
return editor;
|
|
3090
|
+
});
|
|
3091
|
+
});
|
|
3092
|
+
|
|
3093
|
+
pi.on("session_shutdown", () => {
|
|
3094
|
+
try {
|
|
3095
|
+
cursorShapeCleanup?.();
|
|
3096
|
+
} finally {
|
|
3097
|
+
cursorShapeCleanup = null;
|
|
3098
|
+
}
|
|
2265
3099
|
});
|
|
2266
3100
|
}
|