Editor Component: Input, IME, kill-ring, undo
Editor is the heaviest component in @mariozechner/pi-tui — a single 2292-line file that takes on all the grunt work of multiline text editing: keyboard input dispatch, IME candidate window positioning, bracketed paste handling, Emacs-style kill/yank, fish-style undo coalescing, slash commands, and symbol-triggered autocomplete. InteractiveMode uses it as the main input box, and extensions can swap in a different implementation through the EditorComponent interface (vim/emacs modes).
Responsibilities
Editor does four things:
- Input dispatch:
handleInput(data)is the raw terminal byte stream entry point. It processes jump mode, bracketed paste, undo, autocomplete navigation, and ordinary characters in order. Seepackages/tui/src/components/editor.ts:534-630. - Multiline editing:
addNewLinesplits the line at the cursor;insertTextAtCursorInternalhandles multiline text coming in from paste or completion. Seepackages/tui/src/components/editor.ts:1152-1175andpackages/tui/src/components/editor.ts:980-1023. - kill-ring / undo: Emacs kill/yank plus fish-style undo coalescing. Consecutive kills accumulate, and after a yank,
yank-poprotates the ring. Seepackages/tui/src/components/editor.ts:1817-1896andpackages/tui/src/kill-ring.ts:1-50. - IME cursor positioning:
renderinserts aCURSOR_MARKER(a zero-width APC sequence) right before the cursor position.TUIextracts it and moves the hardware cursor there so the IME candidate window sticks to the cursor. Seepackages/tui/src/components/editor.ts:474-501.
Design Motivation
Why not use Node.js readline? Because it has no IME support, no multiline, no undo, and no autocomplete hooks. Editor builds all of these into one component, with three key decisions. First, undo coalescing: consecutive character input collapses into one undo unit by default, with a space starting a new unit, so you do not get undo noise from per-character snapshots. Second, kill-ring accumulation: consecutive deleteWordBackwards calls append the deleted text into the same ring entry — prepend when deleting backward, append when deleting forward — so a single yank can restore the whole run. Third, paste markers: a large paste is not jammed into the text as-is; it is replaced with a [paste #N +M lines] placeholder and expanded via getExpandedText on demand, so a thousand-line paste cannot tank rendering.
Key Files
packages/tui/src/components/editor.ts:217-288—class Editorfields:state(lines/cursorLine/cursorCol),killRing,undoStack,pasteBuffer,autocompleteState.packages/tui/src/components/editor.ts:409-470—render(width)body: word-wrap layout, cursor rendering, padding/border.packages/tui/src/components/editor.ts:534-595—handleInputentry: paste detection, undo, autocomplete navigation.packages/tui/src/components/editor.ts:1084-1122—handlePaste: CSI-u control-byte decoding, non-printable filtering, auto-space before paths.packages/tui/src/components/editor.ts:1935-1949—pushUndoSnapshot/undo:structuredClonestate snapshots.packages/tui/src/kill-ring.ts:1-50—KillRingring buffer;pushsupports prepend/accumulate.packages/tui/src/undo-stack.ts:1-35—UndoStack<S>generic stack, clone-on-push.packages/tui/src/editor-component.ts:11-74—EditorComponentinterface, which extensions can replace.
handleInput first checks for the bracketed paste start/end markers and accumulates into pasteBuffer until \x1b[201~ arrives:
// packages/tui/src/components/editor.ts:559-582
if (data.includes("\x1b[200~")) {
this.isInPaste = true;
this.pasteBuffer = "";
data = data.replace("\x1b[200~", "");
}
if (this.isInPaste) {
this.pasteBuffer += data;
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
if (endIndex !== -1) {
const pasteContent = this.pasteBuffer.substring(0, endIndex);
if (pasteContent.length > 0) this.handlePaste(pasteContent);
this.isInPaste = false;
// ...
}
}Undo coalescing merges consecutive word characters into one undo unit; a space gets its own unit:
// packages/tui/src/components/editor.ts:1027-1037
// - Consecutive word chars coalesce into one undo unit
// - Space captures state before itself (so undo removes space+following word together)
// - Each space is separately undoable
if (!skipUndoCoalescing) {
if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
this.pushUndoSnapshot();
}
this.lastAction = "type-word";
}yank pulls the top of the kill-ring and inserts it. yank-pop must immediately follow a yank; it deletes the previous yank's text and rotates the ring:
// packages/tui/src/components/editor.ts:1832-1848
private yankPop(): void {
if (this.lastAction !== "yank" || this.killRing.length <= 1) return;
this.pushUndoSnapshot();
this.deleteYankedText();
this.killRing.rotate();
const text = this.killRing.peek()!;
this.insertYankedText(text);
this.lastAction = "yank";
}Data Flow
raw stdin -> Editor.handleInput -> state change -> onChange -> external requestRender:
Edges and Failures
- tmux
extended-keys-format=csi-ure-encodes control bytes:handlePasteuses a regex to turn\x1b[<cp>;5uback into the original byte, so a newline is not mistaken for ESC +[106;5uleaking into the editor. Seepackages/tui/src/components/editor.ts:1091-1101. - Large pastes go through a marker: text over the threshold is replaced with
[paste #N +M lines]; the real content lives inpasteRegistryandgetExpandedTextexpands it. Rendering only draws the marker, so it does not stall. - Undo is atomic across a paste:
handlePastecallspushUndoSnapshotonce at the entry, so a single undo goes back to the pre-paste state. - Jump mode intercepts the next key: once jump is active, the next printable character does not insert text; it triggers
jumpToChar. A ctrl character cancels jump. Seepackages/tui/src/components/editor.ts:538-556. - Submit semantics are configurable:
shouldSubmitOnBackslashEnterdecides whether\nsubmits based on whether the keybindings settui.input.submittoenterorshift+enter. Seepackages/tui/src/components/editor.ts:1177-1188.
Summary
Editor is a complete terminal editor: input dispatch, IME, kill-ring, undo, and autocomplete all live in one class. It relies on TUI's diff rendering and the CURSOR_MARKER mechanism to position the hardware cursor; see TUI Class: The Diff-Rendering Scheduler. For how the keyboard byte stream is parsed into key ids, see Keyboard Parsing: The Kitty Protocol. The autocomplete dropdown uses SelectList; see Component Library.