From 7f89b11a11160874ba89f3be7c64b29960bd2df3 Mon Sep 17 00:00:00 2001 From: JJ Liebig Date: Mon, 28 Sep 2026 11:58:09 +0200 Subject: 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. --- .../website/src/content/docs/configuration.mdx | 9 + docs/next/website/src/data/config-reference.json | 4 +- src/app/mod.rs | 39 ++-- src/app/state.rs | 6 +- src/client/shell/config.rs | 12 +- src/client/shell/input.rs | 10 +- src/client/shell/overlays.rs | 2 +- src/client/shell/render.rs | 2 +- src/client/shell/tests/copy.rs | 4 +- src/client/shell/tests/input.rs | 25 +++ src/client/shell/tests/keybindings_settings.rs | 4 +- src/config.rs | 45 ++++- src/config/keybinds.rs | 209 ++++++++++++++++++--- src/config/model.rs | 43 ++++- src/input/keybind_help.rs | 18 +- src/server/keybindings.rs | 5 +- 16 files changed, 347 insertions(+), 90 deletions(-) diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 1a0ce53c..33644e1c 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -142,6 +142,15 @@ navigate_pane_down = "ctrl+j" split_horizontal = "prefix+minus" ``` +`keys.prefix` also accepts an array when you need to enter prefix mode from more than one key, for example to share one config between macOS and a remote Linux machine: + +```toml +[keys] +prefix = ["ctrl+space", "ctrl+s"] +``` + +Every configured prefix enters the same prefix mode and starts every `prefix+...` action. The first entry is the primary prefix shown in the status bar; the in-app help panel at `prefix+?` lists all of them. + The default keymap is prefix-first so Herdr does not steal input from shells, editors, tmux, or terminal apps. Search `keys.` in the [Config reference](/docs/config-reference/) to see every action and default binding. The in-app help panel at `prefix+?` shows the active bindings. A binding may also be an array when one action needs multiple shortcuts: diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index 299c2ced..eb7e08d4 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -477,9 +477,9 @@ "keys": [ { "key": "keys.prefix", - "type": "string", + "type": "keybinding", "default": "\"ctrl+b\"", - "description": "Prefix key to enter prefix mode (e.g. \"ctrl+b\", \"f12\", \"esc\")." + "description": "Prefix key(s) to enter prefix mode (e.g. \"ctrl+b\", \"f12\", \"esc\", or an array to accept several)." }, { "key": "keys.help", diff --git a/src/app/mod.rs b/src/app/mod.rs index e358a562..8fad961c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -361,7 +361,7 @@ impl App { api_rx: tokio::sync::mpsc::UnboundedReceiver, event_hub: crate::api::EventHub, ) -> Self { - let (prefix_code, prefix_mods) = config.prefix_key(); + let prefix_keys = config.prefix_keys(); crate::kitty_graphics::set_enabled(config.kitty_graphics_enabled()); let (event_tx, event_rx) = mpsc::channel::(APP_EVENT_CHANNEL_CAPACITY); let render_notify = Arc::new(Notify::new()); @@ -479,8 +479,7 @@ impl App { toast: None, pending_agent_notifications: std::collections::HashMap::new(), outer_terminal_focus: None, - prefix_code, - prefix_mods, + prefix_keys, headless_size: config.headless_size(), agent_panel_sort, agent_view_override: None, @@ -799,8 +798,7 @@ impl App { if !invalid_section("keys") { match config.live_keybinds_with_diagnostics() { Ok((live, keybind_diagnostics)) => { - self.state.prefix_code = live.prefix.0; - self.state.prefix_mods = live.prefix.1; + self.state.prefix_keys = live.prefix; self.state.keybinds = live.keybinds; match config.local_keybindings_profile_toml() { Ok(profile) => self.client_shell_keybindings_profile = Some(profile), @@ -1706,8 +1704,10 @@ mod tests { assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); assert_eq!(app.state.headless_size, (160, 50)); - assert_eq!(app.state.prefix_code, KeyCode::Char('a')); - assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL); + assert_eq!( + app.state.prefix_keys, + vec![(KeyCode::Char('a'), KeyModifiers::CONTROL)] + ); assert!(app .state .keybinds @@ -1804,7 +1804,10 @@ mod tests { let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.prefix_code, KeyCode::Char('a')); + assert_eq!( + app.state.prefix_keys, + vec![(KeyCode::Char('a'), KeyModifiers::CONTROL)] + ); assert!(app.state.request_client_config_reload); std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR); @@ -1959,17 +1962,14 @@ mod tests { std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let original_prefix = (app.state.prefix_code, app.state.prefix_mods); + let original_prefix = app.state.prefix_keys.clone(); let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial); assert!(report.diagnostics.iter().any(|diagnostic| { diagnostic.contains("keys.new_workspace") && diagnostic.contains("disabling binding") })); - assert_eq!( - (app.state.prefix_code, app.state.prefix_mods), - original_prefix - ); + assert_eq!(app.state.prefix_keys, original_prefix); assert!(app.state.keybinds.new_workspace.bindings.is_empty()); assert_eq!( app.state.toast_config.delivery, @@ -2027,8 +2027,10 @@ mod tests { let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied); - assert_eq!(app.state.prefix_code, KeyCode::Char(' ')); - assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL); + assert_eq!( + app.state.prefix_keys, + vec![(KeyCode::Char(' '), KeyModifiers::CONTROL)] + ); assert!(app .state .keybinds @@ -2117,16 +2119,13 @@ mod tests { std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path); let mut app = test_app(); - let original_prefix = (app.state.prefix_code, app.state.prefix_mods); + let original_prefix = app.state.prefix_keys.clone(); let original_keybinds = app.state.keybinds.new_workspace.clone(); let original_toast_delivery = app.state.toast_config.delivery; let report = app.reload_config(); assert_eq!(report.status, crate::config::ConfigReloadStatus::Failed); - assert_eq!( - (app.state.prefix_code, app.state.prefix_mods), - original_prefix - ); + assert_eq!(app.state.prefix_keys, original_prefix); assert_eq!(app.state.keybinds.new_workspace, original_keybinds); assert_eq!(app.state.toast_config.delivery, original_toast_delivery); assert!(app diff --git a/src/app/state.rs b/src/app/state.rs index 892ec5e9..65c06a6c 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -820,8 +820,7 @@ pub struct AppState { /// None means unsupported or not yet reported, which preserves active-pane suppression. pub outer_terminal_focus: Option, // Config - pub prefix_code: KeyCode, - pub prefix_mods: KeyModifiers, + pub prefix_keys: Vec<(KeyCode, KeyModifiers)>, /// Virtual terminal size (columns, rows) used when no client is attached. pub(crate) headless_size: (u16, u16), pub agent_panel_sort: AgentPanelSort, @@ -1046,8 +1045,7 @@ impl AppState { toast: None, pending_agent_notifications: std::collections::HashMap::new(), outer_terminal_focus: None, - prefix_code: KeyCode::Char('b'), - prefix_mods: KeyModifiers::CONTROL, + prefix_keys: vec![(KeyCode::Char('b'), KeyModifiers::CONTROL)], headless_size: ( crate::config::DEFAULT_HEADLESS_COLS, crate::config::DEFAULT_HEADLESS_ROWS, 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") diff --git a/src/config.rs b/src/config.rs index 60a22a1e..8550097d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,9 +17,9 @@ pub use self::{ upsert_section_value, }, keybinds::{ - format_key_combo, normalize_key_combo, terminal_key_matches_combo, ActionKeybinds, + format_prefix_combos, normalize_key_combo, terminal_key_matches_combo, ActionKeybinds, BindingConfig, CommandKeybindConfig, CustomCommandAction, CustomCommandKeybind, - IndexedKeybind, Keybinds, LiveKeybindConfig, + IndexedKeybind, KeyCombo, Keybinds, LiveKeybindConfig, }, model::{ validated_sidebar_bounds, AgentPanelSortConfig, Config, ConfigReloadReport, @@ -102,7 +102,7 @@ impl Config { .unwrap_or(true) } - pub fn prefix_key(&self) -> (KeyCode, KeyModifiers) { + pub fn prefix_keys(&self) -> Vec<(KeyCode, KeyModifiers)> { self.validated_keybinds().1 } @@ -182,7 +182,7 @@ impl Config { } let mut keys = self.keys.local_profile(&self.keybinds()); - keys.set_prefix(format_key_combo(self.prefix_key())); + keys.set_prefixes(&self.prefix_keys()); toml::to_string_pretty(&KeysProfile { keys }) } } @@ -239,7 +239,42 @@ prefix = "ctrl+" let keybinds = keybindings_from_profile_toml(&profile).unwrap(); assert!(profile.contains("prefix = \"ctrl+b\"")); - assert_eq!(keybinds.prefix, config.prefix_key()); + assert_eq!(keybinds.prefix, config.prefix_keys()); + } + + #[test] + fn local_keybindings_profile_publishes_additional_prefixes_for_old_clients() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = ["ctrl+space", "ctrl+s"] +"#, + ) + .unwrap(); + + let profile = config.local_keybindings_profile_toml().unwrap(); + + // Generation-1 clients parse `prefix` as a single string; the extra + // prefixes ride in the optional `extra_prefixes` field they ignore. + assert!(profile.contains("prefix = \"ctrl+space\"")); + assert!(!profile.contains("prefix = [")); + assert!(profile.contains("extra_prefixes = [\"ctrl+s\"]")); + + let keybinds = keybindings_from_profile_toml(&profile).unwrap(); + assert_eq!(keybinds.prefix, config.prefix_keys()); + } + + #[test] + fn local_keybindings_profile_omits_extra_prefixes_for_a_single_prefix() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+b" +"#, + ) + .unwrap(); + let profile = config.local_keybindings_profile_toml().unwrap(); + assert!(!profile.contains("extra_prefixes")); } #[test] diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 5f4b46de..903f85ca 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -10,12 +10,38 @@ use crate::popup_size::PopupSize; pub type KeyCombo = (KeyCode, KeyModifiers); +/// Built-in prefix used when `keys.prefix` is unset or invalid. +pub(crate) const DEFAULT_PREFIX: KeyCombo = (KeyCode::Char('b'), KeyModifiers::CONTROL); + #[derive(Debug, Clone)] pub struct LiveKeybindConfig { - pub prefix: KeyCombo, + /// Every configured prefix key. The first entry is the primary prefix used + /// for compact display; all entries enter prefix mode. + pub prefix: Vec, pub keybinds: Keybinds, } +impl LiveKeybindConfig { + /// Whether `key` is one of the configured prefix keys. + pub fn matches_prefix(&self, key: &TerminalKey) -> bool { + self.prefix + .iter() + .any(|combo| terminal_key_matches_combo(key, *combo)) + } + + /// Primary prefix combo, used for the compact status bar. + pub fn primary_prefix(&self) -> Option { + self.prefix.first().copied() + } + + /// Primary prefix rendered for the compact status bar. + pub fn primary_prefix_label(&self) -> String { + self.primary_prefix() + .map(format_key_combo) + .unwrap_or_else(|| format_key_combo(DEFAULT_PREFIX)) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(untagged)] pub enum BindingConfig { @@ -45,6 +71,13 @@ impl BindingConfig { } } + pub(crate) fn into_values(self) -> Vec { + match self { + Self::One(value) => vec![value], + Self::Many(values) => values, + } + } + pub(crate) fn has_values(&self) -> bool { self.values().iter().any(|value| !value.trim().is_empty()) } @@ -397,16 +430,19 @@ struct RegisteredBinding { } struct BindingRegistry { - prefix_combo: KeyCombo, + prefix_combos: Vec, prefix_source: BindingSource, direct: std::collections::HashMap, prefix: std::collections::HashMap, } impl BindingRegistry { - fn new(prefix_combo: KeyCombo, prefix_source: BindingSource) -> Self { + fn new(prefix_combos: Vec, prefix_source: BindingSource) -> Self { + let mut normalized: Vec = + prefix_combos.into_iter().map(normalize_key_combo).collect(); + normalized.dedup(); Self { - prefix_combo: normalize_key_combo(prefix_combo), + prefix_combos: normalized, prefix_source, direct: std::collections::HashMap::new(), prefix: std::collections::HashMap::new(), @@ -422,8 +458,18 @@ impl BindingRegistry { }); } + /// Reserve every configured prefix key as a direct/modifier key so no + /// action can hijack it before prefix mode starts. + fn reserve_prefix_keys(&mut self, field: &str, source: BindingSource) { + let combos = self.prefix_combos.clone(); + for combo in combos { + self.reserve_direct(combo, field, source); + } + } + fn prefix_rhs_is_reserved(&self, combo: KeyCombo) -> bool { - normalize_key_combo(combo) == self.prefix_combo + let combo = normalize_key_combo(combo); + self.prefix_combos.contains(&combo) } fn conflict(&self, binding: &ResolvedBinding) -> Option<&RegisteredBinding> { @@ -450,27 +496,28 @@ impl BindingRegistry { } impl Config { - pub(super) fn validated_keybinds(&self) -> (Option, KeyCombo, Vec, Keybinds) { - let mut diagnostics = Vec::new(); - let (prefix, prefix_diag) = parse_key_combo_with_diagnostic( - &self.keys.prefix, - "keys.prefix", - (KeyCode::Char('b'), KeyModifiers::CONTROL), - ); + pub(super) fn validated_keybinds( + &self, + ) -> (Option, Vec, Vec, Keybinds) { + let (prefix_keys, prefix_diag, mut diagnostics) = parse_prefix_keys(&self.keys.prefix); if let Some(diag) = &prefix_diag { warn!(message = %diag, "config diagnostic"); } + for diag in &diagnostics { + warn!(message = %diag, "config diagnostic"); + } let prefix_source = if self.keys.key_field_is_user_configured("prefix") { BindingSource::User } else { BindingSource::Default }; - let mut registry = BindingRegistry::new(prefix, prefix_source); - registry.reserve_direct(prefix, "keys.prefix", prefix_source); - let mut navigate_registry = BindingRegistry::new(prefix, prefix_source); - navigate_registry.reserve_direct(prefix, "keys.prefix", prefix_source); + let mut registry = BindingRegistry::new(prefix_keys.clone(), prefix_source); + registry.reserve_prefix_keys("keys.prefix", prefix_source); + let mut navigate_registry = BindingRegistry::new(prefix_keys.clone(), prefix_source); + navigate_registry.reserve_prefix_keys("keys.prefix", prefix_source); reserve_navigate_runtime_keys(&mut navigate_registry); + let prefix = prefix_keys; macro_rules! empty_action { () => { @@ -1306,19 +1353,45 @@ fn single_key_char(s: &str) -> Option { } } -fn parse_key_combo_with_diagnostic( - s: &str, - field: &str, - fallback: KeyCombo, -) -> (KeyCombo, Option) { - match parse_key_combo(s) { - Some(binding) => (binding, None), - None => { - let diag = format!("invalid keybinding: {field} = {s:?}; using fallback"); - warn!(message = %diag, "config diagnostic"); - (fallback, Some(diag)) +/// Parse the configured prefix keys. Returns the effective list, an optional +/// rejection diagnostic (used when nothing valid remains, so the caller keeps +/// the previous keybinds), and per-entry diagnostics. +fn parse_prefix_keys(config: &BindingConfig) -> (Vec, Option, Vec) { + let mut combos: Vec = Vec::new(); + let mut diagnostics = Vec::new(); + + for raw in config.values() { + let raw = raw.trim(); + match parse_key_combo(raw) { + Some(combo) if !combos.contains(&combo) => combos.push(combo), + Some(_) => {} + None => diagnostics.push(format!( + "invalid keybinding: keys.prefix = {raw:?}; ignoring prefix" + )), } } + + if combos.is_empty() { + let reject = Some(format!( + "invalid keybinding: keys.prefix; using fallback {}", + format_key_combo(DEFAULT_PREFIX) + )); + return (vec![DEFAULT_PREFIX], reject, diagnostics); + } + + (combos, None, diagnostics) +} + +/// Render all configured prefix keys for the help panel. +pub fn format_prefix_combos(prefixes: &[KeyCombo]) -> String { + if prefixes.is_empty() { + return format_key_combo(DEFAULT_PREFIX); + } + prefixes + .iter() + .map(|combo| format_key_combo(*combo)) + .collect::>() + .join(" / ") } pub fn normalize_key_combo((mut code, mut modifiers): KeyCombo) -> KeyCombo { @@ -1532,8 +1605,8 @@ prefix = "ö" ) .unwrap(); assert_eq!( - config.prefix_key(), - (KeyCode::Char('ö'), KeyModifiers::empty()) + config.prefix_keys(), + vec![(KeyCode::Char('ö'), KeyModifiers::empty())] ); assert!(config.collect_diagnostics().is_empty()); } @@ -1821,6 +1894,84 @@ help = "prefix+ctrl+b" assert!(!config.keybinds().help.bindings.is_empty()); } + #[test] + fn multiple_prefix_keys_parse_reserve_and_share_prefix_bindings() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = ["ctrl+space", "ctrl+s"] +help = "prefix+?" +"#, + ) + .unwrap(); + assert_eq!( + config.prefix_keys(), + vec![ + (KeyCode::Char(' '), KeyModifiers::CONTROL), + (KeyCode::Char('s'), KeyModifiers::CONTROL), + ] + ); + assert!(config.collect_diagnostics().is_empty()); + + // A prefix action works regardless of which prefix key started prefix mode. + let help = &config.keybinds().help; + assert!( + help.matches_prefix_key(&TerminalKey::new(KeyCode::Char('?'), KeyModifiers::empty())) + ); + + // Every prefix key is reserved as a prefix-mode right-hand side. + let reserved: Config = toml::from_str( + r#" +[keys] +prefix = ["ctrl+space", "ctrl+s"] +help = "prefix+ctrl+s" +"#, + ) + .unwrap(); + assert!(reserved.keybinds().help.bindings.is_empty()); + assert!(reserved + .collect_diagnostics() + .iter() + .any(|diag| diag.contains("reserved keybinding"))); + } + + #[test] + fn invalid_prefix_entries_are_dropped_while_valid_ones_survive() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = ["ctrl+a", "wat"] +"#, + ) + .unwrap(); + assert_eq!( + config.prefix_keys(), + vec![(KeyCode::Char('a'), KeyModifiers::CONTROL)] + ); + assert!(config + .collect_diagnostics() + .iter() + .any(|diag| diag.contains("keys.prefix") && diag.contains("ignoring prefix"))); + } + + #[test] + fn empty_prefix_list_is_rejected_and_keeps_the_fallback() { + let config: Config = toml::from_str( + r#" +[keys] +prefix = [] +"#, + ) + .unwrap(); + assert_eq!(config.prefix_keys(), vec![DEFAULT_PREFIX]); + assert!(config + .collect_diagnostics() + .iter() + .any(|diag| diag.contains("keys.prefix"))); + // A reload treats the empty list as invalid and keeps the current keybinds. + assert!(config.live_keybinds_with_diagnostics().is_err()); + } + #[test] fn navigate_bindings_allow_plain_keys_and_reject_local_conflicts() { let config: Config = toml::from_str( diff --git a/src/config/model.rs b/src/config/model.rs index ce59eb2e..0e2e57ce 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -334,8 +334,9 @@ pub struct LoadedConfig { #[derive(Debug, Clone, Serialize)] pub struct KeysConfig { - /// Prefix key to enter prefix mode (e.g. "ctrl+b", "f12", "esc"). - pub prefix: String, + /// Prefix key(s) to enter prefix mode (e.g. "ctrl+b", "f12", "esc", or an + /// array to accept several). + pub prefix: BindingConfig, /// Open keybinding help. Default: "prefix+?" pub help: BindingConfig, /// Open settings. Default: "prefix+s" @@ -467,7 +468,12 @@ pub struct KeysConfig { #[serde(default)] pub(crate) struct KeysConfigOverlay { #[serde(skip_serializing_if = "Option::is_none")] - prefix: Option, + prefix: Option, + /// Additional prefix keys published for cross-version compatibility. + /// Older clients parse `prefix` as a single string and ignore this field; + /// new clients merge it into the effective prefix list. + #[serde(skip_serializing_if = "Option::is_none")] + extra_prefixes: Option, #[serde(skip_serializing_if = "Option::is_none")] help: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -592,8 +598,13 @@ pub(crate) struct KeysConfigOverlay { } impl KeysConfigOverlay { - pub(crate) fn set_prefix(&mut self, prefix: String) { - self.prefix = Some(prefix); + pub(crate) fn set_prefixes(&mut self, prefixes: &[super::keybinds::KeyCombo]) { + let mut labels = prefixes + .iter() + .map(|combo| super::keybinds::format_key_combo(*combo)); + self.prefix = Some(BindingConfig::One(labels.next().unwrap_or_default())); + let extra: Vec = labels.collect(); + self.extra_prefixes = (!extra.is_empty()).then_some(BindingConfig::Many(extra)); } } @@ -605,6 +616,25 @@ impl<'de> Deserialize<'de> for KeysConfig { let input = KeysConfigOverlay::deserialize(deserializer)?; let mut keys = KeysConfig::default(); + let prefix_was_supplied = input.prefix.is_some() || input.extra_prefixes.is_some(); + let mut prefix_values = Vec::new(); + if let Some(prefix) = input.prefix { + prefix_values.extend(prefix.into_values()); + } + if let Some(extra) = input.extra_prefixes { + prefix_values.extend(extra.into_values()); + } + if prefix_was_supplied { + // An explicitly empty list stays empty so prefix validation rejects + // it and a reload keeps the current keybindings. + keys.prefix = match prefix_values.len() { + 0 => BindingConfig::Many(Vec::new()), + 1 => BindingConfig::One(prefix_values.remove(0)), + _ => BindingConfig::Many(prefix_values), + }; + keys.user_fields.insert("prefix"); + } + macro_rules! apply_field { ($field:ident) => { if let Some(value) = input.$field { @@ -614,7 +644,6 @@ impl<'de> Deserialize<'de> for KeysConfig { }; } - apply_field!(prefix); apply_field!(help); apply_field!(settings); apply_field!(new_workspace); @@ -1088,7 +1117,7 @@ pub struct ExperimentalConfig { impl Default for KeysConfig { fn default() -> Self { Self { - prefix: "ctrl+b".into(), + prefix: BindingConfig::one("ctrl+b"), help: BindingConfig::one("prefix+?"), settings: BindingConfig::one("prefix+s"), new_workspace: BindingConfig::one("prefix+shift+n"), diff --git a/src/input/keybind_help.rs b/src/input/keybind_help.rs index 2f89b861..cb1fdb99 100644 --- a/src/input/keybind_help.rs +++ b/src/input/keybind_help.rs @@ -63,13 +63,13 @@ fn indexed_range_prefix(bindings: &[IndexedKeybind]) -> Option<&str> { pub(crate) fn keybind_help_groups( keybinds: &Keybinds, - prefix: (crossterm::event::KeyCode, crossterm::event::KeyModifiers), + prefixes: &[crate::config::KeyCombo], ) -> Vec { let mut groups = vec![ ( "global", vec![ - entry(crate::config::format_key_combo(prefix), "prefix mode"), + entry(crate::config::format_prefix_combos(prefixes), "prefix mode"), entry(binding_label(&keybinds.help), "keybinds"), entry(binding_label(&keybinds.settings), "settings"), entry(binding_label(&keybinds.detach), "detach"), @@ -267,4 +267,18 @@ mod tests { assert_eq!(filtered[0].1[0].1, "close pane"); assert!(filter_keybind_help_groups(groups(), "panes").is_empty()); } + + #[test] + fn help_lists_every_configured_prefix() { + let groups = keybind_help_groups( + &Keybinds::default(), + &[ + (KeyCode::Char(' '), KeyModifiers::CONTROL), + (KeyCode::Char('s'), KeyModifiers::CONTROL), + ], + ); + let global = &groups[0].1; + assert_eq!(global[0].0, "ctrl+space / ctrl+s"); + assert_eq!(global[0].1, "prefix mode"); + } } diff --git a/src/server/keybindings.rs b/src/server/keybindings.rs index 262463ff..08046ba4 100644 --- a/src/server/keybindings.rs +++ b/src/server/keybindings.rs @@ -2,7 +2,7 @@ use crate::app; pub(crate) fn app_keybindings(app: &app::App) -> crate::config::LiveKeybindConfig { crate::config::LiveKeybindConfig { - prefix: (app.state.prefix_code, app.state.prefix_mods), + prefix: app.state.prefix_keys.clone(), keybinds: app.state.keybinds.clone(), } } @@ -11,7 +11,6 @@ pub(crate) fn apply_keybindings( app: &mut app::App, keybindings: &crate::config::LiveKeybindConfig, ) { - app.state.prefix_code = keybindings.prefix.0; - app.state.prefix_mods = keybindings.prefix.1; + app.state.prefix_keys = keybindings.prefix.clone(); app.state.keybinds = keybindings.keybinds.clone(); } -- cgit v1.2.3