aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-24 03:37:41 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-24 03:41:16 +0300
commit26205a2ac5e21053bd94a0925efcfca44c6f549b (patch)
tree530fdd445bedadb521759fa89792b98aaf0e3f39
parentc5b3997f3b7d334bd2f35c7aee5216236b58193e (diff)
feat: make navigate movement keybinds configurable
refs #193
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx10
-rw-r--r--src/app/input/navigate.rs291
-rw-r--r--src/config/keybinds.rs273
-rw-r--r--src/config/model.rs18
-rw-r--r--src/main.rs9
-rw-r--r--src/ui/keybind_help.rs20
-rw-r--r--src/ui/menus.rs11
7 files changed, 573 insertions, 59 deletions
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index 23debd45..735befb2 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -96,6 +96,8 @@ new_tab = "prefix+c"
next_tab = "prefix+n"
previous_tab = "prefix+p"
focus_pane_left = "prefix+h"
+navigate_workspace_down = "j"
+navigate_pane_down = "ctrl+j"
split_horizontal = "prefix+minus"
```
@@ -110,6 +112,12 @@ new_workspace = "prefix+shift+n"
new_worktree = "prefix+shift+g"
rename_workspace = "prefix+shift+w"
close_workspace = "prefix+shift+d"
+navigate_workspace_up = "up"
+navigate_workspace_down = "down"
+navigate_pane_left = "h"
+navigate_pane_down = "j"
+navigate_pane_up = "k"
+navigate_pane_right = "l"
new_tab = "prefix+c"
previous_tab = "prefix+p"
next_tab = "prefix+n"
@@ -139,7 +147,7 @@ remove_worktree = "prefix+alt+d"
next_tab = ["prefix+n", "ctrl+alt+]"]
```
-Key strings accept plain keys, modifier combinations such as `ctrl+a`, `shift+n`, `alt+1`, `cmd+k`, and special keys such as `enter`, `tab`, `esc`, `left`, `right`, `up`, and `down`. Named punctuation such as `minus`, `comma`, `ampersand`, `plus`, and `backtick` is also accepted. Plain direct printable keys such as `n` are unsafe because they intercept typing; use `prefix+n` unless you intentionally want a direct binding. Alt, Cmd/Super, and punctuation with modifiers depend on your terminal and tmux settings.
+Key strings accept plain keys, modifier combinations such as `ctrl+a`, `shift+n`, `alt+1`, `cmd+k`, and special keys such as `enter`, `tab`, `esc`, `left`, `right`, `up`, and `down`. Named punctuation such as `minus`, `comma`, `ampersand`, `plus`, and `backtick` is also accepted. Plain direct printable keys such as `n` are unsafe because they intercept typing; use `prefix+n` unless you intentionally want a direct binding. The `navigate_workspace_*` and `navigate_pane_*` fields are navigate-mode-only and may use plain keys such as `j` or `k`; they must not use `prefix+`, `esc`, `enter`, `tab`, `shift+tab`, `left`, `right`, or unmodified `1` through `9`. Left and right arrows are permanent aliases for pane-left and pane-right navigation. These navigate-mode shortcuts are independent from general action bindings such as `focus_pane_down = "prefix+j"`; when both use the same key, the navigate-mode shortcut wins while navigate mode is open. Alt, Cmd/Super, and punctuation with modifiers depend on your terminal and tmux settings.
If you have old custom keybindings and want the new defaults, run `herdr config reset-keys`. Herdr backs up `config.toml`, removes `[keys]` and `[[keys.command]]`, and uses built-in v2 defaults after restart or `herdr server reload-config`.
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 4e8a50b3..108fed8b 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -84,11 +84,11 @@ impl App {
return;
}
- if handle_navigate_reserved_key(&mut self.state, key) {
+ if handle_navigate_reserved_key(&mut self.state, raw_key) {
return;
}
- if let Some(action) = action_for_key(&self.state, raw_key, BindingDispatch::Prefix) {
+ if let Some(action) = navigate_mode_action_for_key(&self.state, raw_key) {
if action == NavigateAction::EditScrollback {
self.launch_focused_scrollback_editor();
} else {
@@ -367,62 +367,76 @@ pub(crate) fn command_for_key(
.cloned()
}
-pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) -> bool {
+pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: TerminalKey) -> bool {
let (code, modifiers) = crate::config::normalize_key_combo((key.code, key.modifiers));
- if !modifiers.is_empty() {
- return false;
- }
-
- match code {
- KeyCode::Enter => {
- if !state.workspaces.is_empty() {
- state.switch_workspace(state.selected);
- leave_navigate_mode(state);
+ if modifiers.is_empty() {
+ match code {
+ KeyCode::Enter => {
+ if !state.workspaces.is_empty() {
+ state.switch_workspace(state.selected);
+ leave_navigate_mode(state);
+ }
+ return true;
}
- true
- }
- KeyCode::Char(c @ '1'..='9') => {
- let idx = (c as usize) - ('1' as usize);
- if let Some(ws_idx) = state.workspace_at_visible_position(idx) {
- state.switch_workspace(ws_idx);
- leave_navigate_mode(state);
+ KeyCode::Char(c @ '1'..='9') => {
+ let idx = (c as usize) - ('1' as usize);
+ if let Some(ws_idx) = state.workspace_at_visible_position(idx) {
+ state.switch_workspace(ws_idx);
+ leave_navigate_mode(state);
+ }
+ return true;
}
- true
- }
- KeyCode::Up => {
- state.move_selected_workspace_by_visible_delta(-1);
- true
- }
- KeyCode::Down => {
- state.move_selected_workspace_by_visible_delta(1);
- true
- }
- KeyCode::Char('h') | KeyCode::Left => {
- state.navigate_pane(NavDirection::Left);
- true
- }
- KeyCode::Char('j') => {
- state.navigate_pane(NavDirection::Down);
- true
- }
- KeyCode::Char('k') => {
- state.navigate_pane(NavDirection::Up);
- true
- }
- KeyCode::Char('l') | KeyCode::Right => {
- state.navigate_pane(NavDirection::Right);
- true
- }
- KeyCode::Tab => {
- state.cycle_pane(false);
- true
- }
- KeyCode::BackTab => {
- state.cycle_pane(true);
- true
+ KeyCode::Tab => {
+ state.cycle_pane(false);
+ return true;
+ }
+ KeyCode::BackTab => {
+ state.cycle_pane(true);
+ return true;
+ }
+ KeyCode::Left => {
+ state.navigate_pane(NavDirection::Left);
+ return true;
+ }
+ KeyCode::Right => {
+ state.navigate_pane(NavDirection::Right);
+ return true;
+ }
+ _ => {}
}
- _ => false,
}
+
+ if state.keybinds.navigate.workspace_up.matches_direct_key(key) {
+ state.move_selected_workspace_by_visible_delta(-1);
+ return true;
+ }
+ if state
+ .keybinds
+ .navigate
+ .workspace_down
+ .matches_direct_key(key)
+ {
+ state.move_selected_workspace_by_visible_delta(1);
+ return true;
+ }
+ if state.keybinds.navigate.pane_left.matches_direct_key(key) {
+ state.navigate_pane(NavDirection::Left);
+ return true;
+ }
+ if state.keybinds.navigate.pane_down.matches_direct_key(key) {
+ state.navigate_pane(NavDirection::Down);
+ return true;
+ }
+ if state.keybinds.navigate.pane_up.matches_direct_key(key) {
+ state.navigate_pane(NavDirection::Up);
+ return true;
+ }
+ if state.keybinds.navigate.pane_right.matches_direct_key(key) {
+ state.navigate_pane(NavDirection::Right);
+ return true;
+ }
+
+ false
}
#[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key
@@ -436,11 +450,11 @@ pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
return;
}
- if handle_navigate_reserved_key(state, key) {
+ if handle_navigate_reserved_key(state, terminal_key) {
return;
}
- if let Some(action) = action_for_key(state, terminal_key, BindingDispatch::Prefix) {
+ if let Some(action) = navigate_mode_action_for_key(state, terminal_key) {
execute_navigate_action_in_context(
state,
&mut terminal_runtimes,
@@ -598,6 +612,20 @@ fn action_for_key(
None
}
+fn navigate_mode_action_for_key(state: &AppState, key: TerminalKey) -> Option<NavigateAction> {
+ let action = action_for_key(state, key, BindingDispatch::Prefix)?;
+ if matches!(
+ action,
+ NavigateAction::FocusPaneLeft
+ | NavigateAction::FocusPaneDown
+ | NavigateAction::FocusPaneUp
+ | NavigateAction::FocusPaneRight
+ ) {
+ return None;
+ }
+ Some(action)
+}
+
#[cfg(test)]
pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) {
let mut terminal_runtimes = TerminalRuntimeRegistry::new();
@@ -1229,6 +1257,159 @@ mod tests {
}
#[test]
+ fn navigate_workspace_keys_are_configurable() {
+ let mut state = state_with_workspaces(&["a", "b"]);
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+navigate_workspace_down = "j"
+navigate_pane_down = "ctrl+j"
+"#,
+ )
+ .unwrap();
+ state.keybinds = config.keybinds();
+ state.selected = 0;
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.selected, 1);
+ assert_eq!(state.mode, Mode::Navigate);
+ }
+
+ #[test]
+ fn navigate_pane_keys_are_configurable() {
+ let mut state = state_with_workspaces(&["test"]);
+ let root = state.workspaces[0].tabs[0].root_pane;
+ let below = state.workspaces[0].test_split(Direction::Vertical);
+ state.workspaces[0].layout.focus_pane(root);
+ state.view.pane_infos = state.workspaces[0]
+ .active_tab()
+ .unwrap()
+ .layout
+ .panes(ratatui::layout::Rect::new(0, 0, 80, 24));
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+navigate_workspace_down = "j"
+navigate_pane_down = "ctrl+j"
+"#,
+ )
+ .unwrap();
+ state.keybinds = config.keybinds();
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL),
+ );
+
+ assert_eq!(state.workspaces[0].focused_pane_id(), Some(below));
+ assert_eq!(state.mode, Mode::Navigate);
+ }
+
+ #[test]
+ fn focus_pane_prefix_rhs_does_not_create_navigate_mode_pane_shortcut() {
+ let mut state = state_with_workspaces(&["test"]);
+ let root = state.workspaces[0].tabs[0].root_pane;
+ let below = state.workspaces[0].test_split(Direction::Vertical);
+ state.workspaces[0].layout.focus_pane(root);
+ state.view.pane_infos = state.workspaces[0]
+ .active_tab()
+ .unwrap()
+ .layout
+ .panes(ratatui::layout::Rect::new(0, 0, 80, 24));
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+focus_pane_down = "prefix+f"
+"#,
+ )
+ .unwrap();
+ state.keybinds = config.keybinds();
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()),
+ );
+ assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()),
+ );
+ assert_eq!(state.workspaces[0].focused_pane_id(), Some(below));
+ assert_eq!(state.mode, Mode::Navigate);
+ }
+
+ #[test]
+ fn customized_navigate_pane_key_disables_matching_prefix_rhs_fallback() {
+ let mut state = state_with_workspaces(&["test"]);
+ let root = state.workspaces[0].tabs[0].root_pane;
+ let below = state.workspaces[0].test_split(Direction::Vertical);
+ state.workspaces[0].layout.focus_pane(root);
+ state.view.pane_infos = state.workspaces[0]
+ .active_tab()
+ .unwrap()
+ .layout
+ .panes(ratatui::layout::Rect::new(0, 0, 80, 24));
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+navigate_pane_down = "ctrl+j"
+"#,
+ )
+ .unwrap();
+ state.keybinds = config.keybinds();
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('j'), KeyModifiers::empty()),
+ );
+ assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL),
+ );
+ assert_eq!(state.workspaces[0].focused_pane_id(), Some(below));
+ assert_eq!(state.mode, Mode::Navigate);
+ }
+
+ #[test]
+ fn left_and_right_arrows_remain_permanent_navigate_pane_aliases() {
+ let mut state = state_with_workspaces(&["test"]);
+ let root = state.workspaces[0].tabs[0].root_pane;
+ let right = state.workspaces[0].test_split(Direction::Horizontal);
+ state.workspaces[0].layout.focus_pane(right);
+ crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 24));
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+navigate_pane_left = "ctrl+h"
+navigate_pane_right = "ctrl+l"
+"#,
+ )
+ .unwrap();
+ state.keybinds = config.keybinds();
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Left, KeyModifiers::empty()),
+ );
+ assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
+ crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 24));
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Right, KeyModifiers::empty()),
+ );
+ assert_eq!(state.workspaces[0].focused_pane_id(), Some(right));
+ assert_eq!(state.mode, Mode::Navigate);
+ }
+
+ #[test]
fn mobile_workspace_keyboard_navigation_keeps_selected_row_visible() {
let mut state = state_with_workspaces(&["a", "b", "c", "d"]);
state.active = Some(0);
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs
index e6109e30..9235cf05 100644
--- a/src/config/keybinds.rs
+++ b/src/config/keybinds.rs
@@ -242,7 +242,19 @@ pub struct CustomCommandKeybind {
/// Parsed keybinds for Herdr actions.
#[derive(Debug, Clone)]
+pub struct NavigateKeybinds {
+ pub workspace_up: ActionKeybinds,
+ pub workspace_down: ActionKeybinds,
+ pub pane_left: ActionKeybinds,
+ pub pane_down: ActionKeybinds,
+ pub pane_up: ActionKeybinds,
+ pub pane_right: ActionKeybinds,
+}
+
+/// Parsed keybinds for Herdr actions.
+#[derive(Debug, Clone)]
pub struct Keybinds {
+ pub navigate: NavigateKeybinds,
pub help: ActionKeybinds,
pub settings: ActionKeybinds,
pub new_workspace: ActionKeybinds,
@@ -363,6 +375,9 @@ impl Config {
let mut registry = BindingRegistry::new(prefix);
registry.reserve_direct(prefix, "keys.prefix");
+ let mut navigate_registry = BindingRegistry::new(prefix);
+ navigate_registry.reserve_direct(prefix, "keys.prefix");
+ reserve_navigate_runtime_keys(&mut navigate_registry);
macro_rules! action {
($field:literal, $config:expr) => {
@@ -376,6 +391,44 @@ impl Config {
}
let mut keybinds = Keybinds {
+ navigate: NavigateKeybinds {
+ workspace_up: parse_navigate_bindings(
+ "keys.navigate_workspace_up",
+ &self.keys.navigate_workspace_up,
+ &mut navigate_registry,
+ &mut diagnostics,
+ ),
+ workspace_down: parse_navigate_bindings(
+ "keys.navigate_workspace_down",
+ &self.keys.navigate_workspace_down,
+ &mut navigate_registry,
+ &mut diagnostics,
+ ),
+ pane_left: parse_navigate_bindings(
+ "keys.navigate_pane_left",
+ &self.keys.navigate_pane_left,
+ &mut navigate_registry,
+ &mut diagnostics,
+ ),
+ pane_down: parse_navigate_bindings(
+ "keys.navigate_pane_down",
+ &self.keys.navigate_pane_down,
+ &mut navigate_registry,
+ &mut diagnostics,
+ ),
+ pane_up: parse_navigate_bindings(
+ "keys.navigate_pane_up",
+ &self.keys.navigate_pane_up,
+ &mut navigate_registry,
+ &mut diagnostics,
+ ),
+ pane_right: parse_navigate_bindings(
+ "keys.navigate_pane_right",
+ &self.keys.navigate_pane_right,
+ &mut navigate_registry,
+ &mut diagnostics,
+ ),
+ },
help: action!("keys.help", &self.keys.help),
settings: action!("keys.settings", &self.keys.settings),
new_workspace: action!("keys.new_workspace", &self.keys.new_workspace),
@@ -486,6 +539,27 @@ impl Config {
}
}
+fn reserve_navigate_runtime_keys(registry: &mut BindingRegistry) {
+ for combo in [
+ (KeyCode::Esc, KeyModifiers::empty()),
+ (KeyCode::Enter, KeyModifiers::empty()),
+ (KeyCode::Tab, KeyModifiers::empty()),
+ (KeyCode::BackTab, KeyModifiers::empty()),
+ (KeyCode::Tab, KeyModifiers::SHIFT),
+ (KeyCode::Left, KeyModifiers::empty()),
+ (KeyCode::Right, KeyModifiers::empty()),
+ ] {
+ registry.reserve_direct(combo, "navigate reserved keys");
+ }
+
+ for idx in '1'..='9' {
+ registry.reserve_direct(
+ (KeyCode::Char(idx), KeyModifiers::empty()),
+ "navigate reserved keys",
+ );
+ }
+}
+
fn parse_action_bindings(
field: &'static str,
config: &BindingConfig,
@@ -541,6 +615,41 @@ fn parse_action_bindings_owned(
ActionKeybinds { bindings }
}
+fn parse_navigate_bindings(
+ field: &'static str,
+ config: &BindingConfig,
+ registry: &mut BindingRegistry,
+ diagnostics: &mut Vec<String>,
+) -> ActionKeybinds {
+ let mut bindings = Vec::new();
+ for raw in config.values() {
+ let raw = raw.trim();
+ if raw.is_empty() {
+ continue;
+ }
+ match parse_binding_string(raw) {
+ Some(ParsedBinding::Single(binding)) => {
+ if reject_navigate_binding(field, &binding, registry, diagnostics) {
+ continue;
+ }
+ registry.register(&binding, field);
+ bindings.push(binding);
+ }
+ Some(ParsedBinding::Range(_)) => {
+ let diag = format!("range keybinding is only valid for indexed actions: {field} = {raw:?}; disabling binding");
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ }
+ None => {
+ let diag = format!("invalid keybinding: {field} = {raw:?}; disabling binding");
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ }
+ }
+ }
+ ActionKeybinds { bindings }
+}
+
fn parse_indexed_bindings(
field: &'static str,
config: &BindingConfig,
@@ -608,6 +717,42 @@ fn append_legacy_indexed_bindings(
}
}
+fn reject_navigate_binding(
+ field: &str,
+ binding: &ResolvedBinding,
+ registry: &BindingRegistry,
+ diagnostics: &mut Vec<String>,
+) -> bool {
+ if binding.trigger.is_prefix() {
+ let diag = format!(
+ "navigate keybinding must not include prefix: {field} = {:?}; disabling binding",
+ binding.label
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ return true;
+ }
+
+ if matches!(normalize_key_combo(binding.trigger.combo()).0, KeyCode::Esc) {
+ let diag = format!(
+ "navigate keybinding cannot use esc: {field} = {:?}; disabling binding",
+ binding.label
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ return true;
+ }
+
+ if let Some(first_field) = registry.conflict(binding) {
+ let diag = format!("{}: kept {first_field}, disabled {field}", binding.label);
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ return true;
+ }
+
+ false
+}
+
fn reject_binding(
field: &str,
binding: &ResolvedBinding,
@@ -1249,6 +1394,134 @@ help = "prefix+ctrl+b"
}
#[test]
+ fn navigate_bindings_allow_plain_keys_and_reject_local_conflicts() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+navigate_workspace_up = "j"
+navigate_workspace_down = "j"
+navigate_pane_down = "ctrl+j"
+"#,
+ )
+ .unwrap();
+ let keybinds = config.keybinds();
+ let diagnostics = config.collect_diagnostics();
+
+ assert!(keybinds
+ .navigate
+ .workspace_up
+ .matches_direct_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty())));
+ assert!(keybinds.navigate.workspace_down.bindings.is_empty());
+ assert!(keybinds
+ .navigate
+ .pane_down
+ .matches_direct_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::CONTROL)));
+ assert!(diagnostics.iter().any(|diag| {
+ diag.contains("kept keys.navigate_workspace_up")
+ && diag.contains("disabled keys.navigate_workspace_down")
+ }));
+ }
+
+ #[test]
+ fn navigate_bindings_reject_runtime_reserved_keys() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+navigate_workspace_up = ["esc", "alt+esc", "enter", "1", "tab", "shift+tab", "left", "right"]
+"#,
+ )
+ .unwrap();
+ let keybinds = config.keybinds();
+ let diagnostics = config.collect_diagnostics();
+
+ assert!(keybinds.navigate.workspace_up.bindings.is_empty());
+ assert_eq!(
+ diagnostics
+ .iter()
+ .filter(|diag| {
+ (diag.contains("navigate reserved keys")
+ || diag.contains("navigate keybinding cannot use esc"))
+ && diag.contains("keys.navigate_workspace_up")
+ })
+ .count(),
+ 8
+ );
+ }
+
+ #[test]
+ fn navigate_bindings_can_reuse_navigate_mode_prefix_rhs_keys() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+navigate_workspace_down = ["n", "f"]
+
+[[keys.command]]
+key = "prefix+f"
+command = "echo hi"
+"#,
+ )
+ .unwrap();
+ let keybinds = config.keybinds();
+ let diagnostics = config.collect_diagnostics();
+
+ assert!(keybinds
+ .navigate
+ .workspace_down
+ .matches_direct_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::empty())));
+ assert!(keybinds
+ .navigate
+ .workspace_down
+ .matches_direct_key(TerminalKey::new(KeyCode::Char('f'), KeyModifiers::empty())));
+ assert!(!keybinds.custom_commands.is_empty());
+ assert!(!diagnostics.iter().any(|diag| {
+ diag.contains("disabled keys.navigate_workspace_down")
+ && (diag.contains("keys.next_tab") || diag.contains("keys.command"))
+ }));
+ }
+
+ #[test]
+ fn navigate_bindings_do_not_conflict_with_general_focus_pane_bindings() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+navigate_pane_down = "j"
+"#,
+ )
+ .unwrap();
+ let keybinds = config.keybinds();
+
+ assert!(keybinds
+ .navigate
+ .pane_down
+ .matches_direct_key(TerminalKey::new(KeyCode::Char('j'), KeyModifiers::empty())));
+ }
+
+ #[test]
+ fn navigate_bindings_reject_prefix_syntax_and_prefix_key() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+prefix = "ctrl+a"
+navigate_workspace_up = "prefix+j"
+navigate_workspace_down = "ctrl+a"
+"#,
+ )
+ .unwrap();
+ let keybinds = config.keybinds();
+ let diagnostics = config.collect_diagnostics();
+
+ assert!(keybinds.navigate.workspace_up.bindings.is_empty());
+ assert!(keybinds.navigate.workspace_down.bindings.is_empty());
+ assert!(diagnostics.iter().any(|diag| {
+ diag.contains("navigate keybinding must not include prefix")
+ && diag.contains("keys.navigate_workspace_up")
+ }));
+ assert!(diagnostics.iter().any(|diag| {
+ diag.contains("kept keys.prefix") && diag.contains("keys.navigate_workspace_down")
+ }));
+ }
+
+ #[test]
fn custom_command_prefix_rhs_equal_to_configured_prefix_is_rejected() {
let config: Config = toml::from_str(
r#"
diff --git a/src/config/model.rs b/src/config/model.rs
index b6b7a2fb..7db8c2f4 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -153,6 +153,18 @@ pub struct KeysConfig {
pub workspace_picker: BindingConfig,
/// Open the session navigator. Default: "prefix+g"
pub goto: BindingConfig,
+ /// Move workspace selection up in navigate mode. Default: "up".
+ pub navigate_workspace_up: BindingConfig,
+ /// Move workspace selection down in navigate mode. Default: "down".
+ pub navigate_workspace_down: BindingConfig,
+ /// Focus the pane to the left in navigate mode. Default: "h". Left arrow is always an alias.
+ pub navigate_pane_left: BindingConfig,
+ /// Focus the pane below in navigate mode. Default: "j".
+ pub navigate_pane_down: BindingConfig,
+ /// Focus the pane above in navigate mode. Default: "k".
+ pub navigate_pane_up: BindingConfig,
+ /// Focus the pane to the right in navigate mode. Default: "l". Right arrow is always an alias.
+ pub navigate_pane_right: BindingConfig,
/// Detach from server/client mode, or exit --no-session mode. Default: "prefix+q".
pub detach: BindingConfig,
/// Reload config.toml in the running app/server. Default: "prefix+shift+r".
@@ -346,6 +358,12 @@ impl Default for KeysConfig {
close_workspace: BindingConfig::one("prefix+shift+d"),
workspace_picker: BindingConfig::one("prefix+w"),
goto: BindingConfig::one("prefix+g"),
+ navigate_workspace_up: BindingConfig::one("up"),
+ navigate_workspace_down: BindingConfig::one("down"),
+ navigate_pane_left: BindingConfig::one("h"),
+ navigate_pane_down: BindingConfig::one("j"),
+ navigate_pane_up: BindingConfig::one("k"),
+ navigate_pane_right: BindingConfig::one("l"),
detach: BindingConfig::one("prefix+q"),
reload_config: BindingConfig::one("prefix+shift+r"),
open_notification_target: BindingConfig::one("prefix+o"),
diff --git a/src/main.rs b/src/main.rs
index a49b76df..4c7063ca 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -140,6 +140,15 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# resize_mode = "prefix+r"
# toggle_sidebar = "prefix+b"
+# Navigate-mode movement. These local shortcuts win while navigate mode is open.
+# They are independent from focus_pane_*. Do not include prefix+, esc, enter, tab, or 1..9 here.
+# navigate_workspace_up = "up"
+# navigate_workspace_down = "down"
+# navigate_pane_left = "h" # left arrow always focuses the pane to the left
+# navigate_pane_down = "j"
+# navigate_pane_up = "k"
+# navigate_pane_right = "l" # right arrow always focuses the pane to the right
+
# Custom commands use the same binding syntax.
# type = "shell" runs detached in the background.
# type = "pane" opens a temporary pane and closes it when the command exits.
diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs
index c1f0fa74..21f4baed 100644
--- a/src/ui/keybind_help.rs
+++ b/src/ui/keybind_help.rs
@@ -69,8 +69,24 @@ pub(super) fn keybind_help_groups(
"navigation",
vec![
("esc".to_string(), "back"),
- ("↑ / ↓".to_string(), "workspace list"),
- ("h j k l / arrows".to_string(), "move focus"),
+ (
+ format!(
+ "{} / {}",
+ keybind_label(&kb.navigate.workspace_up),
+ keybind_label(&kb.navigate.workspace_down)
+ ),
+ "workspace list",
+ ),
+ (
+ format!(
+ "{} / {} / {} / {} / left / right",
+ keybind_label(&kb.navigate.pane_left),
+ keybind_label(&kb.navigate.pane_down),
+ keybind_label(&kb.navigate.pane_up),
+ keybind_label(&kb.navigate.pane_right)
+ ),
+ "move focus",
+ ),
("tab / shift+tab".to_string(), "cycle pane"),
("enter".to_string(), "open workspace"),
("1..9".to_string(), "switch workspace"),
diff --git a/src/ui/menus.rs b/src/ui/menus.rs
index c37d08d6..b711358e 100644
--- a/src/ui/menus.rs
+++ b/src/ui/menus.rs
@@ -15,6 +15,10 @@ fn prefix_rhs_label(bindings: &crate::config::ActionKeybinds) -> String {
.unwrap_or_else(|| "unset".to_string())
}
+fn keybind_label(bindings: &crate::config::ActionKeybinds) -> String {
+ bindings.label().unwrap_or_else(|| "unset".to_string())
+}
+
fn render_bottom_bar(frame: &mut Frame, area: Rect, line: Line<'_>, bg: ratatui::style::Color) {
frame.render_widget(Clear, area);
let buf = frame.buffer_mut();
@@ -78,12 +82,17 @@ pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: R
let settings = prefix_rhs_label(&kb.settings);
let goto = prefix_rhs_label(&kb.goto);
let detach = prefix_rhs_label(&kb.detach);
+ let workspace_nav = format!(
+ "{} / {}",
+ keybind_label(&kb.navigate.workspace_up),
+ keybind_label(&kb.navigate.workspace_down)
+ );
let line = Line::from(vec![
Span::styled(" NAVIGATE ", mode_style),
Span::raw(" "),
Span::styled("esc", key),
Span::styled(" back ", dim),
- Span::styled("↑↓", key),
+ Span::styled(workspace_nav, key),
Span::styled(" ws ", dim),
Span::styled("⇥", key),
Span::styled(" pane ", dim),