From bafddc27ed60c033b8b53a89e4fddf294f5b2dfc Mon Sep 17 00:00:00 2001 From: Sviatoslav Abakumov Date: Sat, 17 Jan 2026 16:28:13 +0400 Subject: [PATCH 1/4] feat(tui): add legacy Alt+letter key sequence support In legacy terminal mode (non-Kitty protocol), Alt+key is sent as ESC followed by the key character. This was only supported for specific keys (space, backspace, arrows) but not for regular letters. Add support for Alt+letter sequences to enable keybindings like Alt+Y. --- packages/tui/src/keys.ts | 9 +++++++++ packages/tui/test/keys.test.ts | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index f997ffb9..c26d0c17 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -971,6 +971,11 @@ export function matchesKey(data: string, keyId: KeyId): boolean { return data === `\x1b${rawCtrlChar(key)}`; } + if (alt && !ctrl && !shift && !_kittyProtocolActive && key >= "a" && key <= "z") { + // Legacy: alt+letter is ESC followed by the letter + if (data === `\x1b${key}`) return true; + } + if (ctrl && !shift && !alt) { const raw = rawCtrlChar(key); if (data === raw) return true; @@ -1073,6 +1078,10 @@ export function parseKey(data: string): string | undefined { if (code >= 1 && code <= 26) { return `ctrl+alt+${String.fromCharCode(code + 96)}`; } + // Legacy alt+letter (ESC followed by letter a-z) + if (code >= 97 && code <= 122) { + return `alt+${String.fromCharCode(code)}`; + } } if (data === "\x1b[A") return "up"; if (data === "\x1b[B") return "down"; diff --git a/packages/tui/test/keys.test.ts b/packages/tui/test/keys.test.ts index ef46bae6..ec7ce1d9 100644 --- a/packages/tui/test/keys.test.ts +++ b/packages/tui/test/keys.test.ts @@ -149,6 +149,12 @@ describe("matchesKey", () => { assert.strictEqual(parseKey("\x1bB"), "alt+left"); assert.strictEqual(matchesKey("\x1bF", "alt+right"), true); assert.strictEqual(parseKey("\x1bF"), "alt+right"); + assert.strictEqual(matchesKey("\x1ba", "alt+a"), true); + assert.strictEqual(parseKey("\x1ba"), "alt+a"); + assert.strictEqual(matchesKey("\x1by", "alt+y"), true); + assert.strictEqual(parseKey("\x1by"), "alt+y"); + assert.strictEqual(matchesKey("\x1bz", "alt+z"), true); + assert.strictEqual(parseKey("\x1bz"), "alt+z"); setKittyProtocolActive(true); assert.strictEqual(matchesKey("\x1b ", "alt+space"), false); @@ -161,6 +167,10 @@ describe("matchesKey", () => { assert.strictEqual(parseKey("\x1bB"), undefined); assert.strictEqual(matchesKey("\x1bF", "alt+right"), false); assert.strictEqual(parseKey("\x1bF"), undefined); + assert.strictEqual(matchesKey("\x1ba", "alt+a"), false); + assert.strictEqual(parseKey("\x1ba"), undefined); + assert.strictEqual(matchesKey("\x1by", "alt+y"), false); + assert.strictEqual(parseKey("\x1by"), undefined); setKittyProtocolActive(false); }); From 9fb7434a062eb7a479a5349b5b02c423a594ce1f Mon Sep 17 00:00:00 2001 From: Sviatoslav Abakumov Date: Sat, 17 Jan 2026 16:28:20 +0400 Subject: [PATCH 2/4] 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"); + }); + }); }); From 505894f4eaada654803217f59c5eb4712d60fb06 Mon Sep 17 00:00:00 2001 From: Sviatoslav Abakumov Date: Sat, 17 Jan 2026 18:54:23 +0400 Subject: [PATCH 3/4] feat(tui): add Alt+D to delete word forward (kill) Adds deleteWordForward action bound to Alt+D, which deletes from cursor to the end of the current word and saves to kill ring for later yanking. Consecutive forward kills append to the same kill ring entry. --- .../src/modes/interactive/interactive-mode.ts | 2 + packages/tui/src/components/editor.ts | 44 +++++++++++++++++++ packages/tui/src/keybindings.ts | 2 + packages/tui/test/editor.test.ts | 32 ++++++++++++++ 4 files changed, 80 insertions(+) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8bff3e99..534d03aa 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -3437,6 +3437,7 @@ export class InteractiveMode { const submit = this.getEditorKeyDisplay("submit"); const newLine = this.getEditorKeyDisplay("newLine"); const deleteWordBackward = this.getEditorKeyDisplay("deleteWordBackward"); + const deleteWordForward = this.getEditorKeyDisplay("deleteWordForward"); const deleteToLineStart = this.getEditorKeyDisplay("deleteToLineStart"); const deleteToLineEnd = this.getEditorKeyDisplay("deleteToLineEnd"); const yank = this.getEditorKeyDisplay("yank"); @@ -3471,6 +3472,7 @@ export class InteractiveMode { | \`${submit}\` | Send message | | \`${newLine}\` | New line${process.platform === "win32" ? " (Ctrl+Enter on Windows Terminal)" : ""} | | \`${deleteWordBackward}\` | Delete word backwards | +| \`${deleteWordForward}\` | Delete word forwards | | \`${deleteToLineStart}\` | Delete to start of line | | \`${deleteToLineEnd}\` | Delete to end of line | | \`${yank}\` | Paste the most-recently-deleted text | diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index f880bef5..b7fc164a 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -646,6 +646,10 @@ export class Editor implements Component, Focusable { this.deleteWordBackwards(); return; } + if (kb.matches(data, "deleteWordForward")) { + this.deleteWordForward(); + return; + } if (kb.matches(data, "deleteCharBackward") || matchesKey(data, "shift+backspace")) { this.handleBackspace(); return; @@ -1242,6 +1246,46 @@ export class Editor implements Component, Focusable { } } + private deleteWordForward(): void { + this.historyIndex = -1; // Exit history browsing mode + + const currentLine = this.state.lines[this.state.cursorLine] || ""; + + // 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) { + // Treat newline as deleted text (forward deletion = append) + 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); + } + } else { + // Save lastAction before cursor movement (moveWordForwards resets it) + const wasKill = this.lastAction === "kill"; + + const oldCursorCol = this.state.cursorCol; + this.moveWordForwards(); + const deleteTo = 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(this.state.cursorCol, deleteTo); + this.addToKillRing(deletedText, false); + this.lastAction = "kill"; + + this.state.lines[this.state.cursorLine] = + currentLine.slice(0, this.state.cursorCol) + currentLine.slice(deleteTo); + } + + if (this.onChange) { + this.onChange(this.getText()); + } + } + private handleForwardDelete(): void { this.historyIndex = -1; // Exit history browsing mode this.lastAction = null; diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index 30432e61..8859e135 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -19,6 +19,7 @@ export type EditorAction = | "deleteCharBackward" | "deleteCharForward" | "deleteWordBackward" + | "deleteWordForward" | "deleteToLineStart" | "deleteToLineEnd" // Text input @@ -69,6 +70,7 @@ export const DEFAULT_EDITOR_KEYBINDINGS: Required = { deleteCharBackward: "backspace", deleteCharForward: "delete", deleteWordBackward: ["ctrl+w", "alt+backspace"], + deleteWordForward: "alt+d", deleteToLineStart: "ctrl+u", deleteToLineEnd: "ctrl+k", // Text input diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index dcf1ddcf..e46d0cea 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -1062,5 +1062,37 @@ describe("Editor component", () => { editor.handleInput("\x1by"); // Alt+Y assert.strictEqual(editor.getText(), "hello SINGLEworld"); }); + + it("Alt+D deletes word forward and saves to kill ring", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("hello world test"); + editor.handleInput("\x01"); // Ctrl+A - go to start + + editor.handleInput("\x1bd"); // Alt+D - deletes "hello" + assert.strictEqual(editor.getText(), " world test"); + + editor.handleInput("\x1bd"); // Alt+D - deletes " world" (skips whitespace, then word) + assert.strictEqual(editor.getText(), " test"); + + // Yank should get accumulated text + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "hello world test"); + }); + + it("Alt+D at end of line deletes newline", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + + editor.setText("line1\nline2"); + // Move to start of document, then to end of first line + editor.handleInput("\x1b[A"); // Up arrow - go to first line + editor.handleInput("\x05"); // Ctrl+E - end of line + + editor.handleInput("\x1bd"); // Alt+D - deletes newline (merges lines) + assert.strictEqual(editor.getText(), "line1line2"); + + editor.handleInput("\x19"); // Ctrl+Y + assert.strictEqual(editor.getText(), "line1\nline2"); + }); }); }); From 18d9d9d7048d397bce21cac4dc72b0d4d6f2f72e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 17 Jan 2026 21:12:48 +0100 Subject: [PATCH 4/4] fix(tui): document kill ring and reset history --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 6 ++++++ packages/tui/CHANGELOG.md | 2 ++ packages/tui/src/components/editor.ts | 1 + 4 files changed, 10 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 05a4adad..ad3a064d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,7 @@ - Export `keyHint`, `appKeyHint`, `editorKey`, `appKey`, `rawKeyHint` for extensions to format keybinding hints consistently - Added `showHardwareCursor` setting to control cursor visibility while still positioning it for IME support. ([#800](https://github.com/badlogic/pi-mono/pull/800) by [@ghoulr](https://github.com/ghoulr)) - Added `ctx.compact()` and `ctx.getContextUsage()` to extension contexts for programmatic compaction and context usage checks. +- Added documentation for delete word forward and kill ring keybindings in interactive mode. ([#810](https://github.com/badlogic/pi-mono/pull/810) by [@Perlence](https://github.com/Perlence)) ## [0.48.0] - 2026-01-16 diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 9522a218..7ad4e94c 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -350,8 +350,11 @@ Both modes are configurable via `/settings`: "one-at-a-time" delivers messages o | Enter | Send message | | Shift+Enter | New line (Ctrl+Enter on Windows Terminal) | | Ctrl+W / Option+Backspace | Delete word backwards | +| Alt+D | Delete word forwards | | Ctrl+U | Delete to start of line | | Ctrl+K | Delete to end of line | +| Ctrl+Y | Paste most recently deleted text | +| Alt+Y | Cycle through deleted text after pasting | **Other:** @@ -397,8 +400,11 @@ All keyboard shortcuts can be customized via `~/.pi/agent/keybindings.json`. Eac | `deleteCharBackward` | `backspace` | Delete char backward | | `deleteCharForward` | `delete` | Delete char forward | | `deleteWordBackward` | `ctrl+w`, `alt+backspace` | Delete word backward | +| `deleteWordForward` | `alt+d` | Delete word forward | | `deleteToLineStart` | `ctrl+u` | Delete to line start | | `deleteToLineEnd` | `ctrl+k` | Delete to line end | +| `yank` | `ctrl+y` | Paste most recently deleted text | +| `yankPop` | `alt+y` | Cycle through deleted text after pasting | | `newLine` | `shift+enter` | Insert new line | | `submit` | `enter` | Submit input | | `tab` | `tab` | Tab/autocomplete | diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 37f6ffed..a4d12dcd 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added - Added `showHardwareCursor` getter and setter to control cursor visibility while keeping IME positioning active. ([#800](https://github.com/badlogic/pi-mono/pull/800) by [@ghoulr](https://github.com/ghoulr)) +- Added Emacs-style kill ring editing with yank and yank-pop keybindings. ([#810](https://github.com/badlogic/pi-mono/pull/810) by [@Perlence](https://github.com/Perlence)) +- Added legacy Alt+letter handling and Alt+D delete word forward support in the editor keymap. ([#810](https://github.com/badlogic/pi-mono/pull/810) by [@Perlence](https://github.com/Perlence)) ## [0.48.0] - 2026-01-16 diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index b7fc164a..ee498bd9 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -1565,6 +1565,7 @@ export class Editor implements Component, Focusable { * Insert text at cursor position (used by yank operations). */ private insertYankedText(text: string): void { + this.historyIndex = -1; // Exit history browsing mode const lines = text.split("\n"); if (lines.length === 1) {