diff --git a/crates/tui/src/tui/composer_ui.rs b/crates/tui/src/tui/composer_ui.rs index fff29272..a73cbd80 100644 --- a/crates/tui/src/tui/composer_ui.rs +++ b/crates/tui/src/tui/composer_ui.rs @@ -121,6 +121,26 @@ pub(crate) fn is_word_cursor_modifier(modifiers: KeyModifiers) -> bool { modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) } +/// On macOS, map `SUPER` (Cmd ⌘) to `CONTROL` when `CONTROL` is not already +/// set, so that terminal emulators that don't pass Ctrl faithfully still work. +/// On all other platforms this is a no-op. +#[cfg(target_os = "macos")] +pub(crate) fn normalize_macos_modifiers(modifiers: KeyModifiers) -> KeyModifiers { + // Strip SUPER and add CONTROL so that exact modifier equality checks + // (e.g. `modifiers == KeyModifiers::CONTROL` in Ctrl+S stashing) work + // correctly after normalization. + if modifiers.contains(KeyModifiers::SUPER) { + (modifiers - KeyModifiers::SUPER) | KeyModifiers::CONTROL + } else { + modifiers + } +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn normalize_macos_modifiers(modifiers: KeyModifiers) -> KeyModifiers { + modifiers +} + pub(crate) fn handle_composer_alt_word_motion_key(app: &mut App, key: KeyEvent) -> bool { if !key.modifiers.contains(KeyModifiers::ALT) || key.modifiers.contains(KeyModifiers::CONTROL) { return false; diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 2c3e4141..88c4a970 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3022,7 +3022,7 @@ async fn run_event_loop( // User interaction — clear the ✅ completion marker from the title. crate::tui::notifications::reset_title_on_interaction(); - let Event::Key(key) = evt else { + let Event::Key(mut key) = evt else { continue; }; @@ -3030,6 +3030,13 @@ async fn run_event_loop( continue; } + // Normalize macOS modifiers: map SUPER (Cmd) to CONTROL so that + // keyboard shortcuts work consistently across terminal emulators + // (Terminal.app, iTerm2, Kitty, etc.) that may report different + // modifier flags (#2938). + let mapped = crate::tui::composer_ui::normalize_macos_modifiers(key.modifiers); + key.modifiers = mapped; + // Decision card keyboard routing (v0.8.43 truth-surface). // When a card is active, number keys 1-9 select options, // j/k or Up/Down navigate, and Enter confirms. diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 66ec4eb4..9d7778e0 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -299,6 +299,35 @@ fn word_cursor_modifier_accepts_control_and_alt() { assert!(!is_word_cursor_modifier(KeyModifiers::SHIFT)); } +#[cfg(target_os = "macos")] +#[test] +fn normalize_macos_modifiers_maps_super_to_control() { + use crate::tui::composer_ui::normalize_macos_modifiers; + // SUPER (Cmd) without CONTROL should gain CONTROL and lose SUPER. + let normalized = normalize_macos_modifiers(KeyModifiers::SUPER); + assert!(normalized.contains(KeyModifiers::CONTROL)); + assert!(!normalized.contains(KeyModifiers::SUPER)); +} + +#[cfg(target_os = "macos")] +#[test] +fn normalize_macos_modifiers_preserves_existing_control() { + use crate::tui::composer_ui::normalize_macos_modifiers; + // CONTROL already set — SUPER should be removed. + let normalized = normalize_macos_modifiers(KeyModifiers::CONTROL | KeyModifiers::SUPER); + assert!(normalized.contains(KeyModifiers::CONTROL)); + assert!(!normalized.contains(KeyModifiers::SUPER)); +} + +#[test] +fn normalize_macos_modifiers_leaves_alt_unchanged() { + use crate::tui::composer_ui::normalize_macos_modifiers; + let normalized = normalize_macos_modifiers(KeyModifiers::ALT); + // On non-macOS this is a no-op; on macOS ALT stays unchanged. + assert!(normalized.contains(KeyModifiers::ALT)); + assert!(!normalized.contains(KeyModifiers::SUPER)); +} + #[test] fn alt_f_and_alt_b_move_by_word_without_inserting_text() { let mut app = create_test_app();