aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-15 20:31:01 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-15 20:31:01 +0300
commit4cbe72ec2d8a174e3c4f9bde2101aa189c7f38e7 (patch)
tree2de696538e494d3c14a920c20877bcd6d4ac39dc
parent43d60fc6629d222ae63813ebcf4531067ba5bc9d (diff)
feat: add indexed keybind families
-rw-r--r--.pi/docs/CONFIGURATION.md26
-rw-r--r--src/app/actions.rs70
-rw-r--r--src/app/input/navigate.rs75
-rw-r--r--src/app/state.rs6
-rw-r--r--src/config/keybinds.rs338
-rw-r--r--src/config/model.rs14
-rw-r--r--src/main.rs11
-rw-r--r--src/ui/keybind_help.rs12
8 files changed, 497 insertions, 55 deletions
diff --git a/.pi/docs/CONFIGURATION.md b/.pi/docs/CONFIGURATION.md
index 5fb924d5..073a369f 100644
--- a/.pi/docs/CONFIGURATION.md
+++ b/.pi/docs/CONFIGURATION.md
@@ -77,14 +77,14 @@ keybindings live under `[keys]`.
supported syntax:
- plain keys: `n`, `x`, `-`, `` ` ``
-- modifiers: `ctrl+b`, `shift+n`, `alt+x`
+- modifiers: `ctrl+b`, `shift+n`, `alt+x`, `cmd+x`, `super+x`
- special keys: `enter`, `esc`, `tab`, `backspace`, `left`, `right`, `up`, `down`
- function keys: `f1`, `f12`
- uppercase letters also imply shift: `D` works like `shift+d`
notes:
- most reliable bindings are plain keys, `ctrl+letter`, `esc`/`tab`/`enter`, and function keys
-- `alt+...` and punctuation-with-modifiers may vary depending on terminal/tmux setup
+- `alt+...`, `cmd`/`super`, and punctuation-with-modifiers may vary depending on terminal/tmux setup
- bindings marked `unset` in the key reference are supported actions with no default key assigned
- for navigate-mode actions, duplicate keybindings are treated as config errors; later conflicting bindings fall back to defaults
@@ -116,6 +116,11 @@ focus_pane_left = "alt+h"
focus_pane_down = "alt+j"
focus_pane_up = "alt+k"
focus_pane_right = "alt+l"
+
+[keys.indexed]
+tabs = "" # optional; e.g. "ctrl" makes ctrl+1..9 switch tabs
+workspaces = "" # optional; e.g. "ctrl+shift" makes ctrl+shift+1..9 switch workspaces
+agents = "" # optional; follows visible agent panel order
```
### key reference
@@ -150,6 +155,23 @@ focus_pane_right = "alt+l"
| `resize_mode` | `r` | enter or leave resize mode |
| `toggle_sidebar` | `b` | collapse or expand the sidebar |
+### indexed keybindings
+
+Use `[keys.indexed]` to bind number keys `1` through `9` as positional shortcuts. Each value is a modifier combo only. Empty values disable that shortcut family.
+
+```toml
+[keys.indexed]
+tabs = ""
+workspaces = ""
+agents = ""
+```
+
+| key | default | action |
+|-----|---------|--------|
+| `tabs` | unset | switch to tab 1-9 in the active workspace, left to right |
+| `workspaces` | unset | switch to workspace 1-9 in sidebar order, top to bottom |
+| `agents` | unset | focus agent row 1-9 in the visible agent panel order |
+
### custom command keybindings
Use `[[keys.command]]` to bind a prefix-mode key to a command. Press the prefix key, then the configured key.
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 9477ca15..21e17bf0 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -331,6 +331,32 @@ impl AppState {
self.cycle_agent_entry(false);
}
+ pub fn focus_agent_entry(&mut self, idx: usize) -> bool {
+ let entries = crate::ui::agent_panel_entries(self);
+ let Some(target) = entries.get(idx) else {
+ return false;
+ };
+ let ws_idx = target.ws_idx;
+ let tab_idx = target.tab_idx;
+ let pane_id = target.pane_id;
+
+ self.switch_workspace(ws_idx);
+ self.switch_tab(tab_idx);
+ if let Some(tab) = self
+ .workspaces
+ .get_mut(ws_idx)
+ .and_then(|ws| ws.tabs.get_mut(tab_idx))
+ {
+ if tab.panes.contains_key(&pane_id) {
+ tab.layout.focus_pane(pane_id);
+ self.mark_session_dirty();
+ self.ensure_agent_panel_entry_visible(idx);
+ return true;
+ }
+ }
+ false
+ }
+
fn cycle_agent_entry(&mut self, forward: bool) {
let entries = crate::ui::agent_panel_entries(self);
if entries.is_empty() {
@@ -351,24 +377,7 @@ impl AppState {
(None, false) => entries.len() - 1,
};
- let target = &entries[target_idx];
- let ws_idx = target.ws_idx;
- let tab_idx = target.tab_idx;
- let pane_id = target.pane_id;
-
- self.switch_workspace(ws_idx);
- self.switch_tab(tab_idx);
- if let Some(tab) = self
- .workspaces
- .get_mut(ws_idx)
- .and_then(|ws| ws.tabs.get_mut(tab_idx))
- {
- if tab.panes.contains_key(&pane_id) {
- tab.layout.focus_pane(pane_id);
- self.mark_session_dirty();
- }
- }
- self.ensure_agent_panel_entry_visible(target_idx);
+ self.focus_agent_entry(target_idx);
}
fn ensure_agent_panel_entry_visible(&mut self, idx: usize) {
@@ -987,6 +996,31 @@ mod tests {
}
#[test]
+ fn focus_agent_entry_uses_agent_panel_order() {
+ let mut first = Workspace::test_new("one");
+ let first_root = first.tabs[0].root_pane;
+ let first_second = first.test_split(Direction::Horizontal);
+ first.tabs[0].layout.focus_pane(first_root);
+ let second = Workspace::test_new("two");
+ let second_root = second.tabs[0].root_pane;
+
+ let mut state = AppState::test_new();
+ state.workspaces = vec![first, second];
+ state.active = Some(0);
+ state.selected = 0;
+ state.mode = Mode::Terminal;
+ state.agent_panel_scope = crate::app::state::AgentPanelScope::AllWorkspaces;
+ mark_agent(&mut state, 0, 0, first_root);
+ mark_agent(&mut state, 0, 0, first_second);
+ mark_agent(&mut state, 1, 0, second_root);
+
+ assert!(state.focus_agent_entry(2));
+
+ assert_eq!(state.active, Some(1));
+ assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root));
+ }
+
+ #[test]
fn next_agent_cycles_only_current_scope_entries() {
let mut first = Workspace::test_new("one");
let first_root = first.tabs[0].root_pane;
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index 6dfaf475..7675835b 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -17,6 +17,10 @@ pub(crate) fn terminal_direct_navigation_action(
state: &AppState,
key: &KeyEvent,
) -> Option<NavigateAction> {
+ if let Some(action) = indexed_navigation_action(state, key) {
+ return Some(action);
+ }
+
let kb = &state.keybinds;
if kb
.previous_workspace
@@ -373,6 +377,9 @@ pub(crate) enum NavigateAction {
NewWorkspace,
RenameWorkspace,
CloseWorkspace,
+ SwitchWorkspace(usize),
+ SwitchTab(usize),
+ FocusAgent(usize),
PreviousWorkspace,
NextWorkspace,
PreviousAgent,
@@ -398,7 +405,40 @@ pub(crate) enum NavigateAction {
Detach,
}
+fn indexed_navigation_action(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> {
+ let KeyCode::Char(c @ '1'..='9') = key.code else {
+ return None;
+ };
+ let idx = (c as usize) - ('1' as usize);
+ let kb = &state.keybinds;
+
+ if kb
+ .indexed_tabs
+ .is_some_and(|mods| key_matches(key, KeyCode::Char(c), mods))
+ {
+ return Some(NavigateAction::SwitchTab(idx));
+ }
+ if kb
+ .indexed_workspaces
+ .is_some_and(|mods| key_matches(key, KeyCode::Char(c), mods))
+ {
+ return Some(NavigateAction::SwitchWorkspace(idx));
+ }
+ if kb
+ .indexed_agents
+ .is_some_and(|mods| key_matches(key, KeyCode::Char(c), mods))
+ {
+ return Some(NavigateAction::FocusAgent(idx));
+ }
+
+ None
+}
+
fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> {
+ if let Some(action) = indexed_navigation_action(state, key) {
+ return Some(action);
+ }
+
let kb = &state.keybinds;
if key_matches(key, kb.new_workspace.0, kb.new_workspace.1) {
return Some(NavigateAction::NewWorkspace);
@@ -526,6 +566,27 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi
}
}
}
+ NavigateAction::SwitchWorkspace(idx) => {
+ if idx < state.workspaces.len() {
+ state.switch_workspace(idx);
+ leave_navigate_mode(state);
+ }
+ }
+ NavigateAction::SwitchTab(idx) => {
+ let tab_exists = state
+ .active
+ .and_then(|ws_idx| state.workspaces.get(ws_idx))
+ .is_some_and(|ws| idx < ws.tabs.len());
+ if tab_exists {
+ state.switch_tab(idx);
+ leave_navigate_mode(state);
+ }
+ }
+ NavigateAction::FocusAgent(idx) => {
+ if state.focus_agent_entry(idx) {
+ leave_navigate_mode(state);
+ }
+ }
NavigateAction::PreviousWorkspace => {
state.previous_workspace();
leave_navigate_mode(state);
@@ -791,6 +852,20 @@ mod tests {
assert_eq!(action, Some(NavigateAction::FocusPaneLeft));
}
+ #[test]
+ fn terminal_direct_indexed_tab_shortcut_maps_to_navigation_action() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.indexed_tabs = Some(KeyModifiers::CONTROL);
+ state.keybinds.indexed_tabs_label = Some("ctrl+1..9".into());
+
+ let action = terminal_direct_navigation_action(
+ &state,
+ &KeyEvent::new(KeyCode::Char('3'), KeyModifiers::CONTROL),
+ );
+
+ assert_eq!(action, Some(NavigateAction::SwitchTab(2)));
+ }
+
#[tokio::test]
async fn custom_command_runs_from_prefix_key_in_navigate_mode() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
diff --git a/src/app/state.rs b/src/app/state.rs
index 383f2bd5..d0d40c72 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -1095,6 +1095,12 @@ impl AppState {
previous_agent_label: None,
next_agent: None,
next_agent_label: None,
+ indexed_tabs: None,
+ indexed_tabs_label: None,
+ indexed_workspaces: None,
+ indexed_workspaces_label: None,
+ indexed_agents: None,
+ indexed_agents_label: None,
new_tab: (KeyCode::Char('c'), KeyModifiers::empty()),
new_tab_label: "c".into(),
rename_tab: None,
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs
index a18dcbc3..de4f03a9 100644
--- a/src/config/keybinds.rs
+++ b/src/config/keybinds.rs
@@ -77,6 +77,12 @@ pub struct Keybinds {
pub previous_agent_label: Option<String>,
pub next_agent: Option<(KeyCode, KeyModifiers)>,
pub next_agent_label: Option<String>,
+ pub indexed_tabs: Option<KeyModifiers>,
+ pub indexed_tabs_label: Option<String>,
+ pub indexed_workspaces: Option<KeyModifiers>,
+ pub indexed_workspaces_label: Option<String>,
+ pub indexed_agents: Option<KeyModifiers>,
+ pub indexed_agents_label: Option<String>,
pub new_tab: (KeyCode, KeyModifiers),
pub new_tab_label: String,
pub rename_tab: Option<(KeyCode, KeyModifiers)>,
@@ -138,12 +144,18 @@ impl Config {
}
struct OptionalBinding {
- scope: BindingScope,
+ scopes: Vec<BindingScope>,
field: &'static str,
value: Option<(KeyCode, KeyModifiers)>,
label: Option<String>,
}
+ struct IndexedBinding {
+ field: &'static str,
+ value: Option<KeyModifiers>,
+ label: Option<String>,
+ }
+
#[derive(Default)]
struct BindingRegistry {
seen: std::collections::HashMap<(BindingScope, KeyCode, KeyModifiers), String>,
@@ -208,14 +220,14 @@ impl Config {
}
fn optional_binding(
- scope: BindingScope,
+ scopes: Vec<BindingScope>,
field: &'static str,
configured_label: &str,
diagnostics: &mut Vec<String>,
) -> OptionalBinding {
if configured_label.trim().is_empty() {
return OptionalBinding {
- scope,
+ scopes,
field,
value: None,
label: None,
@@ -223,7 +235,7 @@ impl Config {
}
match parse_key_combo(configured_label) {
Some(value) => OptionalBinding {
- scope,
+ scopes,
field,
value: Some(value),
label: Some(configured_label.to_string()),
@@ -236,7 +248,41 @@ impl Config {
warn!(message = %diag, "config diagnostic");
diagnostics.push(diag);
OptionalBinding {
- scope,
+ scopes,
+ field,
+ value: None,
+ label: None,
+ }
+ }
+ }
+ }
+
+ fn indexed_binding(
+ field: &'static str,
+ configured_label: &str,
+ diagnostics: &mut Vec<String>,
+ ) -> IndexedBinding {
+ if configured_label.trim().is_empty() {
+ return IndexedBinding {
+ field,
+ value: None,
+ label: None,
+ };
+ }
+ match parse_modifier_combo(configured_label) {
+ Some(value) => IndexedBinding {
+ field,
+ value: Some(value),
+ label: Some(format!("{}+1..9", configured_label.trim())),
+ },
+ None => {
+ let diag = format!(
+ "invalid indexed keybinding: {field} = {:?}; disabling binding",
+ configured_label
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ IndexedBinding {
field,
value: None,
label: None,
@@ -338,105 +384,128 @@ impl Config {
),
];
+ let navigate_scope = || vec![BindingScope::Navigate];
+ let terminal_direct_scope = || vec![BindingScope::TerminalDirect];
+ let direct_navigation_scopes =
+ || vec![BindingScope::Navigate, BindingScope::TerminalDirect];
+
let mut optional_bindings = vec![
optional_binding(
- BindingScope::Navigate,
+ navigate_scope(),
"keys.detach",
&self.keys.detach,
&mut diagnostics,
),
optional_binding(
- BindingScope::Navigate,
+ navigate_scope(),
"keys.reload_config",
&self.keys.reload_config,
&mut diagnostics,
),
optional_binding(
- BindingScope::Navigate,
+ navigate_scope(),
"keys.open_notification_target",
&self.keys.open_notification_target,
&mut diagnostics,
),
optional_binding(
- BindingScope::Navigate,
+ direct_navigation_scopes(),
"keys.previous_workspace",
&self.keys.previous_workspace,
&mut diagnostics,
),
optional_binding(
- BindingScope::Navigate,
+ direct_navigation_scopes(),
"keys.next_workspace",
&self.keys.next_workspace,
&mut diagnostics,
),
optional_binding(
- BindingScope::Navigate,
+ direct_navigation_scopes(),
"keys.previous_agent",
&self.keys.previous_agent,
&mut diagnostics,
),
optional_binding(
- BindingScope::Navigate,
+ direct_navigation_scopes(),
"keys.next_agent",
&self.keys.next_agent,
&mut diagnostics,
),
optional_binding(
- BindingScope::Navigate,
+ navigate_scope(),
"keys.rename_tab",
&self.keys.rename_tab,
&mut diagnostics,
),
optional_binding(
- BindingScope::Navigate,
+ direct_navigation_scopes(),
"keys.previous_tab",
&self.keys.previous_tab,
&mut diagnostics,
),
optional_binding(
- BindingScope::Navigate,
+ direct_navigation_scopes(),
"keys.next_tab",
&self.keys.next_tab,
&mut diagnostics,
),
optional_binding(
- BindingScope::Navigate,
+ navigate_scope(),
"keys.close_tab",
&self.keys.close_tab,
&mut diagnostics,
),
optional_binding(
- BindingScope::Navigate,
+ navigate_scope(),
"keys.rename_pane",
&self.keys.rename_pane,
&mut diagnostics,
),
optional_binding(
- BindingScope::TerminalDirect,
+ terminal_direct_scope(),
"keys.focus_pane_left",
&self.keys.focus_pane_left,
&mut diagnostics,
),
optional_binding(
- BindingScope::TerminalDirect,
+ terminal_direct_scope(),
"keys.focus_pane_down",
&self.keys.focus_pane_down,
&mut diagnostics,
),
optional_binding(
- BindingScope::TerminalDirect,
+ terminal_direct_scope(),
"keys.focus_pane_up",
&self.keys.focus_pane_up,
&mut diagnostics,
),
optional_binding(
- BindingScope::TerminalDirect,
+ terminal_direct_scope(),
"keys.focus_pane_right",
&self.keys.focus_pane_right,
&mut diagnostics,
),
];
+ let mut indexed_bindings = vec![
+ indexed_binding(
+ "keys.indexed.tabs",
+ &self.keys.indexed.tabs,
+ &mut diagnostics,
+ ),
+ indexed_binding(
+ "keys.indexed.workspaces",
+ &self.keys.indexed.workspaces,
+ &mut diagnostics,
+ ),
+ indexed_binding(
+ "keys.indexed.agents",
+ &self.keys.indexed.agents,
+ &mut diagnostics,
+ ),
+ ];
+
let mut registry = BindingRegistry::default();
for binding in &mut bindings {
if let Some(first_field) = registry.conflict(binding.scope, binding.value) {
@@ -452,11 +521,18 @@ impl Config {
registry.register(binding.scope, binding.value, binding.field);
}
+ registry.reserve_if_unbound(BindingScope::TerminalDirect, prefix, "keys.prefix");
+
for binding in &mut optional_bindings {
let Some(value) = binding.value else {
continue;
};
- if let Some(first_field) = registry.conflict(binding.scope, value) {
+
+ let conflict = binding
+ .scopes
+ .iter()
+ .find_map(|scope| registry.conflict(*scope, value));
+ if let Some(first_field) = conflict {
let diag = format!(
"duplicate keybinding: {} conflicts with {}; disabling binding",
binding.field, first_field
@@ -467,7 +543,9 @@ impl Config {
binding.label = None;
continue;
}
- registry.register(binding.scope, value, binding.field);
+ for scope in &binding.scopes {
+ registry.register(*scope, value, binding.field);
+ }
}
registry.reserve_if_unbound(BindingScope::Navigate, prefix, "keys.prefix");
@@ -563,6 +641,47 @@ impl Config {
registry.reserve_if_unbound(BindingScope::Navigate, binding, field);
}
+ for binding in &mut indexed_bindings {
+ let Some(modifiers) = binding.value else {
+ continue;
+ };
+
+ let mut conflict = None;
+ 'indexes: for idx in 1..=9 {
+ let key = (
+ KeyCode::Char(char::from_digit(idx, 10).unwrap_or('1')),
+ modifiers,
+ );
+ for scope in [BindingScope::Navigate, BindingScope::TerminalDirect] {
+ if let Some(first_field) = registry.conflict(scope, key) {
+ conflict = Some(first_field.to_string());
+ break 'indexes;
+ }
+ }
+ }
+
+ if let Some(first_field) = conflict {
+ let diag = format!(
+ "duplicate indexed keybinding: {} conflicts with {}; disabling binding",
+ binding.field, first_field
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ binding.value = None;
+ binding.label = None;
+ continue;
+ }
+
+ for idx in 1..=9 {
+ let key = (
+ KeyCode::Char(char::from_digit(idx, 10).unwrap_or('1')),
+ modifiers,
+ );
+ registry.register(BindingScope::Navigate, key, binding.field);
+ registry.register(BindingScope::TerminalDirect, key, binding.field);
+ }
+ }
+
let mut custom_commands = Vec::new();
for (index, command) in self.keys.command.iter().enumerate() {
let key_field = format!("keys.command[{index}].key");
@@ -630,6 +749,12 @@ impl Config {
previous_agent_label: optional_bindings[5].label.clone(),
next_agent: optional_bindings[6].value,
next_agent_label: optional_bindings[6].label.clone(),
+ indexed_tabs: indexed_bindings[0].value,
+ indexed_tabs_label: indexed_bindings[0].label.clone(),
+ indexed_workspaces: indexed_bindings[1].value,
+ indexed_workspaces_label: indexed_bindings[1].label.clone(),
+ indexed_agents: indexed_bindings[2].value,
+ indexed_agents_label: indexed_bindings[2].label.clone(),
new_tab: bindings[3].value,
new_tab_label: bindings[3].label.clone(),
rename_tab: optional_bindings[7].value,
@@ -681,6 +806,15 @@ pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String {
if modifiers.contains(KeyModifiers::SHIFT) {
parts.push("shift".to_string());
}
+ if modifiers.contains(KeyModifiers::SUPER) {
+ parts.push(super_modifier_label().to_string());
+ }
+ if modifiers.contains(KeyModifiers::HYPER) {
+ parts.push("hyper".to_string());
+ }
+ if modifiers.contains(KeyModifiers::META) {
+ parts.push("meta".to_string());
+ }
let key = match code {
KeyCode::Char(' ') => "space".to_string(),
@@ -710,6 +844,47 @@ pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String {
parts.join("+")
}
+fn super_modifier_label() -> &'static str {
+ if cfg!(target_os = "macos") {
+ "cmd"
+ } else {
+ "super"
+ }
+}
+
+fn parse_modifier_token(token: &str) -> Option<KeyModifiers> {
+ match token.to_lowercase().as_str() {
+ "ctrl" | "control" => Some(KeyModifiers::CONTROL),
+ "shift" => Some(KeyModifiers::SHIFT),
+ "alt" | "option" | "meta" => Some(KeyModifiers::ALT),
+ "cmd" | "command" | "super" => Some(KeyModifiers::SUPER),
+ "hyper" => Some(KeyModifiers::HYPER),
+ _ => None,
+ }
+}
+
+fn parse_modifier_combo(s: &str) -> Option<KeyModifiers> {
+ let mut modifiers = KeyModifiers::empty();
+ let parts: Vec<&str> = s.split('+').collect();
+ if parts.is_empty() {
+ return None;
+ }
+
+ for part in &parts {
+ let trimmed = part.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+ modifiers |= parse_modifier_token(trimmed)?;
+ }
+
+ if modifiers.is_empty() {
+ None
+ } else {
+ Some(modifiers)
+ }
+}
+
pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> {
let parts: Vec<&str> = s.split('+').collect();
let mut modifiers = KeyModifiers::empty();
@@ -717,17 +892,16 @@ pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> {
for part in &parts {
let trimmed = part.trim();
- match trimmed.to_lowercase().as_str() {
- "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
- "shift" => modifiers |= KeyModifiers::SHIFT,
- "alt" | "meta" => modifiers |= KeyModifiers::ALT,
- _ if trimmed.is_empty() => return None,
- _ => {
- if key_str.is_some() {
- return None;
- }
- key_str = Some(trimmed);
+ if trimmed.is_empty() {
+ return None;
+ }
+ if let Some(modifier) = parse_modifier_token(trimmed) {
+ modifiers |= modifier;
+ } else {
+ if key_str.is_some() {
+ return None;
}
+ key_str = Some(trimmed);
}
}
@@ -797,6 +971,23 @@ mod tests {
}
#[test]
+ fn parse_cmd_combo() {
+ assert_eq!(
+ parse_key_combo("cmd+1"),
+ Some((KeyCode::Char('1'), KeyModifiers::SUPER))
+ );
+ }
+
+ #[test]
+ fn parse_modifier_combo_for_indexed_bindings() {
+ assert_eq!(
+ parse_modifier_combo("ctrl+shift"),
+ Some(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
+ );
+ assert_eq!(parse_modifier_combo("1"), None);
+ }
+
+ #[test]
fn parse_special_key() {
assert_eq!(
parse_key_combo("enter"),
@@ -885,6 +1076,9 @@ mod tests {
assert_eq!(kb.detach, None);
assert_eq!(kb.previous_agent, None);
assert_eq!(kb.next_agent, None);
+ assert_eq!(kb.indexed_tabs, None);
+ assert_eq!(kb.indexed_workspaces, None);
+ assert_eq!(kb.indexed_agents, None);
assert_eq!(kb.split_vertical.0, KeyCode::Char('v'));
assert_eq!(kb.split_horizontal.0, KeyCode::Char('-'));
assert_eq!(kb.close_pane.0, KeyCode::Char('x'));
@@ -1111,6 +1305,84 @@ command = "echo hi"
}
#[test]
+ fn indexed_keybinds_parse_from_toml() {
+ let toml = r#"
+[keys.indexed]
+tabs = "ctrl"
+workspaces = "ctrl+shift"
+agents = "alt"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let kb = config.keybinds();
+
+ assert_eq!(kb.indexed_tabs, Some(KeyModifiers::CONTROL));
+ assert_eq!(
+ kb.indexed_workspaces,
+ Some(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
+ );
+ assert_eq!(kb.indexed_agents, Some(KeyModifiers::ALT));
+ assert_eq!(kb.indexed_tabs_label.as_deref(), Some("ctrl+1..9"));
+ }
+
+ #[test]
+ fn indexed_keybinding_conflict_disables_later_family() {
+ let toml = r#"
+[keys.indexed]
+tabs = "ctrl"
+workspaces = "ctrl"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert_eq!(kb.indexed_tabs, Some(KeyModifiers::CONTROL));
+ assert_eq!(kb.indexed_workspaces, None);
+ assert!(diagnostics.iter().any(|d| {
+ d.contains("duplicate indexed keybinding")
+ && d.contains("keys.indexed.workspaces")
+ && d.contains("keys.indexed.tabs")
+ }));
+ }
+
+ #[test]
+ fn terminal_direct_keybinding_conflict_disables_later_binding() {
+ let toml = r#"
+[keys]
+previous_tab = "alt+h"
+focus_pane_left = "alt+h"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert_eq!(
+ kb.previous_tab,
+ Some((KeyCode::Char('h'), KeyModifiers::ALT))
+ );
+ assert_eq!(kb.focus_pane_left, None);
+ assert!(diagnostics
+ .iter()
+ .any(|d| { d.contains("keys.focus_pane_left") && d.contains("keys.previous_tab") }));
+ }
+
+ #[test]
+ fn terminal_direct_keybinding_conflicting_with_prefix_is_disabled() {
+ let toml = r#"
+[keys]
+prefix = "ctrl+b"
+previous_tab = "ctrl+b"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert_eq!(kb.previous_tab, None);
+ assert!(diagnostics
+ .iter()
+ .any(|d| { d.contains("keys.previous_tab") && d.contains("keys.prefix") }));
+ }
+
+ #[test]
fn live_keybinds_reject_invalid_keybinding() {
let config: Config = toml::from_str(
r#"
diff --git a/src/config/model.rs b/src/config/model.rs
index 8ea52724..ed8c4dd5 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -123,10 +123,23 @@ pub struct KeysConfig {
pub resize_mode: String,
/// Toggle sidebar collapse. Default: "b"
pub toggle_sidebar: String,
+ /// Optional indexed shortcuts expanded over number keys 1-9.
+ pub indexed: IndexedKeysConfig,
/// Prefix-mode custom command bindings.
pub command: Vec<CommandKeybindConfig>,
}
+#[derive(Debug, Default, Deserialize)]
+#[serde(default)]
+pub struct IndexedKeysConfig {
+ /// Modifier combo for tab shortcuts 1-9. Unset by default.
+ pub tabs: String,
+ /// Modifier combo for workspace shortcuts 1-9. Unset by default.
+ pub workspaces: String,
+ /// Modifier combo for agent shortcuts 1-9. Unset by default.
+ pub agents: String,
+}
+
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct UiConfig {
@@ -195,6 +208,7 @@ impl Default for KeysConfig {
fullscreen: "f".into(),
resize_mode: "r".into(),
toggle_sidebar: "b".into(),
+ indexed: IndexedKeysConfig::default(),
command: Vec::new(),
}
}
diff --git a/src/main.rs b/src/main.rs
index ecfb9f07..16c11406 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -75,9 +75,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
[keys]
# Prefix key to enter navigate mode (default: "ctrl+b")
# Examples: "ctrl+b", "f12", "esc", "-"
-# Accepted syntax: plain keys, ctrl/shift/alt modifiers, and special keys like enter/tab/esc/left/right/up/down
+# Accepted syntax: plain keys, ctrl/shift/alt/cmd/super modifiers, and special keys like enter/tab/esc/left/right/up/down
# Most reliable bindings are plain keys, ctrl+letter, esc/tab/enter, and function keys.
-# alt+... and punctuation-with-modifiers may depend on your terminal/tmux setup.
+# alt+..., cmd/super, and punctuation-with-modifiers may depend on your terminal/tmux setup.
# prefix = "ctrl+b"
# Navigate-mode actions
@@ -116,6 +116,13 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# type = "pane"
# command = "lazygit"
+# Optional modifier-only shortcuts expanded over number keys 1-9.
+# Empty means disabled. Examples: "ctrl", "ctrl+shift", "alt".
+# [keys.indexed]
+# tabs = "" # e.g. "ctrl" makes ctrl+1..9 switch tabs
+# workspaces = "" # e.g. "ctrl+shift" makes ctrl+shift+1..9 switch workspaces
+# agents = "" # e.g. "alt" makes alt+1..9 focus agent rows
+
[ui]
# Sidebar width (auto-scaled based on workspace names, this sets the default)
# sidebar_width = 26
diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs
index dbeba345..ccc32c5c 100644
--- a/src/ui/keybind_help.rs
+++ b/src/ui/keybind_help.rs
@@ -69,10 +69,18 @@ pub(super) fn keybind_help_groups(
"next workspace",
),
(
+ optional_keybind_label(&kb.indexed_workspaces_label),
+ "switch workspace 1-9",
+ ),
+ (
optional_keybind_label(&kb.previous_agent_label),
"previous agent",
),
(optional_keybind_label(&kb.next_agent_label), "next agent"),
+ (
+ optional_keybind_label(&kb.indexed_agents_label),
+ "focus agent 1-9",
+ ),
(kb.new_tab_label.clone(), "new tab"),
(optional_keybind_label(&kb.rename_tab_label), "rename tab"),
(
@@ -80,6 +88,10 @@ pub(super) fn keybind_help_groups(
"previous tab",
),
(optional_keybind_label(&kb.next_tab_label), "next tab"),
+ (
+ optional_keybind_label(&kb.indexed_tabs_label),
+ "switch tab 1-9",
+ ),
(optional_keybind_label(&kb.close_tab_label), "close tab"),
];
if let Some(label) = &kb.detach_label {