From ee50665278c53cd1739dbc408b8d6caa7780b29b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=95=E6=9F=8F=E9=9D=92?= Date: Fri, 5 Jun 2026 10:45:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=95=BF=E6=8C=89=E5=90=AC=E5=86=99?= =?UTF-8?q?=E9=94=AE=E5=94=A4=E8=B5=B7=20Cloud=20Agent=20=E8=AF=AD?= =?UTF-8?q?=E9=9F=B3=EF=BC=9B=E7=A7=BB=E9=99=A4=20=E2=8C=98=E2=87=A7J/?= =?UTF-8?q?=E2=8C=98=E2=87=A7Enter=20=E7=BB=84=E5=90=88=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按用户新交互方案:toggle 听写模式下,长按右 Option(>550ms)把当前会话升级成 语音 Agent —— 录音→ASR→把转写交给无头 Claude 跑任务→结果弹胶囊(不插入光标)。 短按仍是普通听写,行为完全不变。 实现(最大化复用、最小化对核心听写的改动): - coordinator_state: SessionState 加 voice_agent 标志,begin_session_state 必复位(安全护栏 + 单测) - dictation: handle_pressed(Toggle,Idle) 武装长按计时器;计时器到点且仍按住→置 voice_agent; handle_released(Toggle) 若 voice_agent→end_session(按住说话语义); end_session 拿到转写后分流到 run_voice_agent_transcript(Plan 只读、安全 MVP) - 退役组合键:supervisor 清空 panel/quick 绑定并停止注册;移除两个设置行, 改为只读提示「长按听写键」;删除已无用的 sanitize_agent_hotkeys + 测试 + default_quick - i18n(5 语言):新增 agentVoiceHint / agentVoiceTrigger 限制(已知):仅 toggle 听写模式接长按;hold 模式的「点按=Agent」未接。结果走胶囊, 长文本展示有限。权限固定 Plan 只读,不产生副作用。 验证:cargo check 0 error / coordinator 64 + dictation 33 + coding_agent 23 单测全过 / tsc 0 --- .../app/src-tauri/src/coding_agent_hotkey.rs | 79 ---------- openless-all/app/src-tauri/src/coordinator.rs | 37 ++--- .../src-tauri/src/coordinator/dictation.rs | 136 ++++++++++++++++++ .../app/src-tauri/src/coordinator_state.rs | 18 +++ openless-all/app/src-tauri/src/types.rs | 8 -- openless-all/app/src/i18n/en.ts | 2 + openless-all/app/src/i18n/ja.ts | 2 + openless-all/app/src/i18n/ko.ts | 2 + openless-all/app/src/i18n/zh-CN.ts | 2 + openless-all/app/src/i18n/zh-TW.ts | 2 + .../src/pages/settings/ShortcutsSection.tsx | 58 ++------ 11 files changed, 192 insertions(+), 154 deletions(-) diff --git a/openless-all/app/src-tauri/src/coding_agent_hotkey.rs b/openless-all/app/src-tauri/src/coding_agent_hotkey.rs index 1cc8d676..d28d8635 100644 --- a/openless-all/app/src-tauri/src/coding_agent_hotkey.rs +++ b/openless-all/app/src-tauri/src/coding_agent_hotkey.rs @@ -99,38 +99,6 @@ impl Drop for CodingAgentHotkeyMonitor { } } -/// 单个 agent 绑定是否能被 global-hotkey 注册。单修饰键(如 Right ⌘)无法注册成 -/// 全局快捷键 → 视为无效,需要回退到有效组合键。 -fn agent_binding_is_registrable(binding: &ShortcutBinding) -> bool { - parse_global_hotkey(binding).is_ok() -} - -/// 把可能无效的面板键 / 快取键换成有效默认组合键(⌘⇧Enter / ⌘⇧J)。 -/// -/// 历史版本或手滑可能把 agent 热键存成单修饰键,导致注册无限失败、按下毫无反应。 -/// 返回 `(panel, quick, changed)`;`changed` 为 true 时调用方应持久化并通知前端。 -pub(crate) fn sanitize_agent_hotkeys( - panel: Option, - quick: Option, -) -> (Option, Option, bool) { - let mut changed = false; - let panel = match panel { - Some(b) if !agent_binding_is_registrable(&b) => { - changed = true; - crate::types::default_coding_agent_panel_hotkey() - } - other => other, - }; - let quick = match quick { - Some(b) if !agent_binding_is_registrable(&b) => { - changed = true; - crate::types::default_coding_agent_quick_hotkey() - } - other => other, - }; - (panel, quick, changed) -} - fn spawn_forward( id: u32, rx: Receiver, @@ -216,51 +184,4 @@ mod tests { assert!(out_rx.try_recv().is_err()); } - fn bare_right_command() -> ShortcutBinding { - ShortcutBinding { primary: "RightCommand".into(), modifiers: vec![] } - } - - #[test] - fn sanitize_replaces_bare_modifier_panel() { - // 复现用户的故障:把面板键存成单修饰键 Right ⌘。 - let (panel, quick, changed) = sanitize_agent_hotkeys(Some(bare_right_command()), None); - assert!(changed, "无效绑定必须触发自愈"); - assert_eq!(panel, crate::types::default_coding_agent_panel_hotkey()); - // 修好后必须能被 global-hotkey 真正注册。 - assert!(parse_global_hotkey(&panel.unwrap()).is_ok()); - assert_eq!(quick, None, "没配的键保持 None,不应凭空生成"); - } - - #[test] - fn sanitize_replaces_bare_modifier_quick() { - let (panel, quick, changed) = sanitize_agent_hotkeys(None, Some(bare_right_command())); - assert!(changed); - assert_eq!(panel, None); - assert_eq!(quick, crate::types::default_coding_agent_quick_hotkey()); - assert!(parse_global_hotkey(&quick.unwrap()).is_ok()); - } - - #[test] - fn sanitize_keeps_valid_combo_untouched() { - let panel = Some(ShortcutBinding { - primary: "Enter".into(), - modifiers: vec!["cmd".into(), "shift".into()], - }); - let quick = Some(ShortcutBinding { - primary: "J".into(), - modifiers: vec!["cmd".into(), "shift".into()], - }); - let (p, q, changed) = sanitize_agent_hotkeys(panel.clone(), quick.clone()); - assert!(!changed, "有效组合键不应被改动"); - assert_eq!(p, panel); - assert_eq!(q, quick); - } - - #[test] - fn sanitize_none_is_noop() { - let (p, q, changed) = sanitize_agent_hotkeys(None, None); - assert!(!changed); - assert_eq!(p, None); - assert_eq!(q, None); - } } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index dc2ade54..6f097fa8 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1341,27 +1341,28 @@ fn coding_agent_hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } - let prefs = inner.prefs.get(); - - // 自愈:global-hotkey 无法注册「单修饰键」(如 Right ⌘)。历史版本/手滑可能存进 - // 这种无效绑定,导致热键彻底失效且无任何反馈。检测到就换成有效默认组合键并持久化 - // + 通知前端,保证「按下有反应」。逻辑见 coding_agent_hotkey::sanitize_agent_hotkeys。 - let (panel, quick, sanitized) = crate::coding_agent_hotkey::sanitize_agent_hotkeys( - prefs.coding_agent_panel_hotkey.clone(), - prefs.coding_agent_quick_hotkey.clone(), - ); - if sanitized { - log::warn!("[coord] 快速 Agent 热键含无效单修饰键,已自愈为有效组合键"); - let mut migrated = prefs.clone(); - migrated.coding_agent_panel_hotkey = panel.clone(); - migrated.coding_agent_quick_hotkey = quick.clone(); - if inner.prefs.set(migrated.clone()).is_ok() { - if let Some(app) = inner.app.lock().clone() { - let _ = app.emit("prefs:changed", &migrated); - let _ = app.emit_to("main", "prefs:changed", &migrated); + // 退役:Cloud Agent 的触发已统一到「长按右 Option」(见 dictation 长按计时器), + // 不再使用 ⌘⇧J/⌘⇧Enter 全局组合键。把残留的 panel/quick 绑定一次性清空并持久化, + // 之后 panel/quick 恒为 None,本监听器只会反注册 + 空转。 + { + let prefs = inner.prefs.get(); + if prefs.coding_agent_panel_hotkey.is_some() || prefs.coding_agent_quick_hotkey.is_some() + { + let mut migrated = prefs.clone(); + migrated.coding_agent_panel_hotkey = None; + migrated.coding_agent_quick_hotkey = None; + if inner.prefs.set(migrated.clone()).is_ok() { + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit("prefs:changed", &migrated); + let _ = app.emit_to("main", "prefs:changed", &migrated); + } } + log::info!("[coord] Cloud Agent 组合键已清空,改用长按右 Option 触发"); } } + let prefs = inner.prefs.get(); + let panel = prefs.coding_agent_panel_hotkey.clone(); + let quick = prefs.coding_agent_quick_hotkey.clone(); // 功能关闭,或两个键都没配 → 反注册并睡着等 prefs 改动。 if !prefs.coding_agent_enabled || (panel.is_none() && quick.is_none()) { diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index dfbbe3a5..677510bf 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -14,6 +14,10 @@ use super::*; const HOTKEY_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(250); const STREAMING_INSERT_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(12); +/// Cloud Agent 语音:长按听写键达到此阈值(仍按住 + 仍在录音)即把当前会话升级成语音 +/// Agent。设得比正常点按明显长,避免误触;短按(点一下开始听写)完全不受影响。 +const VOICE_AGENT_LONG_PRESS: std::time::Duration = std::time::Duration::from_millis(550); + /// 跑流式润色路径(opt-in,跨平台)。 /// /// 平台差异: @@ -475,6 +479,10 @@ pub(super) async fn handle_pressed(inner: &Arc) { return; } let _ = begin_session(inner).await; + // 点按 = 听写(不变);长按超阈值 = 升级成语音 Agent。仅在 Agent 功能开启时武装。 + if inner.prefs.get().coding_agent_enabled { + arm_voice_agent_long_press(inner); + } } (HotkeyMode::Toggle, SessionPhase::Listening) => { let _ = end_session(inner).await; @@ -511,6 +519,26 @@ pub(super) async fn handle_released(inner: &Arc) { let mode = inner.prefs.get().hotkey.mode; let phase = inner.state.lock().phase; log::info!("[coord] hotkey released (mode={mode:?}, phase={phase:?})"); + // Toggle 模式下,普通听写松手不做事(点一下停);但若当前会话已升级成语音 Agent + // (长按触发),松手 = 结束「按住说话」并把转写交给 Claude。Starting 阶段则排队。 + if mode == HotkeyMode::Toggle { + let voice_agent = { + let st = inner.state.lock(); + st.voice_agent && matches!(st.phase, SessionPhase::Listening | SessionPhase::Starting) + }; + if voice_agent { + match phase { + SessionPhase::Listening => { + let _ = end_session(inner).await; + } + SessionPhase::Starting => { + request_stop_during_starting(inner, "voice-agent release edge"); + } + _ => {} + } + } + return; + } if mode == HotkeyMode::Hold { match phase { SessionPhase::Listening => { @@ -525,6 +553,108 @@ pub(super) async fn handle_released(inner: &Arc) { } } +/// 长按计时器:begin_session 后武装。到点时若热键仍按住、仍是同一会话、且尚未升级, +/// 就把当前会话标记为语音 Agent。短按(计时器到点前已松手)不受影响。 +fn arm_voice_agent_long_press(inner: &Arc) { + let session_id = inner.state.lock().session_id; + let inner = Arc::clone(inner); + async_runtime::spawn(async move { + tokio::time::sleep(VOICE_AGENT_LONG_PRESS).await; + // 已松手 → 这是一次普通点按(听写),不升级。 + if !inner.hotkey_trigger_held.load(Ordering::SeqCst) { + return; + } + let mut st = inner.state.lock(); + if st.session_id == session_id + && !st.voice_agent + && matches!(st.phase, SessionPhase::Starting | SessionPhase::Listening) + { + st.voice_agent = true; + log::info!("[coord] 长按升级为 Cloud Agent 语音会话 (session={session_id:?})"); + } + }); +} + +/// 语音 Agent 收尾:把转写当作指令交给无头 Claude,结果以胶囊展示(不插入到光标)。 +async fn run_voice_agent_transcript( + inner: &Arc, + _session_id: SessionId, + transcript: String, + elapsed: u64, +) -> Result<(), String> { + log::info!( + "[coord] Cloud Agent 语音:指令 {} 字", + transcript.chars().count() + ); + emit_capsule( + inner, + CapsuleState::Polishing, + 0.0, + elapsed, + Some("Claude 处理中…".to_string()), + None, + ); + + let prefs = inner.prefs.get(); + let prompt = format!( + "下面是用户的语音指令,请直接完成它并只返回结果文本(不要解释、不要前后缀、不要引号):\n\n{transcript}" + ); + let mut req = crate::coding_agent::CodingAgentRequest::new("voice-agent", prompt); + req.model = prefs + .coding_agent_model + .clone() + .filter(|m| !m.trim().is_empty()) + .or_else(|| Some("sonnet".to_string())); + // 只读 Plan 模式:安全 MVP——返回文本、不产生副作用。后续要工具操作再放开。 + req.permission_mode = crate::coding_agent::CodingAgentPermissionMode::Plan; + req.allowed_tools = Vec::new(); + req.max_budget_usd = Some(0.5); + req.timeout_secs = 90; + req.session_persistence = false; + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let run = async_runtime::spawn(async move { + crate::coding_agent::run_claude_agent("claude", req, tx, cancel).await + }); + + let mut final_text = String::new(); + let mut error_msg: Option = None; + while let Some(ev) = rx.recv().await { + match ev { + crate::coding_agent::CodingAgentEvent::Completed { text, .. } => final_text = text, + crate::coding_agent::CodingAgentEvent::Error { message, .. } => { + error_msg = Some(message) + } + _ => {} + } + } + let run_result = run.await; + let final_text = final_text.trim().to_string(); + + inner.state.lock().phase = SessionPhase::Idle; + if final_text.is_empty() { + let msg = error_msg + .or_else(|| match run_result { + Ok(Err(e)) => Some(e.to_string()), + _ => None, + }) + .unwrap_or_else(|| "Claude 无结果(确认已登录 claude 且额度充足)".to_string()); + log::warn!("[coord] Cloud Agent 语音失败: {msg}"); + emit_capsule(inner, CapsuleState::Error, 0.0, elapsed, Some(msg), None); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("voice agent empty".to_string()); + } + + log::info!( + "[coord] Cloud Agent 语音:返回 {} 字", + final_text.chars().count() + ); + emit_capsule(inner, CapsuleState::Done, 0.0, elapsed, Some(final_text), None); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + Ok(()) +} + pub(super) fn request_stop_during_starting(inner: &Arc, reason: &str) { { let mut state = inner.state.lock(); @@ -1531,6 +1661,12 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { raw.text = corrected; } } + + // Cloud Agent 语音分流:长按升级的会话不走润色/插入,转写交给 Claude 跑任务、结果弹胶囊。 + if inner.state.lock().voice_agent { + return run_voice_agent_transcript(inner, current_session_id, raw.text.clone(), elapsed).await; + } + emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None); let prefs = inner.prefs.get(); diff --git a/openless-all/app/src-tauri/src/coordinator_state.rs b/openless-all/app/src-tauri/src/coordinator_state.rs index 82ce5672..3982d3ef 100644 --- a/openless-all/app/src-tauri/src/coordinator_state.rs +++ b/openless-all/app/src-tauri/src/coordinator_state.rs @@ -48,6 +48,9 @@ pub(crate) struct SessionState { /// 用户开始 dictation 时所处的前台 app 标签("Mail (com.apple.mail)" / Windows 窗口标题)。 /// 用作 LLM polish/translate 的上下文前提,让模型按 app 调风格。详见 issue #116。 pub(crate) front_app: Option, + /// Cloud Agent 语音模式:长按听写键超过阈值时置 true。end_session 在拿到转写后 + /// 据此分流——不走润色插入,转而把转写交给 Claude 跑任务、结果弹胶囊。默认 false。 + pub(crate) voice_agent: bool, } impl Default for SessionState { @@ -60,6 +63,7 @@ impl Default for SessionState { focus_target: None, session_id: initial_session_id(), front_app: None, + voice_agent: false, } } } @@ -80,6 +84,8 @@ pub(crate) fn begin_session_state( state.focus_target = focus_target; state.session_id = new_session_id(); state.front_app = front_app; + // 每个新会话默认是普通听写;长按计时器到点后才会把它升级成语音 Agent。 + state.voice_agent = false; Some(state.session_id) } @@ -247,6 +253,18 @@ mod tests { assert_ne!(id, initial_session_id()); } + #[test] + fn begin_session_resets_voice_agent_flag() { + // 安全护栏:上一会话残留的 voice_agent=true 绝不能让下一次普通听写被误判成 + // Cloud Agent(否则听写内容会被发去跑 Claude 而不是插入光标)。 + let mut state = SessionState { + voice_agent: true, + ..Default::default() + }; + begin_session_state(&mut state, None, None).unwrap(); + assert!(!state.voice_agent, "新会话必须从普通听写开始"); + } + #[test] fn begin_session_ignores_non_idle_phase() { let mut state = SessionState { diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 5a48548a..84dd2532 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -1076,14 +1076,6 @@ pub(crate) fn default_coding_agent_panel_hotkey() -> Option { }) } -/// 快取键(选中文本润色)默认值:⌘⇧J。供热键自愈逻辑回退使用。 -pub(crate) fn default_coding_agent_quick_hotkey() -> Option { - Some(ShortcutBinding { - primary: "J".into(), - modifiers: vec!["cmd".into(), "shift".into()], - }) -} - fn default_translation_hotkey() -> ShortcutBinding { ShortcutBinding { primary: "Shift".into(), diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 77224c2e..f33f8a77 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -741,6 +741,8 @@ export const en: typeof zhCN = { agentPolishDesc: 'Select text → press → Claude polishes it → replaces the selection.', agentVoice: 'Cloud Agent (voice)', agentVoiceDesc: 'Press → speak → Claude runs the task → result shown in a capsule. (Coming soon)', + agentVoiceHint: 'Long-press the dictation key to talk; release and Claude runs the task and shows the result in a capsule. Enable it under Advanced first.', + agentVoiceTrigger: 'Long-press dictation key', enable: 'Enable', disable: 'Disable', confirmHint: 'Click ✓ on the capsule', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index f241f207..ab09e89a 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -743,6 +743,8 @@ export const ja: typeof zhCN = { agentPolishDesc: 'テキスト選択 → キー → Claude が推敲 → 選択範囲を置換。', agentVoice: 'Cloud Agent(音声)', agentVoiceDesc: 'キー → 話す → Claude がタスク実行 → 結果をカプセル表示。(近日対応)', + agentVoiceHint: 'ディクテーションキーを長押しして話し、離すと Claude が実行して結果をカプセル表示します。先に「詳細」で有効化してください。', + agentVoiceTrigger: 'ディクテーションキー長押し', enable: '有効化', disable: '無効化', confirmHint: '右側の ✓ をクリック', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 7c753af7..07cc9751 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -743,6 +743,8 @@ export const ko: typeof zhCN = { agentPolishDesc: '텍스트 선택 → 키 → Claude 다듬기 → 선택 영역 교체.', agentVoice: 'Cloud Agent(음성)', agentVoiceDesc: '키 → 말하기 → Claude 작업 실행 → 결과 캡슐 표시. (곧 지원)', + agentVoiceHint: '받아쓰기 키를 길게 눌러 말하고 손을 떼면 Claude가 작업을 실행해 결과를 캡슐로 표시합니다. 먼저 「고급」에서 켜세요.', + agentVoiceTrigger: '받아쓰기 키 길게 누르기', enable: '활성화', disable: '비활성화', confirmHint: '오른쪽 ✓ 클릭', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 769805c3..da25e628 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -739,6 +739,8 @@ export const zhCN = { agentPolishDesc: '选中文本 → 按键 → Claude 润色 → 替换选区。', agentVoice: 'Cloud Agent(语音)', agentVoiceDesc: '按键 → 说话 → Claude 执行任务 → 结果弹胶囊显示。(开发中)', + agentVoiceHint: '长按听写键说话,松开后 Claude 执行并把结果弹胶囊显示。需先在「高级」里开启。', + agentVoiceTrigger: '长按听写键', enable: '启用', disable: '停用', confirmHint: '点击右侧 ✓', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 6d0542c0..3187d3cf 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -741,6 +741,8 @@ export const zhTW: typeof zhCN = { agentPolishDesc: '選取文字 → 按鍵 → Claude 潤色 → 取代選取。', agentVoice: 'Cloud Agent(語音)', agentVoiceDesc: '按鍵 → 說話 → Claude 執行任務 → 結果彈膠囊顯示。(開發中)', + agentVoiceHint: '長按聽寫鍵說話,放開後 Claude 執行並把結果彈膠囊顯示。需先在「進階」裡開啟。', + agentVoiceTrigger: '長按聽寫鍵', enable: '啟用', disable: '停用', confirmHint: '點擊右側 ✓', diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx index d342bfad..cd0d909e 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -155,55 +155,15 @@ export function ShortcutsSection() { )} - - {prefs.codingAgentQuickHotkey ? ( - { - await savePrefs({ ...prefs, codingAgentQuickHotkey: b }); - }} - onDisable={() => void savePrefs({ ...prefs, codingAgentQuickHotkey: null })} - /> - ) : ( - - )} - - - {prefs.codingAgentPanelHotkey ? ( - { - await savePrefs({ ...prefs, codingAgentPanelHotkey: b }); - }} - onDisable={() => void savePrefs({ ...prefs, codingAgentPanelHotkey: null })} - /> - ) : ( - - )} + + {t('settings.shortcuts.agentVoiceTrigger')} {readonlyRows.map(([k, v]) => (