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