mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 05:00:16 +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
|
|
@ -1,6 +1,7 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { stripVTControlCharacters } from "node:util";
|
||||
import type { AutocompleteProvider } from "../src/autocomplete.js";
|
||||
import { Editor } from "../src/components/editor.js";
|
||||
import { TUI } from "../src/tui.js";
|
||||
import { visibleWidth } from "../src/utils.js";
|
||||
|
|
@ -1095,4 +1096,488 @@ describe("Editor component", () => {
|
|||
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