From bacf334bc4152ba8c6c7ff883a9333fce6b7d3f6 Mon Sep 17 00:00:00 2001 From: Sviatoslav Abakumov Date: Sun, 18 Jan 2026 13:42:36 +0400 Subject: [PATCH] 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. --- packages/coding-agent/README.md | 2 + .../src/modes/interactive/interactive-mode.ts | 2 + packages/tui/src/components/editor.ts | 110 +++- packages/tui/src/keybindings.ts | 4 + packages/tui/test/editor.test.ts | 485 ++++++++++++++++++ 5 files changed, 595 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 2994cf50..1e1fbf79 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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+Y | Paste most recently deleted text | | Alt+Y | Cycle through deleted text after pasting | +| Ctrl+- | Undo | **Other:** @@ -405,6 +406,7 @@ All keyboard shortcuts can be customized via `~/.pi/agent/keybindings.json`. Eac | `deleteToLineEnd` | `ctrl+k` | Delete to line end | | `yank` | `ctrl+y` | Paste most recently deleted text | | `yankPop` | `alt+y` | Cycle through deleted text after pasting | +| `undo` | `ctrl+-` | Undo last edit | | `newLine` | `shift+enter` | Insert new line | | `submit` | `enter` | Submit input | | `tab` | `tab` | Tab/autocomplete | diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 5ceb152e..e4e030fa 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3446,6 +3446,7 @@ export class InteractiveMode { const deleteToLineEnd = this.getEditorKeyDisplay("deleteToLineEnd"); const yank = this.getEditorKeyDisplay("yank"); const yankPop = this.getEditorKeyDisplay("yankPop"); + const undo = this.getEditorKeyDisplay("undo"); const tab = this.getEditorKeyDisplay("tab"); // App keybindings @@ -3481,6 +3482,7 @@ export class InteractiveMode { | \`${deleteToLineEnd}\` | Delete to end of line | | \`${yank}\` | Paste the most-recently-deleted text | | \`${yankPop}\` | Cycle through the deleted text after pasting | +| \`${undo}\` | Undo | **Other** | Key | Action | diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index ee498bd9..3c28e1fb 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -290,8 +290,12 @@ export class Editor implements Component, Focusable { private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. // Kill ring for Emacs-style kill/yank operations + // Also tracks undo coalescing: "type-word" means we're mid-word (coalescing) 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 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 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; if (this.historyIndex === -1) { @@ -570,6 +579,12 @@ export class Editor implements Component, Focusable { return; } + // Undo + if (kb.matches(data, "undo")) { + this.undo(); + return; + } + // Handle autocomplete mode if (this.isAutocompleting && this.autocompleteList) { if (kb.matches(data, "selectCancel")) { @@ -585,6 +600,8 @@ export class Editor implements Component, Focusable { if (kb.matches(data, "tab")) { const selected = this.autocompleteList.getSelectedItem(); if (selected && this.autocompleteProvider) { + this.pushUndoSnapshot(); + this.lastAction = null; const result = this.autocompleteProvider.applyCompletion( this.state.lines, this.state.cursorLine, @@ -604,6 +621,8 @@ export class Editor implements Component, Focusable { if (kb.matches(data, "selectConfirm")) { const selected = this.autocompleteList.getSelectedItem(); if (selected && this.autocompleteProvider) { + this.pushUndoSnapshot(); + this.lastAction = null; const result = this.autocompleteProvider.applyCompletion( this.state.lines, this.state.cursorLine, @@ -716,6 +735,8 @@ export class Editor implements Component, Focusable { this.pasteCounter = 0; this.historyIndex = -1; this.scrollOffset = 0; + this.undoStack.length = 0; + this.lastAction = null; if (this.onChange) this.onChange(""); if (this.onSubmit) this.onSubmit(result); @@ -893,23 +914,43 @@ export class Editor implements Component, Focusable { setText(text: string): void { 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.lastAction = null; } /** * Insert text at the current cursor position. * 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 { + if (!text) return; + this.pushUndoSnapshot(); + this.lastAction = null; for (const char of text) { - this.insertCharacter(char); + this.insertCharacter(char, true); } } // 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.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] || ""; @@ -961,6 +1002,8 @@ export class Editor implements Component, Focusable { this.historyIndex = -1; // Exit history browsing mode this.lastAction = null; + this.pushUndoSnapshot(); + // Clean the pasted text 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} ${totalChars} chars]`; for (const char of marker) { - this.insertCharacter(char); + this.insertCharacter(char, true); } - return; } @@ -1010,9 +1052,8 @@ export class Editor implements Component, Focusable { // Single line - just insert each character const text = pastedLines[0] || ""; for (const char of text) { - this.insertCharacter(char); + this.insertCharacter(char, true); } - return; } @@ -1062,6 +1103,8 @@ export class Editor implements Component, Focusable { this.historyIndex = -1; // Exit history browsing mode this.lastAction = null; + this.pushUndoSnapshot(); + const currentLine = this.state.lines[this.state.cursorLine] || ""; const before = currentLine.slice(0, this.state.cursorCol); @@ -1085,6 +1128,8 @@ export class Editor implements Component, Focusable { this.lastAction = null; if (this.state.cursorCol > 0) { + this.pushUndoSnapshot(); + // Delete grapheme before cursor (handles emojis, combining characters, etc.) const line = this.state.lines[this.state.cursorLine] || ""; 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.cursorCol -= graphemeLength; } else if (this.state.cursorLine > 0) { + this.pushUndoSnapshot(); + // Merge with previous line const currentLine = this.state.lines[this.state.cursorLine] || ""; 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] || ""; if (this.state.cursorCol > 0) { + this.pushUndoSnapshot(); + // Calculate text to be deleted and save to kill ring (backward deletion = prepend) const deletedText = currentLine.slice(0, this.state.cursorCol); 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.cursorCol = 0; } else if (this.state.cursorLine > 0) { + this.pushUndoSnapshot(); + // At start of line - merge with previous line, treating newline as deleted text this.addToKillRing("\n", true); this.lastAction = "kill"; @@ -1181,6 +1232,8 @@ export class Editor implements Component, Focusable { const currentLine = this.state.lines[this.state.cursorLine] || ""; if (this.state.cursorCol < currentLine.length) { + this.pushUndoSnapshot(); + // Calculate text to be deleted and save to kill ring (forward deletion = append) const deletedText = currentLine.slice(this.state.cursorCol); this.addToKillRing(deletedText, false); @@ -1189,6 +1242,8 @@ export class Editor implements Component, Focusable { // Delete from cursor to end of line this.state.lines[this.state.cursorLine] = currentLine.slice(0, this.state.cursorCol); } 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 this.addToKillRing("\n", false); 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 (this.state.cursorCol === 0) { if (this.state.cursorLine > 0) { + this.pushUndoSnapshot(); + // Treat newline as deleted text (backward deletion = prepend) this.addToKillRing("\n", true); this.lastAction = "kill"; @@ -1222,6 +1279,8 @@ export class Editor implements Component, Focusable { this.state.cursorCol = previousLine.length; } } else { + this.pushUndoSnapshot(); + // Save lastAction before cursor movement (moveWordBackwards resets it) 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 (this.state.cursorCol >= currentLine.length) { if (this.state.cursorLine < this.state.lines.length - 1) { + this.pushUndoSnapshot(); + // Treat newline as deleted text (forward deletion = append) this.addToKillRing("\n", false); this.lastAction = "kill"; @@ -1263,6 +1324,8 @@ export class Editor implements Component, Focusable { this.state.lines.splice(this.state.cursorLine + 1, 1); } } else { + this.pushUndoSnapshot(); + // Save lastAction before cursor movement (moveWordForwards resets it) const wasKill = this.lastAction === "kill"; @@ -1293,6 +1356,8 @@ export class Editor implements Component, Focusable { const currentLine = this.state.lines[this.state.cursorLine] || ""; if (this.state.cursorCol < currentLine.length) { + this.pushUndoSnapshot(); + // Delete grapheme at cursor position (handles emojis, combining characters, etc.) 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); this.state.lines[this.state.cursorLine] = before + after; } else if (this.state.cursorLine < this.state.lines.length - 1) { + this.pushUndoSnapshot(); + // At end of line - merge with next line const nextLine = this.state.lines[this.state.cursorLine + 1] || ""; this.state.lines[this.state.cursorLine] = currentLine + nextLine; @@ -1532,6 +1599,8 @@ export class Editor implements Component, Focusable { private yank(): void { if (this.killRing.length === 0) return; + this.pushUndoSnapshot(); + const text = this.killRing[this.killRing.length - 1] || ""; 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 if (this.lastAction !== "yank" || this.killRing.length <= 1) return; + this.pushUndoSnapshot(); + // Delete the previously yanked text (still at end of ring before rotation) 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 { this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index 8859e135..39d9716a 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -38,6 +38,8 @@ export type EditorAction = // Kill ring | "yank" | "yankPop" + // Undo + | "undo" // Tool output | "expandTools"; @@ -89,6 +91,8 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required = { // Kill ring yank: "ctrl+y", yankPop: "alt+y", + // Undo + undo: "ctrl+-", // Tool output expandTools: "ctrl+o", }; diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index e46d0cea..046d20c7 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -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"); + }); + }); });