pi-vim 0.1.9 → 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.
Files changed (4) hide show
  1. package/README.md +13 -9
  2. package/index.ts +254 -44
  3. package/package.json +1 -1
  4. package/types.ts +1 -0
package/README.md CHANGED
@@ -41,6 +41,7 @@ Esc # NORMAL mode
41
41
  3gg # jump to absolute line 3
42
42
  2dw # delete two words
43
43
  u # undo
44
+ <C-r> # redo last undone edit (safe no-op when empty)
44
45
  2} # jump two paragraphs forward
45
46
  ```
46
47
 
@@ -71,6 +72,7 @@ ex-commands, etc.).
71
72
  | Join 3 lines with spacing | `3J` |
72
73
  | Jump 2 paragraphs forward | `2}` |
73
74
  | Undo last edit | `u` |
75
+ | Redo last undone edit | `<C-r>` |
74
76
 
75
77
  ---
76
78
 
@@ -278,13 +280,14 @@ Line-wise detection: register content ending in `\n` is treated as line-wise.
278
280
 
279
281
  ---
280
282
 
281
- ### Undo
283
+ ### Undo / Redo
282
284
 
283
- | Key | Action |
284
- |-----|-------------------------------------------------|
285
- | `u` | Undo sends `ctrl+_` (`\x1f`) to the underlying readline editor |
286
-
287
- 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) |
288
291
 
289
292
  ---
290
293
 
@@ -308,7 +311,7 @@ Redo (`<C-r>`) is **not implemented** — see [Out of scope](#out-of-scope).
308
311
  | `w` / `e` / `b` + `W` / `E` / `B` | Cross-line for `word` + `WORD` motions | Cross-line |
309
312
  | `0` / `$` operators | Exclusive of anchor col | `0` inclusive of col 0 |
310
313
  | Undo depth | Delegates to underlying readline undo | Full per-change undo tree |
311
- | 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>` |
312
315
  | Visual mode | Not implemented | `v`, `V`, `<C-v>` |
313
316
  | Text objects | Supports `iw`/`aw` only | Full text-object set |
314
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 |
@@ -332,8 +335,9 @@ These are **explicitly deferred** and not planned for this feature:
332
335
  - Search mode (`/`, `?`, `n`, `N`)
333
336
  - Repeat (`.`)
334
337
  - Extended count prefix beyond currently supported motions (e.g. `:`, global operator counts)
335
- - Redo (`<C-r>`) no native redo primitive in the underlying readline editor;
336
- 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.
337
341
  - Window / tab / buffer management
338
342
  - Plugin / runtime ecosystem compatibility
339
343
 
package/index.ts CHANGED
@@ -77,6 +77,7 @@ import {
77
77
  CTRL_A,
78
78
  CTRL_E,
79
79
  CTRL_K,
80
+ CTRL_R,
80
81
  CTRL_UNDERSCORE,
81
82
  NEWLINE,
82
83
  ESC_DOWN,
@@ -99,6 +100,24 @@ const BRACKETED_PASTE_END = "\x1b[201~";
99
100
  const BRACKETED_PASTE_END_TAIL = BRACKETED_PASTE_END.slice(1);
100
101
  const MAX_COUNT = 9999;
101
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
+
102
121
  export class ModalEditor extends CustomEditor {
103
122
  private mode: Mode = "insert";
104
123
  private pendingMotion: PendingMotion = null;
@@ -111,7 +130,10 @@ export class ModalEditor extends CustomEditor {
111
130
  private lastCharMotion: LastCharMotion | null = null;
112
131
  private discardingBracketedPasteInNormalMode: boolean = false;
113
132
  private pendingEscWhileDiscardingBracketedPasteInNormalMode: boolean = false;
114
- 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;
115
137
 
116
138
  // Unnamed register
117
139
  private unnamedRegister: string = "";
@@ -126,6 +148,183 @@ export class ModalEditor extends CustomEditor {
126
148
  getMode(): Mode { return this.mode; }
127
149
  getText(): string { return this.getLines().join("\n"); }
128
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
+
129
328
  private clearPendingState(): void {
130
329
  this.pendingMotion = null;
131
330
  this.pendingTextObject = null;
@@ -176,6 +375,8 @@ export class ModalEditor extends CustomEditor {
176
375
  }
177
376
 
178
377
  handleInput(data: string): void {
378
+ this.ensureOnChangeHook();
379
+
179
380
  if (this.mode !== "insert") {
180
381
  if (this.discardingBracketedPasteInNormalMode) {
181
382
  if (this.isEscapeLikeInput(data)) {
@@ -227,19 +428,17 @@ export class ModalEditor extends CustomEditor {
227
428
  }
228
429
  // Alt+o: open new line below (stay in insert mode)
229
430
  if (matchesKey(data, Key.alt("o")) || data === "\x1bo") {
230
- super.handleInput(CTRL_E);
231
- super.handleInput(NEWLINE);
431
+ this.openLineBelow();
232
432
  return;
233
433
  }
234
434
  // Alt+Shift+o: open new line above (stay in insert mode)
235
435
  // \x1bO is the legacy sequence for Alt+Shift+O (VT100 SS3 prefix in non-Kitty terminals)
236
436
  if (matchesKey(data, Key.shiftAlt("o")) || data === "\x1bO") {
237
- super.handleInput(CTRL_A);
238
- super.handleInput(NEWLINE);
239
- super.handleInput(ESC_UP);
437
+ this.openLineAbove();
240
438
  return;
241
439
  }
242
- return super.handleInput(data);
440
+ super.handleInput(data);
441
+ return;
243
442
  }
244
443
 
245
444
  if (this.pendingTextObject) {
@@ -631,6 +830,8 @@ export class ModalEditor extends CustomEditor {
631
830
  || data === "p"
632
831
  || data === "P"
633
832
  || data === "J"
833
+ || data === CTRL_R
834
+ || matchesKey(data, "ctrl+r")
634
835
  );
635
836
  const supportsCountedCharMotion = (
636
837
  CHAR_MOTION_KEYS.has(data)
@@ -754,8 +955,13 @@ export class ModalEditor extends CustomEditor {
754
955
  return;
755
956
  }
756
957
 
757
- if (data === "u") {
758
- 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();
759
965
  return;
760
966
  }
761
967
 
@@ -788,6 +994,17 @@ export class ModalEditor extends CustomEditor {
788
994
  super.handleInput(data);
789
995
  }
790
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
+
791
1008
  private handleMappedKey(key: string): void {
792
1009
  const seq = NORMAL_KEYS[key];
793
1010
  switch (key) {
@@ -809,14 +1026,11 @@ export class ModalEditor extends CustomEditor {
809
1026
  super.handleInput(CTRL_A);
810
1027
  break;
811
1028
  case "o":
812
- super.handleInput(CTRL_E);
813
- super.handleInput(NEWLINE);
1029
+ this.openLineBelow();
814
1030
  this.mode = "insert";
815
1031
  break;
816
1032
  case "O":
817
- super.handleInput(CTRL_A);
818
- super.handleInput(NEWLINE);
819
- super.handleInput(ESC_UP);
1033
+ this.openLineAbove();
820
1034
  this.mode = "insert";
821
1035
  break;
822
1036
  case "D":
@@ -951,43 +1165,39 @@ export class ModalEditor extends CustomEditor {
951
1165
  const steps = Math.max(0, count - 1);
952
1166
  if (steps === 0) return;
953
1167
 
954
- const editor = this as unknown as {
955
- state?: { lines?: string[]; cursorLine?: number; cursorCol?: number };
956
- preferredVisualCol?: number;
957
- tui?: { requestRender?: () => void };
958
- };
1168
+ this.applySyntheticEdit(() => {
1169
+ const editor = this as unknown as ModalEditorInternals;
1170
+ const state = editor.state;
1171
+ if (!state || !Array.isArray(state.lines)) return;
959
1172
 
960
- const state = editor.state;
961
- if (!state || !Array.isArray(state.lines)) return;
1173
+ const currentLine = state.cursorLine ?? 0;
1174
+ let joinPoint = state.cursorCol ?? 0;
962
1175
 
963
- const currentLine = state.cursorLine ?? 0;
964
- let joinPoint = state.cursorCol ?? 0;
1176
+ for (let i = 0; i < steps; i++) {
1177
+ if (currentLine >= state.lines.length - 1) break;
965
1178
 
966
- for (let i = 0; i < steps; i++) {
967
- if (currentLine >= state.lines.length - 1) break;
1179
+ const left = state.lines[currentLine]!;
1180
+ const right = state.lines[currentLine + 1]!;
1181
+ let joined: string;
968
1182
 
969
- const left = state.lines[currentLine]!;
970
- const right = state.lines[currentLine + 1]!;
971
- 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
+ }
972
1193
 
973
- if (normalize) {
974
- const trimmedRight = right.trimStart();
975
- const leftEndsWithSpace = left.length > 0 && /\s/.test(left[left.length - 1]!);
976
- const needsSeparator = !leftEndsWithSpace && trimmedRight.length > 0;
977
- joined = needsSeparator ? `${left} ${trimmedRight}` : left + trimmedRight;
978
- joinPoint = left.length;
979
- } else {
980
- joined = left + right;
981
- joinPoint = left.length;
1194
+ state.lines.splice(currentLine, 2, joined);
982
1195
  }
983
1196
 
984
- state.lines.splice(currentLine, 2, joined);
985
- }
986
-
987
- state.cursorLine = currentLine;
988
- state.cursorCol = joinPoint;
989
- editor.preferredVisualCol = joinPoint;
990
- editor.tui?.requestRender?.();
1197
+ state.cursorLine = currentLine;
1198
+ state.cursorCol = joinPoint;
1199
+ editor.preferredVisualCol = joinPoint;
1200
+ });
991
1201
  }
992
1202
 
993
1203
  private isWordChar(ch: string): boolean {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-vim",
3
- "version": "0.1.9",
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