Add the kill ring and undo features to the Input component (#1373)

* feat(tui): extract KillRing and UndoStack, add to Input

Extract kill ring and undo logic from Editor into reusable classes:

- KillRing: ring buffer with accumulation for consecutive kills
- UndoStack<S>: generic stack with clone-on-push semantics

Refactor Editor to use both classes. Add kill ring (kill/yank/
yank-pop), undo with coalescing, and deleteWordForward to Input.

* feat(tui): extract handleBackspace() and handleForwardDelete()
This commit is contained in:
Sviatoslav Abakumov 2026-02-07 18:47:27 +04:00 committed by GitHub
parent da282f9e97
commit 4c2d78f6cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 765 additions and 79 deletions

View file

@ -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<EditorState>();
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) {

View file

@ -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<InputState>();
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, "");

View file

@ -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;
}
}

View file

@ -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<S> {
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;
}
}