diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index c44aae1e..b1d1e76e 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -512,7 +512,14 @@ async fn run_streaming_polish( #[cfg(target_os = "windows")] let sendinput_options = windows_sendinput_options_from_prefs(&inner.prefs.get()); #[cfg(target_os = "macos")] - let macos_newline_mode = inner.prefs.get().macos_newline_mode; + let macos_newline_mode = { + let configured = inner.prefs.get().macos_newline_mode; + let resolved = resolve_macos_newline_mode(configured, front_app); + log::info!( + "[coord] streaming_insert: macOS newline mode configured={configured:?} resolved={resolved:?}" + ); + resolved + }; let typer_handle = tokio::task::spawn_blocking(move || { #[cfg(target_os = "windows")] { @@ -699,6 +706,33 @@ async fn run_streaming_polish( } } +/// 把 Auto 解析成单次听写实际使用的模式。前台应用在听写开始时已经捕获,整个流式上屏 +/// 过程使用同一结果;未知应用保守使用 Shift+Return,避免聊天框换行时误发送。 +#[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))] +fn resolve_macos_newline_mode( + configured: crate::types::MacosNewlineMode, + front_app: Option<&str>, +) -> crate::types::MacosNewlineMode { + use crate::types::MacosNewlineMode; + + if configured != MacosNewlineMode::Auto { + return configured; + } + + let bundle_id = front_app.and_then(|label| { + let front = crate::types::split_front_app_label(label, true); + front.bundle_id.or(front.name) + }); + if bundle_id + .as_deref() + .is_some_and(crate::host_document::is_terminal_bundle_id) + { + MacosNewlineMode::LineFeed + } else { + MacosNewlineMode::ShiftReturn + } +} + #[cfg(target_os = "windows")] pub(super) fn windows_sendinput_options_from_prefs( prefs: &crate::types::UserPreferences, @@ -5363,19 +5397,71 @@ mod tests { append_typed_prefix, batch_asr_chunk_limit_ms, build_transcribe_failed_session, coding_agent_mode_from_pref, default_done_message, drain_streaming_insert_deltas_with, eligible_polish_context_turns, finalize_polished_text, flush_streaming_insert_buffer_with, - pcm_duration_ms, pcm_from_wav_bytes, retry_error_outcome, should_arm_edit_watch, - should_attempt_silent_retry, should_read_cursor_context, insert_delivery_failed, - resolve_less_computer_run_outcome, streaming_insert_eligible, - SilentRetryOutcome, + insert_delivery_failed, pcm_duration_ms, pcm_from_wav_bytes, + resolve_less_computer_run_outcome, resolve_macos_newline_mode, retry_error_outcome, + should_arm_edit_watch, should_attempt_silent_retry, should_read_cursor_context, + streaming_insert_eligible, SilentRetryOutcome, }; #[cfg(any(target_os = "macos", target_os = "linux"))] use super::{desktop_keyless_dictation_provider, DesktopKeylessDictationProvider}; use crate::coordinator::RetranscribeError; use crate::types::{ - ChineseScriptPreference, CorrectionRule, DictationSession, InsertStatus, PolishMode, + ChineseScriptPreference, CorrectionRule, DictationSession, InsertStatus, MacosNewlineMode, + PolishMode, }; use uuid::Uuid; + #[test] + fn macos_auto_newline_uses_line_feed_in_known_terminals() { + for front_app in [ + "Terminal (com.apple.Terminal)", + "iTerm2 (com.googlecode.iterm2)", + "Warp (dev.warp.Warp-Stable)", + "WezTerm (com.github.wez.wezterm)", + "Alacritty (io.alacritty)", + "Alacritty (org.alacritty)", + "kitty (net.kovidgoyal.kitty)", + "Hyper (co.zeit.hyper)", + "Tabby (org.tabby)", + "Tabby (com.tabby)", + "Ghostty (com.mitchellh.ghostty)", + ] { + assert_eq!( + resolve_macos_newline_mode(MacosNewlineMode::Auto, Some(front_app)), + MacosNewlineMode::LineFeed, + "{front_app} should use U+000A" + ); + } + } + + #[test] + fn macos_auto_newline_uses_shift_return_outside_known_terminals() { + for front_app in [ + None, + Some("Terminal"), + Some("Safari (com.apple.Safari)"), + Some("Slack (com.tinyspeck.slackmacgap)"), + ] { + assert_eq!( + resolve_macos_newline_mode(MacosNewlineMode::Auto, front_app), + MacosNewlineMode::ShiftReturn, + "{front_app:?} should use the chat-safe fallback" + ); + } + } + + #[test] + fn macos_explicit_newline_modes_override_target_app_detection() { + let terminal = Some("Terminal (com.apple.Terminal)"); + for configured in [ + MacosNewlineMode::ShiftReturn, + MacosNewlineMode::LineFeed, + MacosNewlineMode::Return, + ] { + assert_eq!(resolve_macos_newline_mode(configured, terminal), configured); + } + } + #[test] fn sandbox_providers_legacy_permission_modes_fail_closed_to_read_only() { use crate::coding_agent::{CodingAgentPermissionMode as M, CodingAgentProvider as P}; diff --git a/openless-all/app/src-tauri/src/host_document/mod.rs b/openless-all/app/src-tauri/src/host_document/mod.rs index ddd2675d..e68afa05 100644 --- a/openless-all/app/src-tauri/src/host_document/mod.rs +++ b/openless-all/app/src-tauri/src/host_document/mod.rs @@ -183,19 +183,15 @@ pub struct GateInputs { /// AX 里表示「密码输入框」的 role/subrole 值。 const AX_SECURE_TEXT_FIELD: &str = "axsecuretextfield"; -/// 一律不读的 app(bundle id 前缀,小写比较)。 +/// 一律不读的敏感 app(bundle id 前缀,小写比较)。 /// /// 不做 UI —— 黑名单 UI 会给用户「配一下就安全了」的错觉,而真正的防线是默认关闭 /// 加这里的硬编码。这份清单只覆盖「内容几乎必然敏感」的两类: /// /// - **密码管理器 / 钥匙串**:正文就是凭据本身。 -/// - **终端**:命令行里混着 token、私钥路径、内网地址,而且很多终端的 AX 会把整个 -/// scrollback 当作一个文本元素返回 —— 一读就是几千行历史命令。 -/// /// 前缀匹配,所以 `com.1password` 能同时盖住 `com.1password.1password` 和其 /// helper 进程。 -const BLOCKED_BUNDLE_PREFIXES: &[&str] = &[ - // 密码管理器 / 钥匙串 +const SENSITIVE_BUNDLE_PREFIXES: &[&str] = &[ "com.1password", "com.agilebits.onepassword", "com.apple.keychainaccess", @@ -207,7 +203,11 @@ const BLOCKED_BUNDLE_PREFIXES: &[&str] = &[ "in.sinew.enpass", "com.sinew.enpass", "com.apple.passwords", - // 终端 +]; + +/// 终端 app 的 bundle id 前缀。除了禁止读取 scrollback,也供 macOS 自动换行模式判断: +/// 已知终端发送 U+000A,其它应用保守发送 Shift+Return。 +const TERMINAL_BUNDLE_PREFIXES: &[&str] = &[ "com.apple.terminal", "com.googlecode.iterm2", "dev.warp.warp", @@ -221,6 +221,15 @@ const BLOCKED_BUNDLE_PREFIXES: &[&str] = &[ "com.mitchellh.ghostty", ]; +fn bundle_id_starts_with_any(bundle_id: &str, prefixes: &[&str]) -> bool { + let lowered = bundle_id.to_ascii_lowercase(); + prefixes.iter().any(|prefix| lowered.starts_with(prefix)) +} + +pub(crate) fn is_terminal_bundle_id(bundle_id: &str) -> bool { + bundle_id_starts_with_any(bundle_id, TERMINAL_BUNDLE_PREFIXES) +} + /// 闸门判定。返回 `Some(reason)` 表示拦下,`None` 表示放行。 /// /// 判定顺序按「代价从低到高」:Secure Input 和 bundle 前缀不需要 AX,先判; @@ -230,10 +239,8 @@ pub fn evaluate_gate(inputs: &GateInputs) -> Option { return Some(BlockReason::SecureInput); } if let Some(bundle) = inputs.bundle_id.as_deref() { - let lowered = bundle.to_ascii_lowercase(); - if BLOCKED_BUNDLE_PREFIXES - .iter() - .any(|prefix| lowered.starts_with(prefix)) + if bundle_id_starts_with_any(bundle, SENSITIVE_BUNDLE_PREFIXES) + || is_terminal_bundle_id(bundle) { return Some(BlockReason::BlockedApp); } diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 5395a8fa..88659644 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -132,17 +132,22 @@ pub enum WindowsSendInputNewlineMode { /// macOS 逐字上屏时换行符怎么发。仅流式插入路径生效。 /// -/// 默认 `ShiftReturn`:macOS 把 U+000A 当 Return 键,而聊天框里 Return 就是「发送」—— -/// 一条带空行的两段话会被从中间劈开发出去。Shift+Return 在聊天框是软换行,在编辑器 / -/// 终端 / 网页输入框里就是普通换行。 +/// 默认 `Auto`:已知终端应用发送 U+000A,其它或未知应用发送 Shift+Return。 +/// +/// Terminal.app 无法区分 Shift+Return 和 Return,里面的 Codex / Claude Code 等 TUI +/// 会把它当成「提交」。`LineFeed` 恢复发送 U+000A,让这些 TUI 将其识别为 Ctrl+J 软换行。 /// /// 保留 `Return` 是因为风格市场里有靠换行发多条消息的风格包,那种效果需要真回车。 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "camelCase")] pub enum MacosNewlineMode { - /// Shift+Return:聊天框软换行,不发送。 + /// 按听写开始时捕获的前台应用自动选择;未知应用安全回退到 Shift+Return。 #[default] + Auto, + /// Shift+Return:聊天框软换行,不发送。 ShiftReturn, + /// U+000A:Terminal.app / CLI Agent 中作为 Ctrl+J 软换行。 + LineFeed, /// Return:聊天框里等于发送 —— 想要「一段话拆成多条消息」的风格包用这个。 Return, } @@ -3706,6 +3711,22 @@ mod tests { assert!(!json.contains("windowsSendinputNewlineMode")); } + #[test] + fn macos_newline_mode_defaults_to_auto() { + let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); + assert_eq!(prefs.macos_newline_mode, MacosNewlineMode::Auto); + } + + #[test] + fn macos_newline_mode_round_trips_line_feed() { + let prefs: UserPreferences = + serde_json::from_str(r#"{"macosNewlineMode":"lineFeed"}"#).unwrap(); + assert_eq!(prefs.macos_newline_mode, MacosNewlineMode::LineFeed); + + let json = serde_json::to_string(&prefs).unwrap(); + assert!(json.contains(r#""macosNewlineMode":"lineFeed""#)); + } + #[test] fn windows_sendinput_insertion_only_serializes_frontend_wire_key() { let enabled = UserPreferences { diff --git a/openless-all/app/src-tauri/src/unicode_keystroke.rs b/openless-all/app/src-tauri/src/unicode_keystroke.rs index 447de6a4..ae2b14b5 100644 --- a/openless-all/app/src-tauri/src/unicode_keystroke.rs +++ b/openless-all/app/src-tauri/src/unicode_keystroke.rs @@ -103,6 +103,8 @@ mod macos_impl { pub(super) enum MacKeystroke { /// 换行:发真实的 Shift+Return 按键(聊天框软换行)。 ShiftReturn, + /// 换行:发送 Unicode U+000A(Terminal.app / CLI Agent 中作为 Ctrl+J 软换行)。 + LineFeed, /// 换行:发真实的 Return 按键(聊天框里等于发送)。 Return, /// CR:不发任何键。`\r\n` 里它只是 `\n` 的前缀,发了会变成两个换行; @@ -113,22 +115,29 @@ mod macos_impl { Unicode, } - /// **换行必须走真实按键,不能当普通 Unicode 字符发。** + /// 默认用真实 Shift+Return;Terminal.app / CLI Agent 可显式选择 Unicode U+000A。 /// /// macOS 的文本输入系统看到 U+000A 就当作 Return —— 在微信 / Slack / Telegram /// 这类聊天框里等价于「发送」。曾经有一条带空行的两段话被逐字上屏,第一个 `\n` /// 直接把上半句发了出去,下半句留在了输入框里。 /// - /// 默认发 Shift+Return:在聊天框是「软换行」(不发送),在编辑器 / 终端 / 网页 - /// textarea 里就是普通换行 —— 两边都对。Windows 侧早有同款结论(见 + /// 默认发 Shift+Return:在聊天框是「软换行」(不发送),在编辑器 / 网页 textarea + /// 里就是普通换行。Windows 侧早有同款结论(见 /// `WindowsSendInputNewlineMode::ShiftEnter`,设置文案直接写着「聊天框选它」)。 /// + /// Terminal.app 不区分 Shift+Return 和 Return,Codex / Claude Code 等 TUI 会把两者 + /// 都当作「提交」。`LineFeed` 发送 U+000A,让这些 TUI 将其识别为 Ctrl+J 软换行。 + /// /// 用户可以在设置里改成 `Return`:风格市场上有靠换行把一段话拆成多条消息的风格包, /// 那种效果要的正是真回车。 pub(super) fn classify_mac_keystroke(ch: char, mode: MacosNewlineMode) -> MacKeystroke { match ch { '\n' => match mode { + // `Auto` 通常会在逐字上屏任务启动前解析;缺少前台应用上下文的调用方 + // 使用对聊天框安全的 Shift+Return 兜底。 + MacosNewlineMode::Auto => MacKeystroke::ShiftReturn, MacosNewlineMode::ShiftReturn => MacKeystroke::ShiftReturn, + MacosNewlineMode::LineFeed => MacKeystroke::LineFeed, MacosNewlineMode::Return => MacKeystroke::Return, }, '\r' => MacKeystroke::Swallow, @@ -162,6 +171,7 @@ mod macos_impl { for ch in text.chars() { let sent = match classify_mac_keystroke(ch, newline_mode) { MacKeystroke::ShiftReturn => send_shift_return(), + MacKeystroke::LineFeed => send_line_feed(), MacKeystroke::Return => send_return(), // 吞掉的 char 也要计数:调用方(`flush_streaming_insert_buffer_with`) // 拿 `typed_chars` 和 `delta.chars().count()` 比对,少一个就判定 @@ -204,6 +214,12 @@ mod macos_impl { post_key_event(KEY_RETURN, KCG_EVENT_FLAG_MASK_SHIFT, None) } + /// 发送 Unicode U+000A。Terminal.app 会把它转给 TUI,Codex / Claude Code 等将其 + /// 识别为 Ctrl+J 软换行,而不是普通 Return 的「提交」。 + fn send_line_feed() -> Result<(), TypeError> { + send_one_codepoint('\n') + } + /// 发一次不带修饰键的 Return。聊天框里这等于「发送」——只有用户在设置里明确选了 /// [`MacosNewlineMode::Return`] 才会走到这里。 fn send_return() -> Result<(), TypeError> { @@ -666,16 +682,16 @@ mod linux_impl { mod tests { use super::TypeError; - /// 默认模式下换行走 Shift+Return —— macOS 把 U+000A 当 Return,聊天框里等于 - /// 「发送」,一条带空行的两段话会被从中间劈开发出去。 + /// 没有前台应用上下文时,未解析的 Auto 安全回退到 Shift+Return,避免聊天框里 + /// U+000A 被当作 Return 后直接发送。 #[test] #[cfg(target_os = "macos")] - fn newline_defaults_to_shift_return() { + fn unresolved_auto_mode_falls_back_to_shift_return() { use super::macos_impl::{classify_mac_keystroke, MacKeystroke}; use crate::types::MacosNewlineMode; let mode = MacosNewlineMode::default(); - assert_eq!(mode, MacosNewlineMode::ShiftReturn, "默认必须是不发送的那个"); + assert_eq!(mode, MacosNewlineMode::Auto); assert_eq!( classify_mac_keystroke('\n', mode), MacKeystroke::ShiftReturn @@ -710,6 +726,24 @@ mod tests { ); } + /// Terminal.app 不区分 Shift+Return 和 Return;显式 LineFeed 模式必须改发 + /// Unicode U+000A,供 Codex / Claude Code 等 TUI 识别为 Ctrl+J 软换行。 + #[test] + #[cfg(target_os = "macos")] + fn line_feed_mode_sends_unicode_lf_for_terminal_cli_agents() { + use super::macos_impl::{classify_mac_keystroke, MacKeystroke}; + use crate::types::MacosNewlineMode; + + assert_eq!( + classify_mac_keystroke('\n', MacosNewlineMode::LineFeed), + MacKeystroke::LineFeed + ); + assert_eq!( + classify_mac_keystroke('中', MacosNewlineMode::LineFeed), + MacKeystroke::Unicode + ); + } + /// 计数契约:`type_unicode_chunk` 返回的 typed_chars 必须等于输入的 char 数, /// 连被吞掉的 `\r` 也要算 —— 调用方拿它跟 `delta.chars().count()` 比对, /// 少一个就判定「部分失败」并丢弃后面所有 delta。 @@ -719,7 +753,12 @@ mod tests { use super::macos_impl::classify_mac_keystroke; use crate::types::MacosNewlineMode; - for mode in [MacosNewlineMode::ShiftReturn, MacosNewlineMode::Return] { + for mode in [ + MacosNewlineMode::Auto, + MacosNewlineMode::ShiftReturn, + MacosNewlineMode::LineFeed, + MacosNewlineMode::Return, + ] { let text = "上半句\r\n\r\n下半句"; // 每个 char 都会被分类成某一种处理方式,没有漏网的。 let counted = text diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 4b1e7c41..f8fc502d 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -879,8 +879,10 @@ export const en: typeof zhCN = { windowsInsertionModeSendInput: 'SendInput keystroke simulation', windowsInsertionModePaste: 'Clipboard paste (Ctrl+V, etc.)', macosNewlineModeLabel: 'Line breaks', - macosNewlineModeDesc: 'Which key a line break sends while typing out text. A plain Return means "send" in chat apps — keep Shift+Return unless your style pack deliberately splits one dictation into several messages.', + macosNewlineModeDesc: 'Auto uses Line Feed (U+000A / Ctrl+J) in known terminal apps and Shift+Return elsewhere. Plain Return sends the message.', + macosNewlineModeAuto: 'Auto (Line Feed in terminals)', macosNewlineModeShiftReturn: 'Shift+Return (newline in chat)', + macosNewlineModeLineFeed: 'Line Feed (terminal CLI / Ctrl+J)', macosNewlineModeReturn: 'Return (split into messages)', windowsSendInputNewlineModeLabel: 'SendInput newline simulation', windowsSendInputNewlineModeDesc: 'How SendInput turns line breaks into keys. Use Shift+Enter for chat boxes; Enter for Notepad / VS Code and most editors.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 835fb26e..2104a860 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -881,8 +881,10 @@ export const ja: typeof zhCN = { windowsInsertionModeSendInput: 'SendInput キー入力シミュレーション', windowsInsertionModePaste: 'クリップボード貼り付け(Ctrl+V など)', macosNewlineModeLabel: '改行の送り方', - macosNewlineModeDesc: '逐次入力時に改行をどのキーで送るか。チャットでは通常の Return は「送信」になります。改行で複数メッセージに分けるスタイルパックを使う場合以外は Shift+Return のままに。', + macosNewlineModeDesc: '自動では既知のターミナルアプリに Line Feed(U+000A / Ctrl+J)、それ以外に Shift+Return を送ります。通常の Return は送信になります。', + macosNewlineModeAuto: '自動(ターミナルでは Line Feed)', macosNewlineModeShiftReturn: 'Shift+Return(チャットで改行)', + macosNewlineModeLineFeed: 'Line Feed(ターミナル CLI / Ctrl+J)', macosNewlineModeReturn: 'Return(複数メッセージに分割)', windowsSendInputNewlineModeLabel: 'SendInput 改行シミュレーション', windowsSendInputNewlineModeDesc: 'SendInput で改行をどのキーとして送るか。チャット入力は Shift+Enter、メモ帳 / VS Code などは Enter。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 6c42f04e..13be4ae5 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -881,8 +881,10 @@ export const ko: typeof zhCN = { windowsInsertionModeSendInput: 'SendInput 키 입력 시뮬레이션', windowsInsertionModePaste: '클립보드 붙여넣기(Ctrl+V 등)', macosNewlineModeLabel: '줄바꿈 처리', - macosNewlineModeDesc: '한 글자씩 입력할 때 줄바꿈을 어느 키로 보낼지. 채팅에서 일반 Return은 전송을 의미합니다. 줄바꿈으로 여러 메시지를 나누는 스타일 팩을 쓰는 게 아니라면 Shift+Return을 유지하세요.', + macosNewlineModeDesc: '자동은 알려진 터미널 앱에서 Line Feed(U+000A / Ctrl+J)를, 그 밖의 앱에서는 Shift+Return을 사용합니다. 일반 Return은 메시지를 전송합니다.', + macosNewlineModeAuto: '자동(터미널에서는 Line Feed)', macosNewlineModeShiftReturn: 'Shift+Return(채팅에서 줄바꿈)', + macosNewlineModeLineFeed: 'Line Feed(터미널 CLI / Ctrl+J)', macosNewlineModeReturn: 'Return(여러 메시지로 분할)', windowsSendInputNewlineModeLabel: 'SendInput 줄바꿈 시뮬레이션', windowsSendInputNewlineModeDesc: 'SendInput 모드에서 줄바꿈을 어떤 키로 보낼지. 채팅 입력창은 Shift+Enter, 메모장 / VS Code 등은 Enter.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 0fed9afb..2cc126af 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -877,8 +877,10 @@ export const zhCN = { windowsInsertionModeSendInput: 'SendInput 逐字模拟', windowsInsertionModePaste: '剪贴板粘贴(Ctrl+V 等)', macosNewlineModeLabel: '换行怎么落', - macosNewlineModeDesc: '逐字上屏时换行符发哪个按键。聊天框里真回车等于发送——除非你用的风格包就是靠换行拆成多条消息,否则保持 Shift+Return。', + macosNewlineModeDesc: '自动会在已知终端应用中使用 Line Feed(U+000A / Ctrl+J),其他应用使用 Shift+Return;Return 会直接发送。', + macosNewlineModeAuto: '自动(终端使用 Line Feed)', macosNewlineModeShiftReturn: 'Shift+Return(聊天框换行)', + macosNewlineModeLineFeed: 'Line Feed(终端 CLI / Ctrl+J)', macosNewlineModeReturn: 'Return(拆成多条消息)', windowsSendInputNewlineModeLabel: 'SendInput 换行模拟', windowsSendInputNewlineModeDesc: 'SendInput 模式下如何把换行符模拟成按键。聊天框通常选 Shift+Enter;记事本 / VS Code 等选 Enter。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 2af57931..6557d886 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -879,8 +879,10 @@ export const zhTW: typeof zhCN = { windowsInsertionModeSendInput: 'SendInput 逐字模擬', windowsInsertionModePaste: '剪貼簿貼上(Ctrl+V 等)', macosNewlineModeLabel: '換行怎麼落', - macosNewlineModeDesc: '逐字上屏時換行符發哪個按鍵。聊天框裡真回車等於發送——除非你用的風格包就是靠換行拆成多條訊息,否則保持 Shift+Return。', + macosNewlineModeDesc: '自動會在已知終端應用中使用 Line Feed(U+000A / Ctrl+J),其他應用使用 Shift+Return;Return 會直接發送。', + macosNewlineModeAuto: '自動(終端使用 Line Feed)', macosNewlineModeShiftReturn: 'Shift+Return(聊天框換行)', + macosNewlineModeLineFeed: 'Line Feed(終端 CLI / Ctrl+J)', macosNewlineModeReturn: 'Return(拆成多條訊息)', windowsSendInputNewlineModeLabel: 'SendInput 換行模擬', windowsSendInputNewlineModeDesc: 'SendInput 模式下如何把換行符模擬成按鍵。聊天框通常選 Shift+Enter;記事本 / VS Code 等選 Enter。', diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 1a1357cd..ad475339 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -60,7 +60,7 @@ export let mockSettings: UserPreferences = { allowNonTsfInsertionFallback: true, windowsInsertionMode: "tsf", windowsSendInputNewlineMode: "enter", - macosNewlineMode: "shiftReturn", + macosNewlineMode: "auto", windowsSendInputInsertionOnly: false, windowsShowOpenlessInKeyboardList: true, workingLanguages: ["简体中文"], diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 6b4c7228..0ea46e6b 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -239,9 +239,9 @@ export type WindowsInsertionMode = 'tsf' | 'sendInput' | 'paste'; /** Windows SendInput 路径的换行模拟方式。 */ export type WindowsSendInputNewlineMode = 'enter' | 'shiftEnter' | 'crlf'; -/** macOS 逐字上屏时换行符怎么发。`return` 在聊天框里等于发送 —— 靠换行拆多条消息的 - * 风格包要的就是这个。 */ -export type MacosNewlineMode = 'shiftReturn' | 'return'; +/** macOS 逐字上屏时换行符怎么发。`auto` 按前台应用选择;`lineFeed` 供终端使用; + * `return` 在聊天框里等于发送 —— 靠换行拆多条消息的风格包要的就是这个。 */ +export type MacosNewlineMode = 'auto' | 'shiftReturn' | 'lineFeed' | 'return'; export type WindowsImeInstallState = | 'installed' diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index 0678d680..2cf6b811 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -528,10 +528,12 @@ export function RecordingInputSection() { desc={t('settings.recording.macosNewlineModeDesc')} > onMacosNewlineModeChange(next as MacosNewlineMode)} options={[ + { value: 'auto', label: t('settings.recording.macosNewlineModeAuto') }, { value: 'shiftReturn', label: t('settings.recording.macosNewlineModeShiftReturn') }, + { value: 'lineFeed', label: t('settings.recording.macosNewlineModeLineFeed') }, { value: 'return', label: t('settings.recording.macosNewlineModeReturn') }, ]} ariaLabel={t('settings.recording.macosNewlineModeLabel')}