pi-vim 0.3.2 → 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/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: "i" | "a" | null = null;
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,30 +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: { insert: (s: string) => string; normal: (s: string) => string } | null;
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 clipboardFn: (text: string) => Promise<void> = async (text: string) => {
144
- await copyToClipboard(text);
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: any,
149
- theme: any,
150
- kb: any,
151
- labelColorizers?: { insert: (s: string) => string; normal: (s: string) => string } | null,
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) => unknown): void {
159
- this.clipboardFn = async (text: string) => {
160
- await fn(text);
161
- };
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;
162
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; }
163
594
  getRegister(): string { return this.unnamedRegister; }
164
595
  setRegister(text: string): void { this.unnamedRegister = text; }
165
596
  getMode(): Mode { return this.mode; }
@@ -348,6 +779,26 @@ export class ModalEditor extends CustomEditor {
348
779
  editor.tui?.requestRender?.();
349
780
  }
350
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
+
351
802
  private clearPendingState(): void {
352
803
  this.pendingMotion = null;
353
804
  this.pendingTextObject = null;
@@ -357,12 +808,69 @@ export class ModalEditor extends CustomEditor {
357
808
  this.pendingG = false;
358
809
  this.pendingGCount = "";
359
810
  this.pendingReplace = false;
811
+ this.clearPendingExCommand();
360
812
  }
361
813
 
362
814
  private isEscapeLikeInput(data: string): boolean {
363
815
  return matchesKey(data, "escape") || matchesKey(data, "ctrl+[");
364
816
  }
365
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
+
366
874
  private stripBracketedPasteInNormalMode(data: string): { filtered: string | null; stripped: boolean } {
367
875
  let chunk = data;
368
876
  let stripped = false;
@@ -401,7 +909,11 @@ export class ModalEditor extends CustomEditor {
401
909
  handleInput(data: string): void {
402
910
  this.ensureOnChangeHook();
403
911
 
404
- if (this.mode !== "insert") {
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") {
405
917
  if (this.discardingBracketedPasteInNormalMode) {
406
918
  if (this.isEscapeLikeInput(data)) {
407
919
  if (this.pendingEscWhileDiscardingBracketedPasteInNormalMode) {
@@ -438,17 +950,20 @@ export class ModalEditor extends CustomEditor {
438
950
  }
439
951
 
440
952
  if (this.isEscapeLikeInput(data)) {
441
- return this.handleEscape();
953
+ this.handleEscape();
954
+ return;
442
955
  }
443
956
 
444
957
  if (this.mode === "insert") {
445
958
  // Shift+Alt+A: go to end of line (like Esc -> A but stay in insert)
446
959
  if (matchesKey(data, Key.shiftAlt("a")) || data === "\x1bA") {
447
- return super.handleInput(CTRL_E);
960
+ super.handleInput(CTRL_E);
961
+ return;
448
962
  }
449
963
  // Shift+Alt+I: go to start of line (like Esc -> I but stay in insert)
450
964
  if (matchesKey(data, Key.shiftAlt("i")) || data === "\x1bI") {
451
- return super.handleInput(CTRL_A);
965
+ super.handleInput(CTRL_A);
966
+ return;
452
967
  }
453
968
  // Alt+o: open new line below (stay in insert mode)
454
969
  if (matchesKey(data, Key.alt("o")) || data === "\x1bo") {
@@ -491,24 +1006,34 @@ export class ModalEditor extends CustomEditor {
491
1006
  return;
492
1007
  }
493
1008
 
1009
+ if (this.pendingExCommand !== null) {
1010
+ this.handlePendingExCommand(data);
1011
+ return;
1012
+ }
1013
+
494
1014
  if (this.pendingTextObject) {
495
- return this.handlePendingTextObject(data);
1015
+ this.handlePendingTextObject(data);
1016
+ return;
496
1017
  }
497
1018
 
498
1019
  if (this.pendingMotion) {
499
- return this.handlePendingMotion(data);
1020
+ this.handlePendingMotion(data);
1021
+ return;
500
1022
  }
501
1023
 
502
1024
  if (this.pendingOperator === "d") {
503
- return this.handlePendingDelete(data);
1025
+ this.handlePendingDelete(data);
1026
+ return;
504
1027
  }
505
1028
 
506
1029
  if (this.pendingOperator === "c") {
507
- return this.handlePendingChange(data);
1030
+ this.handlePendingChange(data);
1031
+ return;
508
1032
  }
509
1033
 
510
1034
  if (this.pendingOperator === "y") {
511
- return this.handlePendingYank(data);
1035
+ this.handlePendingYank(data);
1036
+ return;
512
1037
  }
513
1038
 
514
1039
  this.handleNormalMode(data);
@@ -533,6 +1058,11 @@ export class ModalEditor extends CustomEditor {
533
1058
  }
534
1059
 
535
1060
  private handleEscape(): void {
1061
+ if (this.pendingExCommand !== null) {
1062
+ this.clearPendingExCommand();
1063
+ return;
1064
+ }
1065
+
536
1066
  if (
537
1067
  this.pendingMotion
538
1068
  || this.pendingTextObject
@@ -554,11 +1084,135 @@ export class ModalEditor extends CustomEditor {
554
1084
  }
555
1085
  }
556
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
+
557
1211
  private isPrintableChunk(data: string): boolean {
558
1212
  if (data.length === 0) return false;
559
1213
  for (const char of data) {
560
- const codePoint = char.codePointAt(0)!;
561
- if (codePoint < 32 || codePoint === 127) return false;
1214
+ const codePoint = char.codePointAt(0);
1215
+ if (codePoint === undefined || codePoint < 32 || codePoint === 127) return false;
562
1216
  }
563
1217
  return true;
564
1218
  }
@@ -603,6 +1257,10 @@ export class ModalEditor extends CustomEditor {
603
1257
  return Math.min(MAX_COUNT, total);
604
1258
  }
605
1259
 
1260
+ private hasPendingCount(): boolean {
1261
+ return this.prefixCount.length > 0 || this.operatorCount.length > 0;
1262
+ }
1263
+
606
1264
  private cancelPendingOperator(data: string): void {
607
1265
  this.pendingOperator = null;
608
1266
  this.prefixCount = "";
@@ -619,59 +1277,101 @@ export class ModalEditor extends CustomEditor {
619
1277
  return;
620
1278
  }
621
1279
 
1280
+ const pendingMotion = this.pendingMotion;
1281
+ if (!pendingMotion) return;
1282
+
622
1283
  if (this.pendingOperator === "d") {
623
- this.deleteWithCharMotion(this.pendingMotion!, data);
1284
+ this.deleteWithCharMotion(pendingMotion, data);
624
1285
  this.pendingOperator = null;
625
1286
  } else if (this.pendingOperator === "c") {
626
- this.deleteWithCharMotion(this.pendingMotion!, data);
1287
+ this.deleteWithCharMotion(pendingMotion, data);
627
1288
  this.pendingOperator = null;
628
1289
  this.mode = "insert";
629
1290
  } else if (this.pendingOperator === "y") {
630
- this.yankWithCharMotion(this.pendingMotion!, data);
1291
+ this.yankWithCharMotion(pendingMotion, data);
631
1292
  this.pendingOperator = null;
632
1293
  } else {
633
- this.executeCharMotion(this.pendingMotion!, data);
1294
+ this.executeCharMotion(pendingMotion, data);
634
1295
  }
635
1296
 
636
1297
  this.pendingMotion = null;
637
1298
  }
638
1299
 
639
1300
  private handlePendingTextObject(data: string): void {
640
- if (data !== "w") {
641
- this.pendingTextObject = null;
642
- this.cancelPendingOperator(data);
1301
+ const pendingTextObject = this.pendingTextObject;
1302
+ this.pendingTextObject = null;
1303
+ if (!pendingTextObject) {
1304
+ this.pendingOperator = null;
643
1305
  return;
644
1306
  }
645
1307
 
646
- const count = this.takeTotalCount(1);
647
- const range = this.getWordObjectRange(this.pendingTextObject!, count);
648
- this.pendingTextObject = null;
649
- if (!range || !this.pendingOperator) {
650
- this.pendingOperator = null;
1308
+ const hasCount = this.hasPendingCount();
1309
+
1310
+ if (this.pendingOperator === "y" && hasCount) {
1311
+ this.cancelPendingOperator(data);
651
1312
  return;
652
1313
  }
653
1314
 
654
- const { startAbs, endAbs } = range;
655
- if (this.pendingOperator === "d") {
656
- this.deleteRangeByAbsolute(startAbs, endAbs);
657
- this.pendingOperator = null;
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);
658
1325
  return;
659
1326
  }
660
1327
 
661
- if (this.pendingOperator === "c") {
662
- this.deleteRangeByAbsolute(startAbs, endAbs);
663
- this.pendingOperator = null;
664
- this.mode = "insert";
1328
+ if (hasCount) {
1329
+ this.cancelPendingOperator(data);
665
1330
  return;
666
1331
  }
667
1332
 
668
- if (this.pendingOperator === "y") {
669
- this.yankRangeByAbsolute(startAbs, endAbs);
670
- this.pendingOperator = null;
1333
+ const range = resolveDelimitedTextObjectRange(
1334
+ this.getText(),
1335
+ this.getDelimitedTextObjectCursorAbs(),
1336
+ pendingTextObject,
1337
+ data,
1338
+ );
1339
+ if (!range) {
1340
+ this.cancelPendingOperator(data);
671
1341
  return;
672
1342
  }
673
1343
 
1344
+ this.applyResolvedTextObjectRange(range);
1345
+ }
1346
+
1347
+ private applyResolvedTextObjectRange(range: TextObjectRange): void {
1348
+ const pendingOperator = this.pendingOperator;
674
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
+ }
675
1375
  }
676
1376
 
677
1377
  private handlePendingDelete(data: string): void {
@@ -986,6 +1686,11 @@ export class ModalEditor extends CustomEditor {
986
1686
  return;
987
1687
  }
988
1688
 
1689
+ if (data === ":") {
1690
+ this.startPendingExCommand();
1691
+ return;
1692
+ }
1693
+
989
1694
  if (data === "G") {
990
1695
  this.moveCursorToBufferEnd();
991
1696
  return;
@@ -1076,16 +1781,33 @@ export class ModalEditor extends CustomEditor {
1076
1781
 
1077
1782
  if (data === "w") {
1078
1783
  const count = this.takeTotalCount(1);
1079
- return this.moveWord("forward", "start", count, "word");
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;
1080
1806
  }
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
1807
 
1087
1808
  if (Object.hasOwn(NORMAL_KEYS, data)) {
1088
- return this.handleMappedKey(data);
1809
+ this.handleMappedKey(data);
1810
+ return;
1089
1811
  }
1090
1812
 
1091
1813
  // Pass control sequences (ctrl+c, etc.) to super, ignore printable chars
@@ -1345,13 +2067,14 @@ export class ModalEditor extends CustomEditor {
1345
2067
  for (let i = 0; i < steps; i++) {
1346
2068
  if (currentLine >= state.lines.length - 1) break;
1347
2069
 
1348
- const left = state.lines[currentLine]!;
1349
- const right = state.lines[currentLine + 1]!;
2070
+ const left = state.lines[currentLine] ?? "";
2071
+ const right = state.lines[currentLine + 1] ?? "";
1350
2072
  let joined: string;
1351
2073
 
1352
2074
  if (normalize) {
1353
2075
  const trimmedRight = right.trimStart();
1354
- const leftEndsWithSpace = left.length > 0 && /\s/.test(left[left.length - 1]!);
2076
+ const leftLastChar = left[left.length - 1];
2077
+ const leftEndsWithSpace = leftLastChar !== undefined && /\s/.test(leftLastChar);
1355
2078
  const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
1356
2079
  joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
1357
2080
  joinPoint = left.length;
@@ -1412,6 +2135,18 @@ export class ModalEditor extends CustomEditor {
1412
2135
  return this.getAbsoluteIndex(cursor.line, cursor.col);
1413
2136
  }
1414
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
+
1415
2150
  private findWordTargetInText(
1416
2151
  text: string,
1417
2152
  abs: number,
@@ -1619,11 +2354,18 @@ export class ModalEditor extends CustomEditor {
1619
2354
  }
1620
2355
  }
1621
2356
 
1622
- private writeToRegister(text: string): void {
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 {
1623
2364
  this.unnamedRegister = text;
1624
2365
  if (!text) return;
2366
+ if (!this.shouldMirrorRegisterWrite(source)) return;
1625
2367
 
1626
- void this.clipboardFn(text).catch(() => {});
2368
+ this.clipboardMirror.mirror(text);
1627
2369
  }
1628
2370
 
1629
2371
  private getCurrentLineAndCol(): { line: string; col: number } {
@@ -1653,9 +2395,13 @@ export class ModalEditor extends CustomEditor {
1653
2395
  endIndex = segments.length - 1;
1654
2396
  }
1655
2397
 
2398
+ const startSegment = segments[startIndex];
2399
+ const endSegment = segments[endIndex];
2400
+ if (!startSegment || !endSegment) return null;
2401
+
1656
2402
  return {
1657
- start: segments[startIndex]!.start,
1658
- end: segments[endIndex]!.end,
2403
+ start: startSegment.start,
2404
+ end: endSegment.end,
1659
2405
  };
1660
2406
  }
1661
2407
 
@@ -1775,7 +2521,7 @@ export class ModalEditor extends CustomEditor {
1775
2521
 
1776
2522
  private yankLineRange(startLine: number, endLine: number): void {
1777
2523
  if (this.getLines().length === 0) return;
1778
- this.writeToRegister(this.getLinewisePayload(startLine, endLine));
2524
+ this.writeToRegister(this.getLinewisePayload(startLine, endLine), "yank");
1779
2525
  }
1780
2526
 
1781
2527
  private deleteLinewiseByDelta(delta: number): void {
@@ -1913,14 +2659,14 @@ export class ModalEditor extends CustomEditor {
1913
2659
  return;
1914
2660
  }
1915
2661
 
1916
- if (this.prefixCount.length > 0 || this.operatorCount.length > 0) {
1917
- // Counted forms beyond yy, y{count}j/k, and y{count}{f/F/t/T} are out of scope.
1918
- this.cancelPendingOperator(data);
2662
+ if (data === "i" || data === "a") {
2663
+ this.pendingTextObject = data;
1919
2664
  return;
1920
2665
  }
1921
2666
 
1922
- if (data === "i" || data === "a") {
1923
- this.pendingTextObject = data;
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);
1924
2670
  return;
1925
2671
  }
1926
2672
 
@@ -2010,7 +2756,7 @@ export class ModalEditor extends CustomEditor {
2010
2756
  if (end <= start) return;
2011
2757
 
2012
2758
  // Yank only — no cursor movement, no text mutation
2013
- this.writeToRegister(line.slice(start, end));
2759
+ this.writeToRegister(line.slice(start, end), "yank");
2014
2760
  }
2015
2761
 
2016
2762
  private yankRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean = false): void {
@@ -2019,7 +2765,7 @@ export class ModalEditor extends CustomEditor {
2019
2765
  const rawEnd = Math.max(currentAbs, targetAbs) + (inclusive ? 1 : 0);
2020
2766
  const end = Math.min(rawEnd, text.length);
2021
2767
  if (end <= start) return;
2022
- this.writeToRegister(text.slice(start, end));
2768
+ this.writeToRegister(text.slice(start, end), "yank");
2023
2769
  }
2024
2770
 
2025
2771
  private getCursorFromAbsoluteIndex(text: string, abs: number): { line: number; col: number } {
@@ -2077,73 +2823,43 @@ export class ModalEditor extends CustomEditor {
2077
2823
  }
2078
2824
 
2079
2825
  private getWordObjectRange(
2080
- kind: "i" | "a",
2826
+ kind: TextObjectKind,
2081
2827
  count: number = 1,
2082
- ): { startAbs: number; endAbs: number } | null {
2828
+ semanticClass: WordTextObjectClass = "word",
2829
+ ): TextObjectRange | null {
2083
2830
  const lines = this.getLines();
2084
2831
  const cursor = this.getCursor();
2085
2832
  const line = lines[cursor.line] ?? "";
2086
- if (!line) return null;
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++;
2833
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
2111
2834
 
2112
- let remaining = steps - 1;
2113
- while (remaining > 0) {
2114
- let nextWordStart = end;
2115
- while (nextWordStart < line.length && !hasWordChar(nextWordStart)) nextWordStart++;
2116
- if (nextWordStart >= line.length) break;
2835
+ return resolveWordTextObjectRange(
2836
+ line,
2837
+ lineStartAbs,
2838
+ cursor.col,
2839
+ kind,
2840
+ count,
2841
+ semanticClass,
2842
+ );
2843
+ }
2117
2844
 
2118
- let nextWordEnd = nextWordStart + 1;
2119
- while (nextWordEnd < line.length && hasWordChar(nextWordEnd)) nextWordEnd++;
2845
+ private static readonly PUT_SIZE_LIMIT = 512 * 1024; // 512 KB safety cap
2120
2846
 
2121
- end = nextWordEnd;
2122
- remaining--;
2847
+ private getPasteRegisterText(): string {
2848
+ if (this.clipboardMirror.hasPendingWrite()) {
2849
+ return this.unnamedRegister;
2123
2850
  }
2124
2851
 
2125
- if (kind === "a") {
2126
- let aroundEnd = end;
2127
- while (aroundEnd < line.length && /\s/.test(line[aroundEnd]!)) aroundEnd++;
2128
-
2129
- if (aroundEnd > end) {
2130
- end = aroundEnd;
2131
- } else {
2132
- while (start > 0 && /\s/.test(line[start - 1]!)) start--;
2133
- }
2852
+ try {
2853
+ const clipboardText = this.clipboardReadFn();
2854
+ return clipboardText ?? this.unnamedRegister;
2855
+ } catch {
2856
+ return this.unnamedRegister;
2134
2857
  }
2135
-
2136
- return {
2137
- startAbs: this.getAbsoluteIndex(cursor.line, start),
2138
- endAbs: this.getAbsoluteIndex(cursor.line, end),
2139
- };
2140
2858
  }
2141
2859
 
2142
- private static readonly PUT_SIZE_LIMIT = 512 * 1024; // 512 KB safety cap
2143
-
2144
2860
  private putAfter(): void {
2145
2861
  const count = this.takeTotalCount(1);
2146
- const text = this.unnamedRegister;
2862
+ const text = this.getPasteRegisterText();
2147
2863
  if (!text) return;
2148
2864
  const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
2149
2865
 
@@ -2173,7 +2889,7 @@ export class ModalEditor extends CustomEditor {
2173
2889
 
2174
2890
  private putBefore(): void {
2175
2891
  const count = this.takeTotalCount(1);
2176
- const text = this.unnamedRegister;
2892
+ const text = this.getPasteRegisterText();
2177
2893
  if (!text) return;
2178
2894
  const safeCount = Math.min(count, Math.max(1, Math.floor(ModalEditor.PUT_SIZE_LIMIT / text.length)));
2179
2895
 
@@ -2215,24 +2931,112 @@ export class ModalEditor extends CustomEditor {
2215
2931
  this.deleteRangeByAbsolute(lineStartAbs + start, lineStartAbs + end);
2216
2932
  }
2217
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
+
2218
3013
  render(width: number): string[] {
2219
3014
  const lines = super.render(width);
3015
+ this.syncCursorShapeForRender(lines);
2220
3016
  if (lines.length === 0) return lines;
2221
3017
 
2222
- const rawLabel = this.getModeLabel();
2223
- const colorize = this.labelColorizers
2224
- ? (this.mode === "insert" ? this.labelColorizers.insert : this.labelColorizers.normal)
2225
- : null;
3018
+ const rawLabel = this.fitModeLabel(this.getModeLabel(), width);
3019
+ const colorize = this.getModeLabelColorizer();
2226
3020
  const label = colorize ? colorize(rawLabel) : rawLabel;
2227
3021
  const last = lines.length - 1;
2228
- if (visibleWidth(lines[last]!) >= visibleWidth(rawLabel)) {
2229
- lines[last] = truncateToWidth(lines[last]!, width - visibleWidth(rawLabel), "") + label;
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;
2230
3027
  }
2231
3028
  return lines;
2232
3029
  }
2233
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
+
2234
3037
  private getModeLabel(): string {
2235
3038
  if (this.mode === "insert") return " INSERT ";
3039
+ if (this.pendingExCommand !== null) return ` EX ${this.pendingExCommand}_ `;
2236
3040
 
2237
3041
  const prefixCount = this.prefixCount;
2238
3042
  const operatorCount = this.operatorCount;
@@ -2260,12 +3064,37 @@ export class ModalEditor extends CustomEditor {
2260
3064
  }
2261
3065
 
2262
3066
  export default function (pi: ExtensionAPI) {
3067
+ let cursorShapeCleanup: CursorShapeCleanup | null = null;
3068
+
2263
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
+
2264
3076
  const t = ctx.ui.theme;
3077
+ const reverseVideo = (s: string) => `\x1b[7m${s}\x1b[27m`;
2265
3078
  const colorizers = t ? {
2266
- insert: (s: string) => t.fg("borderMuted", `\x1b[7m${s}\x1b[27m`),
2267
- normal: (s: string) => t.fg("borderAccent", `\x1b[7m${s}\x1b[27m`),
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)),
2268
3082
  } : null;
2269
- ctx.ui.setEditorComponent((tui, theme, kb) => new ModalEditor(tui, theme, kb, colorizers));
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
+ }
2270
3099
  });
2271
3100
  }