Skip to content

Commit 6efc839

Browse files
authored
feat(macos): auto-select newline mode for terminal apps (#988)
* feat(macos): add line feed newline option * feat(macos): auto-select newline mode by target app * style(macos): localize newline fallback comment
1 parent 3f59b13 commit 6efc839

12 files changed

Lines changed: 204 additions & 39 deletions

File tree

openless-all/app/src-tauri/src/coordinator/dictation.rs

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,14 @@ async fn run_streaming_polish(
512512
#[cfg(target_os = "windows")]
513513
let sendinput_options = windows_sendinput_options_from_prefs(&inner.prefs.get());
514514
#[cfg(target_os = "macos")]
515-
let macos_newline_mode = inner.prefs.get().macos_newline_mode;
515+
let macos_newline_mode = {
516+
let configured = inner.prefs.get().macos_newline_mode;
517+
let resolved = resolve_macos_newline_mode(configured, front_app);
518+
log::info!(
519+
"[coord] streaming_insert: macOS newline mode configured={configured:?} resolved={resolved:?}"
520+
);
521+
resolved
522+
};
516523
let typer_handle = tokio::task::spawn_blocking(move || {
517524
#[cfg(target_os = "windows")]
518525
{
@@ -699,6 +706,33 @@ async fn run_streaming_polish(
699706
}
700707
}
701708

709+
/// 把 Auto 解析成单次听写实际使用的模式。前台应用在听写开始时已经捕获,整个流式上屏
710+
/// 过程使用同一结果;未知应用保守使用 Shift+Return,避免聊天框换行时误发送。
711+
#[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))]
712+
fn resolve_macos_newline_mode(
713+
configured: crate::types::MacosNewlineMode,
714+
front_app: Option<&str>,
715+
) -> crate::types::MacosNewlineMode {
716+
use crate::types::MacosNewlineMode;
717+
718+
if configured != MacosNewlineMode::Auto {
719+
return configured;
720+
}
721+
722+
let bundle_id = front_app.and_then(|label| {
723+
let front = crate::types::split_front_app_label(label, true);
724+
front.bundle_id.or(front.name)
725+
});
726+
if bundle_id
727+
.as_deref()
728+
.is_some_and(crate::host_document::is_terminal_bundle_id)
729+
{
730+
MacosNewlineMode::LineFeed
731+
} else {
732+
MacosNewlineMode::ShiftReturn
733+
}
734+
}
735+
702736
#[cfg(target_os = "windows")]
703737
pub(super) fn windows_sendinput_options_from_prefs(
704738
prefs: &crate::types::UserPreferences,
@@ -5363,19 +5397,71 @@ mod tests {
53635397
append_typed_prefix, batch_asr_chunk_limit_ms, build_transcribe_failed_session,
53645398
coding_agent_mode_from_pref, default_done_message, drain_streaming_insert_deltas_with,
53655399
eligible_polish_context_turns, finalize_polished_text, flush_streaming_insert_buffer_with,
5366-
pcm_duration_ms, pcm_from_wav_bytes, retry_error_outcome, should_arm_edit_watch,
5367-
should_attempt_silent_retry, should_read_cursor_context, insert_delivery_failed,
5368-
resolve_less_computer_run_outcome, streaming_insert_eligible,
5369-
SilentRetryOutcome,
5400+
insert_delivery_failed, pcm_duration_ms, pcm_from_wav_bytes,
5401+
resolve_less_computer_run_outcome, resolve_macos_newline_mode, retry_error_outcome,
5402+
should_arm_edit_watch, should_attempt_silent_retry, should_read_cursor_context,
5403+
streaming_insert_eligible, SilentRetryOutcome,
53705404
};
53715405
#[cfg(any(target_os = "macos", target_os = "linux"))]
53725406
use super::{desktop_keyless_dictation_provider, DesktopKeylessDictationProvider};
53735407
use crate::coordinator::RetranscribeError;
53745408
use crate::types::{
5375-
ChineseScriptPreference, CorrectionRule, DictationSession, InsertStatus, PolishMode,
5409+
ChineseScriptPreference, CorrectionRule, DictationSession, InsertStatus, MacosNewlineMode,
5410+
PolishMode,
53765411
};
53775412
use uuid::Uuid;
53785413

5414+
#[test]
5415+
fn macos_auto_newline_uses_line_feed_in_known_terminals() {
5416+
for front_app in [
5417+
"Terminal (com.apple.Terminal)",
5418+
"iTerm2 (com.googlecode.iterm2)",
5419+
"Warp (dev.warp.Warp-Stable)",
5420+
"WezTerm (com.github.wez.wezterm)",
5421+
"Alacritty (io.alacritty)",
5422+
"Alacritty (org.alacritty)",
5423+
"kitty (net.kovidgoyal.kitty)",
5424+
"Hyper (co.zeit.hyper)",
5425+
"Tabby (org.tabby)",
5426+
"Tabby (com.tabby)",
5427+
"Ghostty (com.mitchellh.ghostty)",
5428+
] {
5429+
assert_eq!(
5430+
resolve_macos_newline_mode(MacosNewlineMode::Auto, Some(front_app)),
5431+
MacosNewlineMode::LineFeed,
5432+
"{front_app} should use U+000A"
5433+
);
5434+
}
5435+
}
5436+
5437+
#[test]
5438+
fn macos_auto_newline_uses_shift_return_outside_known_terminals() {
5439+
for front_app in [
5440+
None,
5441+
Some("Terminal"),
5442+
Some("Safari (com.apple.Safari)"),
5443+
Some("Slack (com.tinyspeck.slackmacgap)"),
5444+
] {
5445+
assert_eq!(
5446+
resolve_macos_newline_mode(MacosNewlineMode::Auto, front_app),
5447+
MacosNewlineMode::ShiftReturn,
5448+
"{front_app:?} should use the chat-safe fallback"
5449+
);
5450+
}
5451+
}
5452+
5453+
#[test]
5454+
fn macos_explicit_newline_modes_override_target_app_detection() {
5455+
let terminal = Some("Terminal (com.apple.Terminal)");
5456+
for configured in [
5457+
MacosNewlineMode::ShiftReturn,
5458+
MacosNewlineMode::LineFeed,
5459+
MacosNewlineMode::Return,
5460+
] {
5461+
assert_eq!(resolve_macos_newline_mode(configured, terminal), configured);
5462+
}
5463+
}
5464+
53795465
#[test]
53805466
fn sandbox_providers_legacy_permission_modes_fail_closed_to_read_only() {
53815467
use crate::coding_agent::{CodingAgentPermissionMode as M, CodingAgentProvider as P};

openless-all/app/src-tauri/src/host_document/mod.rs

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -183,19 +183,15 @@ pub struct GateInputs {
183183
/// AX 里表示「密码输入框」的 role/subrole 值。
184184
const AX_SECURE_TEXT_FIELD: &str = "axsecuretextfield";
185185

186-
/// 一律不读的 app(bundle id 前缀,小写比较)。
186+
/// 一律不读的敏感 app(bundle id 前缀,小写比较)。
187187
///
188188
/// 不做 UI —— 黑名单 UI 会给用户「配一下就安全了」的错觉,而真正的防线是默认关闭
189189
/// 加这里的硬编码。这份清单只覆盖「内容几乎必然敏感」的两类:
190190
///
191191
/// - **密码管理器 / 钥匙串**:正文就是凭据本身。
192-
/// - **终端**:命令行里混着 token、私钥路径、内网地址,而且很多终端的 AX 会把整个
193-
/// scrollback 当作一个文本元素返回 —— 一读就是几千行历史命令。
194-
///
195192
/// 前缀匹配,所以 `com.1password` 能同时盖住 `com.1password.1password` 和其
196193
/// helper 进程。
197-
const BLOCKED_BUNDLE_PREFIXES: &[&str] = &[
198-
// 密码管理器 / 钥匙串
194+
const SENSITIVE_BUNDLE_PREFIXES: &[&str] = &[
199195
"com.1password",
200196
"com.agilebits.onepassword",
201197
"com.apple.keychainaccess",
@@ -207,7 +203,11 @@ const BLOCKED_BUNDLE_PREFIXES: &[&str] = &[
207203
"in.sinew.enpass",
208204
"com.sinew.enpass",
209205
"com.apple.passwords",
210-
// 终端
206+
];
207+
208+
/// 终端 app 的 bundle id 前缀。除了禁止读取 scrollback,也供 macOS 自动换行模式判断:
209+
/// 已知终端发送 U+000A,其它应用保守发送 Shift+Return。
210+
const TERMINAL_BUNDLE_PREFIXES: &[&str] = &[
211211
"com.apple.terminal",
212212
"com.googlecode.iterm2",
213213
"dev.warp.warp",
@@ -221,6 +221,15 @@ const BLOCKED_BUNDLE_PREFIXES: &[&str] = &[
221221
"com.mitchellh.ghostty",
222222
];
223223

224+
fn bundle_id_starts_with_any(bundle_id: &str, prefixes: &[&str]) -> bool {
225+
let lowered = bundle_id.to_ascii_lowercase();
226+
prefixes.iter().any(|prefix| lowered.starts_with(prefix))
227+
}
228+
229+
pub(crate) fn is_terminal_bundle_id(bundle_id: &str) -> bool {
230+
bundle_id_starts_with_any(bundle_id, TERMINAL_BUNDLE_PREFIXES)
231+
}
232+
224233
/// 闸门判定。返回 `Some(reason)` 表示拦下,`None` 表示放行。
225234
///
226235
/// 判定顺序按「代价从低到高」:Secure Input 和 bundle 前缀不需要 AX,先判;
@@ -230,10 +239,8 @@ pub fn evaluate_gate(inputs: &GateInputs) -> Option<BlockReason> {
230239
return Some(BlockReason::SecureInput);
231240
}
232241
if let Some(bundle) = inputs.bundle_id.as_deref() {
233-
let lowered = bundle.to_ascii_lowercase();
234-
if BLOCKED_BUNDLE_PREFIXES
235-
.iter()
236-
.any(|prefix| lowered.starts_with(prefix))
242+
if bundle_id_starts_with_any(bundle, SENSITIVE_BUNDLE_PREFIXES)
243+
|| is_terminal_bundle_id(bundle)
237244
{
238245
return Some(BlockReason::BlockedApp);
239246
}

openless-all/app/src-tauri/src/types.rs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,17 +132,22 @@ pub enum WindowsSendInputNewlineMode {
132132

133133
/// macOS 逐字上屏时换行符怎么发。仅流式插入路径生效。
134134
///
135-
/// 默认 `ShiftReturn`:macOS 把 U+000A 当 Return 键,而聊天框里 Return 就是「发送」——
136-
/// 一条带空行的两段话会被从中间劈开发出去。Shift+Return 在聊天框是软换行,在编辑器 /
137-
/// 终端 / 网页输入框里就是普通换行。
135+
/// 默认 `Auto`:已知终端应用发送 U+000A,其它或未知应用发送 Shift+Return。
136+
///
137+
/// Terminal.app 无法区分 Shift+Return 和 Return,里面的 Codex / Claude Code 等 TUI
138+
/// 会把它当成「提交」。`LineFeed` 恢复发送 U+000A,让这些 TUI 将其识别为 Ctrl+J 软换行。
138139
///
139140
/// 保留 `Return` 是因为风格市场里有靠换行发多条消息的风格包,那种效果需要真回车。
140141
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
141142
#[serde(rename_all = "camelCase")]
142143
pub enum MacosNewlineMode {
143-
/// Shift+Return:聊天框软换行,不发送
144+
/// 按听写开始时捕获的前台应用自动选择;未知应用安全回退到 Shift+Return。
144145
#[default]
146+
Auto,
147+
/// Shift+Return:聊天框软换行,不发送。
145148
ShiftReturn,
149+
/// U+000A:Terminal.app / CLI Agent 中作为 Ctrl+J 软换行。
150+
LineFeed,
146151
/// Return:聊天框里等于发送 —— 想要「一段话拆成多条消息」的风格包用这个。
147152
Return,
148153
}
@@ -3706,6 +3711,22 @@ mod tests {
37063711
assert!(!json.contains("windowsSendinputNewlineMode"));
37073712
}
37083713

3714+
#[test]
3715+
fn macos_newline_mode_defaults_to_auto() {
3716+
let prefs: UserPreferences = serde_json::from_str("{}").unwrap();
3717+
assert_eq!(prefs.macos_newline_mode, MacosNewlineMode::Auto);
3718+
}
3719+
3720+
#[test]
3721+
fn macos_newline_mode_round_trips_line_feed() {
3722+
let prefs: UserPreferences =
3723+
serde_json::from_str(r#"{"macosNewlineMode":"lineFeed"}"#).unwrap();
3724+
assert_eq!(prefs.macos_newline_mode, MacosNewlineMode::LineFeed);
3725+
3726+
let json = serde_json::to_string(&prefs).unwrap();
3727+
assert!(json.contains(r#""macosNewlineMode":"lineFeed""#));
3728+
}
3729+
37093730
#[test]
37103731
fn windows_sendinput_insertion_only_serializes_frontend_wire_key() {
37113732
let enabled = UserPreferences {

openless-all/app/src-tauri/src/unicode_keystroke.rs

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ mod macos_impl {
103103
pub(super) enum MacKeystroke {
104104
/// 换行:发真实的 Shift+Return 按键(聊天框软换行)。
105105
ShiftReturn,
106+
/// 换行:发送 Unicode U+000A(Terminal.app / CLI Agent 中作为 Ctrl+J 软换行)。
107+
LineFeed,
106108
/// 换行:发真实的 Return 按键(聊天框里等于发送)。
107109
Return,
108110
/// CR:不发任何键。`\r\n` 里它只是 `\n` 的前缀,发了会变成两个换行;
@@ -113,22 +115,29 @@ mod macos_impl {
113115
Unicode,
114116
}
115117

116-
/// **换行必须走真实按键,不能当普通 Unicode 字符发。**
118+
/// 默认用真实 Shift+Return;Terminal.app / CLI Agent 可显式选择 Unicode U+000A。
117119
///
118120
/// macOS 的文本输入系统看到 U+000A 就当作 Return —— 在微信 / Slack / Telegram
119121
/// 这类聊天框里等价于「发送」。曾经有一条带空行的两段话被逐字上屏,第一个 `\n`
120122
/// 直接把上半句发了出去,下半句留在了输入框里。
121123
///
122-
/// 默认发 Shift+Return:在聊天框是「软换行」(不发送),在编辑器 / 终端 / 网页
123-
/// textarea 里就是普通换行 —— 两边都对。Windows 侧早有同款结论(见
124+
/// 默认发 Shift+Return:在聊天框是「软换行」(不发送),在编辑器 / 网页 textarea
125+
/// 里就是普通换行。Windows 侧早有同款结论(见
124126
/// `WindowsSendInputNewlineMode::ShiftEnter`,设置文案直接写着「聊天框选它」)。
125127
///
128+
/// Terminal.app 不区分 Shift+Return 和 Return,Codex / Claude Code 等 TUI 会把两者
129+
/// 都当作「提交」。`LineFeed` 发送 U+000A,让这些 TUI 将其识别为 Ctrl+J 软换行。
130+
///
126131
/// 用户可以在设置里改成 `Return`:风格市场上有靠换行把一段话拆成多条消息的风格包,
127132
/// 那种效果要的正是真回车。
128133
pub(super) fn classify_mac_keystroke(ch: char, mode: MacosNewlineMode) -> MacKeystroke {
129134
match ch {
130135
'\n' => match mode {
136+
// `Auto` 通常会在逐字上屏任务启动前解析;缺少前台应用上下文的调用方
137+
// 使用对聊天框安全的 Shift+Return 兜底。
138+
MacosNewlineMode::Auto => MacKeystroke::ShiftReturn,
131139
MacosNewlineMode::ShiftReturn => MacKeystroke::ShiftReturn,
140+
MacosNewlineMode::LineFeed => MacKeystroke::LineFeed,
132141
MacosNewlineMode::Return => MacKeystroke::Return,
133142
},
134143
'\r' => MacKeystroke::Swallow,
@@ -162,6 +171,7 @@ mod macos_impl {
162171
for ch in text.chars() {
163172
let sent = match classify_mac_keystroke(ch, newline_mode) {
164173
MacKeystroke::ShiftReturn => send_shift_return(),
174+
MacKeystroke::LineFeed => send_line_feed(),
165175
MacKeystroke::Return => send_return(),
166176
// 吞掉的 char 也要计数:调用方(`flush_streaming_insert_buffer_with`)
167177
// 拿 `typed_chars` 和 `delta.chars().count()` 比对,少一个就判定
@@ -204,6 +214,12 @@ mod macos_impl {
204214
post_key_event(KEY_RETURN, KCG_EVENT_FLAG_MASK_SHIFT, None)
205215
}
206216

217+
/// 发送 Unicode U+000A。Terminal.app 会把它转给 TUI,Codex / Claude Code 等将其
218+
/// 识别为 Ctrl+J 软换行,而不是普通 Return 的「提交」。
219+
fn send_line_feed() -> Result<(), TypeError> {
220+
send_one_codepoint('\n')
221+
}
222+
207223
/// 发一次不带修饰键的 Return。聊天框里这等于「发送」——只有用户在设置里明确选了
208224
/// [`MacosNewlineMode::Return`] 才会走到这里。
209225
fn send_return() -> Result<(), TypeError> {
@@ -666,16 +682,16 @@ mod linux_impl {
666682
mod tests {
667683
use super::TypeError;
668684

669-
/// 默认模式下换行走 Shift+Return —— macOS 把 U+000A 当 Return,聊天框里等于
670-
/// 「发送」,一条带空行的两段话会被从中间劈开发出去
685+
/// 没有前台应用上下文时,未解析的 Auto 安全回退到 Shift+Return,避免聊天框里
686+
/// U+000A 被当作 Return 后直接发送
671687
#[test]
672688
#[cfg(target_os = "macos")]
673-
fn newline_defaults_to_shift_return() {
689+
fn unresolved_auto_mode_falls_back_to_shift_return() {
674690
use super::macos_impl::{classify_mac_keystroke, MacKeystroke};
675691
use crate::types::MacosNewlineMode;
676692

677693
let mode = MacosNewlineMode::default();
678-
assert_eq!(mode, MacosNewlineMode::ShiftReturn, "默认必须是不发送的那个");
694+
assert_eq!(mode, MacosNewlineMode::Auto);
679695
assert_eq!(
680696
classify_mac_keystroke('\n', mode),
681697
MacKeystroke::ShiftReturn
@@ -710,6 +726,24 @@ mod tests {
710726
);
711727
}
712728

729+
/// Terminal.app 不区分 Shift+Return 和 Return;显式 LineFeed 模式必须改发
730+
/// Unicode U+000A,供 Codex / Claude Code 等 TUI 识别为 Ctrl+J 软换行。
731+
#[test]
732+
#[cfg(target_os = "macos")]
733+
fn line_feed_mode_sends_unicode_lf_for_terminal_cli_agents() {
734+
use super::macos_impl::{classify_mac_keystroke, MacKeystroke};
735+
use crate::types::MacosNewlineMode;
736+
737+
assert_eq!(
738+
classify_mac_keystroke('\n', MacosNewlineMode::LineFeed),
739+
MacKeystroke::LineFeed
740+
);
741+
assert_eq!(
742+
classify_mac_keystroke('中', MacosNewlineMode::LineFeed),
743+
MacKeystroke::Unicode
744+
);
745+
}
746+
713747
/// 计数契约:`type_unicode_chunk` 返回的 typed_chars 必须等于输入的 char 数,
714748
/// 连被吞掉的 `\r` 也要算 —— 调用方拿它跟 `delta.chars().count()` 比对,
715749
/// 少一个就判定「部分失败」并丢弃后面所有 delta。
@@ -719,7 +753,12 @@ mod tests {
719753
use super::macos_impl::classify_mac_keystroke;
720754
use crate::types::MacosNewlineMode;
721755

722-
for mode in [MacosNewlineMode::ShiftReturn, MacosNewlineMode::Return] {
756+
for mode in [
757+
MacosNewlineMode::Auto,
758+
MacosNewlineMode::ShiftReturn,
759+
MacosNewlineMode::LineFeed,
760+
MacosNewlineMode::Return,
761+
] {
723762
let text = "上半句\r\n\r\n下半句";
724763
// 每个 char 都会被分类成某一种处理方式,没有漏网的。
725764
let counted = text

openless-all/app/src/i18n/en.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -879,8 +879,10 @@ export const en: typeof zhCN = {
879879
windowsInsertionModeSendInput: 'SendInput keystroke simulation',
880880
windowsInsertionModePaste: 'Clipboard paste (Ctrl+V, etc.)',
881881
macosNewlineModeLabel: 'Line breaks',
882-
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.',
882+
macosNewlineModeDesc: 'Auto uses Line Feed (U+000A / Ctrl+J) in known terminal apps and Shift+Return elsewhere. Plain Return sends the message.',
883+
macosNewlineModeAuto: 'Auto (Line Feed in terminals)',
883884
macosNewlineModeShiftReturn: 'Shift+Return (newline in chat)',
885+
macosNewlineModeLineFeed: 'Line Feed (terminal CLI / Ctrl+J)',
884886
macosNewlineModeReturn: 'Return (split into messages)',
885887
windowsSendInputNewlineModeLabel: 'SendInput newline simulation',
886888
windowsSendInputNewlineModeDesc: 'How SendInput turns line breaks into keys. Use Shift+Enter for chat boxes; Enter for Notepad / VS Code and most editors.',

openless-all/app/src/i18n/ja.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -881,8 +881,10 @@ export const ja: typeof zhCN = {
881881
windowsInsertionModeSendInput: 'SendInput キー入力シミュレーション',
882882
windowsInsertionModePaste: 'クリップボード貼り付け(Ctrl+V など)',
883883
macosNewlineModeLabel: '改行の送り方',
884-
macosNewlineModeDesc: '逐次入力時に改行をどのキーで送るか。チャットでは通常の Return は「送信」になります。改行で複数メッセージに分けるスタイルパックを使う場合以外は Shift+Return のままに。',
884+
macosNewlineModeDesc: '自動では既知のターミナルアプリに Line Feed(U+000A / Ctrl+J)、それ以外に Shift+Return を送ります。通常の Return は送信になります。',
885+
macosNewlineModeAuto: '自動(ターミナルでは Line Feed)',
885886
macosNewlineModeShiftReturn: 'Shift+Return(チャットで改行)',
887+
macosNewlineModeLineFeed: 'Line Feed(ターミナル CLI / Ctrl+J)',
886888
macosNewlineModeReturn: 'Return(複数メッセージに分割)',
887889
windowsSendInputNewlineModeLabel: 'SendInput 改行シミュレーション',
888890
windowsSendInputNewlineModeDesc: 'SendInput で改行をどのキーとして送るか。チャット入力は Shift+Enter、メモ帳 / VS Code などは Enter。',

0 commit comments

Comments
 (0)