青雲的博客
深入浅出 Pi 第八部:终端之上,工程边界之下 第 44 章

键盘输入怎样穿过终端协议到达编辑器

从 ProcessTerminal 的 raw stdin、Kitty 协议协商与 StdinBuffer 分包,追到 TUI 焦点路由、KeybindingsManager 和 Editor 状态修改,解释一个按键为何不能直接等同于一个编辑动作。

源码版本
v0.83.0
验证日期
Commit
845d6ff1f6643aba440341cce877ce1c43ebbc39

第 43 章确认了焦点只属于一个组件。现在把一次实际输入放进这张图:用户在终端里按下 Shift+Enter,Node 收到的可能是一个 CSI-u 序列,可能是传统 escape sequence,也可能只收到普通回车,再由 macOS 原生 modifier helper 补出 Shift 状态。通过 SSH 时,一个序列还可能被拆成几次 data 事件;快速输入时,press 与 release 又可能黏在同一个 chunk 里。

所以 Pi 不能从 process.stdin.on("data") 直接跳到“插入换行”。源码用了四层翻译:ProcessTerminal 管终端模式和协议,StdinBuffer 恢复事件边界,TUI 做全局过滤和焦点路由,编辑器才把输入解释成可配置动作。每一层都丢掉一部分下游不该知道的细节。

ProcessTerminal 先改变会话规则

ProcessTerminal.start() 保存原 raw 状态,把 stdin 切为 raw mode、UTF-8、resume,向 stdout 写 bracketed paste enable sequence,并监听 resize。Windows 额外尝试开启 ENABLE_VIRTUAL_TERMINAL_INPUT,Unix 则主动触发一次 SIGWINCH 刷新尺寸。接下来它查询 Kitty keyboard protocol;终端不支持时,收到 DA sentinel 后退到 modifyOtherKeys。

Kitty 协商请求三项增强:区分模糊 escape code、上报 press/repeat/release、携带 shifted 与 base-layout key。Pi 不假设 write 后马上得到完整回复。它先建立 StdinBuffer,协议回复与普通输入都经过同一个 sequence framing;识别出的协商消息被消费,其他 sequence 才进入 forwardInputSequence()。Apple Terminal 的回车则在转发前结合原生 Shift modifier 做一次兼容映射。

关闭也属于协议的一部分。stop() 关闭 bracketed paste、撤销 Kitty 或 modifyOtherKeys、销毁 buffer、移除 input/resize listener、pause stdin,再还原原 raw mode。drainInput() 则先关协议,短暂吞掉晚到的 key-release,避免慢速 SSH 上的 release 字节落回父 shell。只讨论启动、不讨论恢复,终端状态就可能在 Pi 退出后继续污染用户环境。

StdinBuffer 解决的是 framing,不是 keybinding

stdin 的 chunk 边界没有语义。一个 \x1b[<35;20;5m 鼠标序列可以被拆成 \x1b[<35;20;5m;反过来,多个普通字符与几条 Kitty event 也可以一次到达。StdinBuffer 识别 CSI、OSC、DCS、APC、SS3 和 meta sequence,把不完整后缀留在 buffer,最多等待默认 10ms,完整后按 sequence 发出 data

bracketed paste 走另一条分支。看到 start marker 后,buffer 一直积累到 end marker,整体通过 paste event 发出,再递归处理同一 chunk 中剩余的按键。它还记住一次未修饰 Kitty printable codepoint,若终端紧接着又发出相同 raw character,就丢掉重复字符。这里解决的是“一个物理动作被编码成几份字节”,还没有决定这些字节对应撤销、补全还是提交。

flowchart LR
  accTitle: 一个键盘动作到编辑器状态的四层转换
  accDescr: ProcessTerminal 管 raw mode 和键盘协议,StdinBuffer 重建控制序列边界,TUI 消费终端回复并路由到焦点组件,Editor 通过 keybinding 修改文本或提交。
  TERM["terminal emulator"] --> RAW["ProcessTerminal<br/>raw + protocol negotiation"]
  RAW --> FRAME["StdinBuffer<br/>complete sequences / paste"]
  FRAME --> ROUTE["TUI.handleInput<br/>listeners + focus + release filter"]
  ROUTE --> EDIT["Editor.handleInput"]
  EDIT --> ACTION["move / edit / autocomplete / submit"]

TUI 还会拦下不属于编辑器的消息

终端输入通道同时承载 OSC 颜色报告、cell-size response 和普通键盘数据。TUI.handleInput() 先尝试消费背景色、颜色方案与 cell-size reply,再让全局 input listeners 选择 consume 或改写 data,随后处理 debug shortcut。直到这些步骤都未截获,TUI 才检查 overlay 可见性与焦点恢复,并调用 focusedComponent.handleInput()

Kitty release event 默认在这里被过滤。组件必须显式设置 wantsKeyRelease 才能收到 release;普通 editor 只看到 press 和 repeat。这个默认值很重要,否则按一次 a 可能插入两次。调用 focused handler 后,TUI 无条件请求一次 render,因此编辑器无需自己理解差分绘制。

这里也划出了 extension shortcut 的位置。CustomEditor 可以在应用层先匹配 Pi 自己的 action handlers 与 extension shortcuts,基础 Editor 再处理通用编辑键。TUI 不知道 Ctrl+C 是复制、清空、取消 selector 还是终止运行;它只保证输入交给此刻有权解释它的组件。

matchesKey 把编码差异压成动作名

keys.ts 同时理解传统序列、Kitty CSI-u 和 modifyOtherKeys。Kitty parser 从 codepoint、modifier bitmask 与 event type 还原 key identity,忽略 Caps Lock/Num Lock bit,并考虑 shifted key 与 base-layout key;legacy 路径则保留各终端长期形成的 escape mappings。matchesKey(data, "ctrl+w") 隐藏了这些编码差异,但不会把动作写死。

KeybindingsManager 持有 action id 到一个或多个 KeyId 的映射。默认值把 Enter 绑定到 submit,Shift+Enter/Ctrl+J 绑定到 newline,方向键和 Emacs 风格组合键共同指向 cursor actions;用户配置可以替换某个 action 的全部 keys,manager 还会报告用户绑定冲突。Editor 因而匹配的是 tui.input.submit,不是在业务代码里到处比较 "\r"

Editor 修改的是一组相关状态

Editor 的核心文本状态是 lines + cursorLine + cursorCol,旁边还有 autocomplete request、paste registry、history draft、kill ring、jump mode、preferred visual column 和 undo stack。光标列是 JavaScript string index,但移动与删除按 Intl.Segmenter 得到的 grapheme 操作,emoji、组合字符和 CJK 换行不会简单按 UTF-16 code unit 拆开。

handleInput() 的分支顺序就是冲突优先级:先处理 jump mode 与 bracketed paste,再放过 Ctrl+C 给上层,处理 undo 和 autocomplete,之后是删除、kill ring、移动、newline、submit、history,最后才把 printable character 插入。Enter 只有在 submit 未禁用、没有被 autocomplete confirm 或反斜杠换行规则截获时,才调用 submitValue()

大段 paste 还有一层容易漏掉的状态变换。超过 10 行或 1000 字符时,Editor 把原文保存在 pastes map,只在可见文本里放 [paste #N ...] marker;光标移动和删除把 marker 当作原子 segment。真正 submit 时,expandPasteMarkers() 先恢复原文,再 trim、清空 editor/paste/undo 状态并调用 onSubmit(result)。因此屏幕上的 marker 不是发给模型的 prompt。

用拆包和 paste 测试复现真实边界

release source 完成 offline build 后,可以只跑输入链的三组测试:

repo="${PI_RELEASE_SOURCE_DIR:-/path/to/pi-0.83.0}"
cd "$repo"

node --test --test-reporter=spec \
  packages/tui/test/stdin-buffer.test.ts \
  packages/tui/test/input.test.ts \
  packages/tui/test/editor.test.ts

stdin-buffer.test.ts 会把一条 CSI 分三段送入并确认只发出一个完整 sequence,也覆盖 batched Kitty press/release 和 bracketed paste。editor.test.ts 直接给 Editor 喂传统方向键、CSI-u、paste markers 与 Unicode,检查 text、cursor、history 和 submit callback。两类测试故意分开:前者证明 framing,后者证明动作语义;任意一层通过都不能替另一层背书。

至此,输入已经从不可靠 chunk 变成了确定的 editor state。每插入一个字符、每收到一个 autocomplete result,TUI 都可能被请求渲染;如果每次都清屏重画,流式输出和 spinner 会持续闪烁。第 45 章转向相反方向,拆开 previousLines、viewport 与 synchronized output 怎样把大多数更新限制在几行之内。