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

14 KiB
Raw Permalink Blame History

多行粘贴误提交解决方案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:792enter 触发 submitValue()

2. 根因

bracketed paste 是由程序主动启用的,流程是:

app 输出 \x1b[?2004h
→ terminal 把粘贴内容包成 \x1b[200~ ... \x1b[201~
→ app 识别标记,整段作为 paste 处理

如果中间链路正常:

iTerm2 → tmux → pi-tui

粘贴内容会被作为一次 paste不会逐行提交。

如果 \x1b[200~ / \x1b[201~ 没有到达程序,程序只能看到裸字节流:

line1\rline2\rline3\r

此时:

  • \rentersubmitValue()
  • 于是第一行被直接提交。

需要注意:

  • 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 的 handle_paste() 会把 \r\n / \r 归一化成 \n,避免粘贴里的 CR 触发提交。

5. 推荐方案

采用两层防御:

第一层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 标记时生效,用于识别这种输入:

短时间内连续收到多个普通字符 + Enter

典型触发场景:

line1\rline2\rline3\r

但没有 \x1b[200~ / \x1b[201~ 包裹。

进入 paste-burst 后:

  • 普通字符仍然按现有逻辑正常插入,避免改变当前输入体验。
  • PasteBurst 只记录最近普通字符的时间与数量。
  • 当短时间内累计到足够多的普通字符后,接下来的 Enter / \r 会被当成换行,而不是提交。
  • burst 结束后的短窗口内Enter 仍按换行处理,避免粘贴末尾的 Enter 误提交。

6. PasteBurst 详细设计

6.1 放置位置

建议新增:

packages/pi-tui/src/paste-burst.ts

Editor 使用:

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并根据实测调整

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_MSburst 结束后的一小段时间内Enter 仍按换行处理,避免粘贴末尾的 Enter 误提交。

6.4 状态

Idle
PendingFirstChar
Buffering
EnterSuppressWindow

状态说明:

  • Idle:没有最近输入状态。
  • BurstActive已经连续收到足够多的快速普通字符Enter 应按换行处理。
  • EnterSuppressWindowburst 结束后的短窗口Enter 仍按换行处理。

6.5 核心方法

建议接口:

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) 为 trueaddNewLine(),并 extendWindow(now)
    • 否则走原来的 submit 逻辑。
  4. 如果当前输入是单个可打印字符:
    • 调用 onPlainChar(now) 记录时序。
    • 然后按现有逻辑插入字符。

7. 配置与灰度

建议增加一个开关,避免影响所有用户:

disablePasteBurst?: boolean;

或者使用实验 flag

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 被识别

输入:

a
b
c
<idle>

字符间隔小于 PASTE_BURST_CHAR_INTERVAL_MS 时:

  • 字符仍按现有逻辑插入
  • 不触发 submit
  • 随后的 Enter 在 burst 窗口内按换行处理

8.3 burst 内 Enter 不提交

输入:

abc\rdef\r
<idle>

期望:

  • \r 被当成换行处理
  • 不触发 submit
  • 输入框保留多行文本

8.4 正常手敲不受影响

输入:

a
<等待超过 interval>
b
<等待超过 interval>
Enter

期望:

  • ab 作为正常输入
  • Enter 正常 submit

8.5 Enter suppression window

输入:

abcdefgh
Enter

如果 Enter 距离上次 burst 活动小于 PASTE_ENTER_SUPPRESS_WINDOW_MS

  • 先按换行处理

超过窗口后:

  • 恢复 submit 行为

8.6 非 ASCII / IME

输入中文:

测
试
文
本

快速连续到达时:

  • 中文字符仍按现有逻辑立即插入
  • 快速连续输入后的 Enter 应按换行处理

8.7 disable 开关

disablePasteBurst = true

  • 所有输入按正常 typing 处理
  • 不做 paste-burst Enter 抑制

9. 手动验证

9.1 bracketed paste 正常

在 tmux 里运行:

printf '\033[?2004h'; cat -A; printf '\033[?2004l'

粘贴多行文本,应看到:

^[[200~ ...
...
^[[201~

9.2 CRLF 文件粘贴

使用:

paste-test-crlf.txt

粘贴到 Kimi Code 输入框:

  • 修复前:如果 bracketed paste 生效,也不会复现。
  • 修复后:无论 bracketed paste 是否生效,都应尽量作为整段粘贴处理。

9.3 send-keys 强制模拟裸 Enter

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 快速手敲被误判成粘贴

风险:

用户快速敲一个短命令 + 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

set -g extended-keys on

然后重启 tmux

tmux kill-server
tmux new -s test

可选,不是 bracketed paste 必需:

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

标记到了 → 一次粘贴 → 不逐行提交

但不能保证所有终端、tmux、SSH、Windows 环境都可靠传递标记。

因此完整方案是:

bracketed paste 作为主路径;
paste-burst 启发式作为失效兜底。

这和 Codex 的方向一致。它不能从协议层面“根本消除”标记不到的情况,但能把实际误提交概率降到足够低,并且可测试、可关闭、可灰度。