pi-vim 0.2.0 → 0.3.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.
Files changed (5) hide show
  1. package/README.md +58 -55
  2. package/index.ts +342 -100
  3. package/motions.ts +55 -13
  4. package/package.json +1 -1
  5. package/types.ts +0 -1
package/README.md CHANGED
@@ -1,16 +1,12 @@
1
- # pi-vim — Vim Mode for Pi REPL
1
+ # pi-vim
2
2
 
3
- Modal vim-like editing for Pi's REPL prompt.
4
- Focus: the high-frequency 90% command surface, not full Vim.
3
+ Modal vim-like editing for Pi's input prompt. Covers the high-frequency 90% command surface.
5
4
 
6
- ## TL;DR
5
+ ## why
7
6
 
8
- - Problem: REPL editing is slow with only linear cursor movement.
9
- - Solution: modal editing (`INSERT`/`NORMAL`) with Vim-style motions,
10
- operators, counts, and repeatable workflows.
11
- - Install: `pi install npm:pi-vim`
7
+ You love Pi, you love Vim, you'll love pi-vim.
12
8
 
13
- ## Install
9
+ ## install
14
10
 
15
11
  ```bash
16
12
  pi install npm:pi-vim
@@ -18,19 +14,11 @@ pi install npm:pi-vim
18
14
 
19
15
  Restart Pi after install.
20
16
 
21
- ### Local loading (dev)
17
+ ## stats
22
18
 
23
- ```bash
24
- pi --extension /path/to/pi-vim/index.ts
25
- ```
26
-
27
- Or add to `.pi/settings.json`:
28
-
29
- ```json
30
- {
31
- "extensions": ["./pi-extensions/pi-vim/index.ts"]
32
- }
33
- ```
19
+ - **112 commands**: motions, operators, counts, text objects, undo/redo
20
+ - **sub-µs word motions** via precomputed boundary cache (~4ms startup, ~150KB memory)
21
+ - **0 dependencies**
34
22
 
35
23
  ## 30-second quickstart
36
24
 
@@ -46,23 +34,25 @@ u # undo
46
34
  ```
47
35
 
48
36
  Mode indicator (`INSERT` / `NORMAL`) appears at bottom-right.
37
+ Its label is theme-colored: reverse-video `borderMuted` for
38
+ INSERT, `borderAccent` for NORMAL.
49
39
 
50
- ## Why pi-vim
40
+ ## why pi-vim
51
41
 
52
42
  - Fast modal editing without leaving Pi.
53
43
  - Count-aware motions/operators (`2dw`, `3G`, `d2j`, `2}`).
54
44
  - Strong REPL-focused defaults; safe out-of-scope boundaries documented.
55
45
  - Clipboard/register behavior is explicit and tested.
56
46
 
57
- ## For you / not for you
47
+ ## for you / not for you
58
48
 
59
49
  Use pi-vim if you want fast Vim muscle-memory in Pi prompts.
60
50
  Skip it if you need full Vim feature parity (visual mode, macros, search,
61
51
  ex-commands, etc.).
62
52
 
63
- ## Common recipes
53
+ ## common recipes
64
54
 
65
- | Goal | Keys |
55
+ | goal | keys |
66
56
  |------|------|
67
57
  | Jump to exact line 25 | `25gg` (or `25G`) |
68
58
  | Delete two words | `2dw` |
@@ -76,24 +66,24 @@ ex-commands, etc.).
76
66
 
77
67
  ---
78
68
 
79
- ## Full reference
69
+ ## full reference
80
70
 
81
- ### Mode switching
71
+ ### mode switching
82
72
 
83
- | Key | Action |
73
+ | key | action |
84
74
  |----------|----------------------------------------|
85
75
  | `Esc` / `Ctrl+[` | Insert → Normal mode |
86
76
  | `Esc` / `Ctrl+[` | Normal mode → pass to Pi (abort agent) |
87
77
  | `i` | Normal → Insert at cursor |
88
78
  | `a` | Normal → Insert after cursor |
89
- | `I` | Normal → Insert at line start |
79
+ | `I` | Normal → Insert at first non-whitespace |
90
80
  | `A` | Normal → Insert at line end |
91
81
  | `o` | Normal → open line below + Insert |
92
82
  | `O` | Normal → open line above + Insert |
93
83
 
94
84
  Insert-mode shortcuts (stay in Insert mode):
95
85
 
96
- | Key | Action |
86
+ | key | action |
97
87
  |-----------------|------------------------|
98
88
  | `Shift+Alt+A` | Go to end of line |
99
89
  | `Shift+Alt+I` | Go to start of line |
@@ -102,11 +92,11 @@ Insert-mode shortcuts (stay in Insert mode):
102
92
 
103
93
  ---
104
94
 
105
- ### Navigation (Normal mode)
95
+ ### navigation (normal mode)
106
96
 
107
97
  A `{count}` prefix can be prepended to any navigation key (max: `9999`).
108
98
 
109
- | Key | Action |
99
+ | key | action |
110
100
  |---------------|-------------------------------|
111
101
  | `h` | Left |
112
102
  | `l` | Right |
@@ -116,6 +106,7 @@ A `{count}` prefix can be prepended to any navigation key (max: `9999`).
116
106
  | `{count}j/k` | Move down/up `{count}` lines (clamped to buffer size) |
117
107
  | `0` | Line start |
118
108
  | `^` | First non-whitespace char of line |
109
+ | `_` | First non-whitespace char; with `{count}`, move down `count - 1` lines first |
119
110
  | `$` | Line end |
120
111
  | `gg` | Buffer start (line 1) |
121
112
  | `{count}gg` | Go to line `{count}` (1-indexed, clamped) |
@@ -148,11 +139,11 @@ Operator forms with braces (`d{`, `d}`, `c{`, `c}`, `y{`, `y}`) are out of scope
148
139
 
149
140
  ---
150
141
 
151
- ### Character-find motions (Normal mode)
142
+ ### character-find motions (normal mode)
152
143
 
153
144
  A `{count}` prefix finds the Nth occurrence of `{char}` on the line.
154
145
 
155
- | Key | Action |
146
+ | key | action |
156
147
  |------------------|------------------------------------------------|
157
148
  | `f{char}` | Jump forward to `char` (inclusive) |
158
149
  | `F{char}` | Jump backward to `char` (inclusive) |
@@ -166,17 +157,17 @@ Char-find motions compose with operators: `df{char}`, `ct{char}`, `d{count}t{cha
166
157
 
167
158
  ---
168
159
 
169
- ### Edit operators (Normal mode)
160
+ ### edit operators (normal mode)
170
161
 
171
162
  All operators write to the unnamed register and mirror to the system clipboard
172
163
  (best-effort; clipboard failure never breaks editing).
173
164
 
174
- #### Delete `d{motion}` / `dd`
165
+ #### delete `d{motion}` / `dd`
175
166
 
176
167
  A `{count}` or dual-count prefix (`{pfx}d{op}{motion}`) is supported for
177
168
  word, char-find, and linewise motions. Maximum total count: `9999`.
178
169
 
179
- | Command | Deletes |
170
+ | command | deletes |
180
171
  |-------------------|-----------------------------------------------------------|
181
172
  | `dw` | Forward to next `word` start (exclusive, can cross lines) |
182
173
  | `de` | Forward to `word` end (inclusive, can cross lines) |
@@ -189,6 +180,8 @@ word, char-find, and linewise motions. Maximum total count: `9999`.
189
180
  | `d$` | To end of line |
190
181
  | `d0` | To start of line |
191
182
  | `d^` | To first non-whitespace char of line |
183
+ | `d_` | Current line (linewise, same as `dd`) |
184
+ | `d{count}_` | `{count}` lines (linewise, same as `{count}dd`) |
192
185
  | `dd` | Current line (linewise) |
193
186
  | `{count}dd` | `{count}` lines (linewise) |
194
187
  | `d{count}j` | Current line + `{count}` lines below (linewise) |
@@ -203,11 +196,11 @@ word, char-find, and linewise motions. Maximum total count: `9999`.
203
196
  | `daw` | Around word (includes surrounding spaces) |
204
197
  | `d{count}aw` | Around `{count}` words |
205
198
 
206
- #### Change `c{motion}` / `cc`
199
+ #### change `c{motion}` / `cc`
207
200
 
208
201
  Same motion and count set as `d`. Deletes text then enters Insert mode.
209
202
 
210
- | Command | Action |
203
+ | command | action |
211
204
  |-----------------|------------------------------------|
212
205
  | `cw` | Change `word` + Insert |
213
206
  | `ce` / `cb` | Change to `word` end / previous `word` start |
@@ -218,14 +211,16 @@ Same motion and count set as `d`. Deletes text then enters Insert mode.
218
211
  | `ciw` | Change inner word |
219
212
  | `caw` | Change around word |
220
213
  | `cc` | Delete line content + Insert |
214
+ | `c_` | Change line (linewise, same as `cc`) |
215
+ | `c{count}_` | Change `{count}` lines (linewise) |
221
216
  | `c$` / `c0` / `c^` | Delete to EOL / BOL / first non-whitespace + Insert |
222
217
  | … | All `d` motions apply |
223
218
 
224
- #### Single-key edits
219
+ #### single-key edits
225
220
 
226
221
  A `{count}` prefix is supported for `x`, `p`, `P`. Maximum: `9999`.
227
222
 
228
- | Key | Action |
223
+ | key | action |
229
224
  |--------------|---------------------------------------------------------------|
230
225
  | `x` | Delete char under cursor (no-op at/past EOL) |
231
226
  | `{count}x` | Delete `{count}` chars |
@@ -233,17 +228,21 @@ A `{count}` prefix is supported for `x`, `p`, `P`. Maximum: `9999`.
233
228
  | `S` | Delete line content + Insert mode |
234
229
  | `D` | Delete cursor to EOL (captures `\n` if at EOL with next line) |
235
230
  | `C` | Delete cursor to EOL + Insert mode |
231
+ | `r{char}` | Replace char under cursor with `{char}` (stays in Normal) |
232
+ | `{count}r{char}` | Replace next `{count}` chars with `{char}` |
236
233
 
237
234
  ---
238
235
 
239
- ### Yank `y{motion}` / `yy`
236
+ ### yank `y{motion}` / `yy`
240
237
 
241
238
  Same motion set as `d`. Writes to register, **no text mutation**.
242
239
 
243
- | Command | Yanks |
240
+ | command | yanks |
244
241
  |---------|---------------------------------|
245
242
  | `yy` | Whole line + trailing `\n` |
243
+ | `Y` | Whole line + trailing `\n` (same as `yy`) |
246
244
  | `{count}yy` | `{count}` whole lines + trailing `\n` |
245
+ | `{count}Y` | `{count}` whole lines + trailing `\n` (same as `{count}yy`) |
247
246
  | `y{count}j` | Current line + `{count}` lines below (linewise) |
248
247
  | `y{count}k` | Current line + `{count}` lines above (linewise) |
249
248
  | `yG` | Current line to end of buffer (linewise) |
@@ -256,6 +255,8 @@ Same motion set as `d`. Writes to register, **no text mutation**.
256
255
  | `y$` | To end of line |
257
256
  | `y0` | To start of line |
258
257
  | `y^` | To first non-whitespace char of line |
258
+ | `y_` | Whole line (linewise, same as `yy`) |
259
+ | `y{count}_` | `{count}` whole lines (linewise) |
259
260
  | `yf{c}` | To and including `char` |
260
261
  | `yiw` | Inner word |
261
262
  | `yaw` | Around word (includes spaces) |
@@ -266,9 +267,9 @@ Linewise counted yank (`{count}yy`, `y{count}j/k`) remains supported.
266
267
 
267
268
  ---
268
269
 
269
- ### Put / Paste
270
+ ### put / paste
270
271
 
271
- | Key | Action |
272
+ | key | action |
272
273
  |--------------|-------------------------------------------------------------|
273
274
  | `p` | Put after cursor (char-wise) / new line below (line-wise) |
274
275
  | `P` | Put before cursor (char-wise) / new line above (line-wise) |
@@ -280,22 +281,23 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
280
281
 
281
282
  ---
282
283
 
283
- ### Undo / Redo
284
+ ### undo / redo
284
285
 
285
- | Key | Action |
286
+ | key | action |
286
287
  |-----|--------|
287
- | `u` | Undo in normal mode |
288
+ | `u` | Undo one change in normal mode |
289
+ | `{count}u` | Undo up to `{count}` changes in normal mode; clamps at available history |
288
290
  | `Ctrl+_` | Undo in normal mode (alias for `u`) |
289
291
  | `<C-r>` | Redo one undone change in normal mode; safe no-op when redo history is empty |
290
292
  | `{count}<C-r>` | Redo up to `{count}` undone changes in order; clamps at available history and consumes count state (no leak to the next command) |
291
293
 
292
294
  ---
293
295
 
294
- ## Register and clipboard policy
296
+ ## register and clipboard policy
295
297
 
296
298
  - One unnamed register (like Vim's `""` register).
297
299
  - Every `d`, `c`, `x`, `s`, `S`, `D`, `C`, `y` operator form
298
- (including `dd`, `{count}dd`, `d{count}j/k`, `dG`, `yy`, `{count}yy`,
300
+ (including `dd`/`d_`, `{count}dd`, `d{count}j/k`, `dG`, `yy`/`y_`, `{count}yy`,
299
301
  `y{count}j/k`, `yG`) writes to the register and mirrors to the OS clipboard
300
302
  (via `copyToClipboard`, best-effort).
301
303
  - `p` / `P` read from the unnamed register only (not the OS clipboard).
@@ -303,9 +305,9 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
303
305
 
304
306
  ---
305
307
 
306
- ## Known differences from full Vim
308
+ ## known differences from full Vim
307
309
 
308
- | Area | This extension | Full Vim |
310
+ | area | this extension | full Vim |
309
311
  |-----------------------|----------------------------------------|-------------------------------|
310
312
  | `$` motion | Moves past last char (readline CTRL+E) | Moves to last char |
311
313
  | `w` / `e` / `b` + `W` / `E` / `B` | Cross-line for `word` + `WORD` motions | Cross-line |
@@ -314,7 +316,7 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
314
316
  | Redo | Normal-mode `<C-r>` supported (safe no-op when empty; counted redo is stepwise, clamps to available history, and preserves single-step undo granularity) | `<C-r>` |
315
317
  | Visual mode | Not implemented | `v`, `V`, `<C-v>` |
316
318
  | Text objects | Supports `iw`/`aw` only | Full text-object set |
317
- | Count prefix | Supported for operators, word/char motions, navigation, and edits (`x`, `p`/`P`); capped at `MAX_COUNT=9999` to prevent abuse | Full support |
319
+ | Count prefix | Supported for operators, word/char motions, navigation, and edits (`x`, `r`, `p`/`P`); capped at `MAX_COUNT=9999` to prevent abuse | Full support |
318
320
  | Named registers | Not implemented (`"a`, etc.) | Supported |
319
321
  | Macros | Not implemented (`q`, `@`) | Supported |
320
322
  | Search | Not implemented (`/`, `?`, `n`, `N`) | Supported |
@@ -323,7 +325,7 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
323
325
 
324
326
  ---
325
327
 
326
- ## Out of scope
328
+ ## out of scope
327
329
 
328
330
  These are **explicitly deferred** and not planned for this feature:
329
331
 
@@ -334,6 +336,7 @@ These are **explicitly deferred** and not planned for this feature:
334
336
  - Ex command surface (`:s`, `:g`, `:r`, …)
335
337
  - Search mode (`/`, `?`, `n`, `N`)
336
338
  - Repeat (`.`)
339
+ - Replace mode (`R`) — only single-char `r{char}` is supported
337
340
  - Extended count prefix beyond currently supported motions (e.g. `:`, global operator counts)
338
341
  - No insert-mode `<C-r>` feature expansion beyond current underlying-editor behavior.
339
342
  - No cross-session redo persistence.
@@ -343,7 +346,7 @@ These are **explicitly deferred** and not planned for this feature:
343
346
 
344
347
  ---
345
348
 
346
- ## Architecture notes
349
+ ## architecture notes
347
350
 
348
351
  - `index.ts` — `ModalEditor` subclass of `CustomEditor`; all key handling.
349
352
  - `motions.ts` — pure motion calculation helpers (`findWordMotionTarget`,
package/index.ts CHANGED
@@ -13,11 +13,12 @@
13
13
  * - hjkl: navigation in normal mode
14
14
  * - 0/$: line start/end
15
15
  * - ^: first non-whitespace char of line
16
+ * - _: first non-whitespace (with count: down count-1 lines first); linewise with d/c/y
16
17
  * - x: delete char under cursor
17
18
  * - D: delete to end of line
18
19
  * - S: substitute line (delete line content + insert mode)
19
20
  * - s: substitute char (delete char + insert mode)
20
- * - d{motion}: delete with motion (`w/b/e` + `W/B/E`, `$`, `0`, `^`, `dd`, `f/t/F/T{char}`)
21
+ * - d{motion}: delete with motion (`w/b/e` + `W/B/E`, `$`, `0`, `^`, `dd`/`d_`, `f/t/F/T{char}`)
21
22
  * - c{motion}: change with same motion set as `d` (then enter insert mode)
22
23
  * - y{motion}: yank with same motion set as `d` (no text mutation)
23
24
  * - f{char}: jump to next {char} on line
@@ -72,7 +73,6 @@ import {
72
73
  CHAR_MOTION_KEYS,
73
74
  ESC_LEFT,
74
75
  ESC_RIGHT,
75
- ESC_DELETE,
76
76
  ESC_UP,
77
77
  CTRL_A,
78
78
  CTRL_E,
@@ -87,6 +87,7 @@ import {
87
87
  findCharMotionTarget,
88
88
  findParagraphMotionTarget,
89
89
  findFirstNonWhitespaceColumn,
90
+ getLineGraphemes,
90
91
  type WordMotionClass,
91
92
  } from "./motions.js";
92
93
  import {
@@ -127,6 +128,7 @@ export class ModalEditor extends CustomEditor {
127
128
  private operatorCount: string = "";
128
129
  private pendingG: boolean = false;
129
130
  private pendingGCount: string = "";
131
+ private pendingReplace: boolean = false;
130
132
  private lastCharMotion: LastCharMotion | null = null;
131
133
  private discardingBracketedPasteInNormalMode: boolean = false;
132
134
  private pendingEscWhileDiscardingBracketedPasteInNormalMode: boolean = false;
@@ -134,6 +136,7 @@ export class ModalEditor extends CustomEditor {
134
136
  private readonly redoStack: EditorSnapshot[] = [];
135
137
  private currentTransition: TransitionState = "none";
136
138
  private onChangeHooked: boolean = false;
139
+ private readonly labelColorizers: { insert: (s: string) => string; normal: (s: string) => string } | null;
137
140
 
138
141
  // Unnamed register
139
142
  private unnamedRegister: string = "";
@@ -141,6 +144,16 @@ export class ModalEditor extends CustomEditor {
141
144
  try { copyToClipboard(text); } catch { /* best effort */ }
142
145
  };
143
146
 
147
+ constructor(
148
+ tui: any,
149
+ theme: any,
150
+ kb: any,
151
+ labelColorizers?: { insert: (s: string) => string; normal: (s: string) => string } | null,
152
+ ) {
153
+ super(tui, theme, kb);
154
+ this.labelColorizers = labelColorizers ?? null;
155
+ }
156
+
144
157
  // Test seams
145
158
  setClipboardFn(fn: (text: string) => void): void { this.clipboardFn = fn; }
146
159
  getRegister(): string { return this.unnamedRegister; }
@@ -218,16 +231,22 @@ export class ModalEditor extends CustomEditor {
218
231
  }
219
232
  }
220
233
 
221
- private performUndo(): void {
222
- this.withTransition("undo", () => {
223
- const beforeUndo = this.captureSnapshot();
224
- super.handleInput(CTRL_UNDERSCORE);
225
- const afterUndo = this.captureSnapshot();
226
-
227
- if (this.snapshotChanged(beforeUndo, afterUndo)) {
228
- this.redoStack.push(beforeUndo);
229
- }
230
- });
234
+ private performUndo(count: number = this.takeTotalCount(1)): void {
235
+ const maxSteps = Math.max(1, Math.min(MAX_COUNT, count));
236
+ for (let i = 0; i < maxSteps; i++) {
237
+ let changed = false;
238
+ this.withTransition("undo", () => {
239
+ const beforeUndo = this.captureSnapshot();
240
+ super.handleInput(CTRL_UNDERSCORE);
241
+ const afterUndo = this.captureSnapshot();
242
+
243
+ if (this.snapshotChanged(beforeUndo, afterUndo)) {
244
+ this.redoStack.push(beforeUndo);
245
+ changed = true;
246
+ }
247
+ });
248
+ if (!changed) break;
249
+ }
231
250
  }
232
251
 
233
252
  private performRedo(count: number = this.takeTotalCount(1)): void {
@@ -333,6 +352,7 @@ export class ModalEditor extends CustomEditor {
333
352
  this.operatorCount = "";
334
353
  this.pendingG = false;
335
354
  this.pendingGCount = "";
355
+ this.pendingReplace = false;
336
356
  }
337
357
 
338
358
  private isEscapeLikeInput(data: string): boolean {
@@ -441,6 +461,32 @@ export class ModalEditor extends CustomEditor {
441
461
  return;
442
462
  }
443
463
 
464
+ if (this.pendingReplace) {
465
+ this.pendingReplace = false;
466
+ if (!this.isPrintableInput(data)) {
467
+ this.prefixCount = "";
468
+ this.operatorCount = "";
469
+ return;
470
+ }
471
+
472
+ const count = this.takeTotalCount(1);
473
+ const cursor = this.getCursor();
474
+ const line = this.getLines()[cursor.line] ?? "";
475
+ const range = this.getGraphemeRangeAtCol(line, cursor.col, count);
476
+ if (!range) return;
477
+
478
+ const before = line.slice(0, range.start);
479
+ const after = line.slice(range.end);
480
+ const replacement = data.repeat(count);
481
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
482
+ const text = this.getText();
483
+ const newText = text.slice(0, lineStartAbs) + before + replacement + after
484
+ + text.slice(lineStartAbs + line.length);
485
+ const newCursorAbs = lineStartAbs + before.length + data.length * (count - 1);
486
+ this.replaceTextInBuffer(newText, newCursorAbs);
487
+ return;
488
+ }
489
+
444
490
  if (this.pendingTextObject) {
445
491
  return this.handlePendingTextObject(data);
446
492
  }
@@ -491,6 +537,7 @@ export class ModalEditor extends CustomEditor {
491
537
  || this.operatorCount
492
538
  || this.pendingG
493
539
  || this.pendingGCount
540
+ || this.pendingReplace
494
541
  ) {
495
542
  this.clearPendingState();
496
543
  return;
@@ -513,7 +560,7 @@ export class ModalEditor extends CustomEditor {
513
560
  }
514
561
 
515
562
  private isPrintableInput(data: string): boolean {
516
- return this.isPrintableChunk(data) && Array.from(data).length === 1;
563
+ return this.isPrintableChunk(data) && getLineGraphemes(data).length === 1;
517
564
  }
518
565
 
519
566
  private isDigit(data: string): boolean {
@@ -663,6 +710,13 @@ export class ModalEditor extends CustomEditor {
663
710
  return;
664
711
  }
665
712
 
713
+ if (data === "_") {
714
+ const count = this.takeTotalCount(1);
715
+ this.deleteLinewiseByDelta(count - 1);
716
+ this.pendingOperator = null;
717
+ return;
718
+ }
719
+
666
720
  if (CHAR_MOTION_KEYS.has(data)) {
667
721
  this.pendingMotion = data as PendingMotion;
668
722
  return;
@@ -725,6 +779,28 @@ export class ModalEditor extends CustomEditor {
725
779
  this.mode = "insert";
726
780
  return;
727
781
  }
782
+
783
+ if (data === "_") {
784
+ const count = this.takeTotalCount(1);
785
+ if (count <= 1) {
786
+ this.cutLine();
787
+ } else {
788
+ const currentLine = this.getCursor().line;
789
+ const lines = this.getLines();
790
+ const clampedEnd = Math.min(currentLine + count - 1, lines.length - 1);
791
+ this.writeToRegister(this.getLinewisePayload(currentLine, clampedEnd));
792
+ const before = lines.slice(0, currentLine);
793
+ const after = lines.slice(clampedEnd + 1);
794
+ const newLines = [...before, "", ...after];
795
+ const newText = newLines.join("\n");
796
+ const cursorAbs = before.reduce((acc, l) => acc + l.length + 1, 0);
797
+ this.replaceTextInBuffer(newText, cursorAbs);
798
+ }
799
+ this.pendingOperator = null;
800
+ this.mode = "insert";
801
+ return;
802
+ }
803
+
728
804
  if (CHAR_MOTION_KEYS.has(data)) {
729
805
  this.pendingMotion = data as PendingMotion;
730
806
  return;
@@ -823,13 +899,18 @@ export class ModalEditor extends CustomEditor {
823
899
 
824
900
  const supportsCountedStandaloneEdit = (
825
901
  data === "x"
902
+ || data === "r"
826
903
  || data === "s"
827
904
  || data === "S"
828
905
  || data === "D"
829
906
  || data === "C"
830
907
  || data === "p"
831
908
  || data === "P"
909
+ || data === "Y"
832
910
  || data === "J"
911
+ || data === "u"
912
+ || data === CTRL_UNDERSCORE
913
+ || matchesKey(data, "ctrl+_")
833
914
  || data === CTRL_R
834
915
  || matchesKey(data, "ctrl+r")
835
916
  );
@@ -853,6 +934,7 @@ export class ModalEditor extends CustomEditor {
853
934
  || data === "k"
854
935
  || data === "l"
855
936
  );
937
+ const supportsCountedUnderscore = data === "_";
856
938
 
857
939
  if (supportsCountedNav) {
858
940
  const count = this.takeTotalCount(1);
@@ -862,16 +944,8 @@ export class ModalEditor extends CustomEditor {
862
944
  } else if (data === "l") {
863
945
  this.moveCursorBy(clamped);
864
946
  } else {
865
- // j/k: clamp vertical nav to buffer size to prevent TUI stalls
866
- const lines = this.getLines();
867
- const cursorLine = this.getCursor().line;
868
- const safeCount = data === "j"
869
- ? Math.min(clamped, lines.length - 1 - cursorLine)
870
- : Math.min(clamped, cursorLine);
871
- const seq = data === "j" ? ESC_DOWN : ESC_UP;
872
- for (let i = 0; i < safeCount; i++) {
873
- super.handleInput(seq);
874
- }
947
+ const delta = data === "j" ? clamped : -clamped;
948
+ this.moveCursorVertically(delta);
875
949
  }
876
950
  return;
877
951
  }
@@ -886,6 +960,7 @@ export class ModalEditor extends CustomEditor {
886
960
  && !supportsCountedCharMotion
887
961
  && !supportsCountedWordMotion
888
962
  && !supportsCountedParagraphMotion
963
+ && !supportsCountedUnderscore
889
964
  ) {
890
965
  // Unsupported prefixed forms: drop count and keep processing this key.
891
966
  this.prefixCount = "";
@@ -912,6 +987,11 @@ export class ModalEditor extends CustomEditor {
912
987
  return;
913
988
  }
914
989
 
990
+ if (data === "r") {
991
+ this.pendingReplace = true;
992
+ return;
993
+ }
994
+
915
995
  if (data === "d") {
916
996
  this.pendingOperator = "d";
917
997
  return;
@@ -937,6 +1017,12 @@ export class ModalEditor extends CustomEditor {
937
1017
  return;
938
1018
  }
939
1019
 
1020
+ if (data === "Y") {
1021
+ const count = this.takeTotalCount(1);
1022
+ this.yankLinewiseByDelta(count - 1);
1023
+ return;
1024
+ }
1025
+
940
1026
  if (CHAR_MOTION_KEYS.has(data)) {
941
1027
  this.pendingMotion = data as PendingMotion;
942
1028
  return;
@@ -975,6 +1061,15 @@ export class ModalEditor extends CustomEditor {
975
1061
  return;
976
1062
  }
977
1063
 
1064
+ if (data === "_") {
1065
+ const count = this.takeTotalCount(1);
1066
+ if (count > 1) {
1067
+ this.moveCursorVertically(count - 1);
1068
+ }
1069
+ this.moveCursorToFirstNonWhitespace();
1070
+ return;
1071
+ }
1072
+
978
1073
  if (data === "w") {
979
1074
  const count = this.takeTotalCount(1);
980
1075
  return this.moveWord("forward", "start", count, "word");
@@ -1023,7 +1118,7 @@ export class ModalEditor extends CustomEditor {
1023
1118
  break;
1024
1119
  case "I":
1025
1120
  this.mode = "insert";
1026
- super.handleInput(CTRL_A);
1121
+ this.moveCursorToFirstNonWhitespace();
1027
1122
  break;
1028
1123
  case "o":
1029
1124
  this.openLineBelow();
@@ -1054,6 +1149,12 @@ export class ModalEditor extends CustomEditor {
1054
1149
  case "x":
1055
1150
  this.cutCharUnderCursor();
1056
1151
  break;
1152
+ case "j":
1153
+ this.moveCursorVertically(1);
1154
+ break;
1155
+ case "k":
1156
+ this.moveCursorVertically(-1);
1157
+ break;
1057
1158
  default:
1058
1159
  if (seq) super.handleInput(seq);
1059
1160
  }
@@ -1070,7 +1171,7 @@ export class ModalEditor extends CustomEditor {
1070
1171
  }
1071
1172
 
1072
1173
  if (targetCol !== null && targetCol !== col) {
1073
- this.moveCursorBy(targetCol - col);
1174
+ this.moveCursorToCol(targetCol);
1074
1175
  }
1075
1176
  }
1076
1177
 
@@ -1098,10 +1199,12 @@ export class ModalEditor extends CustomEditor {
1098
1199
  const cursorLine = state.cursorLine as number;
1099
1200
  const cursorCol = state.cursorCol as number;
1100
1201
  const line = state.lines[cursorLine] ?? "";
1202
+ if (this.hasMultiCodeUnitGraphemes(line)) return false;
1203
+
1101
1204
  const target = cursorCol + delta;
1102
1205
 
1103
- // Only short-circuit line-local movement; preserve canonical key replay for
1104
- // any potential cross-line traversal semantics.
1206
+ // Only short-circuit line-local movement when each grapheme is one code
1207
+ // unit; otherwise let the base editor keep cursor boundaries valid.
1105
1208
  if (target < 0 || target > line.length) return false;
1106
1209
 
1107
1210
  state.cursorCol = target;
@@ -1121,38 +1224,100 @@ export class ModalEditor extends CustomEditor {
1121
1224
  }
1122
1225
  }
1123
1226
 
1124
- private moveCursorToLineStart(lineIndex: number): void {
1125
- const lines = this.getLines();
1126
- if (lines.length === 0) {
1127
- super.handleInput(CTRL_A);
1128
- return;
1129
- }
1227
+ private moveCursorVertically(delta: number): void {
1228
+ if (delta === 0) return;
1130
1229
 
1131
- const targetLine = Math.max(0, Math.min(lineIndex, lines.length - 1));
1132
- const currentLine = this.getCursor().line;
1133
- const delta = targetLine - currentLine;
1230
+ const editor = this as unknown as {
1231
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
1232
+ preferredVisualCol?: number | null;
1233
+ lastAction?: string | null;
1234
+ tui?: { requestRender?: () => void };
1235
+ };
1134
1236
 
1135
- if (delta > 0) {
1136
- for (let i = 0; i < delta; i++) {
1137
- super.handleInput(ESC_DOWN);
1138
- }
1139
- } else if (delta < 0) {
1237
+ const state = editor.state;
1238
+ if (!state || !Array.isArray(state.lines) || state.lines.length === 0) {
1239
+ const seq = delta > 0 ? ESC_DOWN : ESC_UP;
1140
1240
  for (let i = 0; i < Math.abs(delta); i++) {
1141
- super.handleInput(ESC_UP);
1241
+ super.handleInput(seq);
1142
1242
  }
1243
+ return;
1143
1244
  }
1144
1245
 
1145
- super.handleInput(CTRL_A);
1246
+ const currentLine = state.cursorLine ?? 0;
1247
+ const targetLine = Math.max(0, Math.min(currentLine + delta, state.lines.length - 1));
1248
+ if (targetLine === currentLine) return;
1249
+
1250
+ const preferredCol = editor.preferredVisualCol ?? state.cursorCol ?? 0;
1251
+ const targetLineText = state.lines[targetLine] ?? "";
1252
+ editor.lastAction = null;
1253
+ state.cursorLine = targetLine;
1254
+ state.cursorCol = Math.min(preferredCol, targetLineText.length);
1255
+ editor.preferredVisualCol = preferredCol;
1256
+ editor.tui?.requestRender?.();
1257
+ }
1258
+
1259
+ private moveCursorToCol(col: number): void {
1260
+ const editor = this as unknown as {
1261
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
1262
+ preferredVisualCol?: number | null;
1263
+ lastAction?: string | null;
1264
+ tui?: { requestRender?: () => void };
1265
+ };
1266
+
1267
+ const state = editor.state;
1268
+ if (!state || !Array.isArray(state.lines)) return;
1269
+
1270
+ editor.lastAction = null;
1271
+ state.cursorCol = col;
1272
+ editor.preferredVisualCol = col;
1273
+ editor.tui?.requestRender?.();
1274
+ }
1275
+
1276
+ private moveCursorToAbsoluteIndex(abs: number): void {
1277
+ const editor = this as unknown as {
1278
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
1279
+ preferredVisualCol?: number | null;
1280
+ lastAction?: string | null;
1281
+ tui?: { requestRender?: () => void };
1282
+ };
1283
+
1284
+ const state = editor.state;
1285
+ if (!state || !Array.isArray(state.lines)) return;
1286
+
1287
+ const { line, col } = this.getCursorFromAbsoluteIndex(this.getText(), abs);
1288
+ editor.lastAction = null;
1289
+ state.cursorLine = line;
1290
+ state.cursorCol = col;
1291
+ editor.preferredVisualCol = col;
1292
+ editor.tui?.requestRender?.();
1293
+ }
1294
+
1295
+ private moveCursorToLineStart(lineIndex: number): void {
1296
+ const editor = this as unknown as {
1297
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
1298
+ preferredVisualCol?: number | null;
1299
+ lastAction?: string | null;
1300
+ tui?: { requestRender?: () => void };
1301
+ };
1302
+
1303
+ const state = editor.state;
1304
+ if (!state || !Array.isArray(state.lines) || state.lines.length === 0) {
1305
+ super.handleInput(CTRL_A);
1306
+ return;
1307
+ }
1308
+
1309
+ const targetLine = Math.max(0, Math.min(lineIndex, state.lines.length - 1));
1310
+ editor.lastAction = null;
1311
+ state.cursorLine = targetLine;
1312
+ state.cursorCol = 0;
1313
+ editor.preferredVisualCol = null;
1314
+ editor.tui?.requestRender?.();
1146
1315
  }
1147
1316
 
1148
1317
  private moveCursorToFirstNonWhitespace(): void {
1149
1318
  const { line, col } = this.getCurrentLineAndCol();
1150
1319
  const targetCol = findFirstNonWhitespaceColumn(line);
1151
- this.moveCursorBy(targetCol - col);
1152
- }
1153
-
1154
- private moveCursorToBufferStart(): void {
1155
- this.moveCursorToLineStart(0);
1320
+ this.moveCursorToCol(targetCol);
1156
1321
  }
1157
1322
 
1158
1323
  private moveCursorToBufferEnd(): void {
@@ -1343,7 +1508,6 @@ export class ModalEditor extends CustomEditor {
1343
1508
  private tryFindWordTargetLineLocal(
1344
1509
  direction: WordMotionDirection,
1345
1510
  target: WordMotionTarget,
1346
- allowSameColumn: boolean = false,
1347
1511
  semanticClass: WordMotionClass = "word",
1348
1512
  ): number | null {
1349
1513
  const cursor = this.getCursor();
@@ -1356,7 +1520,7 @@ export class ModalEditor extends CustomEditor {
1356
1520
  col,
1357
1521
  direction,
1358
1522
  target,
1359
- allowSameColumn,
1523
+ false,
1360
1524
  semanticClass,
1361
1525
  );
1362
1526
  if (targetCol === null) return null;
@@ -1374,10 +1538,10 @@ export class ModalEditor extends CustomEditor {
1374
1538
  semanticClass: WordMotionClass = "word",
1375
1539
  ): boolean {
1376
1540
  const col = this.getCursor().col;
1377
- const targetCol = this.tryFindWordTargetLineLocal(direction, target, false, semanticClass);
1541
+ const targetCol = this.tryFindWordTargetLineLocal(direction, target, semanticClass);
1378
1542
  if (targetCol === null || targetCol === col) return false;
1379
1543
 
1380
- this.moveCursorBy(targetCol - col);
1544
+ this.moveCursorToCol(targetCol);
1381
1545
  return true;
1382
1546
  }
1383
1547
 
@@ -1445,7 +1609,7 @@ export class ModalEditor extends CustomEditor {
1445
1609
  semanticClass,
1446
1610
  );
1447
1611
  if (targetAbs !== currentAbs) {
1448
- this.moveCursorBy(targetAbs - currentAbs);
1612
+ this.moveCursorToAbsoluteIndex(targetAbs);
1449
1613
  }
1450
1614
  return;
1451
1615
  }
@@ -1463,6 +1627,33 @@ export class ModalEditor extends CustomEditor {
1463
1627
  return { line, col };
1464
1628
  }
1465
1629
 
1630
+ private hasMultiCodeUnitGraphemes(line: string): boolean {
1631
+ return getLineGraphemes(line).some((segment) => segment.end - segment.start > 1);
1632
+ }
1633
+
1634
+ private getGraphemeRangeAtCol(
1635
+ line: string,
1636
+ col: number,
1637
+ count: number,
1638
+ clampToLine: boolean = false,
1639
+ ): { start: number; end: number } | null {
1640
+ const clampedCol = Math.max(0, Math.min(col, line.length));
1641
+ const segments = getLineGraphemes(line);
1642
+ const startIndex = segments.findIndex((segment) => clampedCol < segment.end);
1643
+ if (startIndex === -1) return null;
1644
+
1645
+ let endIndex = startIndex + Math.max(1, count) - 1;
1646
+ if (endIndex >= segments.length) {
1647
+ if (!clampToLine) return null;
1648
+ endIndex = segments.length - 1;
1649
+ }
1650
+
1651
+ return {
1652
+ start: segments[startIndex]!.start,
1653
+ end: segments[endIndex]!.end,
1654
+ };
1655
+ }
1656
+
1466
1657
  private isCursorOnNonWhitespace(): boolean {
1467
1658
  const { line, col } = this.getCurrentLineAndCol();
1468
1659
  const ch = line[col];
@@ -1475,13 +1666,19 @@ export class ModalEditor extends CustomEditor {
1475
1666
  }
1476
1667
 
1477
1668
  private cutCharUnderCursor(): void {
1478
- const count = this.takeTotalCount(1);
1479
- const { line, col } = this.getCurrentLineAndCol();
1480
- if (line.length === 0) return; // Don't merge empty lines with x
1481
- if (col >= line.length) return; // Don't delete past end of line
1669
+ const count = Math.max(1, Math.min(MAX_COUNT, this.takeTotalCount(1)));
1670
+ const cursor = this.getCursor();
1671
+ const line = this.getLines()[cursor.line] ?? "";
1672
+ const range = this.getGraphemeRangeAtCol(line, cursor.col, count, true);
1673
+ if (!range) return;
1482
1674
 
1483
- const boundedCount = Math.max(1, Math.min(MAX_COUNT, count));
1484
- this.deleteRange(col, col + boundedCount, false);
1675
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
1676
+ const text = this.getText();
1677
+ this.writeToRegister(line.slice(range.start, range.end));
1678
+ this.replaceTextInBuffer(
1679
+ text.slice(0, lineStartAbs + range.start) + text.slice(lineStartAbs + range.end),
1680
+ lineStartAbs + range.start,
1681
+ );
1485
1682
  }
1486
1683
 
1487
1684
  private cutToEndOfLine(): void {
@@ -1562,19 +1759,13 @@ export class ModalEditor extends CustomEditor {
1562
1759
  this.writeToRegister(payload);
1563
1760
 
1564
1761
  if (endAbs > startAbs) {
1565
- const cursor = this.getCursor();
1566
- const cursorAbs = this.getAbsoluteIndex(cursor.line, cursor.col);
1567
- if (cursorAbs !== startAbs) {
1568
- this.moveCursorBy(startAbs - cursorAbs);
1569
- }
1762
+ const text = this.getText();
1763
+ const newText = text.slice(0, startAbs) + text.slice(endAbs);
1764
+ this.replaceTextInBuffer(newText, startAbs);
1570
1765
 
1571
- const count = endAbs - startAbs;
1572
- for (let i = 0; i < count; i++) {
1573
- super.handleInput(ESC_DELETE);
1574
- }
1766
+ // Ensure cursor is at column 0 of the landing line
1767
+ super.handleInput(CTRL_A);
1575
1768
  }
1576
-
1577
- super.handleInput(CTRL_A);
1578
1769
  }
1579
1770
 
1580
1771
  private yankLineRange(startLine: number, endLine: number): void {
@@ -1637,7 +1828,7 @@ export class ModalEditor extends CustomEditor {
1637
1828
  }
1638
1829
 
1639
1830
  const text = this.getText();
1640
- const currentAbs = this.getAbsoluteIndex(cursor.line, col);
1831
+ const currentAbs = this.getAbsoluteIndexFromCursor();
1641
1832
  const targetAbs = this.findWordTargetInText(
1642
1833
  text,
1643
1834
  currentAbs,
@@ -1705,6 +1896,13 @@ export class ModalEditor extends CustomEditor {
1705
1896
  return;
1706
1897
  }
1707
1898
 
1899
+ if (data === "_") {
1900
+ const count = this.takeTotalCount(1);
1901
+ this.yankLinewiseByDelta(count - 1);
1902
+ this.pendingOperator = null;
1903
+ return;
1904
+ }
1905
+
1708
1906
  if (CHAR_MOTION_KEYS.has(data)) {
1709
1907
  this.pendingMotion = data as PendingMotion;
1710
1908
  return;
@@ -1765,7 +1963,7 @@ export class ModalEditor extends CustomEditor {
1765
1963
  }
1766
1964
 
1767
1965
  const text = this.getText();
1768
- const currentAbs = this.getAbsoluteIndex(cursor.line, col);
1966
+ const currentAbs = this.getAbsoluteIndexFromCursor();
1769
1967
  const targetAbs = this.findWordTargetInText(
1770
1968
  text,
1771
1969
  currentAbs,
@@ -1797,7 +1995,12 @@ export class ModalEditor extends CustomEditor {
1797
1995
  const line = this.getLines()[this.getCursor().line] ?? "";
1798
1996
  const start = Math.min(col, targetCol);
1799
1997
  const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
1800
- const end = Math.min(rawEnd, line.length);
1998
+ let end = Math.min(rawEnd, line.length);
1999
+
2000
+ if (inclusive) {
2001
+ const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1);
2002
+ end = targetRange?.end ?? end;
2003
+ }
1801
2004
 
1802
2005
  if (end <= start) return;
1803
2006
 
@@ -1814,6 +2017,47 @@ export class ModalEditor extends CustomEditor {
1814
2017
  this.writeToRegister(text.slice(start, end));
1815
2018
  }
1816
2019
 
2020
+ private getCursorFromAbsoluteIndex(text: string, abs: number): { line: number; col: number } {
2021
+ const lines = text.length === 0 ? [""] : text.split("\n");
2022
+ let remaining = Math.max(0, Math.min(abs, text.length));
2023
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
2024
+ const line = lines[lineIndex] ?? "";
2025
+ if (remaining <= line.length) return { line: lineIndex, col: remaining };
2026
+ remaining -= line.length + 1;
2027
+ }
2028
+ const lastLine = Math.max(0, lines.length - 1);
2029
+ return { line: lastLine, col: (lines[lastLine] ?? "").length };
2030
+ }
2031
+
2032
+ private replaceTextInBuffer(text: string, cursorAbs: number): void {
2033
+ const editor = this as unknown as {
2034
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
2035
+ preferredVisualCol?: number | null;
2036
+ historyIndex?: number;
2037
+ lastAction?: string | null;
2038
+ onChange?: (text: string) => void;
2039
+ tui?: { requestRender?: () => void };
2040
+ pushUndoSnapshot?: () => void;
2041
+ autocompleteState?: unknown;
2042
+ updateAutocomplete?: () => void;
2043
+ };
2044
+ const state = editor.state;
2045
+ if (!state) return;
2046
+ const currentText = this.getText();
2047
+ if (currentText !== text) editor.pushUndoSnapshot?.();
2048
+ const nextLines = text.length === 0 ? [""] : text.split("\n");
2049
+ const { line, col } = this.getCursorFromAbsoluteIndex(text, cursorAbs);
2050
+ editor.historyIndex = -1;
2051
+ editor.lastAction = null;
2052
+ state.lines = nextLines;
2053
+ state.cursorLine = line;
2054
+ state.cursorCol = col;
2055
+ editor.preferredVisualCol = null;
2056
+ editor.onChange?.(text);
2057
+ if (editor.autocompleteState) editor.updateAutocomplete?.();
2058
+ editor.tui?.requestRender?.();
2059
+ }
2060
+
1817
2061
  private deleteRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean = false): void {
1818
2062
  const text = this.getText();
1819
2063
  const start = Math.min(currentAbs, targetAbs);
@@ -1824,16 +2068,7 @@ export class ModalEditor extends CustomEditor {
1824
2068
 
1825
2069
  this.writeToRegister(text.slice(start, end));
1826
2070
 
1827
- const cursor = this.getCursor();
1828
- const cursorAbs = this.getAbsoluteIndex(cursor.line, cursor.col);
1829
- if (cursorAbs !== start) {
1830
- this.moveCursorBy(start - cursorAbs);
1831
- }
1832
-
1833
- const count = end - start;
1834
- for (let i = 0; i < count; i++) {
1835
- super.handleInput(ESC_DELETE);
1836
- }
2071
+ this.replaceTextInBuffer(text.slice(0, start) + text.slice(end), start);
1837
2072
  }
1838
2073
 
1839
2074
  private getWordObjectRange(
@@ -1960,34 +2195,33 @@ export class ModalEditor extends CustomEditor {
1960
2195
  }
1961
2196
 
1962
2197
  private deleteRange(col: number, targetCol: number, inclusive: boolean): void {
1963
- const line = this.getLines()[this.getCursor().line] ?? "";
1964
-
2198
+ const cursor = this.getCursor();
2199
+ const line = this.getLines()[cursor.line] ?? "";
2200
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
1965
2201
  const start = Math.min(col, targetCol);
1966
2202
  const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
1967
- const end = Math.min(rawEnd, line.length);
2203
+ let end = Math.min(rawEnd, line.length);
1968
2204
 
1969
- if (end <= start) return;
1970
-
1971
- this.writeToRegister(line.slice(start, end));
1972
-
1973
- if (start !== col) {
1974
- this.moveCursorBy(start - col);
2205
+ if (inclusive) {
2206
+ const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1);
2207
+ end = targetRange?.end ?? end;
1975
2208
  }
1976
2209
 
1977
- const count = end - start;
1978
- for (let i = 0; i < count; i++) {
1979
- super.handleInput(ESC_DELETE);
1980
- }
2210
+ this.deleteRangeByAbsolute(lineStartAbs + start, lineStartAbs + end);
1981
2211
  }
1982
2212
 
1983
2213
  render(width: number): string[] {
1984
2214
  const lines = super.render(width);
1985
2215
  if (lines.length === 0) return lines;
1986
2216
 
1987
- const label = this.getModeLabel();
2217
+ const rawLabel = this.getModeLabel();
2218
+ const colorize = this.labelColorizers
2219
+ ? (this.mode === "insert" ? this.labelColorizers.insert : this.labelColorizers.normal)
2220
+ : null;
2221
+ const label = colorize ? colorize(rawLabel) : rawLabel;
1988
2222
  const last = lines.length - 1;
1989
- if (visibleWidth(lines[last]!) >= label.length) {
1990
- lines[last] = truncateToWidth(lines[last]!, width - label.length, "") + label;
2223
+ if (visibleWidth(lines[last]!) >= visibleWidth(rawLabel)) {
2224
+ lines[last] = truncateToWidth(lines[last]!, width - visibleWidth(rawLabel), "") + label;
1991
2225
  }
1992
2226
  return lines;
1993
2227
  }
@@ -1998,6 +2232,9 @@ export class ModalEditor extends CustomEditor {
1998
2232
  const prefixCount = this.prefixCount;
1999
2233
  const operatorCount = this.operatorCount;
2000
2234
 
2235
+ if (this.pendingReplace) {
2236
+ return prefixCount ? ` NORMAL ${prefixCount}r_ ` : " NORMAL r_ ";
2237
+ }
2001
2238
  if (this.pendingOperator && this.pendingMotion) {
2002
2239
  return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}${this.pendingMotion}_ `;
2003
2240
  }
@@ -2019,6 +2256,11 @@ export class ModalEditor extends CustomEditor {
2019
2256
 
2020
2257
  export default function (pi: ExtensionAPI) {
2021
2258
  pi.on("session_start", (_event, ctx) => {
2022
- ctx.ui.setEditorComponent((tui, theme, kb) => new ModalEditor(tui, theme, kb));
2259
+ const t = ctx.ui.theme;
2260
+ const colorizers = t ? {
2261
+ insert: (s: string) => t.fg("borderMuted", `\x1b[7m${s}\x1b[27m`),
2262
+ normal: (s: string) => t.fg("borderAccent", `\x1b[7m${s}\x1b[27m`),
2263
+ } : null;
2264
+ ctx.ui.setEditorComponent((tui, theme, kb) => new ModalEditor(tui, theme, kb, colorizers));
2023
2265
  });
2024
2266
  }
package/motions.ts CHANGED
@@ -125,6 +125,27 @@ export function reverseCharMotion(motion: CharMotion): CharMotion {
125
125
  return reverseMap[motion];
126
126
  }
127
127
 
128
+ const GRAPHEME_SEGMENTER = typeof Intl !== "undefined"
129
+ && typeof Intl.Segmenter === "function"
130
+ ? new Intl.Segmenter(undefined, { granularity: "grapheme" })
131
+ : null;
132
+
133
+ export function getLineGraphemes(line: string): Array<{ start: number; end: number }> {
134
+ const segments: Array<{ start: number; end: number }> = [];
135
+ if (GRAPHEME_SEGMENTER) {
136
+ for (const part of GRAPHEME_SEGMENTER.segment(line)) {
137
+ segments.push({ start: part.index, end: part.index + part.segment.length });
138
+ }
139
+ return segments;
140
+ }
141
+ let start = 0;
142
+ for (const text of Array.from(line)) {
143
+ segments.push({ start, end: start + text.length });
144
+ start += text.length;
145
+ }
146
+ return segments;
147
+ }
148
+
128
149
  /**
129
150
  * Find target column for a character motion (f/F/t/T).
130
151
  * @returns target column or null if not found
@@ -141,7 +162,9 @@ export function findCharMotionTarget(
141
162
  const isTill = motion === "t" || motion === "T";
142
163
  const steps = Number.isFinite(count) && count > 0 ? Math.floor(count) : 1;
143
164
 
144
- let currentPos = col;
165
+ const graphemes = getLineGraphemes(line);
166
+ let currentIndex = graphemes.findIndex(g => col < g.end);
167
+ if (currentIndex === -1) currentIndex = graphemes.length;
145
168
 
146
169
  for (let i = 0; i < steps; i++) {
147
170
  const isFirst = i === 0;
@@ -149,19 +172,38 @@ export function findCharMotionTarget(
149
172
  const tillRepeatOffset = isFirst && isTill && isRepeat ? 1 : 0;
150
173
 
151
174
  if (isForward) {
152
- const searchStart = currentPos + 1 + tillRepeatOffset;
153
- const idx = line.indexOf(targetChar, searchStart);
154
- if (idx === -1) return null;
155
- if (isFinal) return isTill ? idx - 1 : idx;
156
- currentPos = idx;
157
- continue;
175
+ let nextIndex = currentIndex + 1 + tillRepeatOffset;
176
+ let found = -1;
177
+ for (let j = nextIndex; j < graphemes.length; j++) {
178
+ const g = graphemes[j]!;
179
+ // Use startsWith to allow matching base chars if targetChar lacks combining marks,
180
+ // or just exact match since targetChar is typically a full grapheme.
181
+ if (line.slice(g.start, g.end) === targetChar || line.slice(g.start, g.end).startsWith(targetChar)) {
182
+ found = j;
183
+ break;
184
+ }
185
+ }
186
+ if (found === -1) return null;
187
+ if (isFinal) return isTill ? graphemes[found - 1]!.start : graphemes[found]!.start;
188
+ currentIndex = found;
189
+ } else {
190
+ let nextIndex = currentIndex - 1 - tillRepeatOffset;
191
+ let found = -1;
192
+ for (let j = nextIndex; j >= 0; j--) {
193
+ const g = graphemes[j]!;
194
+ if (line.slice(g.start, g.end) === targetChar || line.slice(g.start, g.end).startsWith(targetChar)) {
195
+ found = j;
196
+ break;
197
+ }
198
+ }
199
+ if (found === -1) return null;
200
+ if (isFinal) {
201
+ if (!isTill) return graphemes[found]!.start;
202
+ const afterTarget = graphemes[found + 1];
203
+ return afterTarget ? afterTarget.start : line.length;
204
+ }
205
+ currentIndex = found;
158
206
  }
159
-
160
- const searchStart = currentPos - 1 - tillRepeatOffset;
161
- const idx = line.lastIndexOf(targetChar, searchStart);
162
- if (idx === -1) return null;
163
- if (isFinal) return isTill ? idx + 1 : idx;
164
- currentPos = idx;
165
207
  }
166
208
 
167
209
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vim",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Vim-style modal editing for Pi's TUI editor",
5
5
  "type": "module",
6
6
  "keywords": [
package/types.ts CHANGED
@@ -39,7 +39,6 @@ export const CHAR_MOTION_KEYS = new Set<string>(["f", "F", "t", "T"]);
39
39
  // Escape sequences
40
40
  export const ESC_LEFT = "\x1b[D";
41
41
  export const ESC_RIGHT = "\x1b[C";
42
- export const ESC_DELETE = "\x1b[3~";
43
42
  export const CTRL_A = "\x01"; // line start
44
43
  export const CTRL_E = "\x05"; // line end
45
44
  export const CTRL_K = "\x0b"; // kill to end of line