aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-09-28 11:58:09 +0200
committerGitHub <noreply@github.com>2026-09-28 11:58:09 +0200
commit7f89b11a11160874ba89f3be7c64b29960bd2df3 (patch)
tree0e875b3fbbe5a701cfb0182bda0ce0994267584c /src/app
parent0d5d6f1f317e238c8297076bc6ab5c3a0cd56283 (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/app')
-rw-r--r--src/app/mod.rs39
-rw-r--r--src/app/state.rs6
2 files changed, 21 insertions, 24 deletions
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,