mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 06:04:51 +00:00
feat(tui): add undo support to Editor with the Ctrl+- hotkey
Undo snapshots are captured for all edit operations: - Word insertion, backspace, forward delete - Word/line deletion (Ctrl+W, Ctrl+U, Ctrl+K, Alt+D) - Yank/yank-pop, paste, autocomplete completion - Cursor movement starts a new undo unit - setText() pushes snapshot when content changes Additionally, history browsing captures the undo state on first entry.
This commit is contained in:
parent
c8db8e613f
commit
bacf334bc4
5 changed files with 595 additions and 8 deletions
|
|
@ -355,6 +355,7 @@ Both modes are configurable via `/settings`: "one-at-a-time" delivers messages o
|
||||||
| Ctrl+K | Delete to end of line |
|
| Ctrl+K | Delete to end of line |
|
||||||
| Ctrl+Y | Paste most recently deleted text |
|
| Ctrl+Y | Paste most recently deleted text |
|
||||||
| Alt+Y | Cycle through deleted text after pasting |
|
| Alt+Y | Cycle through deleted text after pasting |
|
||||||
|
| Ctrl+- | Undo |
|
||||||
|
|
||||||
**Other:**
|
**Other:**
|
||||||
|
|
||||||
|
|
@ -405,6 +406,7 @@ All keyboard shortcuts can be customized via `~/.pi/agent/keybindings.json`. Eac
|
||||||
| `deleteToLineEnd` | `ctrl+k` | Delete to line end |
|
| `deleteToLineEnd` | `ctrl+k` | Delete to line end |
|
||||||
| `yank` | `ctrl+y` | Paste most recently deleted text |
|
| `yank` | `ctrl+y` | Paste most recently deleted text |
|
||||||
| `yankPop` | `alt+y` | Cycle through deleted text after pasting |
|
| `yankPop` | `alt+y` | Cycle through deleted text after pasting |
|
||||||
|
| `undo` | `ctrl+-` | Undo last edit |
|
||||||
| `newLine` | `shift+enter` | Insert new line |
|
| `newLine` | `shift+enter` | Insert new line |
|
||||||
| `submit` | `enter` | Submit input |
|
| `submit` | `enter` | Submit input |
|
||||||
| `tab` | `tab` | Tab/autocomplete |
|
| `tab` | `tab` | Tab/autocomplete |
|
||||||
|
|
|
||||||
|
|
@ -3446,6 +3446,7 @@ export class InteractiveMode {
|
||||||
const deleteToLineEnd = this.getEditorKeyDisplay("deleteToLineEnd");
|
const deleteToLineEnd = this.getEditorKeyDisplay("deleteToLineEnd");
|
||||||
const yank = this.getEditorKeyDisplay("yank");
|
const yank = this.getEditorKeyDisplay("yank");
|
||||||
const yankPop = this.getEditorKeyDisplay("yankPop");
|
const yankPop = this.getEditorKeyDisplay("yankPop");
|
||||||
|
const undo = this.getEditorKeyDisplay("undo");
|
||||||
const tab = this.getEditorKeyDisplay("tab");
|
const tab = this.getEditorKeyDisplay("tab");
|
||||||
|
|
||||||
// App keybindings
|
// App keybindings
|
||||||
|
|
@ -3481,6 +3482,7 @@ export class InteractiveMode {
|
||||||
| \`${deleteToLineEnd}\` | Delete to end of line |
|
| \`${deleteToLineEnd}\` | Delete to end of line |
|
||||||
| \`${yank}\` | Paste the most-recently-deleted text |
|
| \`${yank}\` | Paste the most-recently-deleted text |
|
||||||
| \`${yankPop}\` | Cycle through the deleted text after pasting |
|
| \`${yankPop}\` | Cycle through the deleted text after pasting |
|
||||||
|
| \`${undo}\` | Undo |
|
||||||
|
|
||||||
**Other**
|
**Other**
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|
|
|
||||||
|
|
@ -290,8 +290,12 @@ export class Editor implements Component, Focusable {
|
||||||
private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
|
private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc.
|
||||||
|
|
||||||
// Kill ring for Emacs-style kill/yank operations
|
// Kill ring for Emacs-style kill/yank operations
|
||||||
|
// Also tracks undo coalescing: "type-word" means we're mid-word (coalescing)
|
||||||
private killRing: string[] = [];
|
private killRing: string[] = [];
|
||||||
private lastAction: "kill" | "yank" | null = null;
|
private lastAction: "kill" | "yank" | "type-word" | null = null;
|
||||||
|
|
||||||
|
// Undo support
|
||||||
|
private undoStack: EditorState[] = [];
|
||||||
|
|
||||||
public onSubmit?: (text: string) => void;
|
public onSubmit?: (text: string) => void;
|
||||||
public onChange?: (text: string) => void;
|
public onChange?: (text: string) => void;
|
||||||
|
|
@ -360,6 +364,11 @@ export class Editor implements Component, Focusable {
|
||||||
const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases
|
const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases
|
||||||
if (newIndex < -1 || newIndex >= this.history.length) return;
|
if (newIndex < -1 || newIndex >= this.history.length) return;
|
||||||
|
|
||||||
|
// Capture state when first entering history browsing mode
|
||||||
|
if (this.historyIndex === -1 && newIndex >= 0) {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
this.historyIndex = newIndex;
|
this.historyIndex = newIndex;
|
||||||
|
|
||||||
if (this.historyIndex === -1) {
|
if (this.historyIndex === -1) {
|
||||||
|
|
@ -570,6 +579,12 @@ export class Editor implements Component, Focusable {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Undo
|
||||||
|
if (kb.matches(data, "undo")) {
|
||||||
|
this.undo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle autocomplete mode
|
// Handle autocomplete mode
|
||||||
if (this.isAutocompleting && this.autocompleteList) {
|
if (this.isAutocompleting && this.autocompleteList) {
|
||||||
if (kb.matches(data, "selectCancel")) {
|
if (kb.matches(data, "selectCancel")) {
|
||||||
|
|
@ -585,6 +600,8 @@ export class Editor implements Component, Focusable {
|
||||||
if (kb.matches(data, "tab")) {
|
if (kb.matches(data, "tab")) {
|
||||||
const selected = this.autocompleteList.getSelectedItem();
|
const selected = this.autocompleteList.getSelectedItem();
|
||||||
if (selected && this.autocompleteProvider) {
|
if (selected && this.autocompleteProvider) {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
this.lastAction = null;
|
||||||
const result = this.autocompleteProvider.applyCompletion(
|
const result = this.autocompleteProvider.applyCompletion(
|
||||||
this.state.lines,
|
this.state.lines,
|
||||||
this.state.cursorLine,
|
this.state.cursorLine,
|
||||||
|
|
@ -604,6 +621,8 @@ export class Editor implements Component, Focusable {
|
||||||
if (kb.matches(data, "selectConfirm")) {
|
if (kb.matches(data, "selectConfirm")) {
|
||||||
const selected = this.autocompleteList.getSelectedItem();
|
const selected = this.autocompleteList.getSelectedItem();
|
||||||
if (selected && this.autocompleteProvider) {
|
if (selected && this.autocompleteProvider) {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
this.lastAction = null;
|
||||||
const result = this.autocompleteProvider.applyCompletion(
|
const result = this.autocompleteProvider.applyCompletion(
|
||||||
this.state.lines,
|
this.state.lines,
|
||||||
this.state.cursorLine,
|
this.state.cursorLine,
|
||||||
|
|
@ -716,6 +735,8 @@ export class Editor implements Component, Focusable {
|
||||||
this.pasteCounter = 0;
|
this.pasteCounter = 0;
|
||||||
this.historyIndex = -1;
|
this.historyIndex = -1;
|
||||||
this.scrollOffset = 0;
|
this.scrollOffset = 0;
|
||||||
|
this.undoStack.length = 0;
|
||||||
|
this.lastAction = null;
|
||||||
|
|
||||||
if (this.onChange) this.onChange("");
|
if (this.onChange) this.onChange("");
|
||||||
if (this.onSubmit) this.onSubmit(result);
|
if (this.onSubmit) this.onSubmit(result);
|
||||||
|
|
@ -893,23 +914,43 @@ export class Editor implements Component, Focusable {
|
||||||
|
|
||||||
setText(text: string): void {
|
setText(text: string): void {
|
||||||
this.historyIndex = -1; // Exit history browsing mode
|
this.historyIndex = -1; // Exit history browsing mode
|
||||||
|
// Push undo snapshot if content differs (makes programmatic changes undoable)
|
||||||
|
if (this.getText() !== text) {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
}
|
||||||
this.setTextInternal(text);
|
this.setTextInternal(text);
|
||||||
|
this.lastAction = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insert text at the current cursor position.
|
* Insert text at the current cursor position.
|
||||||
* Used for programmatic insertion (e.g., clipboard image markers).
|
* Used for programmatic insertion (e.g., clipboard image markers).
|
||||||
|
* This is atomic for undo - single undo restores entire pre-insert state.
|
||||||
*/
|
*/
|
||||||
insertTextAtCursor(text: string): void {
|
insertTextAtCursor(text: string): void {
|
||||||
|
if (!text) return;
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
this.lastAction = null;
|
||||||
for (const char of text) {
|
for (const char of text) {
|
||||||
this.insertCharacter(char);
|
this.insertCharacter(char, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// All the editor methods from before...
|
// All the editor methods from before...
|
||||||
private insertCharacter(char: string): void {
|
private insertCharacter(char: string, skipUndoCoalescing?: boolean): void {
|
||||||
this.historyIndex = -1; // Exit history browsing mode
|
this.historyIndex = -1; // Exit history browsing mode
|
||||||
this.lastAction = null;
|
|
||||||
|
// Undo coalescing (fish-style):
|
||||||
|
// - Consecutive word chars coalesce into one undo unit
|
||||||
|
// - Space captures state before itself (so undo removes space+following word together)
|
||||||
|
// - Each space is separately undoable
|
||||||
|
// Skip coalescing when called from atomic operations (paste, insertTextAtCursor)
|
||||||
|
if (!skipUndoCoalescing) {
|
||||||
|
if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
}
|
||||||
|
this.lastAction = "type-word";
|
||||||
|
}
|
||||||
|
|
||||||
const line = this.state.lines[this.state.cursorLine] || "";
|
const line = this.state.lines[this.state.cursorLine] || "";
|
||||||
|
|
||||||
|
|
@ -961,6 +1002,8 @@ export class Editor implements Component, Focusable {
|
||||||
this.historyIndex = -1; // Exit history browsing mode
|
this.historyIndex = -1; // Exit history browsing mode
|
||||||
this.lastAction = null;
|
this.lastAction = null;
|
||||||
|
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
|
||||||
// Clean the pasted text
|
// Clean the pasted text
|
||||||
const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||||
|
|
||||||
|
|
@ -1000,9 +1043,8 @@ export class Editor implements Component, Focusable {
|
||||||
? `[paste #${pasteId} +${pastedLines.length} lines]`
|
? `[paste #${pasteId} +${pastedLines.length} lines]`
|
||||||
: `[paste #${pasteId} ${totalChars} chars]`;
|
: `[paste #${pasteId} ${totalChars} chars]`;
|
||||||
for (const char of marker) {
|
for (const char of marker) {
|
||||||
this.insertCharacter(char);
|
this.insertCharacter(char, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1010,9 +1052,8 @@ export class Editor implements Component, Focusable {
|
||||||
// Single line - just insert each character
|
// Single line - just insert each character
|
||||||
const text = pastedLines[0] || "";
|
const text = pastedLines[0] || "";
|
||||||
for (const char of text) {
|
for (const char of text) {
|
||||||
this.insertCharacter(char);
|
this.insertCharacter(char, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1062,6 +1103,8 @@ export class Editor implements Component, Focusable {
|
||||||
this.historyIndex = -1; // Exit history browsing mode
|
this.historyIndex = -1; // Exit history browsing mode
|
||||||
this.lastAction = null;
|
this.lastAction = null;
|
||||||
|
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
|
||||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||||
|
|
||||||
const before = currentLine.slice(0, this.state.cursorCol);
|
const before = currentLine.slice(0, this.state.cursorCol);
|
||||||
|
|
@ -1085,6 +1128,8 @@ export class Editor implements Component, Focusable {
|
||||||
this.lastAction = null;
|
this.lastAction = null;
|
||||||
|
|
||||||
if (this.state.cursorCol > 0) {
|
if (this.state.cursorCol > 0) {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
|
||||||
// Delete grapheme before cursor (handles emojis, combining characters, etc.)
|
// Delete grapheme before cursor (handles emojis, combining characters, etc.)
|
||||||
const line = this.state.lines[this.state.cursorLine] || "";
|
const line = this.state.lines[this.state.cursorLine] || "";
|
||||||
const beforeCursor = line.slice(0, this.state.cursorCol);
|
const beforeCursor = line.slice(0, this.state.cursorCol);
|
||||||
|
|
@ -1100,6 +1145,8 @@ export class Editor implements Component, Focusable {
|
||||||
this.state.lines[this.state.cursorLine] = before + after;
|
this.state.lines[this.state.cursorLine] = before + after;
|
||||||
this.state.cursorCol -= graphemeLength;
|
this.state.cursorCol -= graphemeLength;
|
||||||
} else if (this.state.cursorLine > 0) {
|
} else if (this.state.cursorLine > 0) {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
|
||||||
// Merge with previous line
|
// Merge with previous line
|
||||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||||
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
const previousLine = this.state.lines[this.state.cursorLine - 1] || "";
|
||||||
|
|
@ -1150,6 +1197,8 @@ export class Editor implements Component, Focusable {
|
||||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||||
|
|
||||||
if (this.state.cursorCol > 0) {
|
if (this.state.cursorCol > 0) {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
|
||||||
// Calculate text to be deleted and save to kill ring (backward deletion = prepend)
|
// Calculate text to be deleted and save to kill ring (backward deletion = prepend)
|
||||||
const deletedText = currentLine.slice(0, this.state.cursorCol);
|
const deletedText = currentLine.slice(0, this.state.cursorCol);
|
||||||
this.addToKillRing(deletedText, true);
|
this.addToKillRing(deletedText, true);
|
||||||
|
|
@ -1159,6 +1208,8 @@ export class Editor implements Component, Focusable {
|
||||||
this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);
|
this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol);
|
||||||
this.state.cursorCol = 0;
|
this.state.cursorCol = 0;
|
||||||
} else if (this.state.cursorLine > 0) {
|
} else if (this.state.cursorLine > 0) {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
|
||||||
// At start of line - merge with previous line, treating newline as deleted text
|
// At start of line - merge with previous line, treating newline as deleted text
|
||||||
this.addToKillRing("\n", true);
|
this.addToKillRing("\n", true);
|
||||||
this.lastAction = "kill";
|
this.lastAction = "kill";
|
||||||
|
|
@ -1181,6 +1232,8 @@ export class Editor implements Component, Focusable {
|
||||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||||
|
|
||||||
if (this.state.cursorCol < currentLine.length) {
|
if (this.state.cursorCol < currentLine.length) {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
|
||||||
// Calculate text to be deleted and save to kill ring (forward deletion = append)
|
// Calculate text to be deleted and save to kill ring (forward deletion = append)
|
||||||
const deletedText = currentLine.slice(this.state.cursorCol);
|
const deletedText = currentLine.slice(this.state.cursorCol);
|
||||||
this.addToKillRing(deletedText, false);
|
this.addToKillRing(deletedText, false);
|
||||||
|
|
@ -1189,6 +1242,8 @@ export class Editor implements Component, Focusable {
|
||||||
// Delete from cursor to end of line
|
// Delete from cursor to end of line
|
||||||
this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol);
|
this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol);
|
||||||
} else if (this.state.cursorLine < this.state.lines.length - 1) {
|
} else if (this.state.cursorLine < this.state.lines.length - 1) {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
|
||||||
// At end of line - merge with next line, treating newline as deleted text
|
// At end of line - merge with next line, treating newline as deleted text
|
||||||
this.addToKillRing("\n", false);
|
this.addToKillRing("\n", false);
|
||||||
this.lastAction = "kill";
|
this.lastAction = "kill";
|
||||||
|
|
@ -1211,6 +1266,8 @@ export class Editor implements Component, Focusable {
|
||||||
// If at start of line, behave like backspace at column 0 (merge with previous line)
|
// If at start of line, behave like backspace at column 0 (merge with previous line)
|
||||||
if (this.state.cursorCol === 0) {
|
if (this.state.cursorCol === 0) {
|
||||||
if (this.state.cursorLine > 0) {
|
if (this.state.cursorLine > 0) {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
|
||||||
// Treat newline as deleted text (backward deletion = prepend)
|
// Treat newline as deleted text (backward deletion = prepend)
|
||||||
this.addToKillRing("\n", true);
|
this.addToKillRing("\n", true);
|
||||||
this.lastAction = "kill";
|
this.lastAction = "kill";
|
||||||
|
|
@ -1222,6 +1279,8 @@ export class Editor implements Component, Focusable {
|
||||||
this.state.cursorCol = previousLine.length;
|
this.state.cursorCol = previousLine.length;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
|
||||||
// Save lastAction before cursor movement (moveWordBackwards resets it)
|
// Save lastAction before cursor movement (moveWordBackwards resets it)
|
||||||
const wasKill = this.lastAction === "kill";
|
const wasKill = this.lastAction === "kill";
|
||||||
|
|
||||||
|
|
@ -1254,6 +1313,8 @@ export class Editor implements Component, Focusable {
|
||||||
// If at end of line, merge with next line (delete the newline)
|
// If at end of line, merge with next line (delete the newline)
|
||||||
if (this.state.cursorCol >= currentLine.length) {
|
if (this.state.cursorCol >= currentLine.length) {
|
||||||
if (this.state.cursorLine < this.state.lines.length - 1) {
|
if (this.state.cursorLine < this.state.lines.length - 1) {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
|
||||||
// Treat newline as deleted text (forward deletion = append)
|
// Treat newline as deleted text (forward deletion = append)
|
||||||
this.addToKillRing("\n", false);
|
this.addToKillRing("\n", false);
|
||||||
this.lastAction = "kill";
|
this.lastAction = "kill";
|
||||||
|
|
@ -1263,6 +1324,8 @@ export class Editor implements Component, Focusable {
|
||||||
this.state.lines.splice(this.state.cursorLine + 1, 1);
|
this.state.lines.splice(this.state.cursorLine + 1, 1);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
|
||||||
// Save lastAction before cursor movement (moveWordForwards resets it)
|
// Save lastAction before cursor movement (moveWordForwards resets it)
|
||||||
const wasKill = this.lastAction === "kill";
|
const wasKill = this.lastAction === "kill";
|
||||||
|
|
||||||
|
|
@ -1293,6 +1356,8 @@ export class Editor implements Component, Focusable {
|
||||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||||
|
|
||||||
if (this.state.cursorCol < currentLine.length) {
|
if (this.state.cursorCol < currentLine.length) {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
|
||||||
// Delete grapheme at cursor position (handles emojis, combining characters, etc.)
|
// Delete grapheme at cursor position (handles emojis, combining characters, etc.)
|
||||||
const afterCursor = currentLine.slice(this.state.cursorCol);
|
const afterCursor = currentLine.slice(this.state.cursorCol);
|
||||||
|
|
||||||
|
|
@ -1305,6 +1370,8 @@ export class Editor implements Component, Focusable {
|
||||||
const after = currentLine.slice(this.state.cursorCol + graphemeLength);
|
const after = currentLine.slice(this.state.cursorCol + graphemeLength);
|
||||||
this.state.lines[this.state.cursorLine] = before + after;
|
this.state.lines[this.state.cursorLine] = before + after;
|
||||||
} else if (this.state.cursorLine < this.state.lines.length - 1) {
|
} else if (this.state.cursorLine < this.state.lines.length - 1) {
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
|
||||||
// At end of line - merge with next line
|
// At end of line - merge with next line
|
||||||
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
const nextLine = this.state.lines[this.state.cursorLine + 1] || "";
|
||||||
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
|
this.state.lines[this.state.cursorLine] = currentLine + nextLine;
|
||||||
|
|
@ -1532,6 +1599,8 @@ export class Editor implements Component, Focusable {
|
||||||
private yank(): void {
|
private yank(): void {
|
||||||
if (this.killRing.length === 0) return;
|
if (this.killRing.length === 0) return;
|
||||||
|
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
|
||||||
const text = this.killRing[this.killRing.length - 1] || "";
|
const text = this.killRing[this.killRing.length - 1] || "";
|
||||||
this.insertYankedText(text);
|
this.insertYankedText(text);
|
||||||
|
|
||||||
|
|
@ -1546,6 +1615,8 @@ export class Editor implements Component, Focusable {
|
||||||
// Only works if we just yanked and have more than one entry
|
// Only works if we just yanked and have more than one entry
|
||||||
if (this.lastAction !== "yank" || this.killRing.length <= 1) return;
|
if (this.lastAction !== "yank" || this.killRing.length <= 1) return;
|
||||||
|
|
||||||
|
this.pushUndoSnapshot();
|
||||||
|
|
||||||
// Delete the previously yanked text (still at end of ring before rotation)
|
// Delete the previously yanked text (still at end of ring before rotation)
|
||||||
this.deleteYankedText();
|
this.deleteYankedText();
|
||||||
|
|
||||||
|
|
@ -1668,6 +1739,29 @@ export class Editor implements Component, Focusable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private captureUndoSnapshot(): EditorState {
|
||||||
|
return structuredClone(this.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreUndoSnapshot(snapshot: EditorState): void {
|
||||||
|
Object.assign(this.state, structuredClone(snapshot));
|
||||||
|
}
|
||||||
|
|
||||||
|
private pushUndoSnapshot(): void {
|
||||||
|
this.undoStack.push(this.captureUndoSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
private undo(): void {
|
||||||
|
this.historyIndex = -1; // Exit history browsing mode
|
||||||
|
if (this.undoStack.length === 0) return;
|
||||||
|
const snapshot = this.undoStack.pop()!;
|
||||||
|
this.restoreUndoSnapshot(snapshot);
|
||||||
|
this.lastAction = null;
|
||||||
|
if (this.onChange) {
|
||||||
|
this.onChange(this.getText());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private moveWordForwards(): void {
|
private moveWordForwards(): void {
|
||||||
this.lastAction = null;
|
this.lastAction = null;
|
||||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ export type EditorAction =
|
||||||
// Kill ring
|
// Kill ring
|
||||||
| "yank"
|
| "yank"
|
||||||
| "yankPop"
|
| "yankPop"
|
||||||
|
// Undo
|
||||||
|
| "undo"
|
||||||
// Tool output
|
// Tool output
|
||||||
| "expandTools";
|
| "expandTools";
|
||||||
|
|
||||||
|
|
@ -89,6 +91,8 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
|
||||||
// Kill ring
|
// Kill ring
|
||||||
yank: "ctrl+y",
|
yank: "ctrl+y",
|
||||||
yankPop: "alt+y",
|
yankPop: "alt+y",
|
||||||
|
// Undo
|
||||||
|
undo: "ctrl+-",
|
||||||
// Tool output
|
// Tool output
|
||||||
expandTools: "ctrl+o",
|
expandTools: "ctrl+o",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { stripVTControlCharacters } from "node:util";
|
import { stripVTControlCharacters } from "node:util";
|
||||||
|
import type { AutocompleteProvider } from "../src/autocomplete.js";
|
||||||
import { Editor } from "../src/components/editor.js";
|
import { Editor } from "../src/components/editor.js";
|
||||||
import { TUI } from "../src/tui.js";
|
import { TUI } from "../src/tui.js";
|
||||||
import { visibleWidth } from "../src/utils.js";
|
import { visibleWidth } from "../src/utils.js";
|
||||||
|
|
@ -1095,4 +1096,488 @@ describe("Editor component", () => {
|
||||||
assert.strictEqual(editor.getText(), "line1\nline2");
|
assert.strictEqual(editor.getText(), "line1\nline2");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Undo", () => {
|
||||||
|
it("does nothing when undo stack is empty", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coalesces consecutive word characters into one undo unit", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
editor.handleInput("h");
|
||||||
|
editor.handleInput("e");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput(" ");
|
||||||
|
editor.handleInput("w");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput("r");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("d");
|
||||||
|
assert.strictEqual(editor.getText(), "hello world");
|
||||||
|
|
||||||
|
// Undo removes " world" (space captured state before it, so we restore to "hello")
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "hello");
|
||||||
|
|
||||||
|
// Undo removes "hello"
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes spaces one at a time", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
editor.handleInput("h");
|
||||||
|
editor.handleInput("e");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput(" ");
|
||||||
|
editor.handleInput(" ");
|
||||||
|
assert.strictEqual(editor.getText(), "hello ");
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes second " "
|
||||||
|
assert.strictEqual(editor.getText(), "hello ");
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes first " "
|
||||||
|
assert.strictEqual(editor.getText(), "hello");
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes "hello"
|
||||||
|
assert.strictEqual(editor.getText(), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes newlines and signals next word to capture state", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
editor.handleInput("h");
|
||||||
|
editor.handleInput("e");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput("\n");
|
||||||
|
editor.handleInput("w");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput("r");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("d");
|
||||||
|
assert.strictEqual(editor.getText(), "hello\nworld");
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "hello\n");
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "hello");
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes backspace", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
editor.handleInput("h");
|
||||||
|
editor.handleInput("e");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput("\x7f"); // Backspace
|
||||||
|
assert.strictEqual(editor.getText(), "hell");
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes forward delete", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
editor.handleInput("h");
|
||||||
|
editor.handleInput("e");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput("\x01"); // Ctrl+A - go to start
|
||||||
|
editor.handleInput("\x1b[C"); // Right arrow
|
||||||
|
editor.handleInput("\x1b[3~"); // Delete key
|
||||||
|
assert.strictEqual(editor.getText(), "hllo");
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes Ctrl+W (delete word backward)", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
editor.handleInput("h");
|
||||||
|
editor.handleInput("e");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput(" ");
|
||||||
|
editor.handleInput("w");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput("r");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("d");
|
||||||
|
assert.strictEqual(editor.getText(), "hello world");
|
||||||
|
|
||||||
|
editor.handleInput("\x17"); // Ctrl+W
|
||||||
|
assert.strictEqual(editor.getText(), "hello ");
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes Ctrl+K (delete to line end)", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
editor.handleInput("h");
|
||||||
|
editor.handleInput("e");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput(" ");
|
||||||
|
editor.handleInput("w");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput("r");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("d");
|
||||||
|
editor.handleInput("\x01"); // Ctrl+A - go to start
|
||||||
|
for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C"); // Move right 6 times
|
||||||
|
|
||||||
|
editor.handleInput("\x0b"); // Ctrl+K
|
||||||
|
assert.strictEqual(editor.getText(), "hello ");
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "hello world");
|
||||||
|
|
||||||
|
editor.handleInput("|");
|
||||||
|
assert.strictEqual(editor.getText(), "hello |world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes Ctrl+U (delete to line start)", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
editor.handleInput("h");
|
||||||
|
editor.handleInput("e");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput(" ");
|
||||||
|
editor.handleInput("w");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput("r");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("d");
|
||||||
|
editor.handleInput("\x01"); // Ctrl+A - go to start
|
||||||
|
for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C"); // Move right 6 times
|
||||||
|
|
||||||
|
editor.handleInput("\x15"); // Ctrl+U
|
||||||
|
assert.strictEqual(editor.getText(), "world");
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes yank", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
editor.handleInput("h");
|
||||||
|
editor.handleInput("e");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput(" ");
|
||||||
|
editor.handleInput("\x17"); // Ctrl+W - delete "hello "
|
||||||
|
editor.handleInput("\x19"); // Ctrl+Y - yank
|
||||||
|
assert.strictEqual(editor.getText(), "hello ");
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes single-line paste atomically", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
editor.setText("hello world");
|
||||||
|
editor.handleInput("\x01"); // Ctrl+A - go to start
|
||||||
|
for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); // Move right 5 (after "hello", before space)
|
||||||
|
|
||||||
|
// Simulate bracketed paste of "beep boop"
|
||||||
|
editor.handleInput("\x1b[200~beep boop\x1b[201~");
|
||||||
|
assert.strictEqual(editor.getText(), "hellobeep boop world");
|
||||||
|
|
||||||
|
// Single undo should restore entire pre-paste state
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "hello world");
|
||||||
|
|
||||||
|
editor.handleInput("|");
|
||||||
|
assert.strictEqual(editor.getText(), "hello| world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes multi-line paste atomically", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
editor.setText("hello world");
|
||||||
|
editor.handleInput("\x01"); // Ctrl+A - go to start
|
||||||
|
for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); // Move right 5 (after "hello", before space)
|
||||||
|
|
||||||
|
// Simulate bracketed paste of multi-line text
|
||||||
|
editor.handleInput("\x1b[200~line1\nline2\nline3\x1b[201~");
|
||||||
|
assert.strictEqual(editor.getText(), "helloline1\nline2\nline3 world");
|
||||||
|
|
||||||
|
// Single undo should restore entire pre-paste state
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "hello world");
|
||||||
|
|
||||||
|
editor.handleInput("|");
|
||||||
|
assert.strictEqual(editor.getText(), "hello| world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes insertTextAtCursor atomically", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
editor.setText("hello world");
|
||||||
|
editor.handleInput("\x01"); // Ctrl+A - go to start
|
||||||
|
for (let i = 0; i < 5; i++) editor.handleInput("\x1b[C"); // Move right 5 (after "hello", before space)
|
||||||
|
|
||||||
|
// Programmatic insertion (e.g., clipboard image path)
|
||||||
|
editor.insertTextAtCursor("/tmp/image.png");
|
||||||
|
assert.strictEqual(editor.getText(), "hello/tmp/image.png world");
|
||||||
|
|
||||||
|
// Single undo should restore entire pre-insert state
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "hello world");
|
||||||
|
|
||||||
|
editor.handleInput("|");
|
||||||
|
assert.strictEqual(editor.getText(), "hello| world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes setText to empty string", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
editor.handleInput("h");
|
||||||
|
editor.handleInput("e");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput(" ");
|
||||||
|
editor.handleInput("w");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput("r");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("d");
|
||||||
|
assert.strictEqual(editor.getText(), "hello world");
|
||||||
|
|
||||||
|
editor.setText("");
|
||||||
|
assert.strictEqual(editor.getText(), "");
|
||||||
|
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears undo stack on submit", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
let submitted = "";
|
||||||
|
editor.onSubmit = (text) => {
|
||||||
|
submitted = text;
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.handleInput("h");
|
||||||
|
editor.handleInput("e");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput("\r"); // Enter - submit
|
||||||
|
|
||||||
|
assert.strictEqual(submitted, "hello");
|
||||||
|
assert.strictEqual(editor.getText(), "");
|
||||||
|
|
||||||
|
// Undo should do nothing - stack was cleared
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exits history browsing mode on undo", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
// Add "hello" to history
|
||||||
|
editor.addToHistory("hello");
|
||||||
|
assert.strictEqual(editor.getText(), "");
|
||||||
|
|
||||||
|
// Type "world"
|
||||||
|
editor.handleInput("w");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput("r");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("d");
|
||||||
|
assert.strictEqual(editor.getText(), "world");
|
||||||
|
|
||||||
|
// Ctrl+W - delete word
|
||||||
|
editor.handleInput("\x17"); // Ctrl+W
|
||||||
|
assert.strictEqual(editor.getText(), "");
|
||||||
|
|
||||||
|
// Press Up - enter history browsing, shows "hello"
|
||||||
|
editor.handleInput("\x1b[A"); // Up arrow
|
||||||
|
assert.strictEqual(editor.getText(), "hello");
|
||||||
|
|
||||||
|
// Undo should restore to "" (state before entering history browsing)
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "");
|
||||||
|
|
||||||
|
// Undo again should restore to "world" (state before Ctrl+W)
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undo restores to pre-history state even after multiple history navigations", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
// Add history entries
|
||||||
|
editor.addToHistory("first");
|
||||||
|
editor.addToHistory("second");
|
||||||
|
editor.addToHistory("third");
|
||||||
|
|
||||||
|
// Type something
|
||||||
|
editor.handleInput("c");
|
||||||
|
editor.handleInput("u");
|
||||||
|
editor.handleInput("r");
|
||||||
|
editor.handleInput("r");
|
||||||
|
editor.handleInput("e");
|
||||||
|
editor.handleInput("n");
|
||||||
|
editor.handleInput("t");
|
||||||
|
assert.strictEqual(editor.getText(), "current");
|
||||||
|
|
||||||
|
// Clear editor
|
||||||
|
editor.handleInput("\x17"); // Ctrl+W
|
||||||
|
assert.strictEqual(editor.getText(), "");
|
||||||
|
|
||||||
|
// Navigate through history multiple times
|
||||||
|
editor.handleInput("\x1b[A"); // Up - "third"
|
||||||
|
assert.strictEqual(editor.getText(), "third");
|
||||||
|
editor.handleInput("\x1b[A"); // Up - "second"
|
||||||
|
assert.strictEqual(editor.getText(), "second");
|
||||||
|
editor.handleInput("\x1b[A"); // Up - "first"
|
||||||
|
assert.strictEqual(editor.getText(), "first");
|
||||||
|
|
||||||
|
// Undo should go back to "" (state before we started browsing), not intermediate states
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "");
|
||||||
|
|
||||||
|
// Another undo goes back to "current"
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "current");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cursor movement starts new undo unit", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
editor.handleInput("h");
|
||||||
|
editor.handleInput("e");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput(" ");
|
||||||
|
editor.handleInput("w");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput("r");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("d");
|
||||||
|
assert.strictEqual(editor.getText(), "hello world");
|
||||||
|
|
||||||
|
// Move cursor left 5 (to after "hello ")
|
||||||
|
for (let i = 0; i < 5; i++) editor.handleInput("\x1b[D");
|
||||||
|
|
||||||
|
// Type "lol" in the middle
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("o");
|
||||||
|
editor.handleInput("l");
|
||||||
|
assert.strictEqual(editor.getText(), "hello lolworld");
|
||||||
|
|
||||||
|
// Undo should restore to "hello world" (before inserting "lol")
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "hello world");
|
||||||
|
|
||||||
|
editor.handleInput("|");
|
||||||
|
assert.strictEqual(editor.getText(), "hello |world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no-op delete operations do not push undo snapshots", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
editor.handleInput("h");
|
||||||
|
editor.handleInput("e");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("l");
|
||||||
|
editor.handleInput("o");
|
||||||
|
assert.strictEqual(editor.getText(), "hello");
|
||||||
|
|
||||||
|
// Delete word on empty - multiple times (should be no-ops)
|
||||||
|
editor.handleInput("\x17"); // Ctrl+W - deletes "hello"
|
||||||
|
assert.strictEqual(editor.getText(), "");
|
||||||
|
editor.handleInput("\x17"); // Ctrl+W - no-op (nothing to delete)
|
||||||
|
editor.handleInput("\x17"); // Ctrl+W - no-op
|
||||||
|
|
||||||
|
// Single undo should restore "hello"
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes autocomplete", () => {
|
||||||
|
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||||
|
|
||||||
|
// Create a mock autocomplete provider
|
||||||
|
const mockProvider: AutocompleteProvider = {
|
||||||
|
getSuggestions: (lines, _cursorLine, cursorCol) => {
|
||||||
|
const text = lines[0] || "";
|
||||||
|
const prefix = text.slice(0, cursorCol);
|
||||||
|
if (prefix === "di") {
|
||||||
|
return {
|
||||||
|
items: [{ value: "dist/", label: "dist/" }],
|
||||||
|
prefix: "di",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
applyCompletion: (lines, cursorLine, cursorCol, item, prefix) => {
|
||||||
|
const line = lines[cursorLine] || "";
|
||||||
|
const before = line.slice(0, cursorCol - prefix.length);
|
||||||
|
const after = line.slice(cursorCol);
|
||||||
|
const newLines = [...lines];
|
||||||
|
newLines[cursorLine] = before + item.value + after;
|
||||||
|
return {
|
||||||
|
lines: newLines,
|
||||||
|
cursorLine,
|
||||||
|
cursorCol: cursorCol - prefix.length + item.value.length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.setAutocompleteProvider(mockProvider);
|
||||||
|
|
||||||
|
// Type "di"
|
||||||
|
editor.handleInput("d");
|
||||||
|
editor.handleInput("i");
|
||||||
|
assert.strictEqual(editor.getText(), "di");
|
||||||
|
|
||||||
|
// Press Tab to trigger autocomplete
|
||||||
|
editor.handleInput("\t");
|
||||||
|
// Autocomplete should be showing with "dist/" suggestion
|
||||||
|
assert.strictEqual(editor.isShowingAutocomplete(), true);
|
||||||
|
|
||||||
|
// Press Tab again to accept the suggestion
|
||||||
|
editor.handleInput("\t");
|
||||||
|
assert.strictEqual(editor.getText(), "dist/");
|
||||||
|
assert.strictEqual(editor.isShowingAutocomplete(), false);
|
||||||
|
|
||||||
|
// Undo should restore to "di"
|
||||||
|
editor.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
|
||||||
|
assert.strictEqual(editor.getText(), "di");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue