pi-vim 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,35 +1,89 @@
1
- # vim-bindings — Pi REPL Vim Mode
1
+ # pi-vim — Vim Mode for Pi REPL
2
2
 
3
- A modal vim-like editing extension for Pi's REPL prompt, covering the
4
- high-frequency ("90%") command surface without trying to clone full Vim.
3
+ Modal vim-like editing for Pi's REPL prompt.
4
+ Focus: the high-frequency 90% command surface, not full Vim.
5
5
 
6
- ## Loading
6
+ ## TL;DR
7
7
 
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`
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pi install npm:pi-vim
8
17
  ```
9
- pi --extension /path/to/vim-bindings/index.ts
18
+
19
+ Restart Pi after install.
20
+
21
+ ### Local loading (dev)
22
+
23
+ ```bash
24
+ pi --extension /path/to/pi-vim/index.ts
10
25
  ```
11
26
 
12
27
  Or add to `.pi/settings.json`:
13
28
 
14
29
  ```json
15
30
  {
16
- "extensions": ["./pi-extensions/vim-bindings/index.ts"]
31
+ "extensions": ["./pi-extensions/pi-vim/index.ts"]
17
32
  }
18
33
  ```
19
34
 
20
- The mode indicator (`INSERT` / `NORMAL`) is shown in the bottom-right corner
21
- of the prompt.
35
+ ## 30-second quickstart
36
+
37
+ Try on multi-line input:
38
+
39
+ ```text
40
+ Esc # NORMAL mode
41
+ 3gg # jump to absolute line 3
42
+ 2dw # delete two words
43
+ u # undo
44
+ <C-r> # redo last undone edit (safe no-op when empty)
45
+ 2} # jump two paragraphs forward
46
+ ```
47
+
48
+ Mode indicator (`INSERT` / `NORMAL`) appears at bottom-right.
49
+
50
+ ## Why pi-vim
51
+
52
+ - Fast modal editing without leaving Pi.
53
+ - Count-aware motions/operators (`2dw`, `3G`, `d2j`, `2}`).
54
+ - Strong REPL-focused defaults; safe out-of-scope boundaries documented.
55
+ - Clipboard/register behavior is explicit and tested.
56
+
57
+ ## For you / not for you
58
+
59
+ Use pi-vim if you want fast Vim muscle-memory in Pi prompts.
60
+ Skip it if you need full Vim feature parity (visual mode, macros, search,
61
+ ex-commands, etc.).
62
+
63
+ ## Common recipes
64
+
65
+ | Goal | Keys |
66
+ |------|------|
67
+ | Jump to exact line 25 | `25gg` (or `25G`) |
68
+ | Delete two words | `2dw` |
69
+ | Change to end of line | `C` |
70
+ | Delete current + 2 lines below | `d2j` |
71
+ | Yank 3 lines | `3yy` |
72
+ | Join 3 lines with spacing | `3J` |
73
+ | Jump 2 paragraphs forward | `2}` |
74
+ | Undo last edit | `u` |
75
+ | Redo last undone edit | `<C-r>` |
22
76
 
23
77
  ---
24
78
 
25
- ## Supported command surface
79
+ ## Full reference
26
80
 
27
81
  ### Mode switching
28
82
 
29
83
  | Key | Action |
30
84
  |----------|----------------------------------------|
31
- | `Esc` | Insert → Normal mode |
32
- | `Esc` | Normal mode → pass to Pi (abort agent) |
85
+ | `Esc` / `Ctrl+[` | Insert → Normal mode |
86
+ | `Esc` / `Ctrl+[` | Normal mode → pass to Pi (abort agent) |
33
87
  | `i` | Normal → Insert at cursor |
34
88
  | `a` | Normal → Insert after cursor |
35
89
  | `I` | Normal → Insert at line start |
@@ -61,6 +115,7 @@ A `{count}` prefix can be prepended to any navigation key (max: `9999`).
61
115
  | `{count}h/l` | Move left/right `{count}` cols |
62
116
  | `{count}j/k` | Move down/up `{count}` lines (clamped to buffer size) |
63
117
  | `0` | Line start |
118
+ | `^` | First non-whitespace char of line |
64
119
  | `$` | Line end |
65
120
  | `gg` | Buffer start (line 1) |
66
121
  | `{count}gg` | Go to line `{count}` (1-indexed, clamped) |
@@ -133,6 +188,7 @@ word, char-find, and linewise motions. Maximum total count: `9999`.
133
188
  | `d{count}W/E/B` | Forward/backward `{count}` `WORD` motions |
134
189
  | `d$` | To end of line |
135
190
  | `d0` | To start of line |
191
+ | `d^` | To first non-whitespace char of line |
136
192
  | `dd` | Current line (linewise) |
137
193
  | `{count}dd` | `{count}` lines (linewise) |
138
194
  | `d{count}j` | Current line + `{count}` lines below (linewise) |
@@ -162,7 +218,7 @@ Same motion and count set as `d`. Deletes text then enters Insert mode.
162
218
  | `ciw` | Change inner word |
163
219
  | `caw` | Change around word |
164
220
  | `cc` | Delete line content + Insert |
165
- | `c$` | Delete to EOL + Insert |
221
+ | `c$` / `c0` / `c^` | Delete to EOL / BOL / first non-whitespace + Insert |
166
222
  | … | All `d` motions apply |
167
223
 
168
224
  #### Single-key edits
@@ -199,6 +255,7 @@ Same motion set as `d`. Writes to register, **no text mutation**.
199
255
  | `yB` | Backward to `WORD` start |
200
256
  | `y$` | To end of line |
201
257
  | `y0` | To start of line |
258
+ | `y^` | To first non-whitespace char of line |
202
259
  | `yf{c}` | To and including `char` |
203
260
  | `yiw` | Inner word |
204
261
  | `yaw` | Around word (includes spaces) |
@@ -223,13 +280,14 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
223
280
 
224
281
  ---
225
282
 
226
- ### Undo
227
-
228
- | Key | Action |
229
- |-----|-------------------------------------------------|
230
- | `u` | Undo — sends `ctrl+_` (`\x1f`) to the underlying readline editor |
283
+ ### Undo / Redo
231
284
 
232
- Redo (`<C-r>`) is **not implemented** — see [Out of scope](#out-of-scope).
285
+ | Key | Action |
286
+ |-----|--------|
287
+ | `u` | Undo in normal mode |
288
+ | `Ctrl+_` | Undo in normal mode (alias for `u`) |
289
+ | `<C-r>` | Redo one undone change in normal mode; safe no-op when redo history is empty |
290
+ | `{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) |
233
291
 
234
292
  ---
235
293
 
@@ -253,7 +311,7 @@ Redo (`<C-r>`) is **not implemented** — see [Out of scope](#out-of-scope).
253
311
  | `w` / `e` / `b` + `W` / `E` / `B` | Cross-line for `word` + `WORD` motions | Cross-line |
254
312
  | `0` / `$` operators | Exclusive of anchor col | `0` inclusive of col 0 |
255
313
  | Undo depth | Delegates to underlying readline undo | Full per-change undo tree |
256
- | Redo | Not implemented | `<C-r>` |
314
+ | 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>` |
257
315
  | Visual mode | Not implemented | `v`, `V`, `<C-v>` |
258
316
  | Text objects | Supports `iw`/`aw` only | Full text-object set |
259
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 |
@@ -277,8 +335,9 @@ These are **explicitly deferred** and not planned for this feature:
277
335
  - Search mode (`/`, `?`, `n`, `N`)
278
336
  - Repeat (`.`)
279
337
  - Extended count prefix beyond currently supported motions (e.g. `:`, global operator counts)
280
- - Redo (`<C-r>`) no native redo primitive in the underlying readline editor;
281
- deferred until a suitable hook is available.
338
+ - No insert-mode `<C-r>` feature expansion beyond current underlying-editor behavior.
339
+ - No cross-session redo persistence.
340
+ - No upstream `pi-tui` redo prerequisite in this wave.
282
341
  - Window / tab / buffer management
283
342
  - Plugin / runtime ecosystem compatibility
284
343
 
@@ -295,6 +354,6 @@ These are **explicitly deferred** and not planned for this feature:
295
354
  Run tests:
296
355
 
297
356
  ```
298
- cd vim-bindings
357
+ cd pi-vim
299
358
  npm test
300
359
  ```
package/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Usage: pi --extension ./index.ts
5
5
  *
6
- * - Escape: insert → normal mode (in normal mode, aborts agent)
6
+ * - Escape / ctrl+[: insert → normal mode (in normal mode, aborts agent)
7
7
  * - i: normal → insert mode (at cursor)
8
8
  * - a: insert after cursor
9
9
  * - A: insert at end of line
@@ -12,11 +12,12 @@
12
12
  * - O: open new line above (insert mode)
13
13
  * - hjkl: navigation in normal mode
14
14
  * - 0/$: line start/end
15
+ * - ^: first non-whitespace char of line
15
16
  * - x: delete char under cursor
16
17
  * - D: delete to end of line
17
18
  * - S: substitute line (delete line content + insert mode)
18
19
  * - s: substitute char (delete char + insert mode)
19
- * - d{motion}: delete with motion (`w/b/e` + `W/B/E`, `$`, `0`, `dd`, `f/t/F/T{char}`)
20
+ * - d{motion}: delete with motion (`w/b/e` + `W/B/E`, `$`, `0`, `^`, `dd`, `f/t/F/T{char}`)
20
21
  * - c{motion}: change with same motion set as `d` (then enter insert mode)
21
22
  * - y{motion}: yank with same motion set as `d` (no text mutation)
22
23
  * - f{char}: jump to next {char} on line
@@ -76,6 +77,7 @@ import {
76
77
  CTRL_A,
77
78
  CTRL_E,
78
79
  CTRL_K,
80
+ CTRL_R,
79
81
  CTRL_UNDERSCORE,
80
82
  NEWLINE,
81
83
  ESC_DOWN,
@@ -84,6 +86,7 @@ import {
84
86
  reverseCharMotion,
85
87
  findCharMotionTarget,
86
88
  findParagraphMotionTarget,
89
+ findFirstNonWhitespaceColumn,
87
90
  type WordMotionClass,
88
91
  } from "./motions.js";
89
92
  import {
@@ -97,6 +100,24 @@ const BRACKETED_PASTE_END = "\x1b[201~";
97
100
  const BRACKETED_PASTE_END_TAIL = BRACKETED_PASTE_END.slice(1);
98
101
  const MAX_COUNT = 9999;
99
102
 
103
+ type EditorSnapshot = {
104
+ text: string;
105
+ cursor: { line: number; col: number };
106
+ };
107
+
108
+ type TransitionState = "none" | "undo" | "redo";
109
+
110
+ type ModalEditorInternals = {
111
+ state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
112
+ preferredVisualCol?: number | null;
113
+ lastAction?: string | null;
114
+ historyIndex?: number;
115
+ onChange?: (text: string) => void;
116
+ tui?: { requestRender?: () => void };
117
+ pushUndoSnapshot?: () => void;
118
+ setCursorCol?: (col: number) => void;
119
+ };
120
+
100
121
  export class ModalEditor extends CustomEditor {
101
122
  private mode: Mode = "insert";
102
123
  private pendingMotion: PendingMotion = null;
@@ -109,7 +130,10 @@ export class ModalEditor extends CustomEditor {
109
130
  private lastCharMotion: LastCharMotion | null = null;
110
131
  private discardingBracketedPasteInNormalMode: boolean = false;
111
132
  private pendingEscWhileDiscardingBracketedPasteInNormalMode: boolean = false;
112
- private readonly wordBoundaryCache = new WordBoundaryCache();
133
+ private wordBoundaryCache = new WordBoundaryCache();
134
+ private readonly redoStack: EditorSnapshot[] = [];
135
+ private currentTransition: TransitionState = "none";
136
+ private onChangeHooked: boolean = false;
113
137
 
114
138
  // Unnamed register
115
139
  private unnamedRegister: string = "";
@@ -124,6 +148,183 @@ export class ModalEditor extends CustomEditor {
124
148
  getMode(): Mode { return this.mode; }
125
149
  getText(): string { return this.getLines().join("\n"); }
126
150
 
151
+ override setText(text: string): void {
152
+ this.clearRedoStack();
153
+ super.setText(text);
154
+ }
155
+
156
+ private captureSnapshot(): EditorSnapshot {
157
+ const cursor = this.getCursor();
158
+ return {
159
+ text: this.getText(),
160
+ cursor: { line: cursor.line, col: cursor.col },
161
+ };
162
+ }
163
+
164
+ private requireRedoRestoreState(
165
+ editor: ModalEditorInternals,
166
+ ): { lines: string[]; cursorLine?: number; cursorCol?: number } {
167
+ const state = editor.state;
168
+ if (!state || !Array.isArray(state.lines)) {
169
+ throw new Error("Redo restore prerequisite: editor state unavailable");
170
+ }
171
+ return state;
172
+ }
173
+
174
+ private restoreSnapshot(snapshot: EditorSnapshot): void {
175
+ const editor = this as unknown as ModalEditorInternals;
176
+ const state = this.requireRedoRestoreState(editor);
177
+
178
+ const lines = snapshot.text.split("\n");
179
+ state.lines = lines.length > 0 ? lines : [""];
180
+
181
+ const maxLine = Math.max(0, state.lines.length - 1);
182
+ const cursorLine = Math.max(0, Math.min(snapshot.cursor.line, maxLine));
183
+ const line = state.lines[cursorLine] ?? "";
184
+ const cursorCol = Math.max(0, Math.min(snapshot.cursor.col, line.length));
185
+
186
+ state.cursorLine = cursorLine;
187
+ if (typeof editor.setCursorCol === "function") {
188
+ editor.setCursorCol(cursorCol);
189
+ } else {
190
+ state.cursorCol = cursorCol;
191
+ editor.preferredVisualCol = null;
192
+ }
193
+
194
+ this.invalidateWordBoundaryCache();
195
+
196
+ editor.historyIndex = -1;
197
+ editor.lastAction = null;
198
+ editor.onChange?.(this.getText());
199
+ editor.tui?.requestRender?.();
200
+ }
201
+
202
+ private snapshotChanged(a: EditorSnapshot, b: EditorSnapshot): boolean {
203
+ return a.text !== b.text
204
+ || a.cursor.line !== b.cursor.line
205
+ || a.cursor.col !== b.cursor.col;
206
+ }
207
+
208
+ private withTransition<T>(
209
+ transition: Exclude<TransitionState, "none">,
210
+ action: () => T,
211
+ ): T {
212
+ const previousTransition = this.currentTransition;
213
+ this.currentTransition = transition;
214
+ try {
215
+ return action();
216
+ } finally {
217
+ this.currentTransition = previousTransition;
218
+ }
219
+ }
220
+
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
+ });
231
+ }
232
+
233
+ private performRedo(count: number = this.takeTotalCount(1)): void {
234
+ const maxSteps = Math.max(1, Math.min(MAX_COUNT, count));
235
+ const editor = this as unknown as ModalEditorInternals;
236
+
237
+ for (let i = 0; i < maxSteps; i++) {
238
+ const snapshot = this.redoStack[this.redoStack.length - 1];
239
+ if (!snapshot) break;
240
+
241
+ this.withTransition("redo", () => {
242
+ this.requireRedoRestoreState(editor);
243
+ if (typeof editor.pushUndoSnapshot !== "function") {
244
+ throw new Error(
245
+ "Redo restore prerequisite: pushUndoSnapshot unavailable",
246
+ );
247
+ }
248
+ editor.pushUndoSnapshot();
249
+ this.restoreSnapshot(snapshot);
250
+ this.redoStack.pop();
251
+ });
252
+ }
253
+ }
254
+
255
+ private clearRedoStack(): void {
256
+ this.redoStack.length = 0;
257
+ }
258
+
259
+ private invalidateWordBoundaryCache(): void {
260
+ this.wordBoundaryCache = new WordBoundaryCache();
261
+ }
262
+
263
+ private ensureOnChangeHook(): void {
264
+ if (this.onChangeHooked) return;
265
+
266
+ const editor = this as unknown as ModalEditorInternals;
267
+ const originalOnChange = editor.onChange;
268
+
269
+ editor.onChange = (text: string) => {
270
+ originalOnChange?.(text);
271
+ this.centralInvalidationCheck();
272
+ };
273
+
274
+ this.onChangeHooked = true;
275
+ }
276
+
277
+ private centralInvalidationCheck(): void {
278
+ if (this.redoStack.length === 0) return;
279
+ if (this.currentTransition !== "none") return;
280
+ this.clearRedoStack();
281
+ }
282
+
283
+ private applySyntheticEdit(mutation: () => void): void {
284
+ const editor = this as unknown as ModalEditorInternals;
285
+ if (!editor.state || !Array.isArray(editor.state.lines)) {
286
+ throw new Error(
287
+ "Synthetic edit prerequisite: editor state unavailable",
288
+ );
289
+ }
290
+
291
+ if (typeof editor.pushUndoSnapshot !== "function") {
292
+ throw new Error(
293
+ "Synthetic edit prerequisite: pushUndoSnapshot unavailable",
294
+ );
295
+ }
296
+
297
+ const textBefore = this.getText();
298
+ const preCursorLine = editor.state.cursorLine;
299
+ const preCursorCol = editor.state.cursorCol;
300
+
301
+ mutation();
302
+
303
+ if (this.getText() === textBefore) return;
304
+
305
+ // Text changed — push undo boundary for pre-mutation state.
306
+ // Briefly swap pre-mutation state in for the snapshot, then
307
+ // restore the post-mutation result.
308
+ const postLines = editor.state.lines.slice();
309
+ const postCursorLine = editor.state.cursorLine;
310
+ const postCursorCol = editor.state.cursorCol;
311
+ const postPreferredCol = editor.preferredVisualCol;
312
+
313
+ const preLines = textBefore.split("\n");
314
+ editor.state.lines = preLines.length > 0 ? preLines : [""];
315
+ editor.state.cursorLine = preCursorLine;
316
+ editor.state.cursorCol = preCursorCol;
317
+ editor.pushUndoSnapshot();
318
+
319
+ editor.state.lines = postLines;
320
+ editor.state.cursorLine = postCursorLine;
321
+ editor.state.cursorCol = postCursorCol;
322
+ editor.preferredVisualCol = postPreferredCol;
323
+
324
+ editor.onChange?.(this.getText());
325
+ editor.tui?.requestRender?.();
326
+ }
327
+
127
328
  private clearPendingState(): void {
128
329
  this.pendingMotion = null;
129
330
  this.pendingTextObject = null;
@@ -134,6 +335,10 @@ export class ModalEditor extends CustomEditor {
134
335
  this.pendingGCount = "";
135
336
  }
136
337
 
338
+ private isEscapeLikeInput(data: string): boolean {
339
+ return matchesKey(data, "escape") || matchesKey(data, "ctrl+[");
340
+ }
341
+
137
342
  private stripBracketedPasteInNormalMode(data: string): { filtered: string | null; stripped: boolean } {
138
343
  let chunk = data;
139
344
  let stripped = false;
@@ -170,9 +375,11 @@ export class ModalEditor extends CustomEditor {
170
375
  }
171
376
 
172
377
  handleInput(data: string): void {
378
+ this.ensureOnChangeHook();
379
+
173
380
  if (this.mode !== "insert") {
174
381
  if (this.discardingBracketedPasteInNormalMode) {
175
- if (data === "\x1b") {
382
+ if (this.isEscapeLikeInput(data)) {
176
383
  if (this.pendingEscWhileDiscardingBracketedPasteInNormalMode) {
177
384
  this.pendingEscWhileDiscardingBracketedPasteInNormalMode = false;
178
385
  this.discardingBracketedPasteInNormalMode = false;
@@ -206,7 +413,7 @@ export class ModalEditor extends CustomEditor {
206
413
  data = filtered;
207
414
  }
208
415
 
209
- if (matchesKey(data, "escape")) {
416
+ if (this.isEscapeLikeInput(data)) {
210
417
  return this.handleEscape();
211
418
  }
212
419
 
@@ -221,19 +428,17 @@ export class ModalEditor extends CustomEditor {
221
428
  }
222
429
  // Alt+o: open new line below (stay in insert mode)
223
430
  if (matchesKey(data, Key.alt("o")) || data === "\x1bo") {
224
- super.handleInput(CTRL_E);
225
- super.handleInput(NEWLINE);
431
+ this.openLineBelow();
226
432
  return;
227
433
  }
228
434
  // Alt+Shift+o: open new line above (stay in insert mode)
229
435
  // \x1bO is the legacy sequence for Alt+Shift+O (VT100 SS3 prefix in non-Kitty terminals)
230
436
  if (matchesKey(data, Key.shiftAlt("o")) || data === "\x1bO") {
231
- super.handleInput(CTRL_A);
232
- super.handleInput(NEWLINE);
233
- super.handleInput(ESC_UP);
437
+ this.openLineAbove();
234
438
  return;
235
439
  }
236
- return super.handleInput(data);
440
+ super.handleInput(data);
441
+ return;
237
442
  }
238
443
 
239
444
  if (this.pendingTextObject) {
@@ -625,6 +830,8 @@ export class ModalEditor extends CustomEditor {
625
830
  || data === "p"
626
831
  || data === "P"
627
832
  || data === "J"
833
+ || data === CTRL_R
834
+ || matchesKey(data, "ctrl+r")
628
835
  );
629
836
  const supportsCountedCharMotion = (
630
837
  CHAR_MOTION_KEYS.has(data)
@@ -748,8 +955,13 @@ export class ModalEditor extends CustomEditor {
748
955
  return;
749
956
  }
750
957
 
751
- if (data === "u") {
752
- super.handleInput(CTRL_UNDERSCORE); // ctrl+_ — readline undo
958
+ if (data === "u" || data === CTRL_UNDERSCORE || matchesKey(data, "ctrl+_")) {
959
+ this.performUndo();
960
+ return;
961
+ }
962
+
963
+ if (data === CTRL_R || matchesKey(data, "ctrl+r")) {
964
+ this.performRedo();
753
965
  return;
754
966
  }
755
967
 
@@ -758,6 +970,11 @@ export class ModalEditor extends CustomEditor {
758
970
  return;
759
971
  }
760
972
 
973
+ if (data === "^") {
974
+ this.moveCursorToFirstNonWhitespace();
975
+ return;
976
+ }
977
+
761
978
  if (data === "w") {
762
979
  const count = this.takeTotalCount(1);
763
980
  return this.moveWord("forward", "start", count, "word");
@@ -777,6 +994,17 @@ export class ModalEditor extends CustomEditor {
777
994
  super.handleInput(data);
778
995
  }
779
996
 
997
+ private openLineBelow(): void {
998
+ super.handleInput(CTRL_E);
999
+ super.handleInput(NEWLINE);
1000
+ }
1001
+
1002
+ private openLineAbove(): void {
1003
+ super.handleInput(CTRL_A);
1004
+ super.handleInput(NEWLINE);
1005
+ super.handleInput(ESC_UP);
1006
+ }
1007
+
780
1008
  private handleMappedKey(key: string): void {
781
1009
  const seq = NORMAL_KEYS[key];
782
1010
  switch (key) {
@@ -798,14 +1026,11 @@ export class ModalEditor extends CustomEditor {
798
1026
  super.handleInput(CTRL_A);
799
1027
  break;
800
1028
  case "o":
801
- super.handleInput(CTRL_E);
802
- super.handleInput(NEWLINE);
1029
+ this.openLineBelow();
803
1030
  this.mode = "insert";
804
1031
  break;
805
1032
  case "O":
806
- super.handleInput(CTRL_A);
807
- super.handleInput(NEWLINE);
808
- super.handleInput(ESC_UP);
1033
+ this.openLineAbove();
809
1034
  this.mode = "insert";
810
1035
  break;
811
1036
  case "D":
@@ -920,6 +1145,12 @@ export class ModalEditor extends CustomEditor {
920
1145
  super.handleInput(CTRL_A);
921
1146
  }
922
1147
 
1148
+ private moveCursorToFirstNonWhitespace(): void {
1149
+ const { line, col } = this.getCurrentLineAndCol();
1150
+ const targetCol = findFirstNonWhitespaceColumn(line);
1151
+ this.moveCursorBy(targetCol - col);
1152
+ }
1153
+
923
1154
  private moveCursorToBufferStart(): void {
924
1155
  this.moveCursorToLineStart(0);
925
1156
  }
@@ -934,43 +1165,39 @@ export class ModalEditor extends CustomEditor {
934
1165
  const steps = Math.max(0, count - 1);
935
1166
  if (steps === 0) return;
936
1167
 
937
- const editor = this as unknown as {
938
- state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
939
- preferredVisualCol?: number;
940
- tui?: { requestRender?: () => void };
941
- };
1168
+ this.applySyntheticEdit(() => {
1169
+ const editor = this as unknown as ModalEditorInternals;
1170
+ const state = editor.state;
1171
+ if (!state || !Array.isArray(state.lines)) return;
942
1172
 
943
- const state = editor.state;
944
- if (!state || !Array.isArray(state.lines)) return;
1173
+ const currentLine = state.cursorLine ?? 0;
1174
+ let joinPoint = state.cursorCol ?? 0;
945
1175
 
946
- const currentLine = state.cursorLine ?? 0;
947
- let joinPoint = state.cursorCol ?? 0;
1176
+ for (let i = 0; i < steps; i++) {
1177
+ if (currentLine >= state.lines.length - 1) break;
948
1178
 
949
- for (let i = 0; i < steps; i++) {
950
- if (currentLine >= state.lines.length - 1) break;
1179
+ const left = state.lines[currentLine]!;
1180
+ const right = state.lines[currentLine + 1]!;
1181
+ let joined: string;
951
1182
 
952
- const left = state.lines[currentLine]!;
953
- const right = state.lines[currentLine + 1]!;
954
- let joined: string;
1183
+ if (normalize) {
1184
+ const trimmedRight = right.trimStart();
1185
+ const leftEndsWithSpace = left.length > 0 && /\s/.test(left[left.length - 1]!);
1186
+ const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
1187
+ joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
1188
+ joinPoint = left.length;
1189
+ } else {
1190
+ joined = left + right;
1191
+ joinPoint = left.length;
1192
+ }
955
1193
 
956
- if (normalize) {
957
- const trimmedRight = right.trimStart();
958
- const leftEndsWithSpace = left.length > 0 && /\s/.test(left[left.length - 1]!);
959
- const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
960
- joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
961
- joinPoint = left.length;
962
- } else {
963
- joined = left + right;
964
- joinPoint = left.length;
1194
+ state.lines.splice(currentLine, 2, joined);
965
1195
  }
966
1196
 
967
- state.lines.splice(currentLine, 2, joined);
968
- }
969
-
970
- state.cursorLine = currentLine;
971
- state.cursorCol = joinPoint;
972
- editor.preferredVisualCol = joinPoint;
973
- editor.tui?.requestRender?.();
1197
+ state.cursorLine = currentLine;
1198
+ state.cursorCol = joinPoint;
1199
+ editor.preferredVisualCol = joinPoint;
1200
+ });
974
1201
  }
975
1202
 
976
1203
  private isWordChar(ch: string): boolean {
@@ -1388,6 +1615,11 @@ export class ModalEditor extends CustomEditor {
1388
1615
  return true;
1389
1616
  }
1390
1617
 
1618
+ if (motion === "^") {
1619
+ this.deleteRange(col, findFirstNonWhitespaceColumn(this.getLines()[cursor.line] ?? ""), false);
1620
+ return true;
1621
+ }
1622
+
1391
1623
  const wordMotion = this.resolveWordMotion(motion);
1392
1624
  if (wordMotion) {
1393
1625
  const lineLocalRange = this.tryWordMotionLineLocalRange(
@@ -1511,6 +1743,11 @@ export class ModalEditor extends CustomEditor {
1511
1743
  return true;
1512
1744
  }
1513
1745
 
1746
+ if (motion === "^") {
1747
+ this.yankRange(col, findFirstNonWhitespaceColumn(line), false);
1748
+ return true;
1749
+ }
1750
+
1514
1751
  const wordMotion = this.resolveWordMotion(motion);
1515
1752
  if (wordMotion) {
1516
1753
  const lineLocalRange = this.tryWordMotionLineLocalRange(
package/motions.ts CHANGED
@@ -30,6 +30,14 @@ function clampLineIndex(lines: readonly string[], lineIndex: number): number {
30
30
  return Math.max(0, Math.min(normalized, lines.length - 1));
31
31
  }
32
32
 
33
+ /**
34
+ * Column of first non-whitespace char, or 0 for blank lines.
35
+ */
36
+ export function findFirstNonWhitespaceColumn(line: string): number {
37
+ const match = line.search(/\S/);
38
+ return match === -1 ? 0 : match;
39
+ }
40
+
33
41
  /**
34
42
  * True when line matches ^\s*$.
35
43
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vim",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "Vim-style modal editing for Pi's TUI editor",
5
5
  "type": "module",
6
6
  "keywords": [
package/types.ts CHANGED
@@ -43,6 +43,7 @@ export const ESC_DELETE = "\x1b[3~";
43
43
  export const CTRL_A = "\x01"; // line start
44
44
  export const CTRL_E = "\x05"; // line end
45
45
  export const CTRL_K = "\x0b"; // kill to end of line
46
+ export const CTRL_R = "\x12"; // ctrl+r — readline redo trigger in vim layer
46
47
  export const CTRL_UNDERSCORE = "\x1f"; // ctrl+_ — readline undo
47
48
  export const NEWLINE = "\n"; // newline character
48
49
  export const ESC_UP = "\x1b[A"; // cursor up