feat: add indexed keybind families

This commit is contained in:
Ogulcan Celik 2026-05-15 20:31:01 +03:00
parent 43d60fc662
commit 4cbe72ec2d
8 changed files with 497 additions and 55 deletions

View File

@ -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.

View File

@ -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");

View File

@ -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();

View File

@ -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,

View File

@ -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);
}
}
@ -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(

View File

@ -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(),
}
}

View File

@ -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

View File

@ -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 {