| 1 | //! Keyboard-shortcut predicates and platform-specific labels. |
| 2 | //! |
| 3 | //! These helpers normalise the cross-platform variations between |
| 4 | //! `Ctrl+…` (Linux/Windows) and `Cmd+…` (macOS), legacy `Ctrl+H`-as- |
| 5 | //! backspace handling, and the macOS Option-Latin-character escapes. |
| 6 | //! Centralising them |
| 7 | //! keeps the composer / transcript event loops in `ui.rs` short and |
| 8 | //! lets us add a new platform without touching the call sites. |
| 9 | |
| 10 | use std::borrow::Cow; |
| 11 | |
| 12 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
| 13 | |
| 14 | pub(super) fn has_control_like_modifier(modifiers: KeyModifiers) -> bool { |
| 15 | has_control_like_modifier_for_platform(modifiers, cfg!(target_os = "macos")) |
| 16 | } |
| 17 | |
| 18 | pub(super) fn has_control_like_modifier_for_platform( |
| 19 | modifiers: KeyModifiers, |
| 20 | is_macos: bool, |
| 21 | ) -> bool { |
| 22 | modifiers.contains(KeyModifiers::CONTROL) |
| 23 | || (is_macos && modifiers.contains(KeyModifiers::SUPER)) |
| 24 | } |
| 25 | |
| 26 | /// Compatibility path for enhanced terminal clients that forward `Cmd+C` or |
| 27 | /// `Ctrl+Shift+C` as key events. Most terminals consume these locally, so the |
| 28 | /// user-visible Codewhale binding remains `Ctrl+C` with an active selection. |
| 29 | pub(super) fn is_copy_shortcut(key: &KeyEvent) -> bool { |
| 30 | let is_c = matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C')); |
| 31 | if !is_c { |
| 32 | return false; |
| 33 | } |
| 34 | |
| 35 | if key.modifiers.contains(KeyModifiers::SUPER) { |
| 36 | return true; |
| 37 | } |
| 38 | |
| 39 | key.modifiers.contains(KeyModifiers::CONTROL) && key.modifiers.contains(KeyModifiers::SHIFT) |
| 40 | } |
| 41 | |
| 42 | /// Toggle the file-tree pane: `Ctrl+Shift+E` on Linux/Windows or |
| 43 | /// `Cmd+Shift+E` on macOS. |
| 44 | pub(super) fn is_file_tree_toggle_shortcut(key: &KeyEvent) -> bool { |
| 45 | let is_shifted_e = matches!(key.code, KeyCode::Char('E')) |
| 46 | || (matches!(key.code, KeyCode::Char('e')) && key.modifiers.contains(KeyModifiers::SHIFT)); |
| 47 | if !is_shifted_e { |
| 48 | return false; |
| 49 | } |
| 50 | |
| 51 | let has_forbidden_modifier = |
| 52 | key.modifiers.contains(KeyModifiers::ALT) || key.modifiers.contains(KeyModifiers::SUPER); |
| 53 | let ctrl_shift_e = key.modifiers.contains(KeyModifiers::CONTROL) && !has_forbidden_modifier; |
| 54 | |
| 55 | let cmd_shift_e = key.modifiers.contains(KeyModifiers::SUPER) |
| 56 | && key.modifiers.contains(KeyModifiers::SHIFT) |
| 57 | && !key.modifiers.contains(KeyModifiers::CONTROL) |
| 58 | && !key.modifiers.contains(KeyModifiers::ALT); |
| 59 | |
| 60 | ctrl_shift_e || cmd_shift_e |
| 61 | } |
| 62 | |
| 63 | pub(super) fn tool_details_shortcut_label() -> Cow<'static, str> { |
| 64 | crate::tui::shell_key_routing::tool_details_chord() |
| 65 | } |
| 66 | |
| 67 | /// Compact affordance: platform chord + short verb (`⌥V:output`, `Alt+V:list`). |
| 68 | /// Matches footer notation (`cap:verb`); not a sentence. |
| 69 | pub(super) fn tool_details_shortcut_action_hint(verb: &str) -> String { |
| 70 | format!("{}:{verb}", tool_details_shortcut_label()) |
| 71 | } |
| 72 | |
| 73 | /// Open the full reasoning detail pager for the selected or current turn. |
| 74 | /// Ctrl+O now shows the recorded reasoning timeline, not the whole-turn |
| 75 | /// inspector (#v092-reasoning-fix). |
| 76 | pub(super) fn is_reasoning_detail_shortcut(key: &KeyEvent) -> bool { |
| 77 | matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O')) |
| 78 | && key.modifiers.contains(KeyModifiers::CONTROL) |
| 79 | && !key |
| 80 | .modifiers |
| 81 | .intersects(KeyModifiers::SHIFT | KeyModifiers::ALT | KeyModifiers::SUPER) |
| 82 | } |
| 83 | |
| 84 | /// Open the whole-turn inspector on a dedicated, collision-free chord. |
| 85 | /// Ctrl+Alt+O was free in the keybinding registry; it is distinct from |
| 86 | /// Ctrl+O (reasoning detail) and Ctrl+Shift+O (external editor). |
| 87 | pub(super) fn is_turn_inspector_shortcut(key: &KeyEvent) -> bool { |
| 88 | matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O')) |
| 89 | && key.modifiers.contains(KeyModifiers::CONTROL) |
| 90 | && key.modifiers.contains(KeyModifiers::ALT) |
| 91 | && !key |
| 92 | .modifiers |
| 93 | .intersects(KeyModifiers::SHIFT | KeyModifiers::SUPER) |
| 94 | } |
| 95 | |
| 96 | /// Open the composer draft in `$VISUAL` / `$EDITOR` without colliding with |
| 97 | /// the reasoning detail or Turn Inspector shortcuts. Enhanced protocols can |
| 98 | /// report either character case, but SHIFT must be explicit so Windows Caps |
| 99 | /// Lock cannot misroute Ctrl+O. F4 is the fallback for legacy protocols that |
| 100 | /// cannot encode Ctrl+Shift+O. |
| 101 | pub(super) fn is_external_editor_shortcut(key: &KeyEvent) -> bool { |
| 102 | let ctrl_shift_o = matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O')) |
| 103 | && key.modifiers.contains(KeyModifiers::CONTROL) |
| 104 | && key.modifiers.contains(KeyModifiers::SHIFT) |
| 105 | && !key |
| 106 | .modifiers |
| 107 | .intersects(KeyModifiers::ALT | KeyModifiers::SUPER); |
| 108 | let f4 = matches!(key.code, KeyCode::F(4)) && key.modifiers.is_empty(); |
| 109 | ctrl_shift_o || f4 |
| 110 | } |
| 111 | |
| 112 | /// Select the whole composer draft. `Ctrl+A` is intentionally NOT select-all: |
| 113 | /// it keeps its readline meaning (jump to start of input), matching every |
| 114 | /// other emacs-style binding in the composer. Select-all is therefore: |
| 115 | /// |
| 116 | /// - `Ctrl+Shift+A` on every platform (mirrors `Ctrl+Shift+O` / `Ctrl+Shift+E` |
| 117 | /// precedent for shifted-Ctrl chords; requires an enhanced-keyboard |
| 118 | /// terminal, like those precedents). |
| 119 | /// - `Cmd+A` on macOS terminals that forward Cmd to the app (kitty, WezTerm, |
| 120 | /// iTerm2 with "Left/Right Command" remapping). The event-loop macOS |
| 121 | /// normalization deliberately skips this chord so `Cmd+A` is not collapsed |
| 122 | /// into readline `Ctrl+A`. `Cmd+Shift+A` also lands here after |
| 123 | /// normalization. |
| 124 | pub(super) fn is_select_all_shortcut(key: &KeyEvent) -> bool { |
| 125 | let is_a = matches!(key.code, KeyCode::Char('a') | KeyCode::Char('A')); |
| 126 | if !is_a { |
| 127 | return false; |
| 128 | } |
| 129 | let cmd_a = key.modifiers.contains(KeyModifiers::SUPER) |
| 130 | && !key |
| 131 | .modifiers |
| 132 | .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT); |
| 133 | let ctrl_shift_a = key.modifiers.contains(KeyModifiers::CONTROL) |
| 134 | && key.modifiers.contains(KeyModifiers::SHIFT) |
| 135 | && !key |
| 136 | .modifiers |
| 137 | .intersects(KeyModifiers::ALT | KeyModifiers::SUPER); |
| 138 | cmd_a || ctrl_shift_a |
| 139 | } |
| 140 | |
| 141 | /// Modifier predicate for the v0.8.30 family of `Alt+<key>` transcript- |
| 142 | /// nav shortcuts (`Alt+G` / `Alt+[` / `Alt+]` / `Alt+?` / `Alt+L`). Requires |
| 143 | /// `Alt` and disallows `Ctrl` / `Super` so the |
| 144 | /// bindings don't collide with platform clipboard / window-management |
| 145 | /// shortcuts. `Shift` is permitted so the capital-letter forms work on |
| 146 | /// any keyboard layout that produces them as `Alt+Shift+key`. |
| 147 | /// |
| 148 | /// Plain `Char` events (no modifier, or modifier=`Shift` alone for the |
| 149 | /// uppercase form) fall through to text insertion, which is the whole |
| 150 | /// point — typing "good morning" no longer eats the first `g`. |
| 151 | pub(super) fn alt_nav_modifiers(modifiers: KeyModifiers) -> bool { |
| 152 | modifiers.contains(KeyModifiers::ALT) |
| 153 | && !modifiers.contains(KeyModifiers::CONTROL) |
| 154 | && !modifiers.contains(KeyModifiers::SUPER) |
| 155 | } |
| 156 | |
| 157 | pub(super) fn is_macos_option_v_legacy_key(key: &KeyEvent) -> bool { |
| 158 | is_macos_option_v_legacy_key_for_platform(key, cfg!(target_os = "macos")) |
| 159 | } |
| 160 | |
| 161 | pub(super) fn is_macos_option_v_legacy_key_for_platform(key: &KeyEvent, is_macos: bool) -> bool { |
| 162 | is_macos && key.modifiers.is_empty() && matches!(key.code, KeyCode::Char('\u{221A}')) |
| 163 | } |
| 164 | |
| 165 | /// Paste-from-clipboard: accept `Cmd+V`, `Ctrl+V`, or the legacy raw `\u{16}` |
| 166 | /// byte some terminals emit. A remote terminal normally consumes its local |
| 167 | /// paste chord and sends an `Event::Paste`; accepting both modifier families |
| 168 | /// still keeps enhanced-keyboard clients independent of the remote host OS. |
| 169 | pub(super) fn is_paste_shortcut(key: &KeyEvent) -> bool { |
| 170 | let is_v = matches!(key.code, KeyCode::Char('v') | KeyCode::Char('V')); |
| 171 | let is_legacy_ctrl_v = matches!(key.code, KeyCode::Char('\u{16}')); |
| 172 | if !is_v && !is_legacy_ctrl_v { |
| 173 | return false; |
| 174 | } |
| 175 | |
| 176 | if is_legacy_ctrl_v { |
| 177 | return true; |
| 178 | } |
| 179 | |
| 180 | // Cmd+V on macOS |
| 181 | if key.modifiers.contains(KeyModifiers::SUPER) { |
| 182 | return true; |
| 183 | } |
| 184 | |
| 185 | // Ctrl+V on Linux/Windows |
| 186 | key.modifiers.contains(KeyModifiers::CONTROL) |
| 187 | } |
| 188 | |
| 189 | /// `Ctrl+H` is the legacy ASCII backspace many terminals still emit |
| 190 | /// when the user presses Backspace. Disallows Alt/Super so it doesn't |
| 191 | /// shadow window-management combos. |
| 192 | pub(super) fn is_ctrl_h_backspace(key: &KeyEvent) -> bool { |
| 193 | matches!(key.code, KeyCode::Char('h')) |
| 194 | && key.modifiers.contains(KeyModifiers::CONTROL) |
| 195 | && !key.modifiers.contains(KeyModifiers::ALT) |
| 196 | && !key.modifiers.contains(KeyModifiers::SUPER) |
| 197 | } |
| 198 | |
| 199 | #[cfg(test)] |
| 200 | mod tests { |
| 201 | use super::*; |
| 202 | |
| 203 | #[test] |
| 204 | fn enhanced_keyboard_clipboard_events_are_accepted_cross_platform() { |
| 205 | let mac_copy = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::SUPER); |
| 206 | let mac_paste = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::SUPER); |
| 207 | let linux_copy = KeyEvent::new( |
| 208 | KeyCode::Char('c'), |
| 209 | KeyModifiers::CONTROL | KeyModifiers::SHIFT, |
| 210 | ); |
| 211 | let linux_paste = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL); |
| 212 | |
| 213 | assert!(is_copy_shortcut(&mac_copy)); |
| 214 | assert!(is_paste_shortcut(&mac_paste)); |
| 215 | assert!(is_copy_shortcut(&linux_copy)); |
| 216 | assert!(is_paste_shortcut(&linux_paste)); |
| 217 | } |
| 218 | |
| 219 | #[test] |
| 220 | fn ctrl_o_and_ctrl_shift_o_have_stable_distinct_routes() { |
| 221 | let reasoning = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL); |
| 222 | // Crossterm's native Windows decoder applies Caps Lock to the |
| 223 | // character but does not expose Caps Lock as a modifier. |
| 224 | let reasoning_caps_lock = KeyEvent::new(KeyCode::Char('O'), KeyModifiers::CONTROL); |
| 225 | let editor_lower = KeyEvent::new( |
| 226 | KeyCode::Char('o'), |
| 227 | KeyModifiers::CONTROL | KeyModifiers::SHIFT, |
| 228 | ); |
| 229 | let editor_upper = KeyEvent::new( |
| 230 | KeyCode::Char('O'), |
| 231 | KeyModifiers::CONTROL | KeyModifiers::SHIFT, |
| 232 | ); |
| 233 | |
| 234 | for reasoning in [&reasoning, &reasoning_caps_lock] { |
| 235 | assert!(is_reasoning_detail_shortcut(reasoning)); |
| 236 | assert!(!is_turn_inspector_shortcut(reasoning)); |
| 237 | assert!(!is_external_editor_shortcut(reasoning)); |
| 238 | } |
| 239 | for editor in [&editor_lower, &editor_upper] { |
| 240 | assert!(!is_reasoning_detail_shortcut(editor)); |
| 241 | assert!(!is_turn_inspector_shortcut(editor)); |
| 242 | assert!(is_external_editor_shortcut(editor)); |
| 243 | } |
| 244 | |
| 245 | let editor_legacy_fallback = KeyEvent::new(KeyCode::F(4), KeyModifiers::NONE); |
| 246 | assert!(is_external_editor_shortcut(&editor_legacy_fallback)); |
| 247 | } |
| 248 | |
| 249 | #[test] |
| 250 | fn turn_inspector_uses_collision_free_ctrl_alt_o() { |
| 251 | let turn_inspector = KeyEvent::new( |
| 252 | KeyCode::Char('o'), |
| 253 | KeyModifiers::CONTROL | KeyModifiers::ALT, |
| 254 | ); |
| 255 | let turn_inspector_caps = KeyEvent::new( |
| 256 | KeyCode::Char('O'), |
| 257 | KeyModifiers::CONTROL | KeyModifiers::ALT, |
| 258 | ); |
| 259 | for key in [&turn_inspector, &turn_inspector_caps] { |
| 260 | assert!(is_turn_inspector_shortcut(key)); |
| 261 | assert!(!is_reasoning_detail_shortcut(key)); |
| 262 | assert!(!is_external_editor_shortcut(key)); |
| 263 | } |
| 264 | |
| 265 | // Must not fire for bare Alt+O (would shadow typing) or Ctrl+Shift+O. |
| 266 | let alt_o = KeyEvent::new(KeyCode::Char('o'), KeyModifiers::ALT); |
| 267 | let ctrl_shift_o = KeyEvent::new( |
| 268 | KeyCode::Char('o'), |
| 269 | KeyModifiers::CONTROL | KeyModifiers::SHIFT, |
| 270 | ); |
| 271 | assert!(!is_turn_inspector_shortcut(&alt_o)); |
| 272 | assert!(!is_turn_inspector_shortcut(&ctrl_shift_o)); |
| 273 | } |
| 274 | |
| 275 | #[test] |
| 276 | fn select_all_accepts_ctrl_shift_a_and_cmd_a_but_not_readline_ctrl_a() { |
| 277 | let ctrl_shift_lower = KeyEvent::new( |
| 278 | KeyCode::Char('a'), |
| 279 | KeyModifiers::CONTROL | KeyModifiers::SHIFT, |
| 280 | ); |
| 281 | let ctrl_shift_upper = KeyEvent::new( |
| 282 | KeyCode::Char('A'), |
| 283 | KeyModifiers::CONTROL | KeyModifiers::SHIFT, |
| 284 | ); |
| 285 | let cmd_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER); |
| 286 | assert!(is_select_all_shortcut(&ctrl_shift_lower)); |
| 287 | assert!(is_select_all_shortcut(&ctrl_shift_upper)); |
| 288 | assert!(is_select_all_shortcut(&cmd_a)); |
| 289 | |
| 290 | // Readline home stays readline home. |
| 291 | let readline_ctrl_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL); |
| 292 | assert!(!is_select_all_shortcut(&readline_ctrl_a)); |
| 293 | // Alt combinations and plain typing never select-all. |
| 294 | let alt_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT); |
| 295 | let plain_a = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE); |
| 296 | assert!(!is_select_all_shortcut(&alt_a)); |
| 297 | assert!(!is_select_all_shortcut(&plain_a)); |
| 298 | } |
| 299 | |
| 300 | #[test] |
| 301 | fn tool_details_hint_uses_the_routed_chord_not_plain_typing() { |
| 302 | let label = tool_details_shortcut_label(); |
| 303 | |
| 304 | assert_eq!(label, crate::tui::shell_key_routing::tool_details_chord()); |
| 305 | assert_ne!(label, "v"); |
| 306 | assert_eq!( |
| 307 | tool_details_shortcut_action_hint("output"), |
| 308 | format!("{label}:output") |
| 309 | ); |
| 310 | } |
| 311 | |
| 312 | /// #3256: every surface that advertises tool details must name the chord |
| 313 | /// that `is_tool_details_shortcut` actually handles — the help catalog, |
| 314 | /// shell binding catalog, and in-transcript hint share one source of |
| 315 | /// truth so bare-`v` "details" copy cannot regress while bare `v` types `v`. |
| 316 | #[test] |
| 317 | fn tool_details_hint_tracks_keybinding_catalog_and_handler() { |
| 318 | use crate::localization::MessageId; |
| 319 | use crate::tui::keybindings::KEYBINDINGS; |
| 320 | use crate::tui::shell_key_routing::{ |
| 321 | ShellBindingId, binding, is_tool_details_shortcut, tool_details_chord, |
| 322 | }; |
| 323 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
| 324 | |
| 325 | let catalog_chords: Vec<&str> = KEYBINDINGS |
| 326 | .iter() |
| 327 | .filter(|entry| entry.description_id == MessageId::KbSelectedDetails) |
| 328 | .map(|entry| entry.chord) |
| 329 | .collect(); |
| 330 | assert_eq!(catalog_chords, vec!["Alt+V"]); |
| 331 | assert_eq!(binding(ShellBindingId::ToolDetails).catalog_chord, "Alt+V"); |
| 332 | assert_eq!(binding(ShellBindingId::ToolDetails).footer_chord, "Alt+V"); |
| 333 | |
| 334 | let label = tool_details_shortcut_label(); |
| 335 | assert_eq!(label, tool_details_chord()); |
| 336 | assert!( |
| 337 | label == "Alt+V" || label == "⌥V", |
| 338 | "details hint must advertise Alt+V / ⌥V, got {label}" |
| 339 | ); |
| 340 | assert!(!label.eq_ignore_ascii_case("v")); |
| 341 | let details_hint = tool_details_shortcut_action_hint("details"); |
| 342 | assert_eq!(details_hint, format!("{label}:details")); |
| 343 | assert!(!details_hint.starts_with('v')); |
| 344 | |
| 345 | let plain_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE); |
| 346 | let alt_v = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT); |
| 347 | assert!(!is_tool_details_shortcut(&plain_v)); |
| 348 | assert!(is_tool_details_shortcut(&alt_v)); |
| 349 | } |
| 350 | } |
| 351 |