pi-vim 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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
 
@@ -47,22 +35,22 @@ u # undo
47
35
 
48
36
  Mode indicator (`INSERT` / `NORMAL`) appears at bottom-right.
49
37
 
50
- ## Why pi-vim
38
+ ## why pi-vim
51
39
 
52
40
  - Fast modal editing without leaving Pi.
53
41
  - Count-aware motions/operators (`2dw`, `3G`, `d2j`, `2}`).
54
42
  - Strong REPL-focused defaults; safe out-of-scope boundaries documented.
55
43
  - Clipboard/register behavior is explicit and tested.
56
44
 
57
- ## For you / not for you
45
+ ## for you / not for you
58
46
 
59
47
  Use pi-vim if you want fast Vim muscle-memory in Pi prompts.
60
48
  Skip it if you need full Vim feature parity (visual mode, macros, search,
61
49
  ex-commands, etc.).
62
50
 
63
- ## Common recipes
51
+ ## common recipes
64
52
 
65
- | Goal | Keys |
53
+ | goal | keys |
66
54
  |------|------|
67
55
  | Jump to exact line 25 | `25gg` (or `25G`) |
68
56
  | Delete two words | `2dw` |
@@ -76,11 +64,11 @@ ex-commands, etc.).
76
64
 
77
65
  ---
78
66
 
79
- ## Full reference
67
+ ## full reference
80
68
 
81
- ### Mode switching
69
+ ### mode switching
82
70
 
83
- | Key | Action |
71
+ | key | action |
84
72
  |----------|----------------------------------------|
85
73
  | `Esc` / `Ctrl+[` | Insert → Normal mode |
86
74
  | `Esc` / `Ctrl+[` | Normal mode → pass to Pi (abort agent) |
@@ -93,7 +81,7 @@ ex-commands, etc.).
93
81
 
94
82
  Insert-mode shortcuts (stay in Insert mode):
95
83
 
96
- | Key | Action |
84
+ | key | action |
97
85
  |-----------------|------------------------|
98
86
  | `Shift+Alt+A` | Go to end of line |
99
87
  | `Shift+Alt+I` | Go to start of line |
@@ -102,11 +90,11 @@ Insert-mode shortcuts (stay in Insert mode):
102
90
 
103
91
  ---
104
92
 
105
- ### Navigation (Normal mode)
93
+ ### navigation (normal mode)
106
94
 
107
95
  A `{count}` prefix can be prepended to any navigation key (max: `9999`).
108
96
 
109
- | Key | Action |
97
+ | key | action |
110
98
  |---------------|-------------------------------|
111
99
  | `h` | Left |
112
100
  | `l` | Right |
@@ -116,6 +104,7 @@ A `{count}` prefix can be prepended to any navigation key (max: `9999`).
116
104
  | `{count}j/k` | Move down/up `{count}` lines (clamped to buffer size) |
117
105
  | `0` | Line start |
118
106
  | `^` | First non-whitespace char of line |
107
+ | `_` | First non-whitespace char; with `{count}`, move down `count - 1` lines first |
119
108
  | `$` | Line end |
120
109
  | `gg` | Buffer start (line 1) |
121
110
  | `{count}gg` | Go to line `{count}` (1-indexed, clamped) |
@@ -148,11 +137,11 @@ Operator forms with braces (`d{`, `d}`, `c{`, `c}`, `y{`, `y}`) are out of scope
148
137
 
149
138
  ---
150
139
 
151
- ### Character-find motions (Normal mode)
140
+ ### character-find motions (normal mode)
152
141
 
153
142
  A `{count}` prefix finds the Nth occurrence of `{char}` on the line.
154
143
 
155
- | Key | Action |
144
+ | key | action |
156
145
  |------------------|------------------------------------------------|
157
146
  | `f{char}` | Jump forward to `char` (inclusive) |
158
147
  | `F{char}` | Jump backward to `char` (inclusive) |
@@ -166,17 +155,17 @@ Char-find motions compose with operators: `df{char}`, `ct{char}`, `d{count}t{cha
166
155
 
167
156
  ---
168
157
 
169
- ### Edit operators (Normal mode)
158
+ ### edit operators (normal mode)
170
159
 
171
160
  All operators write to the unnamed register and mirror to the system clipboard
172
161
  (best-effort; clipboard failure never breaks editing).
173
162
 
174
- #### Delete `d{motion}` / `dd`
163
+ #### delete `d{motion}` / `dd`
175
164
 
176
165
  A `{count}` or dual-count prefix (`{pfx}d{op}{motion}`) is supported for
177
166
  word, char-find, and linewise motions. Maximum total count: `9999`.
178
167
 
179
- | Command | Deletes |
168
+ | command | deletes |
180
169
  |-------------------|-----------------------------------------------------------|
181
170
  | `dw` | Forward to next `word` start (exclusive, can cross lines) |
182
171
  | `de` | Forward to `word` end (inclusive, can cross lines) |
@@ -189,6 +178,8 @@ word, char-find, and linewise motions. Maximum total count: `9999`.
189
178
  | `d$` | To end of line |
190
179
  | `d0` | To start of line |
191
180
  | `d^` | To first non-whitespace char of line |
181
+ | `d_` | Current line (linewise, same as `dd`) |
182
+ | `d{count}_` | `{count}` lines (linewise, same as `{count}dd`) |
192
183
  | `dd` | Current line (linewise) |
193
184
  | `{count}dd` | `{count}` lines (linewise) |
194
185
  | `d{count}j` | Current line + `{count}` lines below (linewise) |
@@ -203,11 +194,11 @@ word, char-find, and linewise motions. Maximum total count: `9999`.
203
194
  | `daw` | Around word (includes surrounding spaces) |
204
195
  | `d{count}aw` | Around `{count}` words |
205
196
 
206
- #### Change `c{motion}` / `cc`
197
+ #### change `c{motion}` / `cc`
207
198
 
208
199
  Same motion and count set as `d`. Deletes text then enters Insert mode.
209
200
 
210
- | Command | Action |
201
+ | command | action |
211
202
  |-----------------|------------------------------------|
212
203
  | `cw` | Change `word` + Insert |
213
204
  | `ce` / `cb` | Change to `word` end / previous `word` start |
@@ -218,14 +209,16 @@ Same motion and count set as `d`. Deletes text then enters Insert mode.
218
209
  | `ciw` | Change inner word |
219
210
  | `caw` | Change around word |
220
211
  | `cc` | Delete line content + Insert |
212
+ | `c_` | Change line (linewise, same as `cc`) |
213
+ | `c{count}_` | Change `{count}` lines (linewise) |
221
214
  | `c$` / `c0` / `c^` | Delete to EOL / BOL / first non-whitespace + Insert |
222
215
  | … | All `d` motions apply |
223
216
 
224
- #### Single-key edits
217
+ #### single-key edits
225
218
 
226
219
  A `{count}` prefix is supported for `x`, `p`, `P`. Maximum: `9999`.
227
220
 
228
- | Key | Action |
221
+ | key | action |
229
222
  |--------------|---------------------------------------------------------------|
230
223
  | `x` | Delete char under cursor (no-op at/past EOL) |
231
224
  | `{count}x` | Delete `{count}` chars |
@@ -233,14 +226,16 @@ A `{count}` prefix is supported for `x`, `p`, `P`. Maximum: `9999`.
233
226
  | `S` | Delete line content + Insert mode |
234
227
  | `D` | Delete cursor to EOL (captures `\n` if at EOL with next line) |
235
228
  | `C` | Delete cursor to EOL + Insert mode |
229
+ | `r{char}` | Replace char under cursor with `{char}` (stays in Normal) |
230
+ | `{count}r{char}` | Replace next `{count}` chars with `{char}` |
236
231
 
237
232
  ---
238
233
 
239
- ### Yank `y{motion}` / `yy`
234
+ ### yank `y{motion}` / `yy`
240
235
 
241
236
  Same motion set as `d`. Writes to register, **no text mutation**.
242
237
 
243
- | Command | Yanks |
238
+ | command | yanks |
244
239
  |---------|---------------------------------|
245
240
  | `yy` | Whole line + trailing `\n` |
246
241
  | `{count}yy` | `{count}` whole lines + trailing `\n` |
@@ -256,6 +251,8 @@ Same motion set as `d`. Writes to register, **no text mutation**.
256
251
  | `y$` | To end of line |
257
252
  | `y0` | To start of line |
258
253
  | `y^` | To first non-whitespace char of line |
254
+ | `y_` | Whole line (linewise, same as `yy`) |
255
+ | `y{count}_` | `{count}` whole lines (linewise) |
259
256
  | `yf{c}` | To and including `char` |
260
257
  | `yiw` | Inner word |
261
258
  | `yaw` | Around word (includes spaces) |
@@ -266,9 +263,9 @@ Linewise counted yank (`{count}yy`, `y{count}j/k`) remains supported.
266
263
 
267
264
  ---
268
265
 
269
- ### Put / Paste
266
+ ### put / paste
270
267
 
271
- | Key | Action |
268
+ | key | action |
272
269
  |--------------|-------------------------------------------------------------|
273
270
  | `p` | Put after cursor (char-wise) / new line below (line-wise) |
274
271
  | `P` | Put before cursor (char-wise) / new line above (line-wise) |
@@ -280,9 +277,9 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
280
277
 
281
278
  ---
282
279
 
283
- ### Undo / Redo
280
+ ### undo / redo
284
281
 
285
- | Key | Action |
282
+ | key | action |
286
283
  |-----|--------|
287
284
  | `u` | Undo in normal mode |
288
285
  | `Ctrl+_` | Undo in normal mode (alias for `u`) |
@@ -291,11 +288,11 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
291
288
 
292
289
  ---
293
290
 
294
- ## Register and clipboard policy
291
+ ## register and clipboard policy
295
292
 
296
293
  - One unnamed register (like Vim's `""` register).
297
294
  - Every `d`, `c`, `x`, `s`, `S`, `D`, `C`, `y` operator form
298
- (including `dd`, `{count}dd`, `d{count}j/k`, `dG`, `yy`, `{count}yy`,
295
+ (including `dd`/`d_`, `{count}dd`, `d{count}j/k`, `dG`, `yy`/`y_`, `{count}yy`,
299
296
  `y{count}j/k`, `yG`) writes to the register and mirrors to the OS clipboard
300
297
  (via `copyToClipboard`, best-effort).
301
298
  - `p` / `P` read from the unnamed register only (not the OS clipboard).
@@ -303,9 +300,9 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
303
300
 
304
301
  ---
305
302
 
306
- ## Known differences from full Vim
303
+ ## known differences from full Vim
307
304
 
308
- | Area | This extension | Full Vim |
305
+ | area | this extension | full Vim |
309
306
  |-----------------------|----------------------------------------|-------------------------------|
310
307
  | `$` motion | Moves past last char (readline CTRL+E) | Moves to last char |
311
308
  | `w` / `e` / `b` + `W` / `E` / `B` | Cross-line for `word` + `WORD` motions | Cross-line |
@@ -314,7 +311,7 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
314
311
  | 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
312
  | Visual mode | Not implemented | `v`, `V`, `<C-v>` |
316
313
  | 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 |
314
+ | 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
315
  | Named registers | Not implemented (`"a`, etc.) | Supported |
319
316
  | Macros | Not implemented (`q`, `@`) | Supported |
320
317
  | Search | Not implemented (`/`, `?`, `n`, `N`) | Supported |
@@ -323,7 +320,7 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
323
320
 
324
321
  ---
325
322
 
326
- ## Out of scope
323
+ ## out of scope
327
324
 
328
325
  These are **explicitly deferred** and not planned for this feature:
329
326
 
@@ -334,6 +331,7 @@ These are **explicitly deferred** and not planned for this feature:
334
331
  - Ex command surface (`:s`, `:g`, `:r`, …)
335
332
  - Search mode (`/`, `?`, `n`, `N`)
336
333
  - Repeat (`.`)
334
+ - Replace mode (`R`) — only single-char `r{char}` is supported
337
335
  - Extended count prefix beyond currently supported motions (e.g. `:`, global operator counts)
338
336
  - No insert-mode `<C-r>` feature expansion beyond current underlying-editor behavior.
339
337
  - No cross-session redo persistence.
@@ -343,7 +341,7 @@ These are **explicitly deferred** and not planned for this feature:
343
341
 
344
342
  ---
345
343
 
346
- ## Architecture notes
344
+ ## architecture notes
347
345
 
348
346
  - `index.ts` — `ModalEditor` subclass of `CustomEditor`; all key handling.
349
347
  - `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;
@@ -333,6 +335,7 @@ export class ModalEditor extends CustomEditor {
333
335
  this.operatorCount = "";
334
336
  this.pendingG = false;
335
337
  this.pendingGCount = "";
338
+ this.pendingReplace = false;
336
339
  }
337
340
 
338
341
  private isEscapeLikeInput(data: string): boolean {
@@ -441,6 +444,32 @@ export class ModalEditor extends CustomEditor {
441
444
  return;
442
445
  }
443
446
 
447
+ if (this.pendingReplace) {
448
+ this.pendingReplace = false;
449
+ if (!this.isPrintableInput(data)) {
450
+ this.prefixCount = "";
451
+ this.operatorCount = "";
452
+ return;
453
+ }
454
+
455
+ const count = this.takeTotalCount(1);
456
+ const cursor = this.getCursor();
457
+ const line = this.getLines()[cursor.line] ?? "";
458
+ const range = this.getGraphemeRangeAtCol(line, cursor.col, count);
459
+ if (!range) return;
460
+
461
+ const before = line.slice(0, range.start);
462
+ const after = line.slice(range.end);
463
+ const replacement = data.repeat(count);
464
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
465
+ const text = this.getText();
466
+ const newText = text.slice(0, lineStartAbs) + before + replacement + after
467
+ + text.slice(lineStartAbs + line.length);
468
+ const newCursorAbs = lineStartAbs + before.length + data.length * (count - 1);
469
+ this.replaceTextInBuffer(newText, newCursorAbs);
470
+ return;
471
+ }
472
+
444
473
  if (this.pendingTextObject) {
445
474
  return this.handlePendingTextObject(data);
446
475
  }
@@ -491,6 +520,7 @@ export class ModalEditor extends CustomEditor {
491
520
  || this.operatorCount
492
521
  || this.pendingG
493
522
  || this.pendingGCount
523
+ || this.pendingReplace
494
524
  ) {
495
525
  this.clearPendingState();
496
526
  return;
@@ -513,7 +543,7 @@ export class ModalEditor extends CustomEditor {
513
543
  }
514
544
 
515
545
  private isPrintableInput(data: string): boolean {
516
- return this.isPrintableChunk(data) && Array.from(data).length === 1;
546
+ return this.isPrintableChunk(data) && getLineGraphemes(data).length === 1;
517
547
  }
518
548
 
519
549
  private isDigit(data: string): boolean {
@@ -663,6 +693,13 @@ export class ModalEditor extends CustomEditor {
663
693
  return;
664
694
  }
665
695
 
696
+ if (data === "_") {
697
+ const count = this.takeTotalCount(1);
698
+ this.deleteLinewiseByDelta(count - 1);
699
+ this.pendingOperator = null;
700
+ return;
701
+ }
702
+
666
703
  if (CHAR_MOTION_KEYS.has(data)) {
667
704
  this.pendingMotion = data as PendingMotion;
668
705
  return;
@@ -725,6 +762,28 @@ export class ModalEditor extends CustomEditor {
725
762
  this.mode = "insert";
726
763
  return;
727
764
  }
765
+
766
+ if (data === "_") {
767
+ const count = this.takeTotalCount(1);
768
+ if (count <= 1) {
769
+ this.cutLine();
770
+ } else {
771
+ const currentLine = this.getCursor().line;
772
+ const lines = this.getLines();
773
+ const clampedEnd = Math.min(currentLine + count - 1, lines.length - 1);
774
+ this.writeToRegister(this.getLinewisePayload(currentLine, clampedEnd));
775
+ const before = lines.slice(0, currentLine);
776
+ const after = lines.slice(clampedEnd + 1);
777
+ const newLines = [...before, "", ...after];
778
+ const newText = newLines.join("\n");
779
+ const cursorAbs = before.reduce((acc, l) => acc + l.length + 1, 0);
780
+ this.replaceTextInBuffer(newText, cursorAbs);
781
+ }
782
+ this.pendingOperator = null;
783
+ this.mode = "insert";
784
+ return;
785
+ }
786
+
728
787
  if (CHAR_MOTION_KEYS.has(data)) {
729
788
  this.pendingMotion = data as PendingMotion;
730
789
  return;
@@ -823,6 +882,7 @@ export class ModalEditor extends CustomEditor {
823
882
 
824
883
  const supportsCountedStandaloneEdit = (
825
884
  data === "x"
885
+ || data === "r"
826
886
  || data === "s"
827
887
  || data === "S"
828
888
  || data === "D"
@@ -853,6 +913,7 @@ export class ModalEditor extends CustomEditor {
853
913
  || data === "k"
854
914
  || data === "l"
855
915
  );
916
+ const supportsCountedUnderscore = data === "_";
856
917
 
857
918
  if (supportsCountedNav) {
858
919
  const count = this.takeTotalCount(1);
@@ -862,16 +923,8 @@ export class ModalEditor extends CustomEditor {
862
923
  } else if (data === "l") {
863
924
  this.moveCursorBy(clamped);
864
925
  } 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
- }
926
+ const delta = data === "j" ? clamped : -clamped;
927
+ this.moveCursorVertically(delta);
875
928
  }
876
929
  return;
877
930
  }
@@ -886,6 +939,7 @@ export class ModalEditor extends CustomEditor {
886
939
  && !supportsCountedCharMotion
887
940
  && !supportsCountedWordMotion
888
941
  && !supportsCountedParagraphMotion
942
+ && !supportsCountedUnderscore
889
943
  ) {
890
944
  // Unsupported prefixed forms: drop count and keep processing this key.
891
945
  this.prefixCount = "";
@@ -912,6 +966,11 @@ export class ModalEditor extends CustomEditor {
912
966
  return;
913
967
  }
914
968
 
969
+ if (data === "r") {
970
+ this.pendingReplace = true;
971
+ return;
972
+ }
973
+
915
974
  if (data === "d") {
916
975
  this.pendingOperator = "d";
917
976
  return;
@@ -975,6 +1034,15 @@ export class ModalEditor extends CustomEditor {
975
1034
  return;
976
1035
  }
977
1036
 
1037
+ if (data === "_") {
1038
+ const count = this.takeTotalCount(1);
1039
+ if (count > 1) {
1040
+ this.moveCursorVertically(count - 1);
1041
+ }
1042
+ this.moveCursorToFirstNonWhitespace();
1043
+ return;
1044
+ }
1045
+
978
1046
  if (data === "w") {
979
1047
  const count = this.takeTotalCount(1);
980
1048
  return this.moveWord("forward", "start", count, "word");
@@ -1054,6 +1122,12 @@ export class ModalEditor extends CustomEditor {
1054
1122
  case "x":
1055
1123
  this.cutCharUnderCursor();
1056
1124
  break;
1125
+ case "j":
1126
+ this.moveCursorVertically(1);
1127
+ break;
1128
+ case "k":
1129
+ this.moveCursorVertically(-1);
1130
+ break;
1057
1131
  default:
1058
1132
  if (seq) super.handleInput(seq);
1059
1133
  }
@@ -1070,7 +1144,7 @@ export class ModalEditor extends CustomEditor {
1070
1144
  }
1071
1145
 
1072
1146
  if (targetCol !== null && targetCol !== col) {
1073
- this.moveCursorBy(targetCol - col);
1147
+ this.moveCursorToCol(targetCol);
1074
1148
  }
1075
1149
  }
1076
1150
 
@@ -1098,10 +1172,12 @@ export class ModalEditor extends CustomEditor {
1098
1172
  const cursorLine = state.cursorLine as number;
1099
1173
  const cursorCol = state.cursorCol as number;
1100
1174
  const line = state.lines[cursorLine] ?? "";
1175
+ if (this.hasMultiCodeUnitGraphemes(line)) return false;
1176
+
1101
1177
  const target = cursorCol + delta;
1102
1178
 
1103
- // Only short-circuit line-local movement; preserve canonical key replay for
1104
- // any potential cross-line traversal semantics.
1179
+ // Only short-circuit line-local movement when each grapheme is one code
1180
+ // unit; otherwise let the base editor keep cursor boundaries valid.
1105
1181
  if (target < 0 || target > line.length) return false;
1106
1182
 
1107
1183
  state.cursorCol = target;
@@ -1121,38 +1197,100 @@ export class ModalEditor extends CustomEditor {
1121
1197
  }
1122
1198
  }
1123
1199
 
1124
- private moveCursorToLineStart(lineIndex: number): void {
1125
- const lines = this.getLines();
1126
- if (lines.length === 0) {
1127
- super.handleInput(CTRL_A);
1128
- return;
1129
- }
1200
+ private moveCursorVertically(delta: number): void {
1201
+ if (delta === 0) return;
1130
1202
 
1131
- const targetLine = Math.max(0, Math.min(lineIndex, lines.length - 1));
1132
- const currentLine = this.getCursor().line;
1133
- const delta = targetLine - currentLine;
1203
+ const editor = this as unknown as {
1204
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
1205
+ preferredVisualCol?: number | null;
1206
+ lastAction?: string | null;
1207
+ tui?: { requestRender?: () => void };
1208
+ };
1134
1209
 
1135
- if (delta > 0) {
1136
- for (let i = 0; i < delta; i++) {
1137
- super.handleInput(ESC_DOWN);
1138
- }
1139
- } else if (delta < 0) {
1210
+ const state = editor.state;
1211
+ if (!state || !Array.isArray(state.lines) || state.lines.length === 0) {
1212
+ const seq = delta > 0 ? ESC_DOWN : ESC_UP;
1140
1213
  for (let i = 0; i < Math.abs(delta); i++) {
1141
- super.handleInput(ESC_UP);
1214
+ super.handleInput(seq);
1142
1215
  }
1216
+ return;
1143
1217
  }
1144
1218
 
1145
- super.handleInput(CTRL_A);
1219
+ const currentLine = state.cursorLine ?? 0;
1220
+ const targetLine = Math.max(0, Math.min(currentLine + delta, state.lines.length - 1));
1221
+ if (targetLine === currentLine) return;
1222
+
1223
+ const preferredCol = editor.preferredVisualCol ?? state.cursorCol ?? 0;
1224
+ const targetLineText = state.lines[targetLine] ?? "";
1225
+ editor.lastAction = null;
1226
+ state.cursorLine = targetLine;
1227
+ state.cursorCol = Math.min(preferredCol, targetLineText.length);
1228
+ editor.preferredVisualCol = preferredCol;
1229
+ editor.tui?.requestRender?.();
1230
+ }
1231
+
1232
+ private moveCursorToCol(col: number): void {
1233
+ const editor = this as unknown as {
1234
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
1235
+ preferredVisualCol?: number | null;
1236
+ lastAction?: string | null;
1237
+ tui?: { requestRender?: () => void };
1238
+ };
1239
+
1240
+ const state = editor.state;
1241
+ if (!state || !Array.isArray(state.lines)) return;
1242
+
1243
+ editor.lastAction = null;
1244
+ state.cursorCol = col;
1245
+ editor.preferredVisualCol = col;
1246
+ editor.tui?.requestRender?.();
1247
+ }
1248
+
1249
+ private moveCursorToAbsoluteIndex(abs: number): void {
1250
+ const editor = this as unknown as {
1251
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
1252
+ preferredVisualCol?: number | null;
1253
+ lastAction?: string | null;
1254
+ tui?: { requestRender?: () => void };
1255
+ };
1256
+
1257
+ const state = editor.state;
1258
+ if (!state || !Array.isArray(state.lines)) return;
1259
+
1260
+ const { line, col } = this.getCursorFromAbsoluteIndex(this.getText(), abs);
1261
+ editor.lastAction = null;
1262
+ state.cursorLine = line;
1263
+ state.cursorCol = col;
1264
+ editor.preferredVisualCol = col;
1265
+ editor.tui?.requestRender?.();
1266
+ }
1267
+
1268
+ private moveCursorToLineStart(lineIndex: number): void {
1269
+ const editor = this as unknown as {
1270
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
1271
+ preferredVisualCol?: number | null;
1272
+ lastAction?: string | null;
1273
+ tui?: { requestRender?: () => void };
1274
+ };
1275
+
1276
+ const state = editor.state;
1277
+ if (!state || !Array.isArray(state.lines) || state.lines.length === 0) {
1278
+ super.handleInput(CTRL_A);
1279
+ return;
1280
+ }
1281
+
1282
+ const targetLine = Math.max(0, Math.min(lineIndex, state.lines.length - 1));
1283
+ editor.lastAction = null;
1284
+ state.cursorLine = targetLine;
1285
+ state.cursorCol = 0;
1286
+ editor.preferredVisualCol = null;
1287
+ editor.tui?.requestRender?.();
1146
1288
  }
1147
1289
 
1148
1290
  private moveCursorToFirstNonWhitespace(): void {
1149
1291
  const { line, col } = this.getCurrentLineAndCol();
1150
1292
  const targetCol = findFirstNonWhitespaceColumn(line);
1151
- this.moveCursorBy(targetCol - col);
1152
- }
1153
-
1154
- private moveCursorToBufferStart(): void {
1155
- this.moveCursorToLineStart(0);
1293
+ this.moveCursorToCol(targetCol);
1156
1294
  }
1157
1295
 
1158
1296
  private moveCursorToBufferEnd(): void {
@@ -1343,7 +1481,6 @@ export class ModalEditor extends CustomEditor {
1343
1481
  private tryFindWordTargetLineLocal(
1344
1482
  direction: WordMotionDirection,
1345
1483
  target: WordMotionTarget,
1346
- allowSameColumn: boolean = false,
1347
1484
  semanticClass: WordMotionClass = "word",
1348
1485
  ): number | null {
1349
1486
  const cursor = this.getCursor();
@@ -1356,7 +1493,7 @@ export class ModalEditor extends CustomEditor {
1356
1493
  col,
1357
1494
  direction,
1358
1495
  target,
1359
- allowSameColumn,
1496
+ false,
1360
1497
  semanticClass,
1361
1498
  );
1362
1499
  if (targetCol === null) return null;
@@ -1374,10 +1511,10 @@ export class ModalEditor extends CustomEditor {
1374
1511
  semanticClass: WordMotionClass = "word",
1375
1512
  ): boolean {
1376
1513
  const col = this.getCursor().col;
1377
- const targetCol = this.tryFindWordTargetLineLocal(direction, target, false, semanticClass);
1514
+ const targetCol = this.tryFindWordTargetLineLocal(direction, target, semanticClass);
1378
1515
  if (targetCol === null || targetCol === col) return false;
1379
1516
 
1380
- this.moveCursorBy(targetCol - col);
1517
+ this.moveCursorToCol(targetCol);
1381
1518
  return true;
1382
1519
  }
1383
1520
 
@@ -1445,7 +1582,7 @@ export class ModalEditor extends CustomEditor {
1445
1582
  semanticClass,
1446
1583
  );
1447
1584
  if (targetAbs !== currentAbs) {
1448
- this.moveCursorBy(targetAbs - currentAbs);
1585
+ this.moveCursorToAbsoluteIndex(targetAbs);
1449
1586
  }
1450
1587
  return;
1451
1588
  }
@@ -1463,6 +1600,33 @@ export class ModalEditor extends CustomEditor {
1463
1600
  return { line, col };
1464
1601
  }
1465
1602
 
1603
+ private hasMultiCodeUnitGraphemes(line: string): boolean {
1604
+ return getLineGraphemes(line).some((segment) => segment.end - segment.start > 1);
1605
+ }
1606
+
1607
+ private getGraphemeRangeAtCol(
1608
+ line: string,
1609
+ col: number,
1610
+ count: number,
1611
+ clampToLine: boolean = false,
1612
+ ): { start: number; end: number } | null {
1613
+ const clampedCol = Math.max(0, Math.min(col, line.length));
1614
+ const segments = getLineGraphemes(line);
1615
+ const startIndex = segments.findIndex((segment) => clampedCol < segment.end);
1616
+ if (startIndex === -1) return null;
1617
+
1618
+ let endIndex = startIndex + Math.max(1, count) - 1;
1619
+ if (endIndex >= segments.length) {
1620
+ if (!clampToLine) return null;
1621
+ endIndex = segments.length - 1;
1622
+ }
1623
+
1624
+ return {
1625
+ start: segments[startIndex]!.start,
1626
+ end: segments[endIndex]!.end,
1627
+ };
1628
+ }
1629
+
1466
1630
  private isCursorOnNonWhitespace(): boolean {
1467
1631
  const { line, col } = this.getCurrentLineAndCol();
1468
1632
  const ch = line[col];
@@ -1475,13 +1639,19 @@ export class ModalEditor extends CustomEditor {
1475
1639
  }
1476
1640
 
1477
1641
  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
1642
+ const count = Math.max(1, Math.min(MAX_COUNT, this.takeTotalCount(1)));
1643
+ const cursor = this.getCursor();
1644
+ const line = this.getLines()[cursor.line] ?? "";
1645
+ const range = this.getGraphemeRangeAtCol(line, cursor.col, count, true);
1646
+ if (!range) return;
1482
1647
 
1483
- const boundedCount = Math.max(1, Math.min(MAX_COUNT, count));
1484
- this.deleteRange(col, col + boundedCount, false);
1648
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
1649
+ const text = this.getText();
1650
+ this.writeToRegister(line.slice(range.start, range.end));
1651
+ this.replaceTextInBuffer(
1652
+ text.slice(0, lineStartAbs + range.start) + text.slice(lineStartAbs + range.end),
1653
+ lineStartAbs + range.start,
1654
+ );
1485
1655
  }
1486
1656
 
1487
1657
  private cutToEndOfLine(): void {
@@ -1562,19 +1732,13 @@ export class ModalEditor extends CustomEditor {
1562
1732
  this.writeToRegister(payload);
1563
1733
 
1564
1734
  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
- }
1735
+ const text = this.getText();
1736
+ const newText = text.slice(0, startAbs) + text.slice(endAbs);
1737
+ this.replaceTextInBuffer(newText, startAbs);
1570
1738
 
1571
- const count = endAbs - startAbs;
1572
- for (let i = 0; i < count; i++) {
1573
- super.handleInput(ESC_DELETE);
1574
- }
1739
+ // Ensure cursor is at column 0 of the landing line
1740
+ super.handleInput(CTRL_A);
1575
1741
  }
1576
-
1577
- super.handleInput(CTRL_A);
1578
1742
  }
1579
1743
 
1580
1744
  private yankLineRange(startLine: number, endLine: number): void {
@@ -1637,7 +1801,7 @@ export class ModalEditor extends CustomEditor {
1637
1801
  }
1638
1802
 
1639
1803
  const text = this.getText();
1640
- const currentAbs = this.getAbsoluteIndex(cursor.line, col);
1804
+ const currentAbs = this.getAbsoluteIndexFromCursor();
1641
1805
  const targetAbs = this.findWordTargetInText(
1642
1806
  text,
1643
1807
  currentAbs,
@@ -1705,6 +1869,13 @@ export class ModalEditor extends CustomEditor {
1705
1869
  return;
1706
1870
  }
1707
1871
 
1872
+ if (data === "_") {
1873
+ const count = this.takeTotalCount(1);
1874
+ this.yankLinewiseByDelta(count - 1);
1875
+ this.pendingOperator = null;
1876
+ return;
1877
+ }
1878
+
1708
1879
  if (CHAR_MOTION_KEYS.has(data)) {
1709
1880
  this.pendingMotion = data as PendingMotion;
1710
1881
  return;
@@ -1765,7 +1936,7 @@ export class ModalEditor extends CustomEditor {
1765
1936
  }
1766
1937
 
1767
1938
  const text = this.getText();
1768
- const currentAbs = this.getAbsoluteIndex(cursor.line, col);
1939
+ const currentAbs = this.getAbsoluteIndexFromCursor();
1769
1940
  const targetAbs = this.findWordTargetInText(
1770
1941
  text,
1771
1942
  currentAbs,
@@ -1797,7 +1968,12 @@ export class ModalEditor extends CustomEditor {
1797
1968
  const line = this.getLines()[this.getCursor().line] ?? "";
1798
1969
  const start = Math.min(col, targetCol);
1799
1970
  const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
1800
- const end = Math.min(rawEnd, line.length);
1971
+ let end = Math.min(rawEnd, line.length);
1972
+
1973
+ if (inclusive) {
1974
+ const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1);
1975
+ end = targetRange?.end ?? end;
1976
+ }
1801
1977
 
1802
1978
  if (end <= start) return;
1803
1979
 
@@ -1814,6 +1990,47 @@ export class ModalEditor extends CustomEditor {
1814
1990
  this.writeToRegister(text.slice(start, end));
1815
1991
  }
1816
1992
 
1993
+ private getCursorFromAbsoluteIndex(text: string, abs: number): { line: number; col: number } {
1994
+ const lines = text.length === 0 ? [""] : text.split("\n");
1995
+ let remaining = Math.max(0, Math.min(abs, text.length));
1996
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
1997
+ const line = lines[lineIndex] ?? "";
1998
+ if (remaining <= line.length) return { line: lineIndex, col: remaining };
1999
+ remaining -= line.length + 1;
2000
+ }
2001
+ const lastLine = Math.max(0, lines.length - 1);
2002
+ return { line: lastLine, col: (lines[lastLine] ?? "").length };
2003
+ }
2004
+
2005
+ private replaceTextInBuffer(text: string, cursorAbs: number): void {
2006
+ const editor = this as unknown as {
2007
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
2008
+ preferredVisualCol?: number | null;
2009
+ historyIndex?: number;
2010
+ lastAction?: string | null;
2011
+ onChange?: (text: string) => void;
2012
+ tui?: { requestRender?: () => void };
2013
+ pushUndoSnapshot?: () => void;
2014
+ autocompleteState?: unknown;
2015
+ updateAutocomplete?: () => void;
2016
+ };
2017
+ const state = editor.state;
2018
+ if (!state) return;
2019
+ const currentText = this.getText();
2020
+ if (currentText !== text) editor.pushUndoSnapshot?.();
2021
+ const nextLines = text.length === 0 ? [""] : text.split("\n");
2022
+ const { line, col } = this.getCursorFromAbsoluteIndex(text, cursorAbs);
2023
+ editor.historyIndex = -1;
2024
+ editor.lastAction = null;
2025
+ state.lines = nextLines;
2026
+ state.cursorLine = line;
2027
+ state.cursorCol = col;
2028
+ editor.preferredVisualCol = null;
2029
+ editor.onChange?.(text);
2030
+ if (editor.autocompleteState) editor.updateAutocomplete?.();
2031
+ editor.tui?.requestRender?.();
2032
+ }
2033
+
1817
2034
  private deleteRangeByAbsolute(currentAbs: number, targetAbs: number, inclusive: boolean = false): void {
1818
2035
  const text = this.getText();
1819
2036
  const start = Math.min(currentAbs, targetAbs);
@@ -1824,16 +2041,7 @@ export class ModalEditor extends CustomEditor {
1824
2041
 
1825
2042
  this.writeToRegister(text.slice(start, end));
1826
2043
 
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
- }
2044
+ this.replaceTextInBuffer(text.slice(0, start) + text.slice(end), start);
1837
2045
  }
1838
2046
 
1839
2047
  private getWordObjectRange(
@@ -1960,24 +2168,19 @@ export class ModalEditor extends CustomEditor {
1960
2168
  }
1961
2169
 
1962
2170
  private deleteRange(col: number, targetCol: number, inclusive: boolean): void {
1963
- const line = this.getLines()[this.getCursor().line] ?? "";
1964
-
2171
+ const cursor = this.getCursor();
2172
+ const line = this.getLines()[cursor.line] ?? "";
2173
+ const lineStartAbs = this.getAbsoluteIndex(cursor.line, 0);
1965
2174
  const start = Math.min(col, targetCol);
1966
2175
  const rawEnd = Math.max(col, targetCol) + (inclusive ? 1 : 0);
1967
- const end = Math.min(rawEnd, line.length);
1968
-
1969
- if (end <= start) return;
1970
-
1971
- this.writeToRegister(line.slice(start, end));
2176
+ let end = Math.min(rawEnd, line.length);
1972
2177
 
1973
- if (start !== col) {
1974
- this.moveCursorBy(start - col);
2178
+ if (inclusive) {
2179
+ const targetRange = this.getGraphemeRangeAtCol(line, Math.max(col, targetCol), 1);
2180
+ end = targetRange?.end ?? end;
1975
2181
  }
1976
2182
 
1977
- const count = end - start;
1978
- for (let i = 0; i < count; i++) {
1979
- super.handleInput(ESC_DELETE);
1980
- }
2183
+ this.deleteRangeByAbsolute(lineStartAbs + start, lineStartAbs + end);
1981
2184
  }
1982
2185
 
1983
2186
  render(width: number): string[] {
@@ -1998,6 +2201,9 @@ export class ModalEditor extends CustomEditor {
1998
2201
  const prefixCount = this.prefixCount;
1999
2202
  const operatorCount = this.operatorCount;
2000
2203
 
2204
+ if (this.pendingReplace) {
2205
+ return prefixCount ? ` NORMAL ${prefixCount}r_ ` : " NORMAL r_ ";
2206
+ }
2001
2207
  if (this.pendingOperator && this.pendingMotion) {
2002
2208
  return ` NORMAL ${prefixCount}${this.pendingOperator}${operatorCount}${this.pendingMotion}_ `;
2003
2209
  }
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.2.1",
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