feat: 长按听写键唤起 Cloud Agent 语音;移除 ⌘⇧J/⌘⇧Enter 组合键

按用户新交互方案: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
This commit is contained in:
吕柏青 committed 2026-06-05 10:45:16 +08:00
1 parent 6c8e2843f2
commit ee50665278
11 files changed
+192 -154

No files matched your search

@@ -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<ShortcutBinding>,
quick: Option<ShortcutBinding>,
) -> (Option<ShortcutBinding>, Option<ShortcutBinding>, 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<GlobalHotKeyEvent>,
@@ -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);
}
}
+19 -18
View File
@@ -1341,27 +1341,28 @@ fn coding_agent_hotkey_supervisor_loop(inner: Arc<Inner>) {
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()) {
@@ -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<Inner>) {
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<Inner>) {
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<Inner>) {
}
}
/// 长按计时器:begin_session 后武装。到点时若热键仍按住、仍是同一会话、且尚未升级,
/// 就把当前会话标记为语音 Agent。短按(计时器到点前已松手)不受影响。
fn arm_voice_agent_long_press(inner: &Arc<Inner>) {
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<Inner>,
_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<String> = 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<Inner>, reason: &str) {
{
let mut state = inner.state.lock();
@@ -1531,6 +1661,12 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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();
@@ -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<String>,
/// 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 {
-8
View File
@@ -1076,14 +1076,6 @@ pub(crate) fn default_coding_agent_panel_hotkey() -> Option<ShortcutBinding> {
})
}
/// 快取键(选中文本润色)默认值:⌘⇧J。供热键自愈逻辑回退使用。
pub(crate) fn default_coding_agent_quick_hotkey() -> Option<ShortcutBinding> {
Some(ShortcutBinding {
primary: "J".into(),
modifiers: vec!["cmd".into(), "shift".into()],
})
}
fn default_translation_hotkey() -> ShortcutBinding {
ShortcutBinding {
primary: "Shift".into(),
+2
View File
@@ -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',
+2
View File
@@ -743,6 +743,8 @@ export const ja: typeof zhCN = {
agentPolishDesc: 'テキスト選択 → キー → Claude が推敲 → 選択範囲を置換。',
agentVoice: 'Cloud Agent(音声)',
agentVoiceDesc: 'キー → 話す → Claude がタスク実行 → 結果をカプセル表示。(近日対応)',
agentVoiceHint: 'ディクテーションキーを長押しして話し、離すと Claude が実行して結果をカプセル表示します。先に「詳細」で有効化してください。',
agentVoiceTrigger: 'ディクテーションキー長押し',
enable: '有効化',
disable: '無効化',
confirmHint: '右側の ✓ をクリック',
+2
View File
@@ -743,6 +743,8 @@ export const ko: typeof zhCN = {
agentPolishDesc: '텍스트 선택 → 키 → Claude 다듬기 → 선택 영역 교체.',
agentVoice: 'Cloud Agent(음성)',
agentVoiceDesc: '키 → 말하기 → Claude 작업 실행 → 결과 캡슐 표시. (곧 지원)',
agentVoiceHint: '받아쓰기 키를 길게 눌러 말하고 손을 떼면 Claude가 작업을 실행해 결과를 캡슐로 표시합니다. 먼저 「고급」에서 켜세요.',
agentVoiceTrigger: '받아쓰기 키 길게 누르기',
enable: '활성화',
disable: '비활성화',
confirmHint: '오른쪽 ✓ 클릭',
+2
View File
@@ -739,6 +739,8 @@ export const zhCN = {
agentPolishDesc: '选中文本 → 按键 → Claude 润色 → 替换选区。',
agentVoice: 'Cloud Agent(语音)',
agentVoiceDesc: '按键 → 说话 → Claude 执行任务 → 结果弹胶囊显示。(开发中)',
agentVoiceHint: '长按听写键说话,松开后 Claude 执行并把结果弹胶囊显示。需先在「高级」里开启。',
agentVoiceTrigger: '长按听写键',
enable: '启用',
disable: '停用',
confirmHint: '点击右侧 ✓',
+2
View File
@@ -741,6 +741,8 @@ export const zhTW: typeof zhCN = {
agentPolishDesc: '選取文字 → 按鍵 → Claude 潤色 → 取代選取。',
agentVoice: 'Cloud Agent(語音)',
agentVoiceDesc: '按鍵 → 說話 → Claude 執行任務 → 結果彈膠囊顯示。(開發中)',
agentVoiceHint: '長按聽寫鍵說話,放開後 Claude 執行並把結果彈膠囊顯示。需先在「進階」裡開啟。',
agentVoiceTrigger: '長按聽寫鍵',
enable: '啟用',
disable: '停用',
confirmHint: '點擊右側 ✓',
@@ -155,55 +155,15 @@ export function ShortcutsSection() {
</button>
)}
</SettingRow>
<SettingRow label={t('settings.shortcuts.agentPolish')} desc={t('settings.shortcuts.agentPolishDesc')}>
{prefs.codingAgentQuickHotkey ? (
<ShortcutRecorder
value={prefs.codingAgentQuickHotkey}
alignRecordButton
comboOnly
onSave={async b => {
await savePrefs({ ...prefs, codingAgentQuickHotkey: b });
}}
onDisable={() => void savePrefs({ ...prefs, codingAgentQuickHotkey: null })}
/>
) : (
<button
onClick={() =>
void savePrefs({
...prefs,
codingAgentQuickHotkey: { primary: 'J', modifiers: ['cmd', 'shift'] },
})
}
style={enableBtnStyle}
>
{t('settings.shortcuts.enable', 'Enable')}
</button>
)}
</SettingRow>
<SettingRow label={t('settings.shortcuts.agentVoice')} desc={t('settings.shortcuts.agentVoiceDesc')}>
{prefs.codingAgentPanelHotkey ? (
<ShortcutRecorder
value={prefs.codingAgentPanelHotkey}
alignRecordButton
comboOnly
onSave={async b => {
await savePrefs({ ...prefs, codingAgentPanelHotkey: b });
}}
onDisable={() => void savePrefs({ ...prefs, codingAgentPanelHotkey: null })}
/>
) : (
<button
onClick={() =>
void savePrefs({
...prefs,
codingAgentPanelHotkey: { primary: 'Enter', modifiers: ['cmd', 'shift'] },
})
}
style={enableBtnStyle}
>
{t('settings.shortcuts.enable', 'Enable')}
</button>
)}
<SettingRow label={t('settings.shortcuts.agentVoice')} desc={t('settings.shortcuts.agentVoiceHint')}>
<kbd style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '4px 10px', fontSize: 12, fontFamily: 'var(--ol-font-mono)',
borderRadius: 6, background: 'var(--ol-surface-2)',
border: '0.5px solid var(--ol-line-strong)',
boxShadow: '0 1px 0 rgba(0,0,0,0.04)',
color: 'var(--ol-ink-2)',
}}>{t('settings.shortcuts.agentVoiceTrigger')}</kbd>
</SettingRow>
{readonlyRows.map(([k, v]) => (
<SettingRow key={k} label={k}>