aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx9
-rw-r--r--docs/next/website/src/data/config-reference.json4
-rw-r--r--src/app/mod.rs39
-rw-r--r--src/app/state.rs6
-rw-r--r--src/client/shell/config.rs12
-rw-r--r--src/client/shell/input.rs10
-rw-r--r--src/client/shell/overlays.rs2
-rw-r--r--src/client/shell/render.rs2
-rw-r--r--src/client/shell/tests/copy.rs4
-rw-r--r--src/client/shell/tests/input.rs25
-rw-r--r--src/client/shell/tests/keybindings_settings.rs4
-rw-r--r--src/config.rs45
-rw-r--r--src/config/keybinds.rs209
-rw-r--r--src/config/model.rs43
-rw-r--r--src/input/keybind_help.rs18
-rw-r--r--src/server/keybindings.rs5
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<crate::api::ApiRequestMessage>,
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::<AppEvent>(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<bool>,
// 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<KeyCombo>,
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<KeyCombo> {
+ 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<String> {
+ 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<KeyCombo>,
prefix_source: BindingSource,
direct: std::collections::HashMap<KeyCombo, RegisteredBinding>,
prefix: std::collections::HashMap<KeyCombo, RegisteredBinding>,
}
impl BindingRegistry {
- fn new(prefix_combo: KeyCombo, prefix_source: BindingSource) -> Self {
+ fn new(prefix_combos: Vec<KeyCombo>, prefix_source: BindingSource) -> Self {
+ let mut normalized: Vec<KeyCombo> =
+ 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<String>, KeyCombo, Vec<String>, 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<String>, Vec<KeyCombo>, Vec<String>, 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<char> {
}
}
-fn parse_key_combo_with_diagnostic(
- s: &str,
- field: &str,
- fallback: KeyCombo,
-) -> (KeyCombo, Option<String>) {
- 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<KeyCombo>, Option<String>, Vec<String>) {
+ let mut combos: Vec<KeyCombo> = 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::<Vec<_>>()
+ .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());
}
@@ -1822,6 +1895,84 @@ help = "prefix+ctrl+b"
}
#[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(
r#"
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<String>,
+ prefix: Option<BindingConfig>,
+ /// 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<BindingConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
help: Option<BindingConfig>,
#[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<String> = 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<KeybindHelpGroup> {
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();
}