Skip to content

编辑器组件:输入、IME、kill-ring、undo

源码版本v0.73.1

Editor@mariozechner/pi-tui 里最重的组件——单文件 2292 行,承担多行文本编辑的全部脏活:键盘输入分发、IME 候选窗口定位、bracketed paste 处理、Emacs 风格 kill/yank、fish 风格 undo coalescing、slash 命令与符号触发 autocomplete。InteractiveMode 用它当主输入框,扩展也能通过 EditorComponent 接口替换实现(vim/emacs 模式)。

职责

Editor 做四件事:

  1. 输入分发:handleInput(data) 是 raw 终端字节流入口,按顺序处理 jump mode、bracketed paste、undo、autocomplete 导航、普通字符。见 packages/tui/src/components/editor.ts:534-630
  2. 多行编辑:addNewLine 在光标处切行,insertTextAtCursorInternal 处理粘贴/补全插入的多行文本。见 packages/tui/src/components/editor.ts:1152-1175packages/tui/src/components/editor.ts:980-1023
  3. kill-ring / undo:Emacs kill/yank + fish 风格 undo coalescing,kill 连续删除可累积,yank 后 yank-pop 循环 ring。见 packages/tui/src/components/editor.ts:1817-1896packages/tui/src/kill-ring.ts:1-50
  4. IME 光标定位:render 在光标位置前插 CURSOR_MARKER(零宽 APC 序列),TUI 提取后把硬件光标移到该位置,IME 候选窗口就能贴住光标。见 packages/tui/src/components/editor.ts:474-501

设计动机

为什么不用 Node.js 的 readline?因为它没有 IME 支持、没有多行、没有 undo、没有 autocomplete 钩子。Editor 把这些做进同一组件,关键决策有三个。一是 undo coalescing:连续输入字符默认并成一个 undo 单元(空格触发新单元),避免每个字符都 push 一次快照导致 undo 噪音。二是 kill-ring 累积:连续 deleteWordBackwards 把删除文本拼到同一个 ring 项里,反向删除时 prepend,正向 append,这样 yank 一次能恢复整段。三是 paste marker:大段粘贴不直接塞进文本,而是替换成 [paste #N +M lines] 占位,展开时再调 getExpandedText,避免几千行粘贴把渲染拖死。

关键文件

handleInput 进来先检测 bracketed paste 起止 marker,粘贴期间累积到 pasteBuffer 直到收到 \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;
    // ...
  }
}

undo coalescing 把连续 word 字符合并成一个 undo 单元,空格单独成单元:

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 从 kill-ring 取栈顶插入,yank-pop 必须紧跟 yank,先删上次 yank 的文本再 rotate 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";
}

数据流

raw stdin → Editor.handleInput → 状态变更 → onChange → 外部 requestRender:

边界与失败

  • tmux extended-keys-format=csi-u 把控制字节重编码:handlePaste 用正则把 \x1b[<cp>;5u 还原成原始字节,避免 newline 被当成 ESC + [106;5u 漏进编辑器。见 packages/tui/src/components/editor.ts:1091-1101
  • 大粘贴走 marker:超阈值文本替换成 [paste #N +M lines],真正内容存在 pasteRegistry,getExpandedText 展开。渲染只画 marker,不会卡。
  • undo 跨 paste 原子:handlePaste 入口 pushUndoSnapshot 一次,粘贴后单次 undo 回到粘贴前状态。
  • jump mode 拦截下一键:进入 jump 后下一个 printable 字符不插入文本,而是触发 jumpToChar,ctrl 字符取消 jump。见 packages/tui/src/components/editor.ts:538-556
  • submit 语义可配置:shouldSubmitOnBackslashEnter 根据 keybindings 里 tui.input.submitenter 还是 shift+enter 决定 \n 是否提交。见 packages/tui/src/components/editor.ts:1177-1188

小结

Editor 是个五脏俱全的终端编辑器:输入分发、IME、kill-ring、undo、autocomplete 都在一个类里。它依赖 TUI 的差分渲染和 CURSOR_MARKER 机制定位硬件光标,见 TUI 类:差分渲染的调度中枢;键盘字节流怎么解析成 key id 见 键盘解析:kitty 协议;autocomplete 下拉用 SelectList,见 组件库