Skip to content

Editor Component: Input, IME, kill-ring, undo

源码版本v0.73.1

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:

  1. 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. See packages/tui/src/components/editor.ts:534-630.
  2. Multiline editing: addNewLine splits the line at the cursor; insertTextAtCursorInternal handles multiline text coming in from paste or completion. See packages/tui/src/components/editor.ts:1152-1175 and packages/tui/src/components/editor.ts:980-1023.
  3. kill-ring / undo: Emacs kill/yank plus fish-style undo coalescing. Consecutive kills accumulate, and after a yank, yank-pop rotates the ring. See packages/tui/src/components/editor.ts:1817-1896 and packages/tui/src/kill-ring.ts:1-50.
  4. IME cursor positioning: render inserts a CURSOR_MARKER (a zero-width APC sequence) right before the cursor position. TUI extracts it and moves the hardware cursor there so the IME candidate window sticks to the cursor. See packages/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

handleInput first checks for the bracketed paste start/end markers and accumulates into pasteBuffer until \x1b[201~ arrives:

typescript
// 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:

typescript
// 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:

typescript
// 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-u re-encodes control bytes: handlePaste uses a regex to turn \x1b[<cp>;5u back into the original byte, so a newline is not mistaken for ESC + [106;5u leaking into the editor. See packages/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 in pasteRegistry and getExpandedText expands it. Rendering only draws the marker, so it does not stall.
  • Undo is atomic across a paste: handlePaste calls pushUndoSnapshot once 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. See packages/tui/src/components/editor.ts:538-556.
  • Submit semantics are configurable: shouldSubmitOnBackslashEnter decides whether \n submits based on whether the keybindings set tui.input.submit to enter or shift+enter. See packages/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.