From 2df3fbf66ae29dec2fbaf8b014326e06c3353ac7 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Fri, 21 Aug 2026 17:41:18 +0300 Subject: [PATCH] fix: match generated characters in prefix bindings refs #3079 --- docs/next/CHANGELOG.md | 1 + src/app/input/navigate.rs | 120 +++++++++++++++++++++++++++++++------- 2 files changed, 101 insertions(+), 20 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 91e08af8..484add3a 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Fixed +- Prefix bindings such as `prefix+|` now recognize characters produced by macOS Option and custom keyboard layouts, while exact chords such as `prefix+alt+w` keep priority. (#3079, thanks @vlcinsky) - Direct terminal attaches now preserve multiline pastes as one paste instead of submitting each line separately. (#3054) - Windows panes now keep bare `cursor-agent` launches detected after Cursor hands off to its bundled Node process. (#3032) - Oversized Kitty images no longer prevent smaller images shown later in the same pane from rendering. (#3033) diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 2cb657a7..8e3d7d6e 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -80,27 +80,14 @@ impl App { return; } - if let Some(action) = - non_indexed_action_for_key(&self.state, &raw_key, BindingDispatch::Prefix) - { - self.execute_prefix_key_action(action); - return; + match prefix_binding_for_key(&self.state, &raw_key) { + Some(PrefixBindingMatch::Action(action)) => self.execute_prefix_key_action(action), + Some(PrefixBindingMatch::Command(binding)) => { + self.cancel_copy_mode_if_active(); + self.launch_custom_command(binding, ActionContext::Prefix); + } + None => leave_command_mode(&mut self.state), } - - if let Some(binding) = command_for_key(&self.state, &raw_key, BindingDispatch::Prefix) { - self.cancel_copy_mode_if_active(); - self.launch_custom_command(binding, ActionContext::Prefix); - return; - } - - if let Some(action) = - indexed_navigation_action(&self.state, &raw_key, BindingDispatch::Prefix) - { - self.execute_prefix_key_action(action); - return; - } - - leave_command_mode(&mut self.state); } fn execute_prefix_key_action(&mut self, action: NavigateAction) { @@ -1185,6 +1172,43 @@ pub(crate) enum BindingDispatch { Prefix, } +enum PrefixBindingMatch { + Action(NavigateAction), + Command(crate::config::CustomCommandKeybind), +} + +fn prefix_binding_for_key(state: &AppState, key: &TerminalKey) -> Option { + exact_prefix_binding_for_key(state, key).or_else(|| { + generated_character_key(key) + .as_ref() + .and_then(|generated_key| exact_prefix_binding_for_key(state, generated_key)) + }) +} + +fn exact_prefix_binding_for_key(state: &AppState, key: &TerminalKey) -> Option { + non_indexed_action_for_key(state, key, BindingDispatch::Prefix) + .map(PrefixBindingMatch::Action) + .or_else(|| { + command_for_key(state, key, BindingDispatch::Prefix).map(PrefixBindingMatch::Command) + }) + .or_else(|| { + indexed_navigation_action(state, key, BindingDispatch::Prefix) + .map(PrefixBindingMatch::Action) + }) +} + +fn generated_character_key(key: &TerminalKey) -> Option { + let mut characters = key.generated_text.as_deref()?.chars(); + let character = characters.next()?; + if character.is_control() || characters.next().is_some() { + return None; + } + Some(TerminalKey::new( + KeyCode::Char(character), + crossterm::event::KeyModifiers::empty(), + )) +} + pub(crate) fn command_for_key( state: &AppState, key: &TerminalKey, @@ -2816,6 +2840,62 @@ resize_pane_left = "prefix+shift+left" assert_eq!(action, Some(NavigateAction::LastPane)); } + #[test] + fn generated_character_prefix_binding_falls_back_after_exact_chord() { + let generated_key = crate::input::parse_terminal_key_sequence("\x1b[119;3;124u").unwrap(); + assert_eq!(generated_key.code, KeyCode::Char('w')); + assert_eq!(generated_key.modifiers, KeyModifiers::ALT); + assert_eq!(generated_key.generated_text.as_deref(), Some("|")); + + let generated_only: Config = toml::from_str( + r#" +[keys] +split_vertical = "prefix+|" +"#, + ) + .unwrap(); + let mut state = state_with_workspaces(&["test"]); + state.keybinds = generated_only.keybinds(); + assert!(matches!( + prefix_binding_for_key(&state, &generated_key), + Some(PrefixBindingMatch::Action(NavigateAction::SplitVertical)) + )); + let multi_character_key = + crate::input::parse_terminal_key_sequence("\x1b[119;3;124:120u").unwrap(); + assert!(prefix_binding_for_key(&state, &multi_character_key).is_none()); + + let exact_and_generated: Config = toml::from_str( + r#" +[keys] +split_vertical = "prefix+|" +split_horizontal = "prefix+alt+w" +"#, + ) + .unwrap(); + state.keybinds = exact_and_generated.keybinds(); + assert!(matches!( + prefix_binding_for_key(&state, &generated_key), + Some(PrefixBindingMatch::Action(NavigateAction::SplitHorizontal)) + )); + + let exact_command: Config = toml::from_str( + r#" +[keys] +split_vertical = "prefix+|" + +[[keys.command]] +key = "prefix+alt+w" +command = "echo exact" +"#, + ) + .unwrap(); + state.keybinds = exact_command.keybinds(); + assert!(matches!( + prefix_binding_for_key(&state, &generated_key), + Some(PrefixBindingMatch::Command(binding)) if binding.command == "echo exact" + )); + } + #[test] fn shifted_backslash_layout_prefers_horizontal_split_binding() { let config: Config = toml::from_str(