Skip to content

fix(hotkey): Esc 取消改走独立通道——修复转写/润色期间按 Esc 停不下来 - #853

Merged
H-Chris233 merged 2 commits into
Open-Less:betafrom
bigsongeth:fix/esc-cancel-bypass-blocked-bridge
Jul 31, 2026
Merged

fix(hotkey): Esc 取消改走独立通道——修复转写/润色期间按 Esc 停不下来#853
H-Chris233 merged 2 commits into
Open-Less:betafrom
bigsongeth:fix/esc-cancel-bypass-blocked-bridge

Conversation

@bigsongeth

@bigsongethbigsongeth commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

User description

问题

转写(Transcribing)和 LLM 润色(Polishing)期间按 Esc 完全没有反应,必须干等整段流程跑完。#798 已经给 end_session 加了「转写 vs 取消」的 tokio::select! 赛跑、润色流也有 should_cancel 检查——但取消信号本身送不进来。

根因

Esc 的 HotkeyEvent::Cancelled 与 Pressed/Released 共用同一条 mpsc channel + 同一条 bridge 线程。bridge 为修 #468/#475 的 latch 竞态改成了串行 block_on#489),Hold 松手后 end_session 就在 bridge 线程上同步跑完整段转写 + 润色:

  1. 用户松开听写键 → bridge block_on(end_session),线程被占死;
  2. 用户按 Esc → Cancelled 进 channel 排队,没有人能 recv
  3. wait_for_processing_cancel 每 75ms 轮询的 cancelled 旗标只有 cancel_session 会设置,而 cancel_session 正等着这条被堵死的线程来调——fix(dictation): 转写阶段可中断——按 Esc 立即取消在途 ASR 请求 #798 的 select! 赛跑永远等不到信号;
  4. 流程跑完、bridge 空出来才取出 Cancelled,此时 phase 已回 Idle,cancel 变 no-op。

听写键为修饰键(默认 fn 等)时必现;自定义组合键用户不受影响(按键事件走 ComboHotkeyMonitor 的独立线程,主 monitor 的 channel 空闲)——这也是 #798 验证时没暴露的原因。

修法

CancelledHotkeyEvent 枚举中拆出(编译器强制所有平台改到位),Esc 在 tap/hook 回调里改发独立的 Sender<()>,由专用 esc-cancel-bridge 线程消费并调 cancel_session(纯同步快路径,不 await):

  • hotkey.rsHotkeyMonitor::start 新增 cancel_tx: Sender<()> 参数;macOS CGEventTap 与 Windows WH_KEYBOARD_LL 的 Esc 分支改发 cancel 通道;Linux 占位 adapter / mobile stub 同步签名。
  • hotkey_loops.rs:新增 esc_cancel_bridge_loop + spawn_esc_cancel_bridge;两个 bridge loop 删除 Cancelled 臂;Less Computer 修饰键 monitor 的 Esc 转发同样迁移(cancel_session 幂等,双 tap 重复触发无害)。

Pressed/Released 的串行化语义(#468/#475)完全不动。

验证

🤖 Generated with Claude Code


PR Type

Bug fix, Tests


Description

  • Route Esc cancel through dedicated esc_cancel_bridge thread

  • Remove HotkeyEvent::Cancelled and use Sender<()> cancel channel

  • Update macOS/Windows listeners plus Linux/mobile stubs

  • Add unit tests for recording-skip and idempotent cancel


File Walkthrough

Relevant files
Bug fix
hotkey_loops.rs
Add dedicated Esc cancel bridge loop and tests

openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs

  • Add esc_cancel_bridge_loop and spawn_esc_cancel_bridge for a dedicated
    cancel consumer.
  • Remove HotkeyEvent::Cancelled handling from hotkey and less-computer
    bridge loops.
  • Spawn cancel bridge and pass cancel_tx to HotkeyMonitor::start.
  • Add unit tests covering Processing cancel, recording skip, and
    repeated signals.
+151/-6
hotkey.rs
Split Esc cancel into dedicated channel

openless-all/app/src-tauri/src/hotkey.rs

  • Remove Cancelled variant from HotkeyEvent.
  • Thread Sender<()> cancel channel through monitor, adapters, listener
    loops, and contexts.
  • macOS handle_key_down and Windows dispatch_keyboard_event send Esc on
    cancel channel.
  • Linux placeholder adapter accepts the new cancel channel parameter.
+50/-13
Miscellaneous
hotkey.rs
Update mobile hotkey stub for cancel channel

openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs

  • Remove Cancelled variant from mobile stub HotkeyEvent.
  • Add _cancel_tx parameter to HotkeyMonitor::start.
+1/-1
Enhancement
coordinator.rs
Wire Esc cancel bridge into hotkey startup

openless-all/app/src-tauri/src/coordinator.rs

  • Spawn esc_cancel_bridge before each HotkeyMonitor::start call.
  • Pass cancel channel to initial and supervisor hotkey monitors.
  • Update less-computer modifier monitor to forward Esc cancel.
+2/-1

Open-Less#798 给 end_session 加了「转写 vs 取消」的 select! 赛跑,但取消信号本身
送不进来:hotkey bridge 线程为修 Open-Less#468/Open-Less#475 的 latch 竞态改成了串行
block_on,Hold 松手后 end_session 在 bridge 线程上同步跑完整段转写 +
润色,期间 Esc 的 HotkeyEvent::Cancelled 只能在同一条 channel 里排队。
流程跑完后才被取出,此时 phase 已回 Idle,cancel 变 no-op —— 用户按
Esc 毫无反应,必须干等转写完成。听写键为修饰键(默认 fn 等)时必现;
组合键用户不受影响(按键事件走 ComboHotkeyMonitor 的独立线程),这也是
Open-Less#798 验证时没暴露的原因。
修法:把 Cancelled 从 HotkeyEvent 枚举拆出,Esc 在 tap/hook 回调里改发
独立的 Sender<()>,由专用 esc-cancel-bridge 线程消费并调 cancel_session
(纯同步快路径,不 await)。cancelled 旗标即时置位,end_session 的
select! 赛跑与润色流的 should_cancel 立即命中。macOS CGEventTap 与
Windows WH_KEYBOARD_LL 同步修改;Less Computer 修饰键 monitor 的 Esc
转发同样迁移。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bigsongeth
bigsongeth marked this pull request as draft July 23, 2026 07:49
@github-actions

github-actionsBot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 349b221)

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis ❌

798 - Partially compliant

Compliant requirements:

  • 按 Esc 产生的取消信号现在通过独立线程/通道送达 cancel_session,Processing 期间不会被占用的 hotkey bridge 阻塞,用户可立即中断

Non-compliant requirements:

Requires further human verification:

  • 真机端到端:转写/润色中按 Esc 应立即变“已取消”
  • 各 provider(Volcengine / Bailian / Qwen3 / Whisper / MiMo / Foundry / Sherpa / Apple Speech / local Qwen3)的真机取消行为

468 - Not compliant

Non-compliant requirements:

  • 本 PR 未涉及启动/静默运行逻辑

489 - Partially compliant

Compliant requirements:

  • 本 PR 将 Esc 取消从 Pressed/Released 共用队列中拆出,取消信号不会再与已串行化的边沿事件互相阻塞

Non-compliant requirements:

Requires further human verification:

  • Windows 实机回归:toggle/hold 模式下快捷键二次按键行为
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Possible Gap

Linux 分支的 start_adapter 接收 _cancel_tx 后没有保存或转发,函数返回时 sender 被 drop,
esc_cancel_bridge_loop 的 rx.recv() 会立刻返回 Err,独立取消线程随即退出;同时
HotkeyEvent::Cancelled 已从枚举中移除。如果 Linux fcitx 路径之前依赖该枚举发送 Esc 取消,
本次改动后 Linux 上转写/润色期间的 Esc 取消将没有可用的信号通道。需确认 Linux 是否在取消功能
范围内;若在,应像 macOS/Windows 一样把 cancel_tx 接入 fcitx 事件源,否则该修复在 Linux 上不生效。

pubfn start_adapter(_binding:HotkeyBinding,_tx:Sender<HotkeyEvent>,_cancel_tx:Sender<()>,) -> Result<Box<dynHotkeyAdapter>,HotkeyInstallError>{
log::info!("[hotkey] Linux — fcitx5 plugin handles hotkeys");Ok(Box::new(PlaceholderAdapter{ _tx }))

@bigsongeth
bigsongeth marked this pull request as ready for review July 23, 2026 07:53
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit edcdda7

bigsongeth added a commit to bigsongeth/openless that referenced this pull request Jul 29, 2026
150ms 仲裁窗口只盖住了「修饰键→普通键」间隔够快的组合键;慢一点的(实测日志里
press→combined 中位数 428ms)仍然走事后撤销兜底,本该只是「闪一下」,实际却晚了
几百毫秒才收——观感上像录音真的起来了。
原因不是手速,是排队:TriggerCombined 和 Pressed/Released 挤同一条 channel,而
bridge 为修 Open-Less#468/Open-Less#475 的 latch 竞态改成了串行 block_on —— 这次按下自己的
begin_session(开麦 + ASR 握手)正卡在 bridge 线程上,撤销只能在队列里等它跑完。
日志里那个 428ms 因此是上界,真实键间隔被 begin_session 的耗时撑大了。
修法同 Open-Less#853(Esc 取消):把撤销从 HotkeyEvent 枚举拆出,tap/hook 回调改发独立的
Sender<()>,由专用 combo-abort-bridge 线程消费。macOS CGEventTap 与 Windows
WH_KEYBOARD_LL 同步修改;Less Computer 的修饰键 monitor 走同一条通道,只是 handler
换成 cancel_less_computer_press。
并发面两处:
- 撤销与 Released 之间不再有先后保证。改为只看 hotkey_press_began_session
(每个 Pressed 边沿都会重置)判断要不要撤销,不再看 hotkey_trigger_held ——
否则 Released 抢先跑完清了按住态,撤销会认不出这条会话是自己开的、放着不管,
Auto 模式下就是一条停不下来的录音。清 hotkey_trigger_held 只为吞掉后面的
Released,与撤销与否无关。加了回归测试覆盖这个倒序。
- 撤销落在 begin_session 还在 await 的中途,由既有的
startup_race_status_for_starting / CancelRaced 检查点接住(audit HIGH Open-Less#1),
与 Esc 取消同一条路径。
至此两条路径分工:仲裁窗口内的快组合键胶囊根本不出现;窗口没盖住的慢组合键胶囊
冒个头,按下第二个键即刻消失。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- spawn_esc_cancel_bridge 线程创建失败时 log::error,避免 Esc 取消静默失效
- 新增 esc_cancel_bridge_loop 单元测试:Processing 阶段置 cancelled、
录制快捷键期间跳过、重复信号幂等
@H-Chris233H-Chris233 self-assigned this Jul 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 349b221

@H-Chris233
H-Chris233 merged commit b16950d into Open-Less:betaJul 31, 2026
5 checks passed
H-Chris233 pushed a commit to bigsongeth/openless that referenced this pull request Jul 31, 2026
胶囊转圈时按 Esc,取消会话的同时宿主应用也收到了这个键——一次按键双重
生效(如顺带取消了 Claude 正在生成的回复)。参照输入法的交互模型:组合窗
激活时 Esc 只取消候选词、宿主应用收不到;胶囊显示进行中的会话时同理,
Esc 的语义就是「取消它」,应当被独占消费。
实现:hotkey.rs 新增进程级 ESC_EXCLUSIVE 旗标,由 emit_capsule(所有会话
状态变化的单一出口)维护——胶囊为 Recording/Transcribing/Polishing 且
dictation phase 非 Idle 时置位,终止帧清除。phase 条件排除 QA 会话:QA
的 Esc 由聚焦的浮窗窗口处理,吞键反而会挡掉。置位期间 macOS active tap
对 Esc keydown 返回 null 删除事件,Windows 底层钩子返回 LRESULT(1)——
Windows 本就吞听写触发键,Esc 是补齐同一语义。keyup 不吞:宿主应用几乎
都在 keydown 响应 Esc,孤儿 keyup 无害。
依赖 Open-Less#853(独立取消通道):没有它,Processing 期间吞掉的 Esc 无法真正
触发取消,会变成「吃掉按键但什么都不做」。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
H-Chris233 added a commit that referenced this pull request Aug 1, 2026
* feat(hotkey): 会话激活期间 Esc 由 OpenLess 独占——不再透传宿主应用
胶囊转圈时按 Esc,取消会话的同时宿主应用也收到了这个键——一次按键双重
生效(如顺带取消了 Claude 正在生成的回复)。参照输入法的交互模型:组合窗
激活时 Esc 只取消候选词、宿主应用收不到;胶囊显示进行中的会话时同理,
Esc 的语义就是「取消它」,应当被独占消费。
实现:hotkey.rs 新增进程级 ESC_EXCLUSIVE 旗标,由 emit_capsule(所有会话
状态变化的单一出口)维护——胶囊为 Recording/Transcribing/Polishing 且
dictation phase 非 Idle 时置位,终止帧清除。phase 条件排除 QA 会话:QA
的 Esc 由聚焦的浮窗窗口处理,吞键反而会挡掉。置位期间 macOS active tap
对 Esc keydown 返回 null 删除事件,Windows 底层钩子返回 LRESULT(1)——
Windows 本就吞听写触发键,Esc 是补齐同一语义。keyup 不吞:宿主应用几乎
都在 keydown 响应 Esc,孤儿 keyup 无害。
依赖 #853(独立取消通道):没有它,Processing 期间吞掉的 Esc 无法真正
触发取消,会变成「吃掉按键但什么都不做」。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(hotkey): esc-exclusive 判定提取纯函数并补表格测试
- 提取 esc_exclusive_for_capsule(state, phase) 纯函数,emit_capsule 调用
- 表格测试覆盖:进行中胶囊非 Idle 独占、终止帧清除、QA(phase=Idle)不独占
* fix(hotkey): 修复 voice agent 审批挂起 Esc 失效与胶囊双 emit 竞争
- maybe_request_approval 增加取消感知:会话取消(Esc)时按 Deny 处理并清理
审批注册表,避免审批挂起期间 Esc 被独占吞掉却毫无效果(PR #855 场景)
- run_voice_agent_transcript 审批后若会话已取消,强制结果为 Cancelled,
避免把第一轮拦截文本当 Done 收尾
- schedule_capsule_idle 记录触发时胶囊状态,到点时若期间有更新的 emit 则
跳过隐藏(旧 schedule 不再覆盖新状态,消除取消路径双 emit 竞争)
- 补测试:审批取消感知、schedule 新旧状态竞争
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
H-Chris233 added a commit that referenced this pull request Aug 1, 2026
* fix(hotkey): 修饰键热键被当组合键用时不再唤起听写
modifier-only 触发键(如 Option)按住期间只要按下普通键,就说明用户在打
Option+任意字母/数字键这类组合键、不是想说话。此前 tap/hook 只看修饰键自身的边
沿:按下即 Pressed → 开录音,Auto 模式下快速松手还会被判成「短按锁存」,录音一直
开着停不下来。
- hotkey.rs:macOS CGEventTap / Windows WH_KEYBOARD_LL 在触发键按住期间收到普通
键 KEY_DOWN 时发一次新的 TriggerCombined 边沿(每次按住只发一次;修饰键叠加不
算,Shift 翻译修饰键行为不变),之后仍照常发 Released。
- coordinator:TriggerCombined 清掉按住态(随后的 Released 被 was_held 吞掉,不
再走 Hold 松手 / Auto 短按锁存),并只取消「这一次按下开出来的」会话 ——
toggle 停止 / 被冷却拦下 / 路由给 QA 的按下什么都不动,绝不误杀正在转写的上一
条。同时清掉冷却与防抖时间戳,紧接着那次真想说话的按下不会被静默吞掉。
- Less Computer 的 modifier 触发键走同一条撤销路径(顺带熄灭整屏描边)。
* feat(hotkey): 修饰键热键加 150ms 组合键仲裁窗口——组合键不再闪一下胶囊
上一提交是「事后撤销」:Option+任意字母/数字键仍会真的开麦、弹胶囊、烧一次 ASR
建连,然后被取消——功能对了,观感上闪一下。
改为按下后先等 150ms 再开会话,期间监听器若已报告叠加了普通键,这次按下整条作废:
麦克风不开、胶囊不弹。等待只加在 modifier-only 触发键的「开录」分支上:
- 自定义组合键(Cmd+Shift+D)本身没有歧义,不等;
- toggle 停止 / QA 路由 / 防抖与冷却判定都在窗口之前,时序不变(尤其不挤占
is_queued_chain_press 的 120ms 排队接力窗口);
- 窗口没盖住的慢速组合键(按住 Option 半秒再按 Tab)仍由 TriggerCombined 事后撤销
兜底,两条路径互补。
150ms 覆盖绝大多数组合键的「修饰键→普通键」间隔,又低于人从按键到开口的反应时间,
不吃首字。
* fix(hotkey): 补上移动端 hotkey stub 的 TriggerCombined / trigger_combined_since_press
Android CI(cargo check --target android)红:`mobile_stubs/hotkey.rs` 是移动端的
HotkeyEvent / HotkeyMonitor 替身,桌面端新增的枚举变体与方法必须同步补上,否则
coordinator 里那两条 match 臂和仲裁窗口的调用在移动端找不到符号。
移动端没有键盘监听器,`trigger_combined_since_press` 恒为 false(仲裁窗口等价于
直接放行)。
* fix(hotkey): 组合键撤销改走独立通道——按下第二个键的那一帧胶囊就消失
150ms 仲裁窗口只盖住了「修饰键→普通键」间隔够快的组合键;慢一点的(实测日志里
press→combined 中位数 428ms)仍然走事后撤销兜底,本该只是「闪一下」,实际却晚了
几百毫秒才收——观感上像录音真的起来了。
原因不是手速,是排队:TriggerCombined 和 Pressed/Released 挤同一条 channel,而
bridge 为修 #468/#475 的 latch 竞态改成了串行 block_on —— 这次按下自己的
begin_session(开麦 + ASR 握手)正卡在 bridge 线程上,撤销只能在队列里等它跑完。
日志里那个 428ms 因此是上界,真实键间隔被 begin_session 的耗时撑大了。
修法同 #853(Esc 取消):把撤销从 HotkeyEvent 枚举拆出,tap/hook 回调改发独立的
Sender<()>,由专用 combo-abort-bridge 线程消费。macOS CGEventTap 与 Windows
WH_KEYBOARD_LL 同步修改;Less Computer 的修饰键 monitor 走同一条通道,只是 handler
换成 cancel_less_computer_press。
并发面两处:
- 撤销与 Released 之间不再有先后保证。改为只看 hotkey_press_began_session
(每个 Pressed 边沿都会重置)判断要不要撤销,不再看 hotkey_trigger_held ——
否则 Released 抢先跑完清了按住态,撤销会认不出这条会话是自己开的、放着不管,
Auto 模式下就是一条停不下来的录音。清 hotkey_trigger_held 只为吞掉后面的
Released,与撤销与否无关。加了回归测试覆盖这个倒序。
- 撤销落在 begin_session 还在 await 的中途,由既有的
startup_race_status_for_starting / CancelRaced 检查点接住(audit HIGH #1),
与 Esc 取消同一条路径。
至此两条路径分工:仲裁窗口内的快组合键胶囊根本不出现;窗口没盖住的慢组合键胶囊
冒个头,按下第二个键即刻消失。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hotkey): 取消先收胶囊再拆麦克风 + watchdog 碎觉——撤销不再拖着胶囊一秒
上一提交把撤销决策做到了「按下第二个键的那一帧」(tap → handle_trigger_combined
实测 0.14ms),但真机日志显示胶囊仍然晚 0.8~1 秒才消失。11 次组合键测试分成干净的
两组:这次按下已经把 recorder 开起来的 5 次是 769/900/931/965/986ms,还没开起来的
6 次是 0ms。
链路:cancel_session 先 stop_recorder_for_session 才 emit_capsule。
Recorder::stop() 要 join 音频线程,音频线程退出前要 join liveness watchdog,
watchdog 睡在 1000ms 的检查间隔里 —— 所以停采要等它当前那一觉睡完,胶囊排在后面。
按 Esc 取消走同一个函数,同样拖一秒。
两处一起改(缺一不可,见下):
- coordinator/dictation.rs:cancel_session 把 UI 收尾(finish_cancel_session_state
→ emit_capsule → schedule_capsule_idle → 熄描边)整块提到三个拆资源调用之前。
emit_capsule 仍排在 finish_cancel_session_state 之后 —— 它要读 state.voice_agent /
phase 拼 payload,再提前会发出「还在进行中」的那一帧。拆资源都按 session_id 取,
不依赖 phase,挪到后面语义不变。
- recorder.rs:watchdog 的一个检查间隔切成 50ms 的碎觉来睡,每觉醒来重看 stop_flag。
停采的等待从最坏 1000ms 降到 50ms。判据(回调静默 3s / 首帧 5s)用的是真实时间差
而非醒来次数,灵敏度不变;代价是录音期间线程多醒几次,醒来只读一个时间戳。
为什么必须成对:UI 先收之后,胶囊消失到麦克风真正释放之间有一段窗口,其间旧
recorder 还占着设备。`Recorder` 没有 Drop 停采,recorder 槽被下一条会话覆盖后旧音频
线程会继续跑、抓着麦克风不放。只做第一处会把这段窗口留在 ~1 秒,紧接着那次真想说话
的按下(最快也要 250ms 防抖 + 150ms 仲裁)就可能撞上;watchdog 切碎后窗口降到几十
毫秒,够不着。
副作用:正常说完话松手结束(end_session 也走 Recorder::stop())同样少等将近一秒。
可观察的代价:胶囊消失后系统菜单栏的录音小圆点会多亮几十毫秒。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(hotkey): resolve modifier combo abort after beta merge
* test(hotkey): initialize mac companion press id
* fix(hotkey): clone Linux fcitx combo channel
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@bigsongeth@H-Chris233