diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 94f44205..a4551fde 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,7 +1,9 @@ import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; import { getEditorKeybindings } from "../keybindings.js"; import { matchesKey } from "../keys.js"; +import { KillRing } from "../kill-ring.js"; import { type Component, CURSOR_MARKER, type Focusable, type TUI } from "../tui.js"; +import { UndoStack } from "../undo-stack.js"; import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js"; import { SelectList, type SelectListTheme } from "./select-list.js"; @@ -192,8 +194,7 @@ 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 killRing = new KillRing(); private lastAction: "kill" | "yank" | "type-word" | null = null; // Character jump mode @@ -203,7 +204,7 @@ export class Editor implements Component, Focusable { private preferredVisualCol: number | null = null; // Undo support - private undoStack: EditorState[] = []; + private undoStack = new UndoStack(); public onSubmit?: (text: string) => void; public onChange?: (text: string) => void; @@ -1081,7 +1082,7 @@ export class Editor implements Component, Focusable { this.pasteCounter = 0; this.historyIndex = -1; this.scrollOffset = 0; - this.undoStack.length = 0; + this.undoStack.clear(); this.lastAction = null; if (this.onChange) this.onChange(""); @@ -1268,7 +1269,7 @@ export class Editor implements Component, Focusable { // 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); + this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" }); this.lastAction = "kill"; // Delete from start of line up to cursor @@ -1278,7 +1279,7 @@ export class Editor implements Component, Focusable { this.pushUndoSnapshot(); // At start of line - merge with previous line, treating newline as deleted text - this.addToKillRing("\n", true); + this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" }); this.lastAction = "kill"; const previousLine = this.state.lines[this.state.cursorLine - 1] || ""; @@ -1303,7 +1304,7 @@ export class Editor implements Component, Focusable { // Calculate text to be deleted and save to kill ring (forward deletion = append) const deletedText = currentLine.slice(this.state.cursorCol); - this.addToKillRing(deletedText, false); + this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" }); this.lastAction = "kill"; // Delete from cursor to end of line @@ -1312,7 +1313,7 @@ export class Editor implements Component, Focusable { this.pushUndoSnapshot(); // At end of line - merge with next line, treating newline as deleted text - this.addToKillRing("\n", false); + this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" }); this.lastAction = "kill"; const nextLine = this.state.lines[this.state.cursorLine + 1] || ""; @@ -1336,7 +1337,7 @@ export class Editor implements Component, Focusable { this.pushUndoSnapshot(); // Treat newline as deleted text (backward deletion = prepend) - this.addToKillRing("\n", true); + this.killRing.push("\n", { prepend: true, accumulate: this.lastAction === "kill" }); this.lastAction = "kill"; const previousLine = this.state.lines[this.state.cursorLine - 1] || ""; @@ -1356,10 +1357,8 @@ export class Editor implements Component, Focusable { const deleteFrom = this.state.cursorCol; this.setCursorCol(oldCursorCol); - // Restore kill state for accumulation check, then save to kill ring - this.lastAction = wasKill ? "kill" : null; const deletedText = currentLine.slice(deleteFrom, this.state.cursorCol); - this.addToKillRing(deletedText, true); + this.killRing.push(deletedText, { prepend: true, accumulate: wasKill }); this.lastAction = "kill"; this.state.lines[this.state.cursorLine] = @@ -1383,7 +1382,7 @@ export class Editor implements Component, Focusable { this.pushUndoSnapshot(); // Treat newline as deleted text (forward deletion = append) - this.addToKillRing("\n", false); + this.killRing.push("\n", { prepend: false, accumulate: this.lastAction === "kill" }); this.lastAction = "kill"; const nextLine = this.state.lines[this.state.cursorLine + 1] || ""; @@ -1401,10 +1400,8 @@ export class Editor implements Component, Focusable { const deleteTo = this.state.cursorCol; this.setCursorCol(oldCursorCol); - // Restore kill state for accumulation check, then save to kill ring - this.lastAction = wasKill ? "kill" : null; const deletedText = currentLine.slice(this.state.cursorCol, deleteTo); - this.addToKillRing(deletedText, false); + this.killRing.push(deletedText, { prepend: false, accumulate: wasKill }); this.lastAction = "kill"; this.state.lines[this.state.cursorLine] = @@ -1644,7 +1641,7 @@ export class Editor implements Component, Focusable { this.pushUndoSnapshot(); - const text = this.killRing[this.killRing.length - 1] || ""; + const text = this.killRing.peek()!; this.insertYankedText(text); this.lastAction = "yank"; @@ -1664,11 +1661,10 @@ export class Editor implements Component, Focusable { this.deleteYankedText(); // Rotate the ring: move end to front - const lastEntry = this.killRing.pop()!; - this.killRing.unshift(lastEntry); + this.killRing.rotate(); // Insert the new most recent entry (now at end after rotation) - const text = this.killRing[this.killRing.length - 1]; + const text = this.killRing.peek()!; this.insertYankedText(text); this.lastAction = "yank"; @@ -1721,7 +1717,7 @@ export class Editor implements Component, Focusable { * The yanked text is derived from killRing[end] since it hasn't been rotated yet. */ private deleteYankedText(): void { - const yankedText = this.killRing[this.killRing.length - 1] || ""; + const yankedText = this.killRing.peek(); if (!yankedText) return; const yankLines = yankedText.split("\n"); @@ -1758,46 +1754,15 @@ export class Editor implements Component, Focusable { } } - /** - * Add text to the kill ring. - * If lastAction is "kill", accumulates with the previous entry. - * @param text - The text to add - * @param prepend - If accumulating, prepend (true) or append (false) to existing entry - */ - private addToKillRing(text: string, prepend: boolean): void { - if (!text) return; - - if (this.lastAction === "kill" && this.killRing.length > 0) { - // Accumulate with the most recent entry (at end of array) - const lastEntry = this.killRing.pop(); - if (prepend) { - this.killRing.push(text + lastEntry); - } else { - this.killRing.push(lastEntry + text); - } - } else { - // Add new entry to end of ring - this.killRing.push(text); - } - } - - 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()); + this.undoStack.push(this.state); } private undo(): void { this.historyIndex = -1; // Exit history browsing mode - if (this.undoStack.length === 0) return; - const snapshot = this.undoStack.pop()!; - this.restoreUndoSnapshot(snapshot); + const snapshot = this.undoStack.pop(); + if (!snapshot) return; + Object.assign(this.state, snapshot); this.lastAction = null; this.preferredVisualCol = null; if (this.onChange) { diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index ac310bfd..e0c937b3 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -1,9 +1,16 @@ import { getEditorKeybindings } from "../keybindings.js"; +import { KillRing } from "../kill-ring.js"; import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js"; +import { UndoStack } from "../undo-stack.js"; import { getSegmenter, isPunctuationChar, isWhitespaceChar, visibleWidth } from "../utils.js"; const segmenter = getSegmenter(); +interface InputState { + value: string; + cursor: number; +} + /** * Input component - single-line text input with horizontal scrolling */ @@ -20,6 +27,13 @@ export class Input implements Component, Focusable { private pasteBuffer: string = ""; private isInPaste: boolean = false; + // Kill ring for Emacs-style kill/yank operations + private killRing = new KillRing(); + private lastAction: "kill" | "yank" | "type-word" | null = null; + + // Undo support + private undoStack = new UndoStack(); + getValue(): string { return this.value; } @@ -75,6 +89,12 @@ export class Input implements Component, Focusable { return; } + // Undo + if (kb.matches(data, "undo")) { + this.undo(); + return; + } + // Submit if (kb.matches(data, "submit") || data === "\n") { if (this.onSubmit) this.onSubmit(this.value); @@ -83,25 +103,12 @@ export class Input implements Component, Focusable { // Deletion if (kb.matches(data, "deleteCharBackward")) { - if (this.cursor > 0) { - const beforeCursor = this.value.slice(0, this.cursor); - const graphemes = [...segmenter.segment(beforeCursor)]; - const lastGrapheme = graphemes[graphemes.length - 1]; - const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; - this.value = this.value.slice(0, this.cursor - graphemeLength) + this.value.slice(this.cursor); - this.cursor -= graphemeLength; - } + this.handleBackspace(); return; } if (kb.matches(data, "deleteCharForward")) { - if (this.cursor < this.value.length) { - const afterCursor = this.value.slice(this.cursor); - const graphemes = [...segmenter.segment(afterCursor)]; - const firstGrapheme = graphemes[0]; - const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; - this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength); - } + this.handleForwardDelete(); return; } @@ -110,19 +117,34 @@ export class Input implements Component, Focusable { return; } + if (kb.matches(data, "deleteWordForward")) { + this.deleteWordForward(); + return; + } + if (kb.matches(data, "deleteToLineStart")) { - this.value = this.value.slice(this.cursor); - this.cursor = 0; + this.deleteToLineStart(); return; } if (kb.matches(data, "deleteToLineEnd")) { - this.value = this.value.slice(0, this.cursor); + this.deleteToLineEnd(); + return; + } + + // Kill ring actions + if (kb.matches(data, "yank")) { + this.yank(); + return; + } + if (kb.matches(data, "yankPop")) { + this.yankPop(); return; } // Cursor movement if (kb.matches(data, "cursorLeft")) { + this.lastAction = null; if (this.cursor > 0) { const beforeCursor = this.value.slice(0, this.cursor); const graphemes = [...segmenter.segment(beforeCursor)]; @@ -133,6 +155,7 @@ export class Input implements Component, Focusable { } if (kb.matches(data, "cursorRight")) { + this.lastAction = null; if (this.cursor < this.value.length) { const afterCursor = this.value.slice(this.cursor); const graphemes = [...segmenter.segment(afterCursor)]; @@ -143,11 +166,13 @@ export class Input implements Component, Focusable { } if (kb.matches(data, "cursorLineStart")) { + this.lastAction = null; this.cursor = 0; return; } if (kb.matches(data, "cursorLineEnd")) { + this.lastAction = null; this.cursor = this.value.length; return; } @@ -169,30 +194,153 @@ export class Input implements Component, Focusable { return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f); }); if (!hasControlChars) { - this.value = this.value.slice(0, this.cursor) + data + this.value.slice(this.cursor); - this.cursor += data.length; + this.insertCharacter(data); } } - private deleteWordBackwards(): void { - if (this.cursor === 0) { - return; + private insertCharacter(char: string): void { + // Undo coalescing: consecutive word chars coalesce into one undo unit + if (isWhitespaceChar(char) || this.lastAction !== "type-word") { + this.pushUndo(); } + this.lastAction = "type-word"; + + this.value = this.value.slice(0, this.cursor) + char + this.value.slice(this.cursor); + this.cursor += char.length; + } + + private handleBackspace(): void { + this.lastAction = null; + if (this.cursor > 0) { + this.pushUndo(); + const beforeCursor = this.value.slice(0, this.cursor); + const graphemes = [...segmenter.segment(beforeCursor)]; + const lastGrapheme = graphemes[graphemes.length - 1]; + const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; + this.value = this.value.slice(0, this.cursor - graphemeLength) + this.value.slice(this.cursor); + this.cursor -= graphemeLength; + } + } + + private handleForwardDelete(): void { + this.lastAction = null; + if (this.cursor < this.value.length) { + this.pushUndo(); + const afterCursor = this.value.slice(this.cursor); + const graphemes = [...segmenter.segment(afterCursor)]; + const firstGrapheme = graphemes[0]; + const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; + this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength); + } + } + + private deleteToLineStart(): void { + if (this.cursor === 0) return; + this.pushUndo(); + const deletedText = this.value.slice(0, this.cursor); + this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" }); + this.lastAction = "kill"; + this.value = this.value.slice(this.cursor); + this.cursor = 0; + } + + private deleteToLineEnd(): void { + if (this.cursor >= this.value.length) return; + this.pushUndo(); + const deletedText = this.value.slice(this.cursor); + this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" }); + this.lastAction = "kill"; + this.value = this.value.slice(0, this.cursor); + } + + private deleteWordBackwards(): void { + if (this.cursor === 0) return; + + // Save lastAction before cursor movement (moveWordBackwards resets it) + const wasKill = this.lastAction === "kill"; + + this.pushUndo(); const oldCursor = this.cursor; this.moveWordBackwards(); const deleteFrom = this.cursor; this.cursor = oldCursor; + const deletedText = this.value.slice(deleteFrom, this.cursor); + this.killRing.push(deletedText, { prepend: true, accumulate: wasKill }); + this.lastAction = "kill"; + this.value = this.value.slice(0, deleteFrom) + this.value.slice(this.cursor); this.cursor = deleteFrom; } + private deleteWordForward(): void { + if (this.cursor >= this.value.length) return; + + // Save lastAction before cursor movement (moveWordForwards resets it) + const wasKill = this.lastAction === "kill"; + + this.pushUndo(); + + const oldCursor = this.cursor; + this.moveWordForwards(); + const deleteTo = this.cursor; + this.cursor = oldCursor; + + const deletedText = this.value.slice(this.cursor, deleteTo); + this.killRing.push(deletedText, { prepend: false, accumulate: wasKill }); + this.lastAction = "kill"; + + this.value = this.value.slice(0, this.cursor) + this.value.slice(deleteTo); + } + + private yank(): void { + const text = this.killRing.peek(); + if (!text) return; + + this.pushUndo(); + + this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor); + this.cursor += text.length; + this.lastAction = "yank"; + } + + private yankPop(): void { + if (this.lastAction !== "yank" || this.killRing.length <= 1) return; + + this.pushUndo(); + + // Delete the previously yanked text (still at end of ring before rotation) + const prevText = this.killRing.peek() || ""; + this.value = this.value.slice(0, this.cursor - prevText.length) + this.value.slice(this.cursor); + this.cursor -= prevText.length; + + // Rotate and insert new entry + this.killRing.rotate(); + const text = this.killRing.peek() || ""; + this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor); + this.cursor += text.length; + this.lastAction = "yank"; + } + + private pushUndo(): void { + this.undoStack.push({ value: this.value, cursor: this.cursor }); + } + + private undo(): void { + const snapshot = this.undoStack.pop(); + if (!snapshot) return; + this.value = snapshot.value; + this.cursor = snapshot.cursor; + this.lastAction = null; + } + private moveWordBackwards(): void { if (this.cursor === 0) { return; } + this.lastAction = null; const textBeforeCursor = this.value.slice(0, this.cursor); const graphemes = [...segmenter.segment(textBeforeCursor)]; @@ -226,6 +374,7 @@ export class Input implements Component, Focusable { return; } + this.lastAction = null; const textAfterCursor = this.value.slice(this.cursor); const segments = segmenter.segment(textAfterCursor); const iterator = segments[Symbol.iterator](); @@ -256,6 +405,9 @@ export class Input implements Component, Focusable { } private handlePaste(pastedText: string): void { + this.lastAction = null; + this.pushUndo(); + // Clean the pasted text - remove newlines and carriage returns const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, ""); diff --git a/packages/tui/src/kill-ring.ts b/packages/tui/src/kill-ring.ts new file mode 100644 index 00000000..2292f91a --- /dev/null +++ b/packages/tui/src/kill-ring.ts @@ -0,0 +1,46 @@ +/** + * Ring buffer for Emacs-style kill/yank operations. + * + * Tracks killed (deleted) text entries. Consecutive kills can accumulate + * into a single entry. Supports yank (paste most recent) and yank-pop + * (cycle through older entries). + */ +export class KillRing { + private ring: string[] = []; + + /** + * Add text to the kill ring. + * + * @param text - The killed text to add + * @param opts - Push options + * @param opts.prepend - If accumulating, prepend (backward deletion) or append (forward deletion) + * @param opts.accumulate - Merge with the most recent entry instead of creating a new one + */ + push(text: string, opts: { prepend: boolean; accumulate?: boolean }): void { + if (!text) return; + + if (opts.accumulate && this.ring.length > 0) { + const last = this.ring.pop()!; + this.ring.push(opts.prepend ? text + last : last + text); + } else { + this.ring.push(text); + } + } + + /** Get most recent entry without modifying the ring. */ + peek(): string | undefined { + return this.ring.length > 0 ? this.ring[this.ring.length - 1] : undefined; + } + + /** Move last entry to front (for yank-pop cycling). */ + rotate(): void { + if (this.ring.length > 1) { + const last = this.ring.pop()!; + this.ring.unshift(last); + } + } + + get length(): number { + return this.ring.length; + } +} diff --git a/packages/tui/src/undo-stack.ts b/packages/tui/src/undo-stack.ts new file mode 100644 index 00000000..5b9a7e9c --- /dev/null +++ b/packages/tui/src/undo-stack.ts @@ -0,0 +1,28 @@ +/** + * Generic undo stack with clone-on-push semantics. + * + * Stores deep clones of state snapshots. Popped snapshots are returned + * directly (no re-cloning) since they are already detached. + */ +export class UndoStack { + private stack: S[] = []; + + /** Push a deep clone of the given state onto the stack. */ + push(state: S): void { + this.stack.push(structuredClone(state)); + } + + /** Pop and return the most recent snapshot, or undefined if empty. */ + pop(): S | undefined { + return this.stack.pop(); + } + + /** Remove all snapshots. */ + clear(): void { + this.stack.length = 0; + } + + get length(): number { + return this.stack.length; + } +} diff --git a/packages/tui/test/input.test.ts b/packages/tui/test/input.test.ts index fc61f5e9..78397a4d 100644 --- a/packages/tui/test/input.test.ts +++ b/packages/tui/test/input.test.ts @@ -32,4 +32,499 @@ describe("Input component", () => { assert.strictEqual(input.getValue(), "\\x"); }); + + describe("Kill ring", () => { + it("Ctrl+W saves deleted text to kill ring and Ctrl+Y yanks it", () => { + const input = new Input(); + + input.setValue("foo bar baz"); + // Move cursor to end + input.handleInput("\x05"); // Ctrl+E + + input.handleInput("\x17"); // Ctrl+W - deletes "baz" + assert.strictEqual(input.getValue(), "foo bar "); + + // Move to beginning and yank + input.handleInput("\x01"); // Ctrl+A + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "bazfoo bar "); + }); + + it("Ctrl+U saves deleted text to kill ring", () => { + const input = new Input(); + + input.setValue("hello world"); + // Move cursor to after "hello " + input.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); + + input.handleInput("\x15"); // Ctrl+U - deletes "hello " + assert.strictEqual(input.getValue(), "world"); + + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "hello world"); + }); + + it("Ctrl+K saves deleted text to kill ring", () => { + const input = new Input(); + + input.setValue("hello world"); + input.handleInput("\x01"); // Ctrl+A + input.handleInput("\x0b"); // Ctrl+K - deletes "hello world" + + assert.strictEqual(input.getValue(), ""); + + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "hello world"); + }); + + it("Ctrl+Y does nothing when kill ring is empty", () => { + const input = new Input(); + + input.setValue("test"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "test"); + }); + + it("Alt+Y cycles through kill ring after Ctrl+Y", () => { + const input = new Input(); + + // Create kill ring with multiple entries + input.setValue("first"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "first" + input.setValue("second"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "second" + input.setValue("third"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "third" + + assert.strictEqual(input.getValue(), ""); + + input.handleInput("\x19"); // Ctrl+Y - yanks "third" + assert.strictEqual(input.getValue(), "third"); + + input.handleInput("\x1by"); // Alt+Y - cycles to "second" + assert.strictEqual(input.getValue(), "second"); + + input.handleInput("\x1by"); // Alt+Y - cycles to "first" + assert.strictEqual(input.getValue(), "first"); + + input.handleInput("\x1by"); // Alt+Y - cycles back to "third" + assert.strictEqual(input.getValue(), "third"); + }); + + it("Alt+Y does nothing if not preceded by yank", () => { + const input = new Input(); + + input.setValue("test"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "test" + input.setValue("other"); + input.handleInput("\x05"); // Ctrl+E + + // Type something to break the yank chain + input.handleInput("x"); + assert.strictEqual(input.getValue(), "otherx"); + + input.handleInput("\x1by"); // Alt+Y - should do nothing + assert.strictEqual(input.getValue(), "otherx"); + }); + + it("Alt+Y does nothing if kill ring has one entry", () => { + const input = new Input(); + + input.setValue("only"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "only" + + input.handleInput("\x19"); // Ctrl+Y - yanks "only" + assert.strictEqual(input.getValue(), "only"); + + input.handleInput("\x1by"); // Alt+Y - should do nothing + assert.strictEqual(input.getValue(), "only"); + }); + + it("consecutive Ctrl+W accumulates into one kill ring entry", () => { + const input = new Input(); + + input.setValue("one two three"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "three" + input.handleInput("\x17"); // Ctrl+W - deletes "two " + input.handleInput("\x17"); // Ctrl+W - deletes "one " + + assert.strictEqual(input.getValue(), ""); + + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "one two three"); + }); + + it("non-delete actions break kill accumulation", () => { + const input = new Input(); + + input.setValue("foo bar baz"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "baz" + assert.strictEqual(input.getValue(), "foo bar "); + + input.handleInput("x"); // Typing breaks accumulation + assert.strictEqual(input.getValue(), "foo bar x"); + + input.handleInput("\x17"); // Ctrl+W - deletes "x" (separate entry) + assert.strictEqual(input.getValue(), "foo bar "); + + input.handleInput("\x19"); // Ctrl+Y - most recent is "x" + assert.strictEqual(input.getValue(), "foo bar x"); + + input.handleInput("\x1by"); // Alt+Y - cycle to "baz" + assert.strictEqual(input.getValue(), "foo bar baz"); + }); + + it("non-yank actions break Alt+Y chain", () => { + const input = new Input(); + + input.setValue("first"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W + input.setValue("second"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W + input.setValue(""); + + input.handleInput("\x19"); // Ctrl+Y - yanks "second" + assert.strictEqual(input.getValue(), "second"); + + input.handleInput("x"); // Breaks yank chain + assert.strictEqual(input.getValue(), "secondx"); + + input.handleInput("\x1by"); // Alt+Y - should do nothing + assert.strictEqual(input.getValue(), "secondx"); + }); + + it("kill ring rotation persists after cycling", () => { + const input = new Input(); + + input.setValue("first"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // deletes "first" + input.setValue("second"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // deletes "second" + input.setValue("third"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // deletes "third" + input.setValue(""); + + input.handleInput("\x19"); // Ctrl+Y - yanks "third" + input.handleInput("\x1by"); // Alt+Y - cycles to "second" + assert.strictEqual(input.getValue(), "second"); + + // Break chain and start fresh + input.handleInput("x"); + input.setValue(""); + + // New yank should get "second" (now at end after rotation) + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "second"); + }); + + it("backward deletions prepend, forward deletions append during accumulation", () => { + const input = new Input(); + + input.setValue("prefix|suffix"); + // Position cursor at "|" + input.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); // Move right 6 + + input.handleInput("\x0b"); // Ctrl+K - deletes "|suffix" (forward) + assert.strictEqual(input.getValue(), "prefix"); + + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "prefix|suffix"); + }); + + it("Alt+D deletes word forward and saves to kill ring", () => { + const input = new Input(); + + input.setValue("hello world test"); + input.handleInput("\x01"); // Ctrl+A + + input.handleInput("\x1bd"); // Alt+D - deletes "hello" + assert.strictEqual(input.getValue(), " world test"); + + input.handleInput("\x1bd"); // Alt+D - deletes " world" + assert.strictEqual(input.getValue(), " test"); + + // Yank should get accumulated text + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "hello world test"); + }); + + it("handles yank in middle of text", () => { + const input = new Input(); + + input.setValue("word"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "word" + input.setValue("hello world"); + // Move to middle (after "hello ") + input.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); + + input.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(input.getValue(), "hello wordworld"); + }); + + it("handles yank-pop in middle of text", () => { + const input = new Input(); + + // Create two kill ring entries + input.setValue("FIRST"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "FIRST" + input.setValue("SECOND"); + input.handleInput("\x05"); // Ctrl+E + input.handleInput("\x17"); // Ctrl+W - deletes "SECOND" + + // Set up "hello world" and position cursor after "hello " + input.setValue("hello world"); + input.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); + + input.handleInput("\x19"); // Ctrl+Y - yanks "SECOND" + assert.strictEqual(input.getValue(), "hello SECONDworld"); + + input.handleInput("\x1by"); // Alt+Y - replaces with "FIRST" + assert.strictEqual(input.getValue(), "hello FIRSTworld"); + }); + }); + + describe("Undo", () => { + it("does nothing when undo stack is empty", () => { + const input = new Input(); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), ""); + }); + + it("coalesces consecutive word characters into one undo unit", () => { + const input = new Input(); + + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput(" "); + input.handleInput("w"); + input.handleInput("o"); + input.handleInput("r"); + input.handleInput("l"); + input.handleInput("d"); + assert.strictEqual(input.getValue(), "hello world"); + + // Undo removes " world" + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "hello"); + + // Undo removes "hello" + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), ""); + }); + + it("undoes spaces one at a time", () => { + const input = new Input(); + + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput(" "); + input.handleInput(" "); + assert.strictEqual(input.getValue(), "hello "); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes second " " + assert.strictEqual(input.getValue(), "hello "); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes first " " + assert.strictEqual(input.getValue(), "hello"); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes "hello" + assert.strictEqual(input.getValue(), ""); + }); + + it("undoes backspace", () => { + const input = new Input(); + + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput("\x7f"); // Backspace + assert.strictEqual(input.getValue(), "hell"); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "hello"); + }); + + it("undoes forward delete", () => { + const input = new Input(); + + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput("\x01"); // Ctrl+A - go to start + input.handleInput("\x1b[C"); // Right arrow + input.handleInput("\x1b[3~"); // Delete key + assert.strictEqual(input.getValue(), "hllo"); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "hello"); + }); + + it("undoes Ctrl+W (delete word backward)", () => { + const input = new Input(); + + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput(" "); + input.handleInput("w"); + input.handleInput("o"); + input.handleInput("r"); + input.handleInput("l"); + input.handleInput("d"); + assert.strictEqual(input.getValue(), "hello world"); + + input.handleInput("\x17"); // Ctrl+W + assert.strictEqual(input.getValue(), "hello "); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "hello world"); + }); + + it("undoes Ctrl+K (delete to line end)", () => { + const input = new Input(); + + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput(" "); + input.handleInput("w"); + input.handleInput("o"); + input.handleInput("r"); + input.handleInput("l"); + input.handleInput("d"); + input.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); + + input.handleInput("\x0b"); // Ctrl+K + assert.strictEqual(input.getValue(), "hello "); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "hello world"); + }); + + it("undoes Ctrl+U (delete to line start)", () => { + const input = new Input(); + + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput(" "); + input.handleInput("w"); + input.handleInput("o"); + input.handleInput("r"); + input.handleInput("l"); + input.handleInput("d"); + input.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); + + input.handleInput("\x15"); // Ctrl+U + assert.strictEqual(input.getValue(), "world"); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "hello world"); + }); + + it("undoes yank", () => { + const input = new Input(); + + input.handleInput("h"); + input.handleInput("e"); + input.handleInput("l"); + input.handleInput("l"); + input.handleInput("o"); + input.handleInput(" "); + input.handleInput("\x17"); // Ctrl+W - delete "hello " + input.handleInput("\x19"); // Ctrl+Y - yank + assert.strictEqual(input.getValue(), "hello "); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), ""); + }); + + it("undoes paste atomically", () => { + const input = new Input(); + + input.setValue("hello world"); + input.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 5; i++) input.handleInput("\x1b[C"); + + // Simulate bracketed paste + input.handleInput("\x1b[200~beep boop\x1b[201~"); + assert.strictEqual(input.getValue(), "hellobeep boop world"); + + // Single undo should restore entire pre-paste state + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "hello world"); + }); + + it("undoes Alt+D (delete word forward)", () => { + const input = new Input(); + + input.setValue("hello world"); + input.handleInput("\x01"); // Ctrl+A + + input.handleInput("\x1bd"); // Alt+D - deletes "hello" + assert.strictEqual(input.getValue(), " world"); + + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "hello world"); + }); + + it("cursor movement starts new undo unit", () => { + const input = new Input(); + + input.handleInput("a"); + input.handleInput("b"); + input.handleInput("c"); + input.handleInput("\x01"); // Ctrl+A - movement breaks coalescing + input.handleInput("\x05"); // Ctrl+E + input.handleInput("d"); + input.handleInput("e"); + assert.strictEqual(input.getValue(), "abcde"); + + // Undo removes "de" (typed after movement) + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), "abc"); + + // Undo removes "abc" + input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) + assert.strictEqual(input.getValue(), ""); + }); + }); });