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 +81 -22
- package/index.ts +285 -48
- package/motions.ts +8 -0
- package/package.json +1 -1
- package/types.ts +1 -0
package/README.md
CHANGED
|
@@ -1,35 +1,89 @@
|
|
|
1
|
-
# vim
|
|
1
|
+
# pi-vim — Vim Mode for Pi REPL
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
high-frequency
|
|
3
|
+
Modal vim-like editing for Pi's REPL prompt.
|
|
4
|
+
Focus: the high-frequency 90% command surface, not full Vim.
|
|
5
5
|
|
|
6
|
-
##
|
|
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
|
-
|
|
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
|
|
31
|
+
"extensions": ["./pi-extensions/pi-vim/index.ts"]
|
|
17
32
|
}
|
|
18
33
|
```
|
|
19
34
|
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
##
|
|
79
|
+
## Full reference
|
|
26
80
|
|
|
27
81
|
### Mode switching
|
|
28
82
|
|
|
29
83
|
| Key | Action |
|
|
30
84
|
|----------|----------------------------------------|
|
|
31
|
-
| `Esc`
|
|
32
|
-
| `Esc`
|
|
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$`
|
|
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
|
-
|
|
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 |
|
|
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
|
-
-
|
|
281
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
super.handleInput(NEWLINE);
|
|
233
|
-
super.handleInput(ESC_UP);
|
|
437
|
+
this.openLineAbove();
|
|
234
438
|
return;
|
|
235
439
|
}
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
802
|
-
super.handleInput(NEWLINE);
|
|
1029
|
+
this.openLineBelow();
|
|
803
1030
|
this.mode = "insert";
|
|
804
1031
|
break;
|
|
805
1032
|
case "O":
|
|
806
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
944
|
-
|
|
1173
|
+
const currentLine = state.cursorLine ?? 0;
|
|
1174
|
+
let joinPoint = state.cursorCol ?? 0;
|
|
945
1175
|
|
|
946
|
-
|
|
947
|
-
|
|
1176
|
+
for (let i = 0; i < steps; i++) {
|
|
1177
|
+
if (currentLine >= state.lines.length - 1) break;
|
|
948
1178
|
|
|
949
|
-
|
|
950
|
-
|
|
1179
|
+
const left = state.lines[currentLine]!;
|
|
1180
|
+
const right = state.lines[currentLine + 1]!;
|
|
1181
|
+
let joined: string;
|
|
951
1182
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
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
|
-
|
|
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.
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
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
|