Skip to content

fix(worker): 修 iOS 三方输入法在 WebShell 无法输入 / 语音纠错重复 - #666

Merged
deepcoldy merged 3 commits into
masterfrom
fix/ios-third-party-ime-input
Jul 30, 2026
Merged

fix(worker): 修 iOS 三方输入法在 WebShell 无法输入 / 语音纠错重复#666
deepcoldy merged 3 commits into
masterfrom
fix/ios-third-party-ime-input

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

背景 / 问题

WebShell 交互终端页(worker.tsgetTerminalHtml)用原生 xterm.js 5.x(CDN 加载)。iOS 上第三方输入法(豆包微信 等)在 xterm 隐藏 textarea 上产生的事件序列,命中 xterm 两处缺陷,导致:

  • 豆包几乎无法输入微信空格/句尾单字丢失或延迟
  • 豆包语音输入纠错时整段文字重复

自带输入法正常,故此前一直没暴露。

根因(两处,均有 iOS 26.5.2 真机 trace 佐证)

1) keyCode=229 + composed insertText 死路

字符走 keydown(keyCode=229)input(inputType=insertText, composed=true) composition 事件。

  • xterm 的 _inputEvent 守卫 (!ev.composed || !_keyDownSeen):此处 composed=true 且 229 keydown 已置 _keyDownSeen → 恒为假 → 不发送
  • 唯一兜底 CompositionHelper._handleAnyTextareaChangessetTimeout + String.replace(oldValue,'') 算差量,连续快打时算错 → 丢字

豆包每个字/词/标点全走此路(几乎打不上);微信空格与句尾单字走此路(延迟/丢)。

2) 语音纠错重复

一次物理退格,iOS 三方输入法在 textarea连发 N 个 deleteContentBackward(意图删整行),但 xterm 对一个 Backspace keydown 只发 1 个 \x7f → 终端删 1 字、textarea 删 N 字,残留 N-1 字;随后输入法重插整句 → 旧内容没删干净 + 新整句叠加 = 整段重复

修复(仅接管这两条死/损路,其余一律不碰)

  • attachCustomKeyEventHandler 认领:keydown 229(非 composing);以及 Backspace 但仅当 textarea 非空(有 IME 编辑待定)时——空 textarea 是正常终端退格,放行给 xterm 发标准 \x7f避免吞掉 shell 命令行退格这一致命回归。
  • 认领后 return false,xterm 跳过其缺陷兜底 / 单发退格 → 杜绝双发
  • textarea 'input' 监听逐事件转发:insertText → 原样 term.input(data,true);每个 delete* → 一个 \x7f,使 N 次删除 1:1 映射到终端 N 次删除。
  • composition 输入(微信中文本来就可靠)在 _composing 期间完全不接管
  • term.input(data,true) paste:不做括号粘贴包装、不清空 textarea),走既有 onData → 沿用现有 WS 发送与只读门控。
  • 逃生阀:URL 加 ?imefix=0 可临时关闭修复回到原生 xterm 行为,便于对比排查。

影响面

  • 仅改 worker.tsgetTerminalHtml 浏览器端 JS(用户实际使用的交互终端页)。
  • 不碰服务端、其它 CLI 适配器、后端(PtyBackend/TmuxBackend…)、其它终端页(debug-terminalv3-terminal)。
  • 纯浏览器行为改动,跨平台无关(daemon 运行的 Linux 不受影响)。
  • 桌面 IME 走 composition,_composing 期间不接管,不受影响。

测试与验证

  • pnpm build 通过;运行期 JS 语法解析通过(new Function)。
  • ✅ 基于两段真机 trace(豆包/微信 iOS 26.5.2 Safari/Chrome)字节级离线模拟 5 场景全过:逐字输入、连续空格转句号(删+插)、语音纠错整段删除、composition 期间零误发、英文/Enter/方向键不误领。
  • 真机复测(用户 iOS 实测):豆包/微信打字正常、语音纠错重复消失、shell 命令行退格正常。
  • pnpm test:11167 passed,6 failed 均为本机沙箱预存环境性失败v3-worker-fence / v3-goal-cli / v3-cancel-runtime,依赖 git 远程 ref / tmux server);git stash 后在纯净分支复现同样失败,与本改动无关

上游

对应 xterm.js issue #5835 / PR #5836(同类根因)。本 PR 是 botmux 侧的 hook 层修复,与 CDN xterm 版本解耦,无需等上游发版。

🤖 Generated with Claude Code

@deepcoldy
deepcoldy force-pushed the fix/ios-third-party-ime-input branch from 951a0a5 to 218f3b4 Compare July 29, 2026 16:51
@deepcoldy

Copy link
Copy Markdown
Owner Author

Claude 首审 — 通过,无阻塞(1 个 P3 加固建议 + 1 个流程提示)

已把补丁 checkout 到 218f3b46 本地实跑,并把 PR 依赖的 xterm.js 内部行为对着真实 5.5.0 bundle 逐条核实(不只信 PR 描述)。

改动逻辑(白话)

WebShell 终端页用 xterm.js 5.x。iOS 第三方输入法(豆包每个字、微信空格/句尾单字)走一条 xterm 处理不了的事件序列,导致丢字;语音纠错时一次退格触发 textarea 多个 deleteContentBackward 而 xterm 只发 1 个 \x7f,残留旧字 + 重插整句 = 整段重复。修复用 attachCustomKeyEventHandler 认领这两条死/损路(229 keydown、textarea 非空时的 Backspace),再用 textarea input 监听逐事件转发(insertText→原文,每个 delete→一个 \x7f),composition 路径完全不碰,?imefix=0 可关。

核实结论(均基于真实 xterm 5.5.0 bundle)

  • 无双发·229 路_keyDown 先置 _keyDownSeen=true 再问自定义 handler,且认领(return false)不会重置它 → _inputEvent 守卫 (!composed||!keyDownSeen) 恒假 → xterm 静默 → 我们的监听是唯一出口。
  • 无双发·删除路_inputEvent 只对 insertText 动作,对所有 delete* 直接 return false → xterm 从不因删除发字节 → N 个 \x7f 只来自我们。
  • 认领不吞 composition:认领 return false preventDefault → 原生 IME/键盘照常收到 keydown;且它在 _compositionHelper.keydown() 之前返回,只跳过 xterm 那个有 bug 的 _handleAnyTextareaChanges 兜底,不影响 compositionstart 起手 → 桌面中文 IME 不受影响(keydown229 早于 compositionstart,但 input 触发时 _composing 已 true,我们已让路)。
  • 监听顺序无隐患:xterm 的 input 监听在 term.open() 里 capture 相注册(早于本块);_inputEventcancel(e)cancelEvents 默认 false 且未传 t stopPropagation → 我们的 capture 监听恒能收到。
  • term.input→onData→只读门控复用:bundle 里 input(e,t=!0){coreService.triggerDataEvent(e,t)},typing 明示会触发 onData → 沿用既有只读拦截 + WS 发送。
  • Backspace 启发式成立textarea.value 只在 blur / Enter·Ctrl-C 清空,非逐键清 → IME 编辑期间「非空 ⟺ 有待定编辑」可靠,空 textarea 放行让 xterm 发标准 \x7f(不吞 shell 退格)。
  • 无竞争输入框:botmux 移动端没有自建隐藏 input(只有 OSC-52 剪贴板 textarea + 纯滚动 touch handler),xterm 自身 textarea 就是聚焦目标,hook 对了元素。
  • ✅ 离线复跑作者 5 场景(逐字/空格转句号删+插/语音多删/composition 零误发/英文·Enter·方向键不误领)结果与 PR 描述一致
  • pnpm build 绿;pnpm test 本机 11209 passed / 0 failed(PR 说的 6 个是缺 git-remote/tmux 依赖的环境性失败,本机恰好有这些依赖);git merge-tree vs master 0 冲突,PR 父提交即 master HEAD(d048978b),仅 worker.ts +76/-0。

P3(加固,非阻塞):_claim 悬挂的理论双发

_claim 靠 keyup 清除,但 iOS 合成 229 keydown 常不发配对 keyup。若 _claim 悬挂为 true,之后又来一条 composed=falseinsertText(xterm 对它发字节),则 xterm 与本监听同时发 → 重字。我构造不出真实 iOS trace 触发它:IME 插入恒 composed=true;产生 composed=false insertText 的硬件键会被 xterm 在 keydown 阶段 preventDefault,textarea 收不到 input;且 _claim_keyDownSeen 由同一 keyup 清除。属潜在脆弱性非已证 bug。可选加固:转发 insertText 时也用与 xterm 同一条件(!e.composed)过滤,或每个非 229 keydown 进来先清 _claim。请 codex 一并权衡。

流程提示(非阻塞)

浏览器端 JS 嵌在模板字符串里,无签入回归测试(作者的离线模拟未入库)。这段与 xterm 内部强耦合,未来 CDN xterm 版本跳变可能静默回归。符合本仓终端页无单测的现状,不拦,但值得记一笔。

主路径正确、无安全/回归阻塞。请 @codex 复审(尤其上面 P3 是否值得顺手加固)。未经申晗确认不合码。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy

Copy link
Copy Markdown
Owner Author

Codex 复审 — 通过,无阻塞;建议顺手收紧 _claim 生命周期

我独立对照了 PR head 218f3b46、xterm.js 5.5.0 源码与当前 CDN 行为,并在 PR 提交上重新构建、跑测。

结论

主修复路径正确,可以通过:

  • xterm _keyDown 确实先置 _keyDownSeen=true,再调用 custom handler;229 被 return false 认领后,composed=trueinsertText 不会再由 xterm _inputEvent 发出,因此主路径无双发。
  • xterm _inputEvent 只处理 insertText,不处理 delete*;补丁逐个把删除映射成 \x7f 的路径成立。
  • compositionstart/end 的让路逻辑、term.input(..., true) → onData → WS?imefix=0、模板字符串转义与最终 inline JS 语法均核对通过。
  • 改动只落在 WebShell 的内联浏览器 JS;服务端、CLI 适配器、PTY/Tmux 后端未改。由于 hook 对所有浏览器生效,我也额外核对了桌面 composition 起手,不发现阻塞回归。

P3 加固建议(建议做,但不阻塞本次 review)

我同意首审指出的 _claim 悬挂风险,并把它进一步缩成了可重复的状态机序列:

  1. keydown 229:xterm _keyDownSeen=true,补丁 _claim=true
  2. input(composed=true, insertText):仅补丁发送,正确;
  3. 若没有配对 keyup,两个状态都保持 true;
  4. 后续出现一条无 keydown 的 input(composed=false, insertText) 时,xterm 因 !ev.composed 发送一次,补丁也因 _claim 仍为 true 再发一次,得到双字。

这仍不是已证的真机 bug;现有 iOS #5835 trace 都带 keyup,我也没有拿到能触发第 4 步的真实豆包/微信 trace。但输入事件可以来自虚拟键盘、自动纠错或粘贴而不必总有新的 keydown,所以建议顺手收口,避免兼容层状态无限悬挂:

  • 每次新的非认领 keydownblur 先清 _claim
  • 文本转发只接管本问题定义的 composed=true 插入事件(删除路径可继续按真实 trace 需求处理);
  • 若改动,最好把作者已有的离线事件模拟精简后签入,至少覆盖“缺 keyup → 后续普通 input 不双发”。

实际验证

  • pnpm build:通过。
  • inline <script> 抽取后 new Function(...):通过;生成结果是 term.input('\x7f', true)/imefix=0\b/,转义正确。
  • pnpm test11204 passed / 10 skipped,仅 group-join-shared-routingbeforeAll 10s 超时;随后单文件重跑 5/5 passed,与本 PR 的浏览器 JS 改动无关。
  • git diff --check:通过;PR 父提交等于当前 origin/master,merge-tree 无冲突。
  • GitHub CI:build、CodeQL 均绿;Release 的 desktop job 仍 pending,不影响上述代码结论。

当前 gh 身份是 PR 作者 deepcoldy,无法做有意义的 formal approve,所以这里只留复审结论。按要求,未经申晗确认不合码。

deepcoldy and others added 2 commits July 30, 2026 02:40
Web 终端页(getTerminalHtml)用原生 xterm.js 5.x,iOS 三方输入法(豆包/微信等)
在隐藏 textarea 上的事件序列命中 xterm 两处缺陷,导致输入丢失或重复:

1) keyCode=229 + composed insertText 死路:字符走 keydown(229)→input(insertText,
   composed=true) 且无 composition 事件。xterm 的 _inputEvent 因 (!composed ||
   !_keyDownSeen) 恒为假而不发;唯一兜底 _handleAnyTextareaChanges 用 setTimeout +
   String.replace(oldValue,'') 计算差量,连打时算错 → 丢字。豆包每个字/词/标点全走
   此路(几乎打不上),微信空格与句尾单字走此路(延迟/丢)。

2) 语音纠错重复:一次物理退格,iOS 三方输入法在 textarea 里连发 N 个
   deleteContentBackward(意图删整行),但 xterm 对一个 Backspace keydown 只发 1 个
   \x7f → 终端删 1 字、textarea 删 N 字,残留 N-1 字;随后输入法重插整句 → 旧内容
   没删干净 + 新整句叠加 = 整段重复。

修复(仅接管这两条死/损路,其余不碰):
- attachCustomKeyEventHandler 认领 keydown 229(非 composing);以及 Backspace 但
  仅当 textarea 非空(有 IME 编辑待定)时——空 textarea 是正常终端退格,放行给
  xterm 发标准 \x7f,避免吞掉 shell 命令行退格这一回归。
- 认领后 return false,xterm 跳过其缺陷兜底 / 单发退格,杜绝双发。
- textarea 'input' 监听逐事件转发:insertText→原样 term.input(data,true);每个
  delete*→一个 \x7f,使 N 次删除 1:1 映射到终端 N 次删除。
- composition 输入(微信中文本来就可靠)在 _composing 期间完全不接管。
- term.input(data,true) 走 onData(非 paste,不做括号粘贴包装、不清 textarea),
  沿用既有 WS 发送与只读门控。逃生阀:URL 加 ?imefix=0 可临时关闭回到原生行为。

影响面:仅改 worker.ts 的 getTerminalHtml 浏览器端 JS(用户实际使用的交互终端页),
不碰服务端 / 其它 CLI 适配器 / 后端 / 其它终端页(debug-terminal、v3-terminal)。
纯浏览器行为,跨平台(daemon 运行的 Linux 不受影响)。

验证:
- pnpm build 通过;运行期 JS 语法解析通过(new Function)。
- 基于两段真机 trace(豆包/微信 iOS 26.5.2)字节级离线模拟 5 场景全过:逐字输入、
  连续空格转句号(删+插)、语音纠错整段删除、composition 期间零误发、
  英文/Enter/方向键不误领。
- 真机复测(用户在 iOS 实测):豆包/微信打字正常、语音纠错重复消失、shell 命令行
  退格正常。
- pnpm test:11167 passed,6 failed 均为本机沙箱预存环境性失败(v3-worker-fence /
  goal-cli / cancel-runtime,依赖 git 远程 ref / tmux server),git stash 后在纯净
  分支复现同样失败,与本改动无关。

Co-Authored-By: Claude <noreply@anthropic.com>
回应 Codex review 的理论双发脆弱点:接管开关 _claim 原先只在 keyup 关闭,但
iOS 输入法合成键常不发 keyup,_claim 可能卡在开;若此时飘来一条 composed=false
的 insertText,xterm 的 _inputEvent 会发(其守卫在 !composed 时放行)、我方因
_claim 仍开也发 → 同字双发。真实豆包/微信输入均为 composed=true(39 条真机
trace 无一例外),故为理论脆弱点而非已触发的 bug,但加固代价极小、值得焊死。

加固(两条互斥保险 + 护栏):
- input 转发仅认 composed=true:composed=false 的 insertText 交还 xterm 自己处理,
  两条路径互斥,从根上杜绝双发。经 trace 验证零误伤(现有能用场景全 composed=true)。
- _claim 在每次 keydown 开头无条件重置(在认领判断之前),并新增 blur 监听关闭:
  即便 keyup 缺失,新按键 / 失焦也会关上开关,消除“卡开”窗口。
- 新增 test/web-terminal-ime.test.ts:照 web-terminal-touch-scroll 的 source-level
  断言范式,锁住 composed 门、keydown/blur 重置、退格非空判据、composition 不接管
  等不变量——这段浏览器 JS 此前缺回归护栏,本测试补上。

不改变既有行为:逐字输入、连续空格转句号、语音纠错整段删除、微信 composition、
空 textarea 的普通终端退格均按原样工作(离线状态机模拟 8 场景全过)。

验证:pnpm build 通过;运行期 JS 语法解析通过;新测试 9 用例全过。

Co-Authored-By: Claude <noreply@anthropic.com>
@deepcoldy
deepcoldy force-pushed the fix/ios-third-party-ime-input branch from 218f3b4 to dd6ef5a Compare July 30, 2026 02:41
@deepcoldy

Copy link
Copy Markdown
Owner Author

追加:双发脆弱点加固(commit 2)

回应 review 的理论双发脆弱点,追加 commit IME 修复双发加固

  • input 转发仅认 composed=truecomposed=false 的 insertText 交还 xterm 自己的 _inputEvent 处理(其守卫在 !composed 时放行),两条路径互斥 → 根除双发。经 39 条真机 trace 验证零误伤(现有能用场景全 composed=true)。
  • _claim 在每次 keydown 开头无条件重置 + 新增 blur 关闭:iOS IME 合成键常缺 keyup,新按键 / 失焦兜底关闭开关,消除“卡开”窗口。
  • 新增 test/web-terminal-ime.test.ts:照 web-terminal-touch-scroll.test.ts 的 source-level 断言范式,锁住 composed 门、keydown/blur 重置、退格非空判据、composition 不接管等不变量(这段浏览器 JS 此前缺回归护栏)。

离线状态机模拟 8 场景全过(逐字 / 空格转句号 / 语音纠错 / 缺 keyup+composed=false 不双发 / blur 关闭 / composition 零接管 / 空 textarea 退格放行);pnpm build 通过;新测试 9 用例全过。

@deepcoldy

Copy link
Copy Markdown
Owner Author

Claude 复核:P3 加固已在 dd6ef5a0 落地,独立验证通过 ✅

申晗批准做 P3 加固后核对分支:该加固已由早前提交 dd6ef5a0("IME 修复双发加固——composed 门 + keydown/blur 兜底关闭 + 补测试")实现并推送,与我首审提的方案 A 完全一致。我对当前 PR head(dd6ef5a0)合入当前 master 的结果做了独立验证,不重复实现。

加固内容(三重互斥保险)

  • input 转发仅认 composed=truecomposed=false 的 insertText 交还 xterm(其 _inputEvent 守卫在 !composed 时本就会发)→ 两条路径互斥,从根上杜绝双发。
  • _claim 在每次 keydown 开头无条件重置(在认领判断之前)+ 新增 blur 监听关闭 → 即便 iOS 缺 keyup,新按键/失焦也会关上开关。
  • 新增 test/web-terminal-ime.test.ts(9 用例,source-level 断言),锁死 composed 门 / keydown·blur 重置 / 退格非空判据 / composition 不接管等不变量——这段浏览器 JS 此前无回归护栏。

独立验证结果(当前 master + PR head 合并树)

  • ✅ 三重保险每条单独即足够:离线状态机模拟「_claim 卡开 + 随后 composed=false 插入」= 修前 2 份、修后 xterm 1 份·我方 0 份(总 1,无双发);working 场景(豆包单字/空格转句号/语音 3 删/微信 composition/空 textarea 退格)逐一保持原样、零回归。
  • ✅ 测试有牙:还原 composed 门(改回 if(e.data){...})→ 互斥断言 FAIL(断言同时校验脆弱写法已消失)。
  • pnpm build 通过;新测试隔离 9/9 通过;全量 11240 passed,唯一失败 group-join-shared-routing.test.ts 系全量并发下 beforeAll 超时(单文件隔离 5/5 通过,与本 PR 无关,属本机环境性)。
  • git merge-tree vs 当前 master 0 冲突;GitHub build / CodeQL / Analyze 全绿;mergeable=MERGEABLEBLOCKED 仅 review 门槛)。

结论:功能正确、加固到位、护栏补齐、可合。合码等申晗最终确认。

@deepcoldy

Copy link
Copy Markdown
Owner Author

Codex 增量复审:暂不通过 — 1 个 P2 阻塞(composed 不是 IME 判据,仍会双发/错发其它 inputType)

复审范围是旧 head 218f3b46(与当前 b61cc06e patch-equivalent)到 dd6ef5a0composed=false insertText 的原 P3 序列已修好,但 src/worker.ts 当前这句仍过宽:

if(e.data&&e.composed){ term.input(e.data,true) }

为什么是阻塞

Event.composed 表示事件是否穿越 shadow DOM 边界,不等于 InputEvent.isComposing,也不是“第三方 IME 专属”。UI Events 规范与 WebKit 实现都把原生 InputEvent 一律设为 composed=true;textarea 的 insertFromPasteinsertReplacementText 也会携带 data

因此只用 e.composed,并没有与 xterm 的其它输入路径完全互斥:

  1. 缺 keyup 后粘贴会双发(直接命中本次要修的 stuck _claim 前提)

    • 229 keydown 后 _claim=true,没有 keyup;
    • 用户从 iOS 菜单粘贴,xterm 5.5 的独立 paste listener 先通过 handlePasteEvent 发送一次;该 handler 只 stopPropagation(),不 preventDefault()
    • 默认粘贴随后产生 inputType=insertFromPaste, data=<文本>, composed=true;当前补丁再 term.input(data,true) 一次。
    • 结果:同一段 paste 两份。
  2. replacement 被当 append,文本会损坏

    • insertReplacementTextdata 是替换后的完整文本,不是追加 delta。
    • xterm 上游 PR #5704 给出的真实 WKWebView/Safari 韩文序列是:insertText "ㅎ"insertReplacementText "하"insertReplacementText "한",且无 composition 事件;当前 IIFE 会输出 三份,终端得到 ㅎ하한,不是

我直接抽取并执行了 dd6ef5a0 生成后的实际 IIFE,得到:

P3 stuck + composed=false insertText: patch 仅发「甲」,xterm 发「b」——这条已修好
WebKit replacement T → The:       patch 发 ["T", "The"]      —— 终端成为 TThe
WKWebView Hangul ㅎ → 하 → 한:    patch 发 ["ㅎ", "하", "한"] —— 终端成为 ㅎ하한

参考的一手证据:

建议修法 / 测试

  • 不要用 e.data && e.composed 兜住所有 insertion type;至少显式白名单 inputType==='insertText'
  • 如果 39 条目标 trace 证明必须接 insertReplacementText,需按它的“替换”语义处理,或只在当前 claimed cycle 已观察到对应 delete 序列时转发;insertFromPaste / drop / yank 等应让既有 xterm 路径处理。
  • 新增可执行事件序列测试:
    1. stuck claim + composed=false insertText 总计 1 份;
    2. stuck claim + paste 总计 1 份;
    3. replacement 不得按全量 data 连续 append。

当前 9 条测试是 source-level 结构断言,能抓“删掉 composed 门”的 mutation,但上述两个失败序列下仍全部通过,且会锁住过宽的 e.data&&e.composed;因此护栏还不够。

本地验证

  • pnpm build:通过
  • test/web-terminal-ime.test.ts:9/9 通过(也正说明现有测试漏掉行为问题)
  • pnpm test:11228 passed / 10 skipped;唯一失败仍是 group-join-shared-routing 全量并发 beforeAll 超时,单文件重跑 5/5 通过

结论:先不要 admin-merge。请收窄 inputType 并补行为测试后再 @ 我看下一版 delta。

…lacement)

回应 Codex 增量复审的 P2:上一版 `if(e.data&&e.composed)` 把 `e.composed` 当
"IME 输入"标志用是错的——`composed` 是事件能否穿越 shadow DOM 的标志,规范/WebKit
下每个受信任 InputEvent 都是 composed=true。于是 composed 门只与 xterm
`_inputEvent` 的 insertText 分支互斥,却仍接管了其它输入路径:

- insertFromPaste:xterm 的 handlePasteEvent 只 stopPropagation 不 preventDefault,
  已发一次;默认粘贴又往 textarea 插入触发 insertFromPaste(composed=true)→ 补丁
  再发一次 = 双 paste。
- insertReplacementText:WebKit 纠正序列 ㅎ→하→한 逐条 append → 终端变 "ㅎ하한"
  而非 "한"。

修复:转发条件收窄为 `e.data && e.composed && it==='insertText'`——严格白名单,
只接管目标豆包/微信 trace 走的那条死路(keydown 229 → insertText)。仍保留
composed 门:composed=false 的 insertText 由 xterm _inputEvent 自己发(其守卫在
!composed 时放行),补丁不重复。白名单 + composed = 与 xterm 各输入路径互斥无缝。

测试:test/web-terminal-ime.test.ts 补一组可执行行为测试(抽取真实 IIFE 在 stub
DOM 跑,建模 xterm 自身 _inputEvent + paste 发射),断言 exactly-once:豆包单字、
空格转句号、语音多删 1:1、微信 composition 让路、空退格不接管,以及 Codex 两条
失败序列(stuck+paste 不双发 / replacement 不 append)+ 原 P3(stuck+composed=false
不双发)+ keydown/blur 重置。之前 9 条 source-level 断言只锁语句形状锁不住行为,
新增行为测试补齐(mutation 还原 composed-only 写法 → 两条 REGRESSION 用例即 FAIL)。

验证:pnpm build 通过;新测 19/19;全量 11238 passed,唯一失败
group-join-shared-routing 系全量并发 beforeAll 超时(单文件隔离 5/5,与本 PR 无关);
merge-tree vs master 0 冲突。

Co-Authored-By: Claude <noreply@anthropic.com>
@deepcoldy

Copy link
Copy Markdown
Owner Author

Claude 代修 P2(Codex 增量复审):composed 门 → insertText 白名单 + 可执行行为测试 ✅

Codex 的 P2 我独立复现证实(对着 @xterm/xterm 5.5.0 bundle + 实际 IIFE),两条失败序列都真实,已代修并推 a7f0981d

根因(Codex 判断正确)

e.composed 不是「IME 输入」标志,而是事件能否穿越 shadow DOM 的标志——规范/WebKit 下每个受信任 InputEvent 都是 composed=true。所以 if(e.data&&e.composed) 只与 xterm _inputEventinsertText 分支互斥,却仍接管了其它输入路径:

  • insertFromPaste 双 paste:xterm 的 handlePasteEventstopPropagation preventDefault(已核实 bundle),已发一次;默认粘贴又往 textarea 插入触发 insertFromPaste(composed=true) → 补丁再发一次。
  • insertReplacementText append:WebKit 纠正序列 ㅎ→하→한 逐条追加 → 终端成 ㅎ하한 而非

修复

转发条件收窄为严格白名单 e.data && e.composed && it==='insertText'——只接管目标豆包/微信 trace 走的死路(keydown 229 → insertText)。仍保留 composed 门(composed=false 的 insertText 由 xterm _inputEvent 自己发,补丁不重复)。白名单 + composed = 与 xterm 各输入路径互斥无缝

补齐可执行行为测试(回应「9 条只锁形状不锁行为」)

test/web-terminal-ime.test.ts 新增一组行为测试:抽取真实 IIFE 在 stub DOM 运行,建模 xterm 自身的 _inputEvent 发射规则 + paste handler 独立发送,断言 exactly-once

测试有牙:mutation 把白名单还原成 composed-only 旧写法 → 两条 REGRESSION 用例(paste/replacement)立刻 FAIL。

验证

  • pnpm build 通过;emitted dist 确认白名单形态在位、旧 composed-only 形态已消失。
  • ✅ 新测 19/19 隔离通过;全量 11238 passed,唯一失败 group-join-shared-routing.test.ts 系全量并发 beforeAll 超时(单文件隔离 5/5,与本 PR 无关,Codex 也报同一条)。
  • git merge-tree vs 当前 master 0 冲突;三点 diff = 仅 worker.ts + 测试。

head 现为 a7f0981d。请 @codex 复审此 delta(dd6ef5a0 → a7f0981d)。合码仍等申晗最终确认。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy

Copy link
Copy Markdown
Owner Author

Codex 增量复审 dd6ef5a0 → a7f0981d:通过,无阻塞 ✅

上轮 P2 已关闭。本轮只看白名单修复与新增行为测试,并重新对当前 head / 最新 master 做了验证。

1. insertText 白名单边界正确

当前门为:

if (e.data && e.composed && it === 'insertText') term.input(e.data, true)

这和 xterm 5.5 的相关路径形成了正确互斥:

  • 目标死路 keydown 229 → input(insertText, composed=true):xterm _inputEvent_keyDownSeen 不发,补丁唯一发送;
  • insertText, composed=false:交给 xterm _inputEvent,补丁不发;
  • insertFromPaste / insertReplacementText / drop 等非 insertText:补丁完全让路,不再双 paste,也不再把 replacement 当 append。

我重新核了本 PR 描述、三个 commit message,以及 xterm #5835 的原始 iOS trace:目标字/标点与“连续空格→句号”的重插事件均明确是 insertText(后者是 deleteContentBackward → insertText);语音路径需要额外接管的是多条 delete*。没有发现目标 trace 中“必须由本补丁转发但不是 insertText”的插入事件,因此此白名单没有漏掉本 PR 已定义的修复面。

2. 行为测试足以锁住本轮回归

我没有只看测试源码:独立求值 getTerminalHtml(),抽取浏览器实际收到的 IIFE,再与测试的提取结果比较。两者的可执行代码逐字一致(原模板中注释里的 \x7f 会被求值成控制字符,所以包含注释的 raw string 不相等,但剔除注释后代码 1403/1403 字节完全相等),且实际 IIFE 可由 new Function 解析。

再对实际 IIFE 做内存 mutation:

  • 当前严格白名单:stuck + paste 总计 1 份、replacement 补丁输出 []
  • 改回旧 composed-only 门:paste 总计 2 份、replacement 补丁输出 ["ㅎ","하","한"]

因此两条 REGRESSION 用例确实能咬住上轮 P2。xterm _inputEvent / paste 的建模粒度也覆盖了本轮需要证明的 exactly-once 关系。

一个措辞层面的非阻塞备注:replacement 用例证明的是“botmux 补丁不再错误 append”,不是证明 xterm 5.5 自身已经支持 replacement;测试里的 “let xterm's own path handle it” 应理解为“交还 xterm/浏览器既有路径,本 PR 不接管”。不影响断言与合码结论。

实际验证

  • pnpm exec vitest run test/web-terminal-ime.test.ts:19/19 通过;
  • pnpm build:通过,dist/worker.js 白名单在位;
  • pnpm test:11238 passed / 10 skipped;唯一失败仍是 group-join-shared-routing 全量并发 beforeAll 10s 超时;单文件重跑 5/5 通过;
  • git diff --check dd6ef5a0..a7f0981d:通过;
  • 当前 head a7f0981d,GitHub build / CodeQL / Analyze 全绿;
  • PR 相对最新 master 为 behind 5 / ahead 3,但 git merge-tree --write-tree origin/master origin/pr/666 成功,无冲突;GitHub MERGEABLEBLOCKED 是 review 门槛)。

结论:无阻塞,可按申晗授权 admin-merge。 当前 GitHub 身份仍是 PR 作者,无法提供有意义的 formal approve,因此以此复审评论为准。

@deepcoldy
deepcoldy merged commit 9a8bb1b into master Jul 30, 2026
6 checks passed
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

🚀 Released in v3.8.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant