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:
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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -743,6 +743,8 @@ export const ja: typeof zhCN = {
|
||||
agentPolishDesc: 'テキスト選択 → キー → Claude が推敲 → 選択範囲を置換。',
|
||||
agentVoice: 'Cloud Agent(音声)',
|
||||
agentVoiceDesc: 'キー → 話す → Claude がタスク実行 → 結果をカプセル表示。(近日対応)',
|
||||
agentVoiceHint: 'ディクテーションキーを長押しして話し、離すと Claude が実行して結果をカプセル表示します。先に「詳細」で有効化してください。',
|
||||
agentVoiceTrigger: 'ディクテーションキー長押し',
|
||||
enable: '有効化',
|
||||
disable: '無効化',
|
||||
confirmHint: '右側の ✓ をクリック',
|
||||
|
||||
@@ -743,6 +743,8 @@ export const ko: typeof zhCN = {
|
||||
agentPolishDesc: '텍스트 선택 → 키 → Claude 다듬기 → 선택 영역 교체.',
|
||||
agentVoice: 'Cloud Agent(음성)',
|
||||
agentVoiceDesc: '키 → 말하기 → Claude 작업 실행 → 결과 캡슐 표시. (곧 지원)',
|
||||
agentVoiceHint: '받아쓰기 키를 길게 눌러 말하고 손을 떼면 Claude가 작업을 실행해 결과를 캡슐로 표시합니다. 먼저 「고급」에서 켜세요.',
|
||||
agentVoiceTrigger: '받아쓰기 키 길게 누르기',
|
||||
enable: '활성화',
|
||||
disable: '비활성화',
|
||||
confirmHint: '오른쪽 ✓ 클릭',
|
||||
|
||||
@@ -739,6 +739,8 @@ export const zhCN = {
|
||||
agentPolishDesc: '选中文本 → 按键 → Claude 润色 → 替换选区。',
|
||||
agentVoice: 'Cloud Agent(语音)',
|
||||
agentVoiceDesc: '按键 → 说话 → Claude 执行任务 → 结果弹胶囊显示。(开发中)',
|
||||
agentVoiceHint: '长按听写键说话,松开后 Claude 执行并把结果弹胶囊显示。需先在「高级」里开启。',
|
||||
agentVoiceTrigger: '长按听写键',
|
||||
enable: '启用',
|
||||
disable: '停用',
|
||||
confirmHint: '点击右侧 ✓',
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in new issue
Block a user