Skip to content

Componente editor: entrada, IME, kill-ring, undo

源码版本v0.73.1

Editor es el componente más pesado de @mariozechner/pi-tui: 2292 líneas en un solo archivo, haciendo todo el trabajo sucio de edición multilínea: dispatch de teclado, posicionamiento de la ventana de candidatos IME, bracketed paste, kill/yank estilo Emacs, undo coalescing estilo fish, y autocompletado por slash y disparadores de símbolos. InteractiveMode lo usa como caja de entrada principal, y las extensiones pueden reemplazarlo vía la interfaz EditorComponent (modos vim/emacs).

Responsabilidades

Editor hace cuatro cosas:

  1. Dispatch de entrada: handleInput(data) es la entrada de byte stream raw de la terminal; procesa en orden jump mode, bracketed paste, undo, navegación de autocompletado, caracteres normales. Ver packages/tui/src/components/editor.ts:534-630.
  2. Edición multilínea: addNewLine corta la línea en la posición del cursor, insertTextAtCursorInternal gestiona inserción multilínea de pegados/completados. Ver packages/tui/src/components/editor.ts:1152-1175 y packages/tui/src/components/editor.ts:980-1023.
  3. kill-ring / undo: kill/yank estilo Emacs + undo coalescing estilo fish; los kill consecuentes pueden acumularse, y tras yank se hace yank-pop rotando el ring. Ver packages/tui/src/components/editor.ts:1817-1896 y packages/tui/src/kill-ring.ts:1-50.
  4. Posicionamiento del cursor IME: render inserta CURSOR_MARKER (una secuencia APC de ancho cero) antes de la posición del cursor; TUI lo extrae y mueve el cursor hardware a esa posición, dejando la ventana de candidatos IME pegada al cursor. Ver packages/tui/src/components/editor.ts:474-501.

Motivación de diseño

¿Por qué no usar el readline de Node.js? Porque no soporta IME, ni multilínea, ni undo, ni hooks de autocompletado. Editor lo mete todo en un mismo componente, con tres decisiones clave. Una, undo coalescing: la entrada consecutiva de caracteres se une por defecto en una sola unidad de undo (el espacio dispara una nueva), evitando que cada carácter empuje un snapshot y llene el undo de ruido. Dos, acumulación en kill-ring: deleteWordBackwards consecutivo concatena el texto borrado en el mismo ítem del ring, prependiendo al borrar hacia atrás y appendiceando al borrar hacia adelante; así un solo yank puede restaurar el bloque entero. Tres, paste marker: las pegadas grandes no se insertan en el texto, se reemplazan por un placeholder [paste #N +M lines] y al expandir se llama getExpandedText, evitando que miles de líneas pegadas ahoguen el render.

Archivos clave

handleInput al entrar primero detecta los marcadores de bracketed paste y acumula en pasteBuffer hasta recibir \x1b[201~:

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;
    // ...
  }
}

El undo coalescing funde los caracteres word consecutivos en una unidad de undo, el espacio es su propia unidad:

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 saca la cima del kill-ring y la inserta; yank-pop debe seguir a yank, primero borra el texto del yank anterior y luego rota el 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";
}

Flujo de datos

raw stdin → Editor.handleInput → mutación de estado → onChangerequestRender externo:

Límites y fallos

  • tmux extended-keys-format=csi-u recodifica bytes de control: handlePaste usa regex para reconstruir \x1b[<cp>;5u al byte original, evitando que un newline se cuele como ESC + [106;5u en el editor. Ver packages/tui/src/components/editor.ts:1091-1101.
  • Paste grande vía marker: el texto que supera el umbral se reemplaza por [paste #N +M lines], el contenido real vive en pasteRegistry; getExpandedText lo expande. El render sólo dibuja el marker, sin lag.
  • Undo atómico entre paste: handlePaste hace un único pushUndoSnapshot al entrar; tras pegar, un solo undo vuelve al estado previo.
  • Jump mode intercepta la siguiente tecla: al entrar en jump, el siguiente carácter imprimible no inserta texto sino que dispara jumpToChar; los caracteres ctrl cancelan el jump. Ver packages/tui/src/components/editor.ts:538-556.
  • Semántica de submit configurable: shouldSubmitOnBackslashEnter decide si \n hace submit según tui.input.submit en keybindings (enter o shift+enter). Ver packages/tui/src/components/editor.ts:1177-1188.

Resumen

Editor es un editor de terminal completo: dispatch de entrada, IME, kill-ring, undo y autocompletado, todo en una clase. Depende del render diferencial de TUI y del mecanismo CURSOR_MARKER para posicionar el cursor hardware; ver clase TUI: centro de scheduling de renderizado diferencial. Cómo el byte stream de teclado se parsea a key id en parseo de teclado: protocolo kitty; el dropdown de autocompletado usa SelectList, ver biblioteca de componentes.