编辑器组件:输入、IME、kill-ring、undo
Editor 是 @mariozechner/pi-tui 里最重的组件——单文件 2292 行,承担多行文本编辑的全部脏活:键盘输入分发、IME 候选窗口定位、bracketed paste 处理、Emacs 风格 kill/yank、fish 风格 undo coalescing、slash 命令与符号触发 autocomplete。InteractiveMode 用它当主输入框,扩展也能通过 EditorComponent 接口替换实现(vim/emacs 模式)。
职责
Editor 做四件事:
- 输入分发:
handleInput(data)是 raw 终端字节流入口,按顺序处理 jump mode、bracketed paste、undo、autocomplete 导航、普通字符。见packages/tui/src/components/editor.ts:534-630。 - 多行编辑:
addNewLine在光标处切行,insertTextAtCursorInternal处理粘贴/补全插入的多行文本。见packages/tui/src/components/editor.ts:1152-1175与packages/tui/src/components/editor.ts:980-1023。 - kill-ring / undo:Emacs kill/yank + fish 风格 undo coalescing,kill 连续删除可累积,yank 后
yank-pop循环 ring。见packages/tui/src/components/editor.ts:1817-1896与packages/tui/src/kill-ring.ts:1-50。 - 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,避免几千行粘贴把渲染拖死。
关键文件
packages/tui/src/components/editor.ts:217-288—class Editor字段:state(lines/cursorLine/cursorCol)、killRing、undoStack、pasteBuffer、autocompleteState。packages/tui/src/components/editor.ts:409-470—render(width)主体:word-wrap 布局、光标渲染、padding/边框。packages/tui/src/components/editor.ts:534-595—handleInput入口:paste 检测、undo、autocomplete 导航。packages/tui/src/components/editor.ts:1084-1122—handlePaste:CSI-u 控制字节解码、过滤非 printable、路径前自动补空格。packages/tui/src/components/editor.ts:1935-1949—pushUndoSnapshot/undo:structuredClone状态快照。packages/tui/src/kill-ring.ts:1-50—KillRing环形缓冲,push支持 prepend/accumulate。packages/tui/src/undo-stack.ts:1-35—UndoStack<S>通用栈,clone-on-push。packages/tui/src/editor-component.ts:11-74—EditorComponent接口,扩展可替换实现。
handleInput 进来先检测 bracketed paste 起止 marker,粘贴期间累积到 pasteBuffer 直到收到 \x1b[201~:
// 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 单元,空格单独成单元:
// 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:
// 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.submit是enter还是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,见 组件库。