diff options
| author | JJ Liebig <jonathan.liebig@gmail.com> | 2026-09-28 11:58:09 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-28 11:58:09 +0200 |
| commit | 7f89b11a11160874ba89f3be7c64b29960bd2df3 (patch) | |
| tree | 0e875b3fbbe5a701cfb0182bda0ce0994267584c /src/client | |
| parent | 0d5d6f1f317e238c8297076bc6ab5c3a0cd56283 (diff) | |
feat: support multiple prefix keys (#4653)
* feat: support multiple prefix keys
keys.prefix accepts an array in addition to a single string so one
config can define several prefix keys. Every configured prefix enters
the same prefix mode, and the help panel lists them all.
Published keybinding profiles keep the primary prefix as a scalar and
carry the rest in an optional extra_prefixes field so older clients
keep working with the primary prefix.
* fix: reject an empty prefix list on reload
An explicitly empty keys.prefix array now stays empty through config
parsing so validation rejects it and a reload keeps the current
keybindings instead of silently falling back to ctrl+b.
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/shell/config.rs | 12 | ||||
| -rw-r--r-- | src/client/shell/input.rs | 10 | ||||
| -rw-r--r-- | src/client/shell/overlays.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/render.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/tests/copy.rs | 4 | ||||
| -rw-r--r-- | src/client/shell/tests/input.rs | 25 | ||||
| -rw-r--r-- | src/client/shell/tests/keybindings_settings.rs | 4 |
7 files changed, 41 insertions, 18 deletions
diff --git a/src/client/shell/config.rs b/src/client/shell/config.rs index 2d16b00f..bfa1fb2f 100644 --- a/src/client/shell/config.rs +++ b/src/client/shell/config.rs @@ -139,7 +139,7 @@ impl ClientShellConfig { .live_keybinds_with_diagnostics() .map(|(keybinds, _diagnostics)| keybinds) .unwrap_or_else(|_diagnostics| LiveKeybindConfig { - prefix: config.prefix_key(), + prefix: config.prefix_keys(), keybinds: config.keybinds(), }), local_keys: config.keys.clone(), @@ -459,7 +459,7 @@ mod tests { next.ui.agent_panel_sort = crate::config::AgentPanelSortConfig::Priority; next.ui.status_indicators = crate::config::StatusIndicatorStyle::Symbols; next.ui.sidebar.agents = toml::from_str("rows = [[{ token = 'machine', rules = [{ equals = 'Local', bold = true }] }]]\nrow_gap = 2").unwrap(); - next.keys.prefix = "ctrl+a".to_owned(); + next.keys.prefix = crate::config::BindingConfig::one("ctrl+a"); let diagnostics = shell.apply_live_config(&next, &[], &[]); @@ -491,7 +491,7 @@ mod tests { assert_eq!(shell.agents, previous); assert_eq!( shell.keybinds.prefix, - (KeyCode::Char('a'), KeyModifiers::CONTROL) + vec![(KeyCode::Char('a'), KeyModifiers::CONTROL)] ); } @@ -523,19 +523,19 @@ mod tests { fn live_reload_preserves_invalid_client_owned_sections() { let mut initial = Config::default(); initial.ui.sidebar_width = 29; - initial.keys.prefix = "ctrl+x".to_owned(); + initial.keys.prefix = crate::config::BindingConfig::one("ctrl+x"); let mut shell = ClientShellConfig::from_config(&initial); let mut invalid = Config::default(); invalid.ui.sidebar_width = 35; - invalid.keys.prefix = "ctrl+a".to_owned(); + invalid.keys.prefix = crate::config::BindingConfig::one("ctrl+a"); let invalid_sections = vec!["ui".to_owned(), "keys".to_owned()]; shell.apply_live_config(&invalid, &[], &invalid_sections); assert_eq!(shell.sidebar_width, 29); assert_eq!( shell.keybinds.prefix, - (KeyCode::Char('x'), KeyModifiers::CONTROL) + vec![(KeyCode::Char('x'), KeyModifiers::CONTROL)] ); } } diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index 0e14e18c..413e29f5 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -569,7 +569,7 @@ impl ClientShellState { self.record_binding(binding, outcome); return None; } - if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { + if self.config.keybinds.matches_prefix(key) { self.mode = ClientShellMode::Prefix; outcome.repaint = true; return None; @@ -584,7 +584,7 @@ impl ClientShellState { } else { ClientShellMode::Terminal }; - if crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) { + if self.config.keybinds.matches_prefix(key) { self.mode = return_mode; outcome.repaint = true; return self.focused_pane_id().map(ClientInputTarget::Pane); @@ -619,7 +619,7 @@ impl ClientShellState { .copy_mode .as_ref() .is_none_or(|copy_mode| copy_mode.search_prompt.is_none()) - && crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) + && self.config.keybinds.matches_prefix(key) { self.mode = ClientShellMode::Prefix; outcome.repaint = true; @@ -649,9 +649,7 @@ impl ClientShellState { use crate::input::{KeybindAction, KeybindDispatch, KeybindMatch}; self.pending_workspace_highlight = None; - if key.code == KeyCode::Esc - || crate::config::terminal_key_matches_combo(key, self.config.keybinds.prefix) - { + if key.code == KeyCode::Esc || self.config.keybinds.matches_prefix(key) { self.mode = self.copy_or_terminal_mode(); self.navigate_workspace_id = None; outcome.repaint = true; diff --git a/src/client/shell/overlays.rs b/src/client/shell/overlays.rs index c8eee5f4..7116713b 100644 --- a/src/client/shell/overlays.rs +++ b/src/client/shell/overlays.rs @@ -1044,7 +1044,7 @@ fn help_lines( use ratatui::text::{Line, Span}; let groups = crate::input::filter_keybind_help_groups( - crate::input::keybind_help_groups(&keybinds.keybinds, keybinds.prefix), + crate::input::keybind_help_groups(&keybinds.keybinds, &keybinds.prefix), query, ); let key_width = groups diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs index 82cb2ac4..248216c1 100644 --- a/src/client/shell/render.rs +++ b/src/client/shell/render.rs @@ -68,7 +68,7 @@ pub(super) fn render_mode_bar( palette.accent }) .add_modifier(Modifier::BOLD); - let prefix = crate::config::format_key_combo(keybinds.prefix); + let prefix = keybinds.primary_prefix_label(); let prefix_rhs = |bindings: &crate::config::ActionKeybinds| { bindings .prefix_rhs_label() diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index c0d528e1..317950cf 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -1856,7 +1856,7 @@ fn copy_mode_survives_mouse_motion_and_parks_across_focus_changes() { .as_ref() .is_some_and(|copy_mode| copy_mode.selection.is_some())); - let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix; + let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix[0]; state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( prefix_key, prefix_modifiers, @@ -2038,7 +2038,7 @@ fn queued_copy_keys_preserve_prefix_order() { let origin = state.copy_mode.as_ref().expect("copy mode").cursor; let motion = state.handle_input_bytes(b"w"); state.handle_input_bytes(b"l"); - let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix; + let (prefix_key, prefix_modifiers) = state.config.keybinds.prefix[0]; state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( prefix_key, prefix_modifiers, diff --git a/src/client/shell/tests/input.rs b/src/client/shell/tests/input.rs index b54548f2..145c7ae6 100644 --- a/src/client/shell/tests/input.rs +++ b/src/client/shell/tests/input.rs @@ -650,3 +650,28 @@ fn styled_client_composition_preserves_pane_hyperlinks() { let link = frame.cells[index].hyperlink.expect("linked cell") as usize; assert_eq!(frame.hyperlinks[link], "https://example.test"); } + +#[test] +fn every_configured_prefix_enters_prefix_mode() { + let mut config = Config::default(); + config.keys.prefix = + crate::config::BindingConfig::Many(vec!["ctrl+space".to_owned(), "ctrl+s".to_owned()]); + let mut state = ClientShellState::new(ClientShellConfig::from_config(&config)); + state.set_snapshot(Box::new(snapshot())); + + for combo in [ + (KeyCode::Char(' '), KeyModifiers::CONTROL), + (KeyCode::Char('s'), KeyModifiers::CONTROL), + ] { + let _ = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + combo.0, combo.1, + ))]); + assert_eq!(state.mode, ClientShellMode::Prefix); + + let _ = state.handle_raw_events(vec![RawInputEvent::Key(crate::input::TerminalKey::new( + KeyCode::Esc, + KeyModifiers::empty(), + ))]); + assert_eq!(state.mode, ClientShellMode::Terminal); + } +} diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs index e1b43a01..dd60fe11 100644 --- a/src/client/shell/tests/keybindings_settings.rs +++ b/src/client/shell/tests/keybindings_settings.rs @@ -282,7 +282,7 @@ command = "local-only" .unwrap(); let remote_local = ClientShellConfig::from_config(&local) .with_keybinding_source(ClientShellKeybindingSource::RemoteLocal); - assert_eq!(remote_local.keybinds.prefix.0, KeyCode::Char('a')); + assert_eq!(remote_local.keybinds.prefix[0].0, KeyCode::Char('a')); assert!(remote_local.keybinds.keybinds.custom_commands.is_empty()); assert_eq!( remote_local.keybinds.keybinds.new_tab.label().as_deref(), @@ -377,7 +377,7 @@ new_tab = "prefix+n" }); state.set_snapshot(Box::new(projection)); - assert_eq!(state.config.keybinds.prefix.0, KeyCode::Char('x')); + assert_eq!(state.config.keybinds.prefix[0].0, KeyCode::Char('x')); assert_eq!( state.config.keybinds.keybinds.new_tab.label().as_deref(), Some("prefix+n") |
