From 9fb7434a062eb7a479a5349b5b02c423a594ce1f Mon Sep 17 00:00:00 2001 From: Sviatoslav Abakumov Date: Sat, 17 Jan 2026 16:28:20 +0400 Subject: [PATCH] feat(tui): implement Emacs-style kill ring for Editor Add kill ring functionality with: - Ctrl+W/U/K save deleted text to kill ring - Ctrl+Y yanks (pastes) most recent deletion - Alt+Y cycles through kill ring (after Ctrl+Y) - Consecutive deletions accumulate into single entry --- .../src/modes/interactive/interactive-mode.ts | 4 + packages/tui/src/components/editor.ts | 204 +++++++++- packages/tui/src/keybindings.ts | 6 + packages/tui/test/editor.test.ts | 365 ++++++++++++++++++ 4 files changed, 577 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 84637b24..8bff3e99 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3439,6 +3439,8 @@ export class InteractiveMode { const deleteWordBackward = this.getEditorKeyDisplay("deleteWordBackward"); const deleteToLineStart = this.getEditorKeyDisplay("deleteToLineStart"); const deleteToLineEnd = this.getEditorKeyDisplay("deleteToLineEnd"); + const yank = this.getEditorKeyDisplay("yank"); + const yankPop = this.getEditorKeyDisplay("yankPop"); const tab = this.getEditorKeyDisplay("tab"); // App keybindings @@ -3471,6 +3473,8 @@ export class InteractiveMode { | \`${deleteWordBackward}\` | Delete word backwards | | \`${deleteToLineStart}\` | Delete to start of line | | \`${deleteToLineEnd}\` | Delete to end of line | +| \`${yank}\` | Paste the most-recently-deleted text | +| \`${yankPop}\` | Cycle through the deleted text after pasting | **Other** | Key | Action | diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 37350bc2..f880bef5 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1,3 +1,4 @@ +import assert from "node:assert"; import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js"; import { getEditorKeybindings } from "../keybindings.js"; import { matchesKey } from "../keys.js"; @@ -288,6 +289,10 @@ export class Editor implements Component, Focusable { private history: string[] = []; private historyIndex: number = -1; // -1 = not browsing, 0 = most recent, 1 = older, etc. + // Kill ring for Emacs-style kill/yank operations + private killRing: string[] = []; + private lastAction: "kill" | "yank" | null = null; + public onSubmit?: (text: string) => void; public onChange?: (text: string) => void; public disableSubmit: boolean = false; @@ -349,6 +354,7 @@ export class Editor implements Component, Focusable { } private navigateHistory(direction: 1 | -1): void { + this.lastAction = null; if (this.history.length === 0) return; const newIndex = this.historyIndex - direction; // Up(-1) increases index, Down(1) decreases @@ -366,6 +372,9 @@ export class Editor implements Component, Focusable { /** Internal setText that doesn't reset history state - used by navigateHistory */ private setTextInternal(text: string): void { + // Reset kill ring state - external text changes break accumulation/yank chains + this.lastAction = null; + const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); this.state.lines = lines.length === 0 ? [""] : lines; this.state.cursorLine = this.state.lines.length - 1; @@ -646,6 +655,16 @@ export class Editor implements Component, Focusable { return; } + // Kill ring actions + if (kb.matches(data, "yank")) { + this.yank(); + return; + } + if (kb.matches(data, "yankPop")) { + this.yankPop(); + return; + } + // Cursor movement actions if (kb.matches(data, "cursorLineStart")) { this.moveToLineStart(); @@ -886,6 +905,7 @@ export class Editor implements Component, Focusable { // All the editor methods from before... private insertCharacter(char: string): void { this.historyIndex = -1; // Exit history browsing mode + this.lastAction = null; const line = this.state.lines[this.state.cursorLine] || ""; @@ -935,6 +955,7 @@ export class Editor implements Component, Focusable { private handlePaste(pastedText: string): void { this.historyIndex = -1; // Exit history browsing mode + this.lastAction = null; // Clean the pasted text const cleanText = pastedText.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); @@ -1035,6 +1056,7 @@ export class Editor implements Component, Focusable { private addNewLine(): void { this.historyIndex = -1; // Exit history browsing mode + this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1056,6 +1078,7 @@ export class Editor implements Component, Focusable { private handleBackspace(): void { this.historyIndex = -1; // Exit history browsing mode + this.lastAction = null; if (this.state.cursorCol > 0) { // Delete grapheme before cursor (handles emojis, combining characters, etc.) @@ -1107,10 +1130,12 @@ export class Editor implements Component, Focusable { } private moveToLineStart(): void { + this.lastAction = null; this.state.cursorCol = 0; } private moveToLineEnd(): void { + this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; this.state.cursorCol = currentLine.length; } @@ -1121,11 +1146,19 @@ export class Editor implements Component, Focusable { const currentLine = this.state.lines[this.state.cursorLine] || ""; if (this.state.cursorCol > 0) { + // 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.lastAction = "kill"; + // Delete from start of line up to cursor this.state.lines[this.state.cursorLine] = currentLine.slice(this.state.cursorCol); this.state.cursorCol = 0; } else if (this.state.cursorLine > 0) { - // At start of line - merge with previous line + // At start of line - merge with previous line, treating newline as deleted text + this.addToKillRing("\n", true); + this.lastAction = "kill"; + const previousLine = this.state.lines[this.state.cursorLine - 1] || ""; this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; this.state.lines.splice(this.state.cursorLine, 1); @@ -1144,10 +1177,18 @@ export class Editor implements Component, Focusable { const currentLine = this.state.lines[this.state.cursorLine] || ""; if (this.state.cursorCol < currentLine.length) { + // 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.lastAction = "kill"; + // 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) { - // At end of line - merge with next line + // At end of line - merge with next line, treating newline as deleted text + this.addToKillRing("\n", false); + this.lastAction = "kill"; + const nextLine = this.state.lines[this.state.cursorLine + 1] || ""; this.state.lines[this.state.cursorLine] = currentLine + nextLine; this.state.lines.splice(this.state.cursorLine + 1, 1); @@ -1166,6 +1207,10 @@ 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) { + // Treat newline as deleted text (backward deletion = prepend) + this.addToKillRing("\n", true); + this.lastAction = "kill"; + const previousLine = this.state.lines[this.state.cursorLine - 1] || ""; this.state.lines[this.state.cursorLine - 1] = previousLine + currentLine; this.state.lines.splice(this.state.cursorLine, 1); @@ -1173,11 +1218,20 @@ export class Editor implements Component, Focusable { this.state.cursorCol = previousLine.length; } } else { + // Save lastAction before cursor movement (moveWordBackwards resets it) + const wasKill = this.lastAction === "kill"; + const oldCursorCol = this.state.cursorCol; this.moveWordBackwards(); const deleteFrom = this.state.cursorCol; this.state.cursorCol = 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.lastAction = "kill"; + this.state.lines[this.state.cursorLine] = currentLine.slice(0, deleteFrom) + currentLine.slice(this.state.cursorCol); this.state.cursorCol = deleteFrom; @@ -1190,6 +1244,7 @@ export class Editor implements Component, Focusable { private handleForwardDelete(): void { this.historyIndex = -1; // Exit history browsing mode + this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; @@ -1292,6 +1347,7 @@ export class Editor implements Component, Focusable { } private moveCursor(deltaLine: number, deltaCol: number): void { + this.lastAction = null; const width = this.lastWidth; if (deltaLine !== 0) { @@ -1355,6 +1411,7 @@ export class Editor implements Component, Focusable { * Moves cursor by the page size while keeping it in bounds. */ private pageScroll(direction: -1 | 1): void { + this.lastAction = null; const width = this.lastWidth; const terminalRows = this.tui.terminal.rows; const pageSize = Math.max(5, Math.floor(terminalRows * 0.3)); @@ -1381,6 +1438,7 @@ export class Editor implements Component, Focusable { } private moveWordBackwards(): void { + this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; // If at start of line, move to end of previous line @@ -1424,7 +1482,149 @@ export class Editor implements Component, Focusable { this.state.cursorCol = newCol; } + /** + * Yank (paste) the most recent kill ring entry at cursor position. + */ + private yank(): void { + if (this.killRing.length === 0) return; + + const text = this.killRing[this.killRing.length - 1] || ""; + this.insertYankedText(text); + + this.lastAction = "yank"; + } + + /** + * Cycle through kill ring (only works immediately after yank or yank-pop). + * Replaces the last yanked text with the previous entry in the ring. + */ + private yankPop(): void { + // Only works if we just yanked and have more than one entry + if (this.lastAction !== "yank" || this.killRing.length <= 1) return; + + // Delete the previously yanked text (still at end of ring before rotation) + this.deleteYankedText(); + + // Rotate the ring: move end to front + const lastEntry = this.killRing.pop(); + assert(lastEntry !== undefined); // Since killRing was not empty + this.killRing.unshift(lastEntry); + + // Insert the new most recent entry (now at end after rotation) + const text = this.killRing[this.killRing.length - 1]; + this.insertYankedText(text); + + this.lastAction = "yank"; + } + + /** + * Insert text at cursor position (used by yank operations). + */ + private insertYankedText(text: string): void { + const lines = text.split("\n"); + + if (lines.length === 1) { + // Single line - insert at cursor + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const before = currentLine.slice(0, this.state.cursorCol); + const after = currentLine.slice(this.state.cursorCol); + this.state.lines[this.state.cursorLine] = before + text + after; + this.state.cursorCol += text.length; + } else { + // Multi-line insert + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const before = currentLine.slice(0, this.state.cursorCol); + const after = currentLine.slice(this.state.cursorCol); + + // First line merges with text before cursor + this.state.lines[this.state.cursorLine] = before + (lines[0] || ""); + + // Insert middle lines + for (let i = 1; i < lines.length - 1; i++) { + this.state.lines.splice(this.state.cursorLine + i, 0, lines[i] || ""); + } + + // Last line merges with text after cursor + const lastLineIndex = this.state.cursorLine + lines.length - 1; + this.state.lines.splice(lastLineIndex, 0, (lines[lines.length - 1] || "") + after); + + // Update cursor position + this.state.cursorLine = lastLineIndex; + this.state.cursorCol = (lines[lines.length - 1] || "").length; + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + /** + * Delete the previously yanked text (used by yank-pop). + * 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] || ""; + if (!yankedText) return; + + const yankLines = yankedText.split("\n"); + + if (yankLines.length === 1) { + // Single line - delete backward from cursor + const currentLine = this.state.lines[this.state.cursorLine] || ""; + const deleteLen = yankedText.length; + const before = currentLine.slice(0, this.state.cursorCol - deleteLen); + const after = currentLine.slice(this.state.cursorCol); + this.state.lines[this.state.cursorLine] = before + after; + this.state.cursorCol -= deleteLen; + } else { + // Multi-line delete - cursor is at end of last yanked line + const startLine = this.state.cursorLine - (yankLines.length - 1); + const startCol = (this.state.lines[startLine] || "").length - (yankLines[0] || "").length; + + // Get text after cursor on current line + const afterCursor = (this.state.lines[this.state.cursorLine] || "").slice(this.state.cursorCol); + + // Get text before yank start position + const beforeYank = (this.state.lines[startLine] || "").slice(0, startCol); + + // Remove all lines from startLine to cursorLine and replace with merged line + this.state.lines.splice(startLine, yankLines.length, beforeYank + afterCursor); + + // Update cursor + this.state.cursorLine = startLine; + this.state.cursorCol = startCol; + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + + /** + * 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 moveWordForwards(): void { + this.lastAction = null; const currentLine = this.state.lines[this.state.cursorLine] || ""; // If at end of line, move to start of next line diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index e83e0a5d..30432e61 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -34,6 +34,9 @@ export type EditorAction = | "selectCancel" // Clipboard | "copy" + // Kill ring + | "yank" + | "yankPop" // Tool output | "expandTools"; @@ -81,6 +84,9 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required = { selectCancel: ["escape", "ctrl+c"], // Clipboard copy: "ctrl+c", + // Kill ring + yank: "ctrl+y", + yankPop: "alt+y", // Tool output expandTools: "ctrl+o", }; diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 6db84bff..dcf1ddcf 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -698,4 +698,369 @@ describe("Editor component", () => { assert.ok(contentLine.includes("1234567890"), "Content should contain the word"); }); }); + + describe("Kill ring", () => { + it("Ctrl+W saves deleted text to kill ring and Ctrl+Y yanks it", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("foo bar baz"); + editor.handleInput("\x17"); // Ctrl+W - deletes "baz" + assert.strictEqual(editor.getText(), "foo bar "); + + // Move to beginning and yank + editor.handleInput("\x01"); // Ctrl+A + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "bazfoo bar "); + }); + + it("Ctrl+U saves deleted text to kill ring", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + // Move cursor to middle + editor.handleInput("\x01"); // Ctrl+A (start) + editor.handleInput("\x1b[C"); // Right 5 times + editor.handleInput("\x1b[C"); + editor.handleInput("\x1b[C"); + editor.handleInput("\x1b[C"); + editor.handleInput("\x1b[C"); + editor.handleInput("\x1b[C"); // After "hello " + + editor.handleInput("\x15"); // Ctrl+U - deletes "hello " + assert.strictEqual(editor.getText(), "world"); + + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "hello world"); + }); + + it("Ctrl+K saves deleted text to kill ring", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A (start) + editor.handleInput("\x0b"); // Ctrl+K - deletes "hello world" + + assert.strictEqual(editor.getText(), ""); + + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "hello world"); + }); + + it("Ctrl+Y does nothing when kill ring is empty", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("test"); + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "test"); + }); + + it("Alt+Y cycles through kill ring after Ctrl+Y", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Create kill ring with multiple entries + editor.setText("first"); + editor.handleInput("\x17"); // Ctrl+W - deletes "first" + editor.setText("second"); + editor.handleInput("\x17"); // Ctrl+W - deletes "second" + editor.setText("third"); + editor.handleInput("\x17"); // Ctrl+W - deletes "third" + + // Kill ring now has: [first, second, third] + assert.strictEqual(editor.getText(), ""); + + editor.handleInput("\x19"); // Ctrl+Y - yanks "third" (most recent) + assert.strictEqual(editor.getText(), "third"); + + editor.handleInput("\x1by"); // Alt+Y - cycles to "second" + assert.strictEqual(editor.getText(), "second"); + + editor.handleInput("\x1by"); // Alt+Y - cycles to "first" + assert.strictEqual(editor.getText(), "first"); + + editor.handleInput("\x1by"); // Alt+Y - cycles back to "third" + assert.strictEqual(editor.getText(), "third"); + }); + + it("Alt+Y does nothing if not preceded by yank", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("test"); + editor.handleInput("\x17"); // Ctrl+W - deletes "test" + editor.setText("other"); + + // Type something to break the yank chain + editor.handleInput("x"); + assert.strictEqual(editor.getText(), "otherx"); + + // Alt+Y should do nothing + editor.handleInput("\x1by"); // Alt+Y + assert.strictEqual(editor.getText(), "otherx"); + }); + + it("Alt+Y does nothing if kill ring has ≤1 entry", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("only"); + editor.handleInput("\x17"); // Ctrl+W - deletes "only" + + editor.handleInput("\x19"); // Ctrl+Y - yanks "only" + assert.strictEqual(editor.getText(), "only"); + + editor.handleInput("\x1by"); // Alt+Y - should do nothing (only 1 entry) + assert.strictEqual(editor.getText(), "only"); + }); + + it("consecutive Ctrl+W accumulates into one kill ring entry", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("one two three"); + editor.handleInput("\x17"); // Ctrl+W - deletes "three" + editor.handleInput("\x17"); // Ctrl+W - deletes "two " (prepended) + editor.handleInput("\x17"); // Ctrl+W - deletes "one " (prepended) + + assert.strictEqual(editor.getText(), ""); + + // Should be one combined entry + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "one two three"); + }); + + it("Ctrl+U accumulates multiline deletes including newlines", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Start with multiline text, cursor at end + editor.setText("line1\nline2\nline3"); + // Cursor is at end of line3 (line 2, col 5) + + // Delete "line3" + editor.handleInput("\x15"); // Ctrl+U + assert.strictEqual(editor.getText(), "line1\nline2\n"); + + // Delete newline (at start of empty line 2, merges with line1) + editor.handleInput("\x15"); // Ctrl+U + assert.strictEqual(editor.getText(), "line1\nline2"); + + // Delete "line2" + editor.handleInput("\x15"); // Ctrl+U + assert.strictEqual(editor.getText(), "line1\n"); + + // Delete newline + editor.handleInput("\x15"); // Ctrl+U + assert.strictEqual(editor.getText(), "line1"); + + // Delete "line1" + editor.handleInput("\x15"); // Ctrl+U + assert.strictEqual(editor.getText(), ""); + + // All deletions accumulated into one entry: "line1\nline2\nline3" + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "line1\nline2\nline3"); + }); + + it("backward deletions prepend, forward deletions append during accumulation", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("prefix|suffix"); + // Position cursor at | + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C"); // Move right 6 times + + editor.handleInput("\x0b"); // Ctrl+K - deletes "suffix" (forward) + editor.handleInput("\x0b"); // Ctrl+K - deletes "|" (forward, appended) + assert.strictEqual(editor.getText(), "prefix"); + + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "prefix|suffix"); + }); + + it("non-delete actions break kill accumulation", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Delete "baz", then type "x" to break accumulation, then delete "x" + editor.setText("foo bar baz"); + editor.handleInput("\x17"); // Ctrl+W - deletes "baz" + assert.strictEqual(editor.getText(), "foo bar "); + + editor.handleInput("x"); // Typing breaks accumulation + assert.strictEqual(editor.getText(), "foo bar x"); + + editor.handleInput("\x17"); // Ctrl+W - deletes "x" (separate entry, not accumulated) + assert.strictEqual(editor.getText(), "foo bar "); + + // Yank most recent - should be "x", not "xbaz" + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "foo bar x"); + + // Cycle to previous - should be "baz" (separate entry) + editor.handleInput("\x1by"); // Alt+Y + assert.strictEqual(editor.getText(), "foo bar baz"); + }); + + it("non-yank actions break Alt+Y chain", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("first"); + editor.handleInput("\x17"); // Ctrl+W + editor.setText("second"); + editor.handleInput("\x17"); // Ctrl+W + editor.setText(""); + + editor.handleInput("\x19"); // Ctrl+Y - yanks "second" + assert.strictEqual(editor.getText(), "second"); + + editor.handleInput("x"); // Type breaks yank chain + assert.strictEqual(editor.getText(), "secondx"); + + editor.handleInput("\x1by"); // Alt+Y - should do nothing + assert.strictEqual(editor.getText(), "secondx"); + }); + + it("kill ring rotation persists after cycling", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("first"); + editor.handleInput("\x17"); // deletes "first" + editor.setText("second"); + editor.handleInput("\x17"); // deletes "second" + editor.setText("third"); + editor.handleInput("\x17"); // deletes "third" + editor.setText(""); + + // Ring: [first, second, third] + + editor.handleInput("\x19"); // Ctrl+Y - yanks "third" + editor.handleInput("\x1by"); // Alt+Y - cycles to "second", ring rotates + + // Now ring is: [third, first, second] + assert.strictEqual(editor.getText(), "second"); + + // Do something else + editor.handleInput("x"); + editor.setText(""); + + // New yank should get "second" (now at end after rotation) + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "second"); + }); + + it("consecutive deletions across lines coalesce into one entry", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // "1\n2\n3" with cursor at end, delete everything with Ctrl+W + editor.setText("1\n2\n3"); + editor.handleInput("\x17"); // Ctrl+W - deletes "3" + assert.strictEqual(editor.getText(), "1\n2\n"); + + editor.handleInput("\x17"); // Ctrl+W - deletes newline (merge with prev line) + assert.strictEqual(editor.getText(), "1\n2"); + + editor.handleInput("\x17"); // Ctrl+W - deletes "2" + assert.strictEqual(editor.getText(), "1\n"); + + editor.handleInput("\x17"); // Ctrl+W - deletes newline + assert.strictEqual(editor.getText(), "1"); + + editor.handleInput("\x17"); // Ctrl+W - deletes "1" + assert.strictEqual(editor.getText(), ""); + + // All deletions should have accumulated into one entry + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "1\n2\n3"); + }); + + it("Ctrl+K at line end deletes newline and coalesces", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // "ab" on line 1, "cd" on line 2, cursor at end of line 1 + editor.setText(""); + editor.handleInput("a"); + editor.handleInput("b"); + editor.handleInput("\n"); + editor.handleInput("c"); + editor.handleInput("d"); + // Move to end of first line + editor.handleInput("\x1b[A"); // Up arrow + editor.handleInput("\x05"); // Ctrl+E - end of line + + // Now at end of "ab", Ctrl+K should delete newline (merge with "cd") + editor.handleInput("\x0b"); // Ctrl+K - deletes newline + assert.strictEqual(editor.getText(), "abcd"); + + // Continue deleting + editor.handleInput("\x0b"); // Ctrl+K - deletes "cd" + assert.strictEqual(editor.getText(), "ab"); + + // Both deletions should accumulate + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "ab\ncd"); + }); + + it("handles yank in middle of text", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("word"); + editor.handleInput("\x17"); // Ctrl+W - deletes "word" + editor.setText("hello world"); + + // Move to middle (after "hello ") + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C"); + + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "hello wordworld"); + }); + + it("handles yank-pop in middle of text", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Create two kill ring entries + editor.setText("FIRST"); + editor.handleInput("\x17"); // Ctrl+W - deletes "FIRST" + editor.setText("SECOND"); + editor.handleInput("\x17"); // Ctrl+W - deletes "SECOND" + + // Ring: ["FIRST", "SECOND"] + + // Set up "hello world" and position cursor after "hello " + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A - go to start of line + for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C"); // Move right 6 + + // Yank "SECOND" in the middle + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "hello SECONDworld"); + + // Yank-pop replaces "SECOND" with "FIRST" + editor.handleInput("\x1by"); // Alt+Y + assert.strictEqual(editor.getText(), "hello FIRSTworld"); + }); + + it("multiline yank and yank-pop in middle of text", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + // Create single-line entry + editor.setText("SINGLE"); + editor.handleInput("\x17"); // Ctrl+W - deletes "SINGLE" + + // Create multiline entry via consecutive Ctrl+U + editor.setText("A\nB"); + editor.handleInput("\x15"); // Ctrl+U - deletes "B" + editor.handleInput("\x15"); // Ctrl+U - deletes newline + editor.handleInput("\x15"); // Ctrl+U - deletes "A" + // Ring: ["SINGLE", "A\nB"] + + // Insert in middle of "hello world" + editor.setText("hello world"); + editor.handleInput("\x01"); // Ctrl+A + for (let i = 0; i < 6; i++) editor.handleInput("\x1b[C"); + + // Yank multiline "A\nB" + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "hello A\nBworld"); + + // Yank-pop replaces with "SINGLE" + editor.handleInput("\x1by"); // Alt+Y + assert.strictEqual(editor.getText(), "hello SINGLEworld"); + }); + }); });