kimi-code/plan/bracketed-paste-paste-burst-solution.md
liruifengv 9091627257
fix(tui): avoid submitting rapid multi-line paste bursts (#1305)
* fix(tui): avoid submitting rapid multi-line paste bursts

* chore(tui): remove paste burst settings picker

* fix(tui): reset paste burst on DEL backspace
2026-07-03 14:21:02 +08:00

530 lines
No EOL
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 多行粘贴误提交解决方案Bracketed Paste + Paste-Burst Fallback
## 1. 背景
在 iTerm2 + tmux 环境下,向 Kimi Code CLI 输入框粘贴包含换行的文本时,预期行为是整段文本进入输入框;但在某些环境下,回车前的语句会被直接提交执行。
实测结论:
- 当 bracketed paste 标记 `\x1b[200~` / `\x1b[201~` 正常到达时pi-tui 能正确识别成一次粘贴,不会逐行提交。
- 当 bracketed paste 标记没有到达时,粘贴内容里的 `\r` 会被当成裸 Enter触发提交。
- 单独的 `\n` 在 pi-tui 的 `Editor` 里会被当成换行插入;单独的 `\r` 才是主要提交来源。
关键代码位置:
- `packages/pi-tui/src/terminal.ts:147`:启动时写入 `\x1b[?2004h`,启用 bracketed paste。
- `packages/pi-tui/src/terminal.ts:412`:退出时写入 `\x1b[?2004l`,关闭 bracketed paste。
- `packages/pi-tui/src/stdin-buffer.ts:315-369`:识别 `\x1b[200~` / `\x1b[201~`,组装 bracketed paste。
- `packages/pi-tui/src/keys.ts:921`:裸 `\r` 被映射成 `enter`
- `packages/pi-tui/src/components/editor.ts:780`:单独的 `\n` 被当成换行。
- `packages/pi-tui/src/components/editor.ts:792``enter` 触发 `submitValue()`
## 2. 根因
bracketed paste 是由程序主动启用的,流程是:
```text
app 输出 \x1b[?2004h
→ terminal 把粘贴内容包成 \x1b[200~ ... \x1b[201~
→ app 识别标记,整段作为 paste 处理
```
如果中间链路正常:
```text
iTerm2 → tmux → pi-tui
```
粘贴内容会被作为一次 paste不会逐行提交。
如果 `\x1b[200~` / `\x1b[201~` 没有到达程序,程序只能看到裸字节流:
```text
line1\rline2\rline3\r
```
此时:
- `\r``enter``submitValue()`
- 于是第一行被直接提交。
需要注意:
- iTerm2 的 “Applications in terminal may access clipboard” 是 OSC 52 剪贴板访问,不是 bracketed paste 开关。
- tmux 的 `set-clipboard` / `allow-passthrough` 主要服务于 OSC 52 / escape passthrough也不是 bracketed paste 开关。
- 真正决定 bracketed paste 是否生效的是:终端是否在 app 启用 `\x1b[?2004h` 后,把 `\x1b[200~` / `\x1b[201~` 透传给 app。
## 3. 目标
解决方案需要满足:
1. bracketed paste 正常时,多行粘贴作为整体插入,不逐行提交。
2. bracketed paste 失效时,多行粘贴尽量不逐行提交。
3. 不影响正常手敲输入和正常 Enter 提交。
4. 可测试、可关闭、可灰度。
5. 不依赖用户手动修改 tmux 配置作为唯一修复手段。
## 4. 现有实现对比
### 4.1 kimi-code pi-tui
已有:
- 启用 `\x1b[?2004h`
- `StdinBuffer` 识别 `\x1b[200~` / `\x1b[201~`
- `Editor.handlePaste()` 处理粘贴内容
缺少:
- bracketed paste 标记完全没到时的启发式兜底。
### 4.2 oh-my-pi
oh-my-pi 是 pi-tui 的演进 fork核心机制相同但健壮性更强
- 同样启用 `\x1b[?2004h`
- `StdinBuffer` 组装 bracketed paste性能更好
- 有 paste watchdog防止结束标记丢失后卡死
- 有 paste byte limit限制内存
- `bracketed-paste.ts` 解码 tmux extended-keys 重编码的控制字节
但它仍然依赖 bracketed paste 标记到达,**没有解决“标记完全没到”的情况**。
### 4.3 Codex
Codex 的处理最值得参考。它是两层方案:
1. 正常路径:使用 crossterm 的 bracketed paste。
2. 兜底路径:在没有 bracketed paste 的终端上,用 `PasteBurst` 启发式识别“快速连续输入 + Enter”的粘贴突发。
参考:
- [`codex-rs/tui/Cargo.toml`](https://github.com/openai/codex/blob/main/codex-rs/tui/Cargo.toml)
- [`codex-rs/tui/src/tui.rs`](https://github.com/openai/codex/blob/main/codex-rs/tui/src/tui.rs)
- [`codex-rs/tui/src/tui/event_stream.rs`](https://github.com/openai/codex/blob/main/codex-rs/tui/src/tui/event_stream.rs)
- [`codex-rs/tui/src/bottom_pane/paste_burst.rs`](https://github.com/openai/codex/blob/main/codex-rs/tui/src/bottom_pane/paste_burst.rs)
- [`codex-rs/tui/src/bottom_pane/chat_composer.rs`](https://github.com/openai/codex/blob/main/codex-rs/tui/src/bottom_pane/chat_composer.rs)
Codex 的 `handle_paste()` 会把 `\r\n` / `\r` 归一化成 `\n`,避免粘贴里的 CR 触发提交。
## 5. 推荐方案
采用两层防御:
```text
第一层bracketed paste 正常路径
第二层paste-burst 启发式兜底
```
### 5.1 第一层:保持并增强 bracketed paste
保留现有 bracketed paste 流程,并可从 oh-my-pi backport 以下健壮性改进:
1. tmux extended-keys 重编码解码
处理 tmux 在 bracketed paste 内把控制字节重编码成:
- `\x1b[106;5u`
- `\x1b[27;5;106~`
2. paste watchdog
开始标记到了但结束标记丢失时,不能永远卡在 paste mode。
3. paste byte limit
防止异常输入无限增长内存。
4. O(n) paste 组装
避免大粘贴时字符串重复拼接导致卡顿。
这些改进能增强“标记到了之后”的稳定性,但不能解决“标记没到”的问题。
### 5.2 第二层:新增 paste-burst 启发式兜底
新增一个 `PasteBurst` 状态机,只在**没有 bracketed paste 标记**时生效,用于识别这种输入:
```text
短时间内连续收到多个普通字符 + Enter
```
典型触发场景:
```text
line1\rline2\rline3\r
```
但没有 `\x1b[200~` / `\x1b[201~` 包裹。
进入 paste-burst 后:
- 普通字符仍然按现有逻辑正常插入,避免改变当前输入体验。
- `PasteBurst` 只记录最近普通字符的时间与数量。
- 当短时间内累计到足够多的普通字符后,接下来的 Enter / `\r` 会被当成换行,而不是提交。
- burst 结束后的短窗口内Enter 仍按换行处理,避免粘贴末尾的 Enter 误提交。
## 6. PasteBurst 详细设计
### 6.1 放置位置
建议新增:
```text
packages/pi-tui/src/paste-burst.ts
```
`Editor` 使用:
```text
packages/pi-tui/src/components/editor.ts
```
理由:
-`\r` / `\n` 是在 `Editor.handleInput()` 里被解释成 submit / newline 的。
- `StdinBuffer` 只负责序列切分,不应引入输入框语义。
- `PasteBurst` 是纯状态机,便于单独测试。
### 6.2 输入分类
`PasteBurst` 只处理这些输入:
1. 单个可打印字符
包括 ASCII、中文、Emoji 等。
2. Enter
主要是裸 `\r`
3. 非字符输入
例如方向键、Ctrl/Alt 组合键、popup 导航键等。遇到这些输入时,应调用 `reset()` 清空 burst 状态,再按正常逻辑处理。
不要把带修饰键的输入喂给 `PasteBurst`,避免把快捷键误判成粘贴。
### 6.3 推荐参数
初始值建议参考 Codex并根据实测调整
```ts
const PASTE_BURST_MIN_CHARS = 8;
const PASTE_BURST_CHAR_INTERVAL_MS = 8;
const PASTE_BURST_ACTIVE_IDLE_TIMEOUT_MS = 30;
const PASTE_ENTER_SUPPRESS_WINDOW_MS = 120;
```
含义:
- `PASTE_BURST_MIN_CHARS`:连续多少个普通字符后才可能是粘贴。
- `PASTE_BURST_CHAR_INTERVAL_MS`:两个字符间隔小于该值才认为属于同一突发。
- `PASTE_BURST_ACTIVE_IDLE_TIMEOUT_MS`:进入 burst 后Enter 在这个窗口内仍按换行处理。
- `PASTE_ENTER_SUPPRESS_WINDOW_MS`burst 结束后的一小段时间内Enter 仍按换行处理,避免粘贴末尾的 Enter 误提交。
### 6.4 状态
```text
Idle
PendingFirstChar
Buffering
EnterSuppressWindow
```
状态说明:
- `Idle`:没有最近输入状态。
- `BurstActive`已经连续收到足够多的快速普通字符Enter 应按换行处理。
- `EnterSuppressWindow`burst 结束后的短窗口Enter 仍按换行处理。
### 6.5 核心方法
建议接口:
```ts
class PasteBurst {
onPlainChar(now: number): void;
shouldInsertNewlineInsteadOfSubmit(now: number): boolean;
extendWindow(now: number): void;
reset(): void;
}
```
### 6.6 ASCII / 非 ASCII 差异
为了尽量降低对正常手敲的影响,当前实现不 hold 任何字符:
- ASCII 和非 ASCII 字符都立即插入。
- `PasteBurst` 只根据输入时序判断接下来的 Enter 是否应该被当成换行。
- 这样不会引入“先显示一个字符,再突然变成 paste”的闪烁也不会影响 IME 输入。
### 6.7 与 `Editor.handleInput()` 集成
处理顺序建议:
1. 如果当前输入是 bracketed paste 标记:
- 走现有 `handlePaste()`
- 调用 `pasteBurst.reset()`,避免显式 paste 和 burst 状态互相污染。
2. 如果当前输入不是普通字符、也不是 Enter
- 调用 `pasteBurst.reset()`,避免状态泄漏到后续输入。
3. 如果当前输入是裸 Enter
- 先保留现有 backslash workaround。
- 如果 `shouldInsertNewlineInsteadOfSubmit(now)` 为 true`addNewLine()`,并 `extendWindow(now)`
- 否则走原来的 submit 逻辑。
4. 如果当前输入是单个可打印字符:
- 调用 `onPlainChar(now)` 记录时序。
- 然后按现有逻辑插入字符。
## 7. 配置与灰度
建议增加一个开关,避免影响所有用户:
```ts
disablePasteBurst?: boolean;
```
或者使用实验 flag
```text
KIMI_CODE_EXPERIMENTAL_PASTE_BURST
```
推荐发布顺序:
1. 默认关闭,先在测试和内部使用。
2. 手动验证 iTerm2 + tmux、Windows Terminal、普通 macOS Terminal。
3. 观察是否有正常手敲被误判成 paste。
4. 默认开启。
5. 保留关闭开关作为 escape hatch。
## 8. 测试计划
建议把测试加到现有 `Editor` 测试文件里,避免新增过多测试文件。
### 8.1 bracketed paste 路径不变
- 输入 `\x1b[200~a\r\nb\x1b[201~`
- 应作为一次 paste 插入
- 不触发 submit
### 8.2 非 bracketed paste burst 被识别
输入:
```text
a
b
c
<idle>
```
字符间隔小于 `PASTE_BURST_CHAR_INTERVAL_MS` 时:
- 字符仍按现有逻辑插入
- 不触发 submit
- 随后的 Enter 在 burst 窗口内按换行处理
### 8.3 burst 内 Enter 不提交
输入:
```text
abc\rdef\r
<idle>
```
期望:
- `\r` 被当成换行处理
- 不触发 submit
- 输入框保留多行文本
### 8.4 正常手敲不受影响
输入:
```text
a
<等待超过 interval>
b
<等待超过 interval>
Enter
```
期望:
- `a``b` 作为正常输入
- Enter 正常 submit
### 8.5 Enter suppression window
输入:
```text
abcdefgh
Enter
```
如果 Enter 距离上次 burst 活动小于 `PASTE_ENTER_SUPPRESS_WINDOW_MS`
- 先按换行处理
超过窗口后:
- 恢复 submit 行为
### 8.6 非 ASCII / IME
输入中文:
```text
```
快速连续到达时:
- 中文字符仍按现有逻辑立即插入
- 快速连续输入后的 Enter 应按换行处理
### 8.7 disable 开关
`disablePasteBurst = true`
- 所有输入按正常 typing 处理
- 不做 paste-burst Enter 抑制
## 9. 手动验证
### 9.1 bracketed paste 正常
在 tmux 里运行:
```sh
printf '\033[?2004h'; cat -A; printf '\033[?2004l'
```
粘贴多行文本,应看到:
```text
^[[200~ ...
...
^[[201~
```
### 9.2 CRLF 文件粘贴
使用:
```text
paste-test-crlf.txt
```
粘贴到 Kimi Code 输入框:
- 修复前:如果 bracketed paste 生效,也不会复现。
- 修复后:无论 bracketed paste 是否生效,都应尽量作为整段粘贴处理。
### 9.3 send-keys 强制模拟裸 Enter
```sh
tmux send-keys -t <target> -l '复现第一行'
tmux send-keys -t <target> Enter
tmux send-keys -t <target> -l '复现第二行'
tmux send-keys -t <target> Enter
```
修复前:裸 Enter 会提交。
修复后:如果输入足够快,应被 paste-burst 合并为一次粘贴,至少不应逐行误提交。
注意:`send-keys` 的时序可能受 tmux 和系统调度影响,不能完全等同于真实粘贴,只能作为近似验证。
## 10. 风险与缓解
### 10.1 快速手敲被误判成粘贴
风险:
```text
用户快速敲一个短命令 + Enter
```
可能被误判成 paste。
缓解:
- 只处理无修饰键的普通字符。
- 要求至少 `PASTE_BURST_MIN_CHARS` 个连续快速字符。
- 提供 disable 开关。
- 初始默认关闭,先灰度。
### 10.2 短 Enter suppression 影响正常提交
`PASTE_ENTER_SUPPRESS_WINDOW_MS` 可能让用户在快速输入后按 Enter 时被当成换行。
缓解:
- 窗口保持较短,例如 80-120ms。
- 只在前一个输入被判定为 burst 后开启。
- 正常慢速输入不开启。
### 10.3 慢速粘贴无法识别
如果终端把粘贴拆成很慢的 chunk间隔超过 `PASTE_BURST_CHAR_INTERVAL_MS`,启发式可能失效。
缓解:
- 可适当提高 interval。
- 但不能无限提高,否则会误伤正常输入。
- bracketed paste 仍是主路径,启发式只是兜底。
## 11. 环境侧建议
应用层启发式是兜底,环境侧仍建议保持健康配置。
`~/.tmux.conf`
```conf
set -g extended-keys on
```
然后重启 tmux
```sh
tmux kill-server
tmux new -s test
```
可选,不是 bracketed paste 必需:
```conf
set -g set-clipboard on
set -g allow-passthrough on
```
这两行主要用于 OSC 52 / escape passthrough不应被当作 bracketed paste 修复手段。
## 12. 推荐落地步骤
1. 新增 `PasteBurst` 状态机,先写单元测试。
2.`Editor.handleInput()` 接入,默认关闭。
3. 确保 bracketed paste 显式路径不变。
4. 增加 disable 开关或实验 flag。
5. 跑现有测试,确认无回归。
6. 手动验证 iTerm2 + tmux、`send-keys`、CRLF 文件粘贴。
7. 内部试用一段时间。
8. 默认开启,保留关闭开关。
## 13. 结论
根本机制是 bracketed paste
```text
标记到了 → 一次粘贴 → 不逐行提交
```
但不能保证所有终端、tmux、SSH、Windows 环境都可靠传递标记。
因此完整方案是:
```text
bracketed paste 作为主路径;
paste-burst 启发式作为失效兜底。
```
这和 Codex 的方向一致。它不能从协议层面“根本消除”标记不到的情况,但能把实际误提交概率降到足够低,并且可测试、可关闭、可灰度。