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) { @@ -986,6 +995,31 @@ mod tests { assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second)); } + #[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"); 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 { + 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 { + 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 { + 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, pub next_agent: Option<(KeyCode, KeyModifiers)>, pub next_agent_label: Option, + pub indexed_tabs: Option, + pub indexed_tabs_label: Option, + pub indexed_workspaces: Option, + pub indexed_workspaces_label: Option, + pub indexed_agents: Option, + pub indexed_agents_label: Option, 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, field: &'static str, value: Option<(KeyCode, KeyModifiers)>, label: Option, } + struct IndexedBinding { + field: &'static str, + value: Option, + label: Option, + } + #[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, field: &'static str, configured_label: &str, diagnostics: &mut Vec, ) -> 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, + ) -> 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 { + 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 { + 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); } } @@ -796,6 +970,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!( @@ -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')); @@ -1110,6 +1304,84 @@ command = "echo hi" assert!(kb.custom_commands.is_empty()); } + #[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( 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, } +#[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 @@ -68,11 +68,19 @@ pub(super) fn keybind_help_groups( optional_keybind_label(&kb.next_workspace_label), "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 {