fix(insertion): make Windows/Linux paste shortcut configurable (#360)

模拟粘贴硬编码 Ctrl+V,kitty / alacritty / wezterm / gnome-terminal /
foot 等 Linux 终端只接受 Ctrl+Shift+V,听写文本只剩在剪贴板里,看起来
像"什么也没插入"。issue #360 报告了 kitty 这个具体场景。

新增 `PasteShortcut` 枚举(`CtrlV` / `CtrlShiftV` / `ShiftInsert`)作为
UserPreferences 字段,串到 simulate_paste 的 enigo 路径。Settings →
录音 多了"模拟粘贴快捷键"一行,仅在非 macOS 显示(macOS 走 AX 直写
不受影响)。默认 `CtrlV` 与历史行为一致,老配置文件没有 pasteShortcut
字段时 `#[serde(default)]` 回退到默认值,不破坏既有用户。

涵盖:
- types.rs:新枚举 + 字段 + UserPreferencesWire 串接
- insertion.rs:simulate_paste(shortcut),paste_keys 把枚举拆成
  (modifiers, primary),按下/释放严格对称,中途出错也反向释放避免卡键
- coordinator.rs:从 prefs 读取,串到三个 insert 调用点 + Windows IME 链
- 前端 TS 类型 / Settings 选择器 / 5 种语言 i18n
- 单元测试:默认 CtrlV、JSON 反序列化、paste_keys 三种映射

cargo test --lib 全部 181 通过;npm run build 干净。
This commit is contained in:
baiqing committed 2026-05-09 13:15:33 +08:00
1 parent 15442b5aad
commit eaca99bdf4
12 files changed
+214 -26

No files matched your search

+11 -5
View File
@@ -43,7 +43,7 @@ use crate::selection::{capture_selection, SelectionContext};
use crate::types::{
CapsulePayload, CapsuleState, ChineseScriptPreference, DictationSession, HotkeyCapability,
HotkeyMode, HotkeyStatus, HotkeyStatusState, InsertStatus, OutputLanguagePreference,
PolishMode,
PasteShortcut, PolishMode,
};
#[cfg(target_os = "windows")]
use crate::windows_ime_ipc::ImeSubmitTarget;
@@ -2725,6 +2725,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
let prefs = inner.prefs.get();
let restore_clipboard = prefs.restore_clipboard_after_paste;
let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback;
let paste_shortcut: PasteShortcut = prefs.paste_shortcut;
let status = if focus_ready_for_paste {
#[cfg(target_os = "windows")]
{
@@ -2735,13 +2736,16 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
&polished,
restore_clipboard,
allow_non_tsf_insertion_fallback,
paste_shortcut,
ime_target,
)
.await
}
#[cfg(not(target_os = "windows"))]
{
inner.inserter.insert(&polished, restore_clipboard)
inner
.inserter
.insert(&polished, restore_clipboard, paste_shortcut)
}
} else {
log::warn!(
@@ -2952,6 +2956,7 @@ async fn insert_with_windows_ime_first(
polished: &str,
restore_clipboard: bool,
allow_non_tsf_insertion_fallback: bool,
paste_shortcut: PasteShortcut,
ime_target: Option<ImeSubmitTarget>,
) -> InsertStatus {
let prepared = {
@@ -2964,7 +2969,7 @@ async fn insert_with_windows_ime_first(
allow_non_tsf_insertion_fallback,
InsertStatus::Failed,
) {
return insert_via_non_tsf_fallback(inner, polished, restore_clipboard);
return insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut);
}
log::warn!("[windows-ime] non-TSF insertion fallback is disabled; failing insert");
return InsertStatus::Failed;
@@ -2989,7 +2994,7 @@ async fn insert_with_windows_ime_first(
if ime_status == InsertStatus::Inserted {
ime_status
} else if should_try_non_tsf_insertion_fallback(allow_non_tsf_insertion_fallback, ime_status) {
insert_via_non_tsf_fallback(inner, polished, restore_clipboard)
insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut)
} else {
log::warn!("[windows-ime] TSF did not insert; non-TSF insertion fallback is disabled");
InsertStatus::Failed
@@ -3009,6 +3014,7 @@ fn insert_via_non_tsf_fallback(
inner: &Arc<Inner>,
polished: &str,
restore_clipboard: bool,
paste_shortcut: PasteShortcut,
) -> InsertStatus {
if inner.inserter.insert_via_unicode_keystrokes(polished) == InsertStatus::Inserted {
log::info!("[windows-ime] TSF unavailable; inserted via Unicode SendInput");
@@ -3016,7 +3022,7 @@ fn insert_via_non_tsf_fallback(
} else {
inner
.inserter
.insert_via_clipboard_fallback(polished, restore_clipboard)
.insert_via_clipboard_fallback(polished, restore_clipboard, paste_shortcut)
}
}
+96 -21
View File
@@ -20,7 +20,7 @@ use once_cell::sync::Lazy;
#[cfg(not(target_os = "macos"))]
use parking_lot::Mutex;
use crate::types::InsertStatus;
use crate::types::{InsertStatus, PasteShortcut};
#[cfg(target_os = "windows")]
const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(750);
@@ -38,12 +38,19 @@ impl TextInserter {
/// Insert `text` at the current cursor position.
/// `restore_clipboard_after_paste` 仅在 Windows/Linux 路径下决定 paste 之后是否恢复
/// 用户原剪贴板。macOS 走 AX 直写,参数被忽略。详见 issue #111。
/// `paste_shortcut` 决定 Windows/Linux 上模拟按下的粘贴快捷键。详见 issue #360:
/// kitty/alacritty 等终端只接受 Ctrl+Shift+V,硬编码 Ctrl+V 会被吞掉。
#[cfg(not(target_os = "macos"))]
pub fn insert(&self, text: &str, restore_clipboard_after_paste: bool) -> InsertStatus {
pub fn insert(
&self,
text: &str,
restore_clipboard_after_paste: bool,
paste_shortcut: PasteShortcut,
) -> InsertStatus {
if text.is_empty() {
return InsertStatus::CopiedFallback;
}
insert_with_clipboard_restore(text, restore_clipboard_after_paste)
insert_with_clipboard_restore(text, restore_clipboard_after_paste, paste_shortcut)
}
#[cfg(not(target_os = "macos"))]
@@ -51,8 +58,9 @@ impl TextInserter {
&self,
text: &str,
restore_clipboard_after_paste: bool,
paste_shortcut: PasteShortcut,
) -> InsertStatus {
self.insert(text, restore_clipboard_after_paste)
self.insert(text, restore_clipboard_after_paste, paste_shortcut)
}
#[cfg(target_os = "windows")]
@@ -70,8 +78,15 @@ impl TextInserter {
}
/// Insert `text` at the current cursor position.
/// macOS 走 AX 直写 / Cmd+V:`_restore_clipboard_after_paste` 与 `_paste_shortcut`
/// 仅为跨平台调用方对齐签名而存在,本路径不读它们。
#[cfg(target_os = "macos")]
pub fn insert(&self, text: &str, _restore_clipboard_after_paste: bool) -> InsertStatus {
pub fn insert(
&self,
text: &str,
_restore_clipboard_after_paste: bool,
_paste_shortcut: PasteShortcut,
) -> InsertStatus {
if text.is_empty() {
return InsertStatus::CopiedFallback;
}
@@ -171,7 +186,11 @@ fn copy_to_clipboard_with_restore_plan(text: &str) -> Result<ClipboardRestorePla
}
#[cfg(not(target_os = "macos"))]
fn insert_with_clipboard_restore(text: &str, restore_clipboard_after_paste: bool) -> InsertStatus {
fn insert_with_clipboard_restore(
text: &str,
restore_clipboard_after_paste: bool,
paste_shortcut: PasteShortcut,
) -> InsertStatus {
let restore_plan = match copy_to_clipboard_with_restore_plan(text) {
Ok(plan) => plan,
Err(err) => {
@@ -180,7 +199,7 @@ fn insert_with_clipboard_restore(text: &str, restore_clipboard_after_paste: bool
}
};
if let Err(err) = simulate_paste() {
if let Err(err) = simulate_paste(paste_shortcut) {
log::warn!("[insertion] simulated paste failed: {}", err);
return InsertStatus::CopiedFallback;
}
@@ -302,20 +321,49 @@ fn simulate_paste() -> Result<(), String> {
macos::post_cmd_v()
}
/// 把用户配置的 PasteShortcut 拆成 `(modifiers, primary)`。modifier 顺序决定 enigo
/// 按下/释放顺序,跟物理键盘一致:先 Ctrl 再 Shift 再主键,释放反向。
#[cfg(not(target_os = "macos"))]
fn simulate_paste() -> Result<(), String> {
use enigo::{Direction, Enigo, Key, Keyboard, Settings};
let mut enigo = Enigo::new(&Settings::default()).map_err(|e| e.to_string())?;
let modifier = Key::Control;
enigo
.key(modifier, Direction::Press)
.map_err(|e| e.to_string())?;
let press_v = enigo.key(Key::Unicode('v'), Direction::Click);
let release_modifier = enigo.key(modifier, Direction::Release);
if let Err(e) = release_modifier {
return Err(e.to_string());
fn paste_keys(shortcut: PasteShortcut) -> (Vec<enigo::Key>, enigo::Key) {
use enigo::Key;
match shortcut {
PasteShortcut::CtrlV => (vec![Key::Control], Key::Unicode('v')),
PasteShortcut::CtrlShiftV => (vec![Key::Control, Key::Shift], Key::Unicode('v')),
PasteShortcut::ShiftInsert => (vec![Key::Shift], Key::Insert),
}
}
#[cfg(not(target_os = "macos"))]
fn simulate_paste(shortcut: PasteShortcut) -> Result<(), String> {
use enigo::{Direction, Enigo, Keyboard, Settings};
let (modifiers, primary) = paste_keys(shortcut);
let mut enigo = Enigo::new(&Settings::default()).map_err(|e| e.to_string())?;
// 按下顺序:所有 modifier → 主键点击;
// 释放顺序:modifier 反向释放,确保即使中途出错也尽量把按键状态还原回来。
for modifier in &modifiers {
if let Err(e) = enigo.key(*modifier, Direction::Press) {
// 按下失败时反向释放已经按下的 modifier,避免卡键。
for already_pressed in modifiers.iter().take_while(|m| *m != modifier).rev() {
let _ = enigo.key(*already_pressed, Direction::Release);
}
return Err(e.to_string());
}
}
let click_result = enigo.key(primary, Direction::Click);
let mut release_err: Option<String> = None;
for modifier in modifiers.iter().rev() {
if let Err(e) = enigo.key(*modifier, Direction::Release) {
release_err.get_or_insert_with(|| e.to_string());
}
}
click_result.map_err(|e| e.to_string())?;
if let Some(err) = release_err {
return Err(err);
}
press_v.map_err(|e| e.to_string())?;
Ok(())
}
@@ -474,15 +522,42 @@ mod tests {
assert!(!should_restore_clipboard(None, "dictated text"));
}
/// issue #360: 用户配置的快捷键必须真的映射到对应按键,否则 Settings UI
/// 改了也没用。这里只检查 modifier 数量 + 主键,不依赖 enigo 内部 PartialEq。
#[test]
#[cfg(not(target_os = "macos"))]
fn paste_keys_match_configured_shortcut() {
use enigo::Key;
let (mods, primary) = paste_keys(PasteShortcut::CtrlV);
assert_eq!(mods.len(), 1);
assert!(matches!(mods[0], Key::Control));
assert!(matches!(primary, Key::Unicode('v')));
let (mods, primary) = paste_keys(PasteShortcut::CtrlShiftV);
assert_eq!(mods.len(), 2);
assert!(matches!(mods[0], Key::Control));
assert!(matches!(mods[1], Key::Shift));
assert!(matches!(primary, Key::Unicode('v')));
let (mods, primary) = paste_keys(PasteShortcut::ShiftInsert);
assert_eq!(mods.len(), 1);
assert!(matches!(mods[0], Key::Shift));
assert!(matches!(primary, Key::Insert));
}
#[test]
fn empty_insertions_never_touch_clipboard_or_paste_path() {
let inserter = TextInserter::new();
assert_eq!(inserter.insert("", true), InsertStatus::CopiedFallback);
assert_eq!(
inserter.insert("", true, PasteShortcut::CtrlV),
InsertStatus::CopiedFallback
);
#[cfg(not(target_os = "macos"))]
{
assert_eq!(
inserter.insert_via_clipboard_fallback("", true),
inserter.insert_via_clipboard_fallback("", true, PasteShortcut::CtrlV),
InsertStatus::CopiedFallback
);
}
+50
View File
@@ -45,6 +45,21 @@ pub enum OutputLanguagePreference {
Ko,
}
/// 模拟粘贴时实际按下的快捷键。macOS 走 AX 直写 / Cmd+V,本枚举只在
/// Windows / Linux 的 simulate_paste 路径生效。详见 issue #360:kitty 等
/// Linux 终端只接受 Ctrl+Shift+V,硬编码 Ctrl+V 会被吞掉,听写文本只剩
/// 在剪贴板里。默认 `CtrlV` 与历史行为一致;用户在 Settings 里改成
/// `CtrlShiftV`(kitty/alacritty/wezterm/gnome-terminal/foot/...)或
/// `ShiftInsert`(xterm/urxvt)后,simulate_paste 用对应组合。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum PasteShortcut {
#[default]
CtrlV,
CtrlShiftV,
ShiftInsert,
}
/// Auto-update 渠道。决定 Settings → 关于 里展示哪一类版本信息。
/// `Stable` 沿用 `tauri-plugin-updater` 的默认 endpoints(即 `tauri.conf.json`
/// 里的 `latest-{{target}}-{{arch}}.json`),与发版 pipeline 对齐。
@@ -145,6 +160,11 @@ pub struct UserPreferences {
/// 关掉就把听写文本留在剪贴板,让 simulate_paste 实际没生效时用户能 Ctrl+V 找回。
/// macOS 走 AX 直写,不受这个开关影响。详见 issue #111。
pub restore_clipboard_after_paste: bool,
/// Windows / Linux 的模拟粘贴键。macOS 走 AX 直写不受影响。详见 issue #360:
/// kitty 等 Linux 终端不接受 Ctrl+V,只能配 Ctrl+Shift+V。默认 CtrlV 与历史
/// 行为一致,不破坏既有用户。
#[serde(default)]
pub paste_shortcut: PasteShortcut,
/// Windows: 是否允许 TSF 失败后继续使用 SendInput / 粘贴类非 TSF 兜底。
/// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。
#[serde(default = "default_true")]
@@ -288,6 +308,8 @@ struct UserPreferencesWire {
active_asr_provider: String,
active_llm_provider: String,
restore_clipboard_after_paste: bool,
#[serde(default)]
paste_shortcut: PasteShortcut,
allow_non_tsf_insertion_fallback: bool,
working_languages: Vec<String>,
translation_target_language: String,
@@ -339,6 +361,7 @@ impl Default for UserPreferencesWire {
active_asr_provider: prefs.active_asr_provider,
active_llm_provider: prefs.active_llm_provider,
restore_clipboard_after_paste: prefs.restore_clipboard_after_paste,
paste_shortcut: prefs.paste_shortcut,
allow_non_tsf_insertion_fallback: prefs.allow_non_tsf_insertion_fallback,
working_languages: prefs.working_languages,
translation_target_language: prefs.translation_target_language,
@@ -388,6 +411,7 @@ impl<'de> Deserialize<'de> for UserPreferences {
active_asr_provider: wire.active_asr_provider,
active_llm_provider: wire.active_llm_provider,
restore_clipboard_after_paste: wire.restore_clipboard_after_paste,
paste_shortcut: wire.paste_shortcut,
allow_non_tsf_insertion_fallback: wire.allow_non_tsf_insertion_fallback,
working_languages: wire.working_languages,
translation_target_language: wire.translation_target_language,
@@ -504,6 +528,7 @@ impl Default for UserPreferences {
active_asr_provider: default_active_asr_provider(),
active_llm_provider: "ark".into(),
restore_clipboard_after_paste: true,
paste_shortcut: PasteShortcut::default(),
allow_non_tsf_insertion_fallback: true,
working_languages: default_working_languages(),
translation_target_language: String::new(),
@@ -1127,6 +1152,31 @@ mod tests {
assert!(prefs.allow_non_tsf_insertion_fallback);
}
/// issue #360: 默认值必须是 CtrlV,跟历史行为一致;老配置文件没有
/// pasteShortcut 字段时反序列化也得回到 CtrlV,否则会把现有用户的粘贴
/// 行为静默改掉。
#[test]
fn paste_shortcut_defaults_to_ctrl_v() {
let prefs = UserPreferences::default();
assert_eq!(prefs.paste_shortcut, PasteShortcut::CtrlV);
let from_empty: UserPreferences = serde_json::from_str("{}").unwrap();
assert_eq!(from_empty.paste_shortcut, PasteShortcut::CtrlV);
}
#[test]
fn paste_shortcut_round_trips_explicit_values() {
for (raw, expected) in [
("ctrlV", PasteShortcut::CtrlV),
("ctrlShiftV", PasteShortcut::CtrlShiftV),
("shiftInsert", PasteShortcut::ShiftInsert),
] {
let json = format!(r#"{{ "pasteShortcut": "{raw}" }}"#);
let prefs: UserPreferences = serde_json::from_str(&json).unwrap();
assert_eq!(prefs.paste_shortcut, expected, "raw={raw}");
}
}
#[test]
fn legacy_custom_hotkey_without_custom_binding_is_rejected() {
let result = serde_json::from_str::<UserPreferences>(
+5
View File
@@ -313,6 +313,11 @@ export const en: typeof zhCN = {
muteDuringRecordingDesc: 'Temporarily mute system output during voice input, then restore the previous mute state when recording stops, is cancelled, or fails.',
restoreClipboardLabel: 'Restore clipboard after insert',
restoreClipboardDesc: 'Windows / Linux only: restore your original clipboard after a successful paste (default on). Turn off to keep the dictation text in the clipboard so you can manually Ctrl+V if the simulated paste did not actually land. See issue #111.',
pasteShortcutLabel: 'Simulated paste shortcut',
pasteShortcutDesc: 'Windows / Linux only: which key combo to simulate when inserting text. Terminals like kitty, alacritty, wezterm, gnome-terminal, and foot only accept Ctrl+Shift+V; xterm / urxvt accept Shift+Insert. Takes effect on your next dictation. See issue #360.',
pasteShortcutCtrlV: 'Ctrl+V (default / most apps)',
pasteShortcutCtrlShiftV: 'Ctrl+Shift+V (kitty / alacritty / wezterm / most terminals)',
pasteShortcutShiftInsert: 'Shift+Insert (xterm / urxvt)',
comboRecordLabel: 'Record shortcut',
comboRecordDesc: 'Click, then press your desired key combination (e.g. \u2318\u21E7D). Supports Toggle and Push-to-talk modes.',
comboRecordBtn: 'Record shortcut',
+5
View File
@@ -315,6 +315,11 @@ export const ja: typeof zhCN = {
muteDuringRecordingDesc: '音声入力開始時にシステム出力を一時的にミュートし、停止/キャンセル/エラー後に元のミュート状態を復元。スピーカーの音がマイクに入らないようにします。',
restoreClipboardLabel: '入力後にクリップボードを復元',
restoreClipboardDesc: 'Windows / Linux のみ:ペースト成功後に元のクリップボード内容を復元(既定 ON)。OFF にするとディクテーションテキストがクリップボードに残り、ペーストが効かなかった場合に手動で Ctrl+V できます。詳細は issue #111。',
pasteShortcutLabel: '貼り付けショートカット',
pasteShortcutDesc: 'Windows / Linux のみ:テキスト挿入時に模擬するショートカット。kitty / alacritty / wezterm / gnome-terminal などのターミナルは Ctrl+Shift+V のみを受け付けます。xterm / urxvt は Shift+Insert。次回のディクテーションから有効。詳細は issue #360。',
pasteShortcutCtrlV: 'Ctrl+V(既定 / ほとんどのアプリ)',
pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / ほとんどのターミナル)',
pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)',
comboRecordLabel: 'ショートカットを記録',
comboRecordDesc: 'クリック後、希望するキーの組み合わせ(例:⌘⇧D)を押してください。トグル / 押し続けの両方に対応。',
comboRecordBtn: 'ショートカットを記録',
+5
View File
@@ -315,6 +315,11 @@ export const ko: typeof zhCN = {
muteDuringRecordingDesc: '음성 입력 시작 시 시스템 출력을 일시적으로 음소거하고, 정지/취소/오류 후 원래 음소거 상태를 복원합니다. 스피커 소리가 마이크에 들어가지 않도록 합니다.',
restoreClipboardLabel: '입력 후 클립보드 복원',
restoreClipboardDesc: 'Windows / Linux 만: 붙여넣기 성공 후 원래 클립보드 내용을 복원합니다(기본 ON). OFF 시 받아쓰기 텍스트가 클립보드에 남아 붙여넣기가 실패한 경우 수동으로 Ctrl+V 할 수 있습니다. issue #111 참조.',
pasteShortcutLabel: '붙여넣기 단축키',
pasteShortcutDesc: 'Windows / Linux 만: 텍스트 삽입 시 시뮬레이션할 단축키. kitty / alacritty / wezterm / gnome-terminal 같은 터미널은 Ctrl+Shift+V 만 받습니다. xterm / urxvt 는 Shift+Insert. 다음 받아쓰기부터 적용. issue #360 참조.',
pasteShortcutCtrlV: 'Ctrl+V (기본 / 대부분 앱)',
pasteShortcutCtrlShiftV: 'Ctrl+Shift+V (kitty / alacritty / wezterm / 대부분 터미널)',
pasteShortcutShiftInsert: 'Shift+Insert (xterm / urxvt)',
comboRecordLabel: '단축키 녹화',
comboRecordDesc: '클릭 후 원하는 단축키 조합(예: ⌘⇧D)을 누르세요. 토글 및 누르기 모드 모두 지원합니다.',
comboRecordBtn: '단축키 녹화',
+5
View File
@@ -311,6 +311,11 @@ export const zhCN = {
muteDuringRecordingDesc: '开始语音输入时临时静音系统输出,停止、取消或出错后恢复原来的静音状态,避免扬声器声音被麦克风收进去。',
restoreClipboardLabel: '插入后恢复剪贴板',
restoreClipboardDesc: '仅 Windows / Linux:粘贴成功后恢复你原来的剪贴板内容(默认开)。关掉就把听写文本留在剪贴板,模拟粘贴没真正落地时可以手动 Ctrl+V 找回。详见 issue #111。',
pasteShortcutLabel: '模拟粘贴快捷键',
pasteShortcutDesc: '仅 Windows / Linux:插入文本时模拟按下的粘贴键。kitty / alacritty / wezterm / gnome-terminal 等终端只接受 Ctrl+Shift+V;xterm / urxvt 用 Shift+Insert。改完下一次听写即生效。详见 issue #360。',
pasteShortcutCtrlV: 'Ctrl+V(默认 / 多数应用)',
pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / 多数终端)',
pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)',
comboRecordLabel: '录制快捷键',
comboRecordDesc: '点击后按下你想要的快捷键组合(如 ⌘⇧D),支持 Toggle 和 Hold 模式。',
comboRecordBtn: '录制快捷键',
+5
View File
@@ -320,6 +320,11 @@ export const zhTW: typeof zhCN = {
muteDuringRecordingDesc: '開始語音輸入時臨時靜音系統輸出,停止、取消或出錯後恢復原來的靜音狀態,避免揚聲器聲音被麥克風收進去。',
restoreClipboardLabel: '插入後恢復剪貼板',
restoreClipboardDesc: '僅 Windows / Linux:粘貼成功後恢復你原來的剪貼板內容(默認開)。關掉就把聽寫文本留在剪貼板,模擬粘貼沒真正落地時可以手動 Ctrl+V 找回。詳見 issue #111。',
pasteShortcutLabel: '模擬粘貼快捷鍵',
pasteShortcutDesc: '僅 Windows / Linux:插入文本時模擬按下的粘貼鍵。kitty / alacritty / wezterm / gnome-terminal 等終端只接受 Ctrl+Shift+V;xterm / urxvt 用 Shift+Insert。改完下一次聽寫即生效。詳見 issue #360。',
pasteShortcutCtrlV: 'Ctrl+V(默認 / 多數應用)',
pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / 多數終端)',
pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)',
allowNonTsfFallbackLabel: '允許非 TSF 兜底',
allowNonTsfFallbackDesc: '僅 Windows:TSF 直接上屏失敗後,允許改用 Unicode SendInput、快捷鍵粘貼或 WM_PASTE。關閉後可驗證是否真實使用 TSF 輸入。',
historyRetentionLabel: '歷史保留天數',
+1
View File
@@ -55,6 +55,7 @@ const mockSettings: UserPreferences = {
activeAsrProvider: 'foundry-local-whisper',
activeLlmProvider: 'ark',
restoreClipboardAfterPaste: true,
pasteShortcut: 'ctrlV',
allowNonTsfInsertionFallback: true,
workingLanguages: ['简体中文'],
translationTargetLanguage: '',
@@ -22,6 +22,7 @@ const previousPrefs: UserPreferences = {
activeAsrProvider: 'volcengine',
activeLlmProvider: 'ark',
restoreClipboardAfterPaste: true,
pasteShortcut: 'ctrlV',
allowNonTsfInsertionFallback: true,
workingLanguages: ['简体中文'],
translationTargetLanguage: '',
+11
View File
@@ -102,6 +102,13 @@ export type QaHotkeyBinding = ShortcutBinding;
/** 自定义录音组合键绑定。当 hotkey.trigger == 'custom' 时使用。 */
export type ComboBinding = ShortcutBinding;
/** 模拟粘贴时按下的快捷键。仅 Windows/Linux 生效;macOS 走 AX 直写。
* - ctrlV : 标准粘贴(默认;大多数编辑器、浏览器、IDE)
* - ctrlShiftV : kitty / alacritty / wezterm / gnome-terminal / foot 等终端
* - shiftInsert : xterm / urxvt 等老派 X11 终端
* 详见 issue #360。 */
export type PasteShortcut = 'ctrlV' | 'ctrlShiftV' | 'shiftInsert';
export type WindowsImeInstallState =
| 'installed'
| 'notInstalled'
@@ -130,6 +137,10 @@ export interface UserPreferences {
activeLlmProvider: string;
/** 仅 Windows/Linux:粘贴成功后是否恢复用户原剪贴板。默认 true。详见 issue #111。 */
restoreClipboardAfterPaste: boolean;
/** 仅 Windows/Linux:模拟粘贴时按下的快捷键。详见 issue #360:kitty/alacritty
* 等终端只接受 Ctrl+Shift+V,硬编码 Ctrl+V 会被吞掉,听写文本只剩在剪贴板里。
* macOS 走 AX 直写不受影响。默认 'ctrlV' 与历史行为一致。 */
pasteShortcut: PasteShortcut;
/** Windows:TSF 失败后是否允许 SendInput / 粘贴类非 TSF 兜底。关闭后可验证是否真实 TSF 上屏。 */
allowNonTsfInsertionFallback: boolean;
/** 用户的工作语言(多选,原生名);作为前提注入 LLM polish/translate prompt 头部。 */
+19
View File
@@ -49,6 +49,7 @@ import type {
HotkeyStatus,
HotkeyTrigger,
MicrophoneDevice,
PasteShortcut,
PermissionStatus,
WindowsImeStatus,
} from '../lib/types';
@@ -268,6 +269,8 @@ function RecordingSection() {
savePrefs({ ...prefs, microphoneDeviceName });
const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) =>
savePrefs({ ...prefs, restoreClipboardAfterPaste });
const onPasteShortcutChange = (pasteShortcut: PasteShortcut) =>
savePrefs({ ...prefs, pasteShortcut });
const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) =>
savePrefs({ ...prefs, allowNonTsfInsertionFallback });
// 历史保留 / 对话感知 polish 上下文窗口都用裸 number input;空字符串时回滚到默认值。
@@ -434,6 +437,22 @@ function RecordingSection() {
>
<Toggle on={prefs.restoreClipboardAfterPaste} onToggle={onRestoreClipboardChange} />
</SettingRow>
{capability.adapter !== 'macEventTap' && (
<SettingRow
label={t('settings.recording.pasteShortcutLabel')}
desc={t('settings.recording.pasteShortcutDesc')}
>
<select
value={prefs.pasteShortcut}
onChange={e => onPasteShortcutChange(e.target.value as PasteShortcut)}
style={{ ...inputStyle, maxWidth: 220 }}
>
<option value="ctrlV">{t('settings.recording.pasteShortcutCtrlV')}</option>
<option value="ctrlShiftV">{t('settings.recording.pasteShortcutCtrlShiftV')}</option>
<option value="shiftInsert">{t('settings.recording.pasteShortcutShiftInsert')}</option>
</select>
</SettingRow>
)}
{capability.adapter === 'windowsLowLevel' && (
<SettingRow
label={t('settings.recording.allowNonTsfFallbackLabel')}