# Peri TUI 输入框踩坑记，键盘操作那么难？？？

> **[Peri Code](https://github.com/konghayao/peri)** — 用 Rust 写的开源 Coding Agent，兼容 Claude Code 生态。<https://github.com/KonghaYao/peri>

Peri 的 TUI 里有一个输入框，用户在里面打字、粘贴代码、发消息给 Agent。第一版上线时，CJK 字符点击后光标位置偏移、粘贴内容静默丢失、macOS 上某个快捷键无响应。这些 bug 看起来零散，根源是同一件事——终端的输入模型和浏览器完全不同，我们一开始没当回事。

浏览器的 `<textarea>` 封装了键盘规范化、字符宽度计算、光标定位、粘贴事件。终端里这些全是空白，要么自己实现，要么踩坑。

## 浏览器替你做掉的那些事

`<textarea>` 收到一次 `keydown` 事件，里面带了标准化的 `key`、`code`、`charCode`，浏览器把平台差异、输入法状态、控制字符全处理好了。光标移动、选区更新、字符插入全在 DOM 里，鼠标点击之后浏览器直接告诉你光标落在第几个字符，粘贴内容通过 `paste` 事件统一交付——终端里这三件事全部缺席。

## 键盘事件是原始字节序列

终端键盘协议可以追溯到 1978 年 DEC 公司发布的 VT100——当时键盘信号通过串行线传输，用转义序列表示特殊键。几十年过去，这套协议的基本编码方式没有本质改变，现代终端模拟器仍然兼容它。

Peri 用 crossterm 接收键盘事件。crossterm 把原始字节序列解析成 `KeyCode` 枚举，但解析规则取决于终端模式——Kitty Protocol 开启时能拿到 key_code + modifiers 的完整组合，旧终端只有 `\x1b[A` 这样的转义序列。两个终端收到同一个按键，crossterm 解析出来的结构可能不一样。

我们没有独立实现这层解析，直接依赖 crossterm。它处理了绝大多数差异，但 crossterm 处理不了的部分——比如某些终端把 Alt+字母 序列化成 `\x1b` + 字母，恰好和功能键前缀冲突——我们不得不在快捷键设计上绕开，而不是强行解析。

## 字符混排与排版系统设计

输入区里同时存在 ASCII、CJK、emoji，三种字符的存储单位和显示宽度完全不同。

Rust 字符串按字节存储，`"你好"` 是 6 字节，`.chars().count()` 才是 2。光标位置在内部用字符索引表示——「在第 2 个字符之后」，不是「在第 6 个字节之后」。早期有一处直接用字节索引做截断，对 ASCII 没问题，对 CJK 直接 panic，因为索引落在了多字节字符的中间字节上。正确的做法是用字符级操作——`chars().take(n).collect()` 安全截断，或者用 `char_indices().nth(n)` 拿到字符边界对应的字节偏移。

终端显示按列计算，不按字符——ASCII 占 1 列，CJK 占 2 列，大多数 emoji 占 2 列。`"你好world"` 是 7 个字符，显示宽度是 9 列。字符索引和显示列数在 CJK 混排下立刻分叉，不能混用。

Peri 的排版系统分三层处理这个问题。存储层用字符索引，所有光标位置、选区范围都是字符计数。渲染层用 `unicode-width` 把字符索引转成列宽再绘制——前 n 个字符的显示列数是 `chars().take(n)` 逐字符查 `width()` 再累加起来。行折叠边界单独计算——一行最多显示 N 列，折叠点必须落在字符边界上，不能在 CJK 字符的第一列和第二列之间断开，所以要逐字符加宽度，一旦累加值超过行宽上限就在当前字符前折行。三层之间每次跳转都是一次换算，不存在「直接用就行」的情况。

## 鼠标点击换算不能偷懒

鼠标点击事件给的是终端列坐标——屏幕上第几列。用户点了一个 CJK 字符，终端报告的 x 是它的显示起始列。

光标要落到字符索引，不是显示列。两者不对应，必须从字符串头部逐字符累加宽度——对当前行每个字符查 `unicode-width`，宽度逐个累加，当累加值超过鼠标点击的列号，就停在前一个字符的索引上。直接把列坐标当字符索引用，纯英文测不出来，中文一混排立刻偏。

第一版鼠标点击 CJK 字符时光标偏右 1 到 N 个位置，取决于点击位置前有多少 CJK 字符。排查也不直观——纯英文测试完全正常，只有输入中文再用鼠标点才复现。

## Paste 不走键盘事件链

浏览器里粘贴是 `paste` 事件，内容在 `clipboardData.getData('text')` 里。

crossterm 里粘贴是独立的 `Event::Paste(String)`，和 `Event::Key` 完全平级，走不同的分发路径。如果事件处理器只覆盖 `Event::Key`，粘贴内容静默消失——没有报错，没有日志，用户盯着空白输入框不知道发生了什么。

Peri 早期版本确实漏掉了这个分支，粘贴功能上线后发现完全不工作。修复只需要加一个 match arm，但找到原因花了一些时间——因为粘贴没有触发任何错误路径，看起来像功能从未存在过。

## 快捷键的设计边界

浏览器里 Ctrl+A、Shift+End、Alt+字母 大多可以自由绑定，因为浏览器已经规范化了修饰键组合。

终端里有几类组合键不能用。

**Shift+字母** 在文本编辑状态下等同于大写字母——`Shift+S` 就是 `S`，没有办法区分修饰键还是用户输入大写字母。

**Alt+字母** 在 macOS 上是系统级的特殊字符输入——`Alt+E` 打出 `´`，`Alt+S` 打出 `ß`，`Alt+G` 打出 `©`。这些字符直接作为普通文本字符到达应用层，crossterm 拿到的是字符本身，而不是带 Alt 修饰的 `KeyEvent`。应用层如果想检测「用户按了 Alt+字母」，只能反向通过字符值来判断——这条路不可靠，不同键盘布局映射不同，不同 macOS 版本也可能变。**Alt+Enter** 在部分终端里同样被截获，无法到达应用层。我们最初设计了 Alt+Enter 发送消息，macOS 用户反映完全无响应——不是 bug，是终端吃掉了这个事件。

**PageUp/PageDown** 在部分终端模拟器里硬绑定到终端自身的滚动缓冲区，不发给运行其中的应用程序。

Peri 的快捷键规则落定为——全局操作用 `Ctrl+字母`，面板内导航用方向键、Space、Enter、Esc，完全回避 Shift+字母 和 Alt 修饰键。多行输入用 `Ctrl+J`，不用 Alt+Enter。不用 PageUp/PageDown 做滚动，统一用 `Ctrl+U`/`Ctrl+D`。

## 终端抽象没有天花板，只有地板

浏览器的 textarea 把输入模型的上限定高了——键盘事件标准化，粘贴内容可靠，光标坐标一致。终端只给你原始事件流，地板在那里，其余全靠自己建。

知道这件事之后，每一个「明明很简单」的输入功能背后，都能看到它真正的工程量。Peri 的输入区现在稳定支持 CJK 混排、鼠标点击定位、安全粘贴、跨平台快捷键，这些功能合在一起写起来并不多，踩坑的过程让人印象深刻。

项目地址：[github.com/konghayao/peri](https://github.com/konghayao/peri)
