aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/input/mod.rs23
-rw-r--r--src/app/input/modal.rs50
-rw-r--r--src/app/input/navigate.rs725
-rw-r--r--src/app/input/terminal.rs174
-rw-r--r--src/app/mod.rs39
-rw-r--r--src/app/state.rs86
-rw-r--r--src/cli.rs116
-rw-r--r--src/config.rs12
-rw-r--r--src/config/io.rs94
-rw-r--r--src/config/keybinds.rs2002
-rw-r--r--src/config/model.rs187
-rw-r--r--src/main.rs88
-rw-r--r--src/remote.rs163
-rw-r--r--src/ui.rs63
-rw-r--r--src/ui/keybind_help.rs143
-rw-r--r--src/ui/menus.rs65
-rw-r--r--src/ui/onboarding.rs2
-rw-r--r--src/ui/panes.rs5
-rw-r--r--src/ui/release_notes.rs10
19 files changed, 2364 insertions, 1683 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 4cafc926..245f935b 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -56,23 +56,24 @@ impl App {
pub(super) async fn handle_key(&mut self, key: TerminalKey) {
match self.state.mode {
Mode::Terminal => self.handle_terminal_key(key).await,
+ Mode::Prefix => self.handle_prefix_key(key),
Mode::Navigate => self.handle_navigate_key(key),
_ => {
- let key = key.as_key_event();
+ let key_event = key.as_key_event();
match self.state.mode {
- Mode::Onboarding => self.handle_onboarding_key(key),
- Mode::ReleaseNotes => self.handle_release_notes_key(key),
- Mode::ProductAnnouncement => self.handle_product_announcement_key(key),
- Mode::Navigate => unreachable!(),
+ Mode::Onboarding => self.handle_onboarding_key(key_event),
+ Mode::ReleaseNotes => self.handle_release_notes_key(key_event),
+ Mode::ProductAnnouncement => self.handle_product_announcement_key(key_event),
+ Mode::Prefix | Mode::Navigate => unreachable!(),
Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => {
- handle_rename_key(&mut self.state, key)
+ handle_rename_key(&mut self.state, key_event)
}
Mode::Resize => handle_resize_key(&mut self.state, key),
- Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key),
- Mode::ContextMenu => handle_context_menu_key(&mut self.state, key),
- Mode::Settings => self.handle_settings_key(key),
- Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key),
- Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key),
+ Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key_event),
+ Mode::ContextMenu => handle_context_menu_key(&mut self.state, key_event),
+ Mode::Settings => self.handle_settings_key(key_event),
+ Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key_event),
+ Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key_event),
Mode::Terminal => unreachable!(),
}
}
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs
index ff5e8582..5b585221 100644
--- a/src/app/input/modal.rs
+++ b/src/app/input/modal.rs
@@ -2,7 +2,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Direction, Rect};
use crate::{
- app::state::{key_matches, AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode},
+ app::state::{AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode},
+ input::TerminalKey,
layout::NavDirection,
};
@@ -448,14 +449,12 @@ pub(crate) fn handle_rename_key(state: &mut AppState, key: KeyEvent) {
}
}
-pub(crate) fn handle_resize_key(state: &mut AppState, key: KeyEvent) {
+pub(crate) fn handle_resize_key(state: &mut AppState, raw_key: TerminalKey) {
+ let key = raw_key.as_key_event();
if key.code == KeyCode::Esc
|| key.code == KeyCode::Enter
- || key_matches(
- &key,
- state.keybinds.resize_mode.0,
- state.keybinds.resize_mode.1,
- )
+ || state.keybinds.resize_mode.matches_prefix_key(raw_key)
+ || state.keybinds.resize_mode.matches_direct_key(raw_key)
{
if state.active.is_some() {
state.mode = Mode::Terminal;
@@ -648,12 +647,43 @@ mod tests {
fn custom_resize_key_exits_resize_mode() {
let mut state = state_with_workspaces(&["test"]);
state.mode = Mode::Resize;
- state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.resize_mode_label = "g".into();
+ state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("g");
handle_resize_key(
&mut state,
- KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn direct_resize_key_exits_resize_mode() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.mode = Mode::Resize;
+ state.keybinds.resize_mode = crate::config::ActionKeybinds::direct("ctrl+alt+r");
+
+ handle_resize_key(
+ &mut state,
+ TerminalKey::new(
+ KeyCode::Char('r'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT,
+ ),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn resize_key_exit_matches_enhanced_shifted_punctuation() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.mode = Mode::Resize;
+ state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("?");
+
+ handle_resize_key(
+ &mut state,
+ TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT)
+ .with_shifted_codepoint('?' as u32),
);
assert_eq!(state.mode, Mode::Terminal);
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
index a2967ca3..8bf10832 100644
--- a/src/app/input/navigate.rs
+++ b/src/app/input/navigate.rs
@@ -11,7 +11,7 @@ use ratatui::layout::Direction;
use crate::{
app::{
- state::{key_matches, AppState, Mode},
+ state::{AppState, Mode},
App,
},
input::TerminalKey,
@@ -20,109 +20,84 @@ use crate::{
pub(crate) fn terminal_direct_navigation_action(
state: &AppState,
- key: &KeyEvent,
+ key: TerminalKey,
) -> Option<NavigateAction> {
- if let Some(action) = indexed_navigation_action(state, key) {
- return Some(action);
- }
+ action_for_key(state, key, BindingDispatch::Direct)
+}
- let kb = &state.keybinds;
- if kb
- .previous_workspace
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::PreviousWorkspace);
- }
- if kb
- .next_workspace
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::NextWorkspace);
- }
- if kb
- .previous_agent
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::PreviousAgent);
- }
- if kb
- .next_agent
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::NextAgent);
- }
- if kb
- .previous_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::PreviousTab);
- }
- if kb
- .next_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::NextTab);
- }
- if kb
- .focus_pane_left
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::FocusPaneLeft);
- }
- if kb
- .focus_pane_down
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::FocusPaneDown);
- }
- if kb
- .focus_pane_up
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::FocusPaneUp);
- }
- if kb
- .focus_pane_right
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::FocusPaneRight);
- }
- None
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum ActionContext {
+ Direct,
+ Prefix,
+ Navigate,
}
impl App {
- pub(crate) fn handle_navigate_key(&mut self, raw_key: TerminalKey) {
+ pub(crate) fn handle_prefix_key(&mut self, raw_key: TerminalKey) {
let key = raw_key.as_key_event();
self.state.update_dismissed = true;
- if self.state.is_prefix(&key) {
+ if self.state.is_prefix_key(raw_key) {
if !self.pass_through_key_to_focused_pane(raw_key) {
- leave_navigate_mode(&mut self.state);
+ leave_command_mode(&mut self.state);
}
return;
}
if key.code == KeyCode::Esc {
- leave_navigate_mode(&mut self.state);
+ leave_command_mode(&mut self.state);
return;
}
- if let Some(action) = navigate_action_for_key(&self.state, &key) {
+ if let Some(action) = action_for_key(&self.state, raw_key, BindingDispatch::Prefix) {
if action == NavigateAction::EditScrollback {
+ let previous_mode = self.state.mode;
self.launch_focused_scrollback_editor();
+ finish_action_context(&mut self.state, ActionContext::Prefix, previous_mode);
} else {
- execute_navigate_action(&mut self.state, action);
+ execute_navigate_action_in_context(&mut self.state, action, ActionContext::Prefix);
}
self.selection_autoscroll_deadline = None;
return;
}
+ if let Some(binding) = command_for_key(&self.state, raw_key, BindingDispatch::Prefix) {
+ self.launch_custom_command(binding, ActionContext::Prefix);
+ return;
+ }
+
+ leave_command_mode(&mut self.state);
+ }
+
+ pub(crate) fn handle_navigate_key(&mut self, raw_key: TerminalKey) {
+ let key = raw_key.as_key_event();
+ self.state.update_dismissed = true;
+
+ if key.code == KeyCode::Esc || self.state.is_prefix_key(raw_key) {
+ leave_navigate_mode(&mut self.state);
+ return;
+ }
+
if handle_navigate_reserved_key(&mut self.state, key) {
return;
}
- if let Some(binding) = navigate_custom_command_for_key(&self.state, &key) {
- self.launch_custom_command(binding);
+ if let Some(action) = action_for_key(&self.state, raw_key, BindingDispatch::Prefix) {
+ if action == NavigateAction::EditScrollback {
+ self.launch_focused_scrollback_editor();
+ } else {
+ execute_navigate_action_in_context(
+ &mut self.state,
+ action,
+ ActionContext::Navigate,
+ );
+ }
+ self.selection_autoscroll_deadline = None;
+ return;
+ }
+
+ if let Some(binding) = command_for_key(&self.state, raw_key, BindingDispatch::Prefix) {
+ self.launch_custom_command(binding, ActionContext::Navigate);
}
}
@@ -143,7 +118,12 @@ impl App {
true
}
- fn launch_custom_command(&mut self, binding: crate::config::CustomCommandKeybind) {
+ pub(super) fn launch_custom_command(
+ &mut self,
+ binding: crate::config::CustomCommandKeybind,
+ context: ActionContext,
+ ) {
+ let previous_mode = self.state.mode;
let previous_toast = self.state.toast.clone();
let result = match binding.action {
crate::config::CustomCommandAction::Shell => self.spawn_custom_command(&binding),
@@ -152,7 +132,7 @@ impl App {
}
};
match result {
- Ok(()) => leave_navigate_mode(&mut self.state),
+ Ok(()) => finish_custom_command_context(&mut self.state, context, previous_mode),
Err(err) => {
self.state.toast = Some(crate::app::state::ToastNotification {
kind: crate::app::state::ToastKind::NeedsAttention,
@@ -161,6 +141,7 @@ impl App {
target: None,
});
self.sync_toast_deadline(previous_toast);
+ finish_custom_command_context(&mut self.state, context, previous_mode);
}
}
}
@@ -233,7 +214,7 @@ impl App {
Ok(())
}
- fn launch_focused_scrollback_editor(&mut self) {
+ pub(super) fn launch_focused_scrollback_editor(&mut self) {
let previous_toast = self.state.toast.clone();
match self.open_focused_scrollback_in_editor() {
Ok(()) => self.sync_toast_deadline(previous_toast),
@@ -359,25 +340,35 @@ impl App {
}
}
-fn navigate_custom_command_for_key(
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum BindingDispatch {
+ Direct,
+ Prefix,
+}
+
+pub(crate) fn command_for_key(
state: &AppState,
- key: &KeyEvent,
+ key: TerminalKey,
+ dispatch: BindingDispatch,
) -> Option<crate::config::CustomCommandKeybind> {
state
.keybinds
.custom_commands
.iter()
- .find(|binding| key_matches(key, binding.key.0, binding.key.1))
+ .find(|binding| match dispatch {
+ BindingDispatch::Direct => binding.bindings.matches_direct_key(key),
+ BindingDispatch::Prefix => binding.bindings.matches_prefix_key(key),
+ })
.cloned()
}
pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) -> bool {
- match key.code {
- KeyCode::Char('q') => {
- super::modal::request_quit_or_detach(state);
- leave_navigate_mode(state);
- true
- }
+ 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);
@@ -393,14 +384,6 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent)
}
true
}
- KeyCode::Char('s') => {
- super::settings::open_settings(state);
- true
- }
- KeyCode::Char('?') => {
- super::modal::open_keybind_help(state);
- true
- }
KeyCode::Up => {
if state.selected > 0 {
state.selected -= 1;
@@ -446,18 +429,20 @@ pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent)
#[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key
pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
state.update_dismissed = true;
+ let terminal_key = TerminalKey::from(key);
- if state.is_prefix(&key) || key.code == KeyCode::Esc {
+ if state.is_prefix_key(terminal_key) || key.code == KeyCode::Esc {
leave_navigate_mode(state);
return;
}
- if let Some(action) = navigate_action_for_key(state, &key) {
- execute_navigate_action(state, action);
+ if handle_navigate_reserved_key(state, key) {
return;
}
- let _ = handle_navigate_reserved_key(state, key);
+ if let Some(action) = action_for_key(state, terminal_key, BindingDispatch::Prefix) {
+ execute_navigate_action_in_context(state, action, ActionContext::Navigate);
+ }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -468,6 +453,7 @@ pub(crate) enum NavigateAction {
SwitchWorkspace(usize),
SwitchTab(usize),
FocusAgent(usize),
+ WorkspacePicker,
PreviousWorkspace,
NextWorkspace,
PreviousAgent,
@@ -489,158 +475,129 @@ pub(crate) enum NavigateAction {
Zoom,
EnterResizeMode,
ToggleSidebar,
+ CyclePaneNext,
+ CyclePanePrevious,
+ Help,
+ Settings,
+ Quit,
ReloadConfig,
OpenNotificationTarget,
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);
+fn indexed_navigation_action(
+ state: &AppState,
+ key: TerminalKey,
+ dispatch: BindingDispatch,
+) -> Option<NavigateAction> {
let kb = &state.keybinds;
+ let trigger_matches = |binding: &crate::config::IndexedKeybind| match dispatch {
+ BindingDispatch::Direct => binding.trigger.is_direct(),
+ BindingDispatch::Prefix => binding.trigger.is_prefix(),
+ };
- if kb
- .indexed_tabs
- .is_some_and(|mods| key_matches(key, KeyCode::Char(c), mods))
- {
- return Some(NavigateAction::SwitchTab(idx));
+ for binding in &kb.switch_tab {
+ if trigger_matches(binding) {
+ if let Some(idx) = binding.matched_index(key) {
+ 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));
+ for binding in &kb.switch_workspace {
+ if trigger_matches(binding) {
+ if let Some(idx) = binding.matched_index(key) {
+ 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));
+ for binding in &kb.focus_agent {
+ if trigger_matches(binding) {
+ if let Some(idx) = binding.matched_index(key) {
+ 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) {
+fn action_matches(
+ bindings: &crate::config::ActionKeybinds,
+ key: TerminalKey,
+ dispatch: BindingDispatch,
+) -> bool {
+ match dispatch {
+ BindingDispatch::Direct => bindings.matches_direct_key(key),
+ BindingDispatch::Prefix => bindings.matches_prefix_key(key),
+ }
+}
+
+fn action_for_key(
+ state: &AppState,
+ key: TerminalKey,
+ dispatch: BindingDispatch,
+) -> Option<NavigateAction> {
+ if let Some(action) = indexed_navigation_action(state, key, dispatch) {
return Some(action);
}
let kb = &state.keybinds;
- if key_matches(key, kb.new_workspace.0, kb.new_workspace.1) {
- return Some(NavigateAction::NewWorkspace);
- }
- if key_matches(key, kb.rename_workspace.0, kb.rename_workspace.1) {
- return Some(NavigateAction::RenameWorkspace);
- }
- if key_matches(key, kb.close_workspace.0, kb.close_workspace.1) {
- return Some(NavigateAction::CloseWorkspace);
- }
- if kb
- .previous_workspace
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::PreviousWorkspace);
- }
- if kb
- .next_workspace
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::NextWorkspace);
- }
- if kb
- .previous_agent
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::PreviousAgent);
- }
- if kb
- .next_agent
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::NextAgent);
- }
- if key_matches(key, kb.new_tab.0, kb.new_tab.1) {
- return Some(NavigateAction::NewTab);
- }
- if kb
- .rename_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::RenameTab);
- }
- if kb
- .previous_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::PreviousTab);
- }
- if kb
- .next_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::NextTab);
- }
- if kb
- .close_tab
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::CloseTab);
- }
- if kb
- .rename_pane
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::RenamePane);
- }
- if kb
- .edit_scrollback
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::EditScrollback);
- }
- if key_matches(key, kb.split_vertical.0, kb.split_vertical.1) {
- return Some(NavigateAction::SplitVertical);
- }
- if key_matches(key, kb.split_horizontal.0, kb.split_horizontal.1) {
- return Some(NavigateAction::SplitHorizontal);
- }
- if key_matches(key, kb.close_pane.0, kb.close_pane.1) {
- return Some(NavigateAction::ClosePane);
- }
- if key_matches(key, kb.zoom.0, kb.zoom.1) {
- return Some(NavigateAction::Zoom);
- }
- if key_matches(key, kb.resize_mode.0, kb.resize_mode.1) {
- return Some(NavigateAction::EnterResizeMode);
- }
- if key_matches(key, kb.toggle_sidebar.0, kb.toggle_sidebar.1) {
- return Some(NavigateAction::ToggleSidebar);
- }
- if kb
- .reload_config
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::ReloadConfig);
- }
- if kb
- .open_notification_target
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::OpenNotificationTarget);
- }
- if kb
- .detach
- .is_some_and(|(code, mods)| key_matches(key, code, mods))
- {
- return Some(NavigateAction::Detach);
+ for (bindings, action) in [
+ (&kb.help, NavigateAction::Help),
+ (&kb.settings, NavigateAction::Settings),
+ (&kb.quit, NavigateAction::Quit),
+ (&kb.workspace_picker, NavigateAction::WorkspacePicker),
+ (&kb.new_workspace, NavigateAction::NewWorkspace),
+ (&kb.rename_workspace, NavigateAction::RenameWorkspace),
+ (&kb.close_workspace, NavigateAction::CloseWorkspace),
+ (&kb.previous_workspace, NavigateAction::PreviousWorkspace),
+ (&kb.next_workspace, NavigateAction::NextWorkspace),
+ (&kb.previous_agent, NavigateAction::PreviousAgent),
+ (&kb.next_agent, NavigateAction::NextAgent),
+ (&kb.new_tab, NavigateAction::NewTab),
+ (&kb.rename_tab, NavigateAction::RenameTab),
+ (&kb.previous_tab, NavigateAction::PreviousTab),
+ (&kb.next_tab, NavigateAction::NextTab),
+ (&kb.close_tab, NavigateAction::CloseTab),
+ (&kb.rename_pane, NavigateAction::RenamePane),
+ (&kb.edit_scrollback, NavigateAction::EditScrollback),
+ (&kb.focus_pane_left, NavigateAction::FocusPaneLeft),
+ (&kb.focus_pane_down, NavigateAction::FocusPaneDown),
+ (&kb.focus_pane_up, NavigateAction::FocusPaneUp),
+ (&kb.focus_pane_right, NavigateAction::FocusPaneRight),
+ (&kb.cycle_pane_next, NavigateAction::CyclePaneNext),
+ (&kb.cycle_pane_previous, NavigateAction::CyclePanePrevious),
+ (&kb.split_vertical, NavigateAction::SplitVertical),
+ (&kb.split_horizontal, NavigateAction::SplitHorizontal),
+ (&kb.close_pane, NavigateAction::ClosePane),
+ (&kb.zoom, NavigateAction::Zoom),
+ (&kb.resize_mode, NavigateAction::EnterResizeMode),
+ (&kb.toggle_sidebar, NavigateAction::ToggleSidebar),
+ (&kb.reload_config, NavigateAction::ReloadConfig),
+ (
+ &kb.open_notification_target,
+ NavigateAction::OpenNotificationTarget,
+ ),
+ (&kb.detach, NavigateAction::Detach),
+ ] {
+ if action_matches(bindings, key, dispatch) {
+ return Some(action);
+ }
}
None
}
+#[cfg(test)]
pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) {
+ execute_navigate_action_in_context(state, action, ActionContext::Navigate);
+}
+
+pub(super) fn execute_navigate_action_in_context(
+ state: &mut AppState,
+ action: NavigateAction,
+ context: ActionContext,
+) {
+ let previous_mode = state.mode;
match action {
NavigateAction::NewWorkspace => {
state.request_new_workspace = true;
@@ -682,6 +639,10 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi
leave_navigate_mode(state);
}
}
+ NavigateAction::WorkspacePicker => {
+ state.mobile_switcher_scroll = 0;
+ state.mode = Mode::Navigate;
+ }
NavigateAction::PreviousWorkspace => {
state.previous_workspace();
leave_navigate_mode(state);
@@ -699,11 +660,13 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi
leave_navigate_mode(state);
}
NavigateAction::NewTab => {
- if state.prompt_new_tab_name {
- super::modal::open_new_tab_dialog(state);
- } else {
- state.request_new_tab = true;
- leave_navigate_mode(state);
+ if state.active.is_some() {
+ if state.prompt_new_tab_name {
+ super::modal::open_new_tab_dialog(state);
+ } else {
+ state.request_new_tab = true;
+ leave_navigate_mode(state);
+ }
}
}
NavigateAction::RenameTab => super::modal::open_rename_active_tab(state, false),
@@ -754,6 +717,20 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi
state.sidebar_collapsed = !state.sidebar_collapsed;
leave_navigate_mode(state);
}
+ NavigateAction::CyclePaneNext => {
+ state.cycle_pane(false);
+ leave_navigate_mode(state);
+ }
+ NavigateAction::CyclePanePrevious => {
+ state.cycle_pane(true);
+ leave_navigate_mode(state);
+ }
+ NavigateAction::Help => super::modal::open_keybind_help(state),
+ NavigateAction::Settings => super::settings::open_settings(state),
+ NavigateAction::Quit => {
+ super::modal::request_quit_or_detach(state);
+ leave_navigate_mode(state);
+ }
NavigateAction::ReloadConfig => {
state.request_reload_config = true;
leave_navigate_mode(state);
@@ -769,6 +746,8 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi
leave_navigate_mode(state);
}
}
+
+ finish_action_context(state, context, previous_mode);
}
fn leave_navigate_mode(state: &mut AppState) {
@@ -777,6 +756,34 @@ fn leave_navigate_mode(state: &mut AppState) {
}
}
+fn finish_action_context(state: &mut AppState, context: ActionContext, previous_mode: Mode) {
+ if matches!(context, ActionContext::Direct | ActionContext::Prefix)
+ && state.mode == previous_mode
+ {
+ leave_command_mode(state);
+ }
+}
+
+fn finish_custom_command_context(
+ state: &mut AppState,
+ context: ActionContext,
+ previous_mode: Mode,
+) {
+ if context == ActionContext::Navigate {
+ leave_navigate_mode(state);
+ } else {
+ finish_action_context(state, context, previous_mode);
+ }
+}
+
+fn leave_command_mode(state: &mut AppState) {
+ state.mode = if state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+}
+
fn write_scrollback_temp_file(content: &str) -> io::Result<std::path::PathBuf> {
let mut last_collision = None;
for attempt in 0..16 {
@@ -850,8 +857,7 @@ mod tests {
#[test]
fn custom_rename_key_enters_rename_mode() {
let mut state = state_with_workspaces(&["test"]);
- state.keybinds.rename_workspace = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.rename_workspace_label = "g".into();
+ state.keybinds.rename_workspace = crate::config::ActionKeybinds::prefix("g");
handle_navigate_key(
&mut state,
@@ -865,8 +871,7 @@ mod tests {
#[test]
fn custom_new_workspace_key_requests_and_exits_navigate() {
let mut state = state_with_workspaces(&["test"]);
- state.keybinds.new_workspace = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.new_workspace_label = "g".into();
+ state.keybinds.new_workspace = crate::config::ActionKeybinds::prefix("g");
handle_navigate_key(
&mut state,
@@ -880,8 +885,7 @@ mod tests {
#[test]
fn custom_sidebar_toggle_key_toggles_and_exits_navigate() {
let mut state = state_with_workspaces(&["test"]);
- state.keybinds.toggle_sidebar = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.toggle_sidebar_label = "g".into();
+ state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("g");
assert!(!state.sidebar_collapsed);
handle_navigate_key(
@@ -896,8 +900,7 @@ mod tests {
#[test]
fn custom_resize_key_enters_resize_mode() {
let mut state = state_with_workspaces(&["test"]);
- state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.resize_mode_label = "g".into();
+ state.keybinds.resize_mode = crate::config::ActionKeybinds::prefix("g");
handle_navigate_key(
&mut state,
@@ -910,8 +913,7 @@ mod tests {
#[test]
fn custom_reload_config_key_requests_reload_and_exits_navigate() {
let mut state = state_with_workspaces(&["test"]);
- state.keybinds.reload_config = Some((KeyCode::Char('g'), KeyModifiers::empty()));
- state.keybinds.reload_config_label = Some("g".into());
+ state.keybinds.reload_config = crate::config::ActionKeybinds::prefix("g");
handle_navigate_key(
&mut state,
@@ -928,8 +930,7 @@ mod tests {
state.active = Some(0);
state.selected = 0;
state.mode = Mode::Navigate;
- state.keybinds.open_notification_target = Some((KeyCode::Char('g'), KeyModifiers::empty()));
- state.keybinds.open_notification_target_label = Some("g".into());
+ state.keybinds.open_notification_target = crate::config::ActionKeybinds::prefix("g");
let target_workspace_id = state.workspaces[1].id.clone();
let target_pane = state.workspaces[1].tabs[0].root_pane;
state.toast = Some(crate::app::state::ToastNotification {
@@ -989,12 +990,11 @@ mod tests {
#[test]
fn terminal_direct_agent_shortcut_maps_to_navigation_action() {
let mut state = state_with_workspaces(&["test"]);
- state.keybinds.next_agent = Some((KeyCode::Char('a'), KeyModifiers::ALT));
- state.keybinds.next_agent_label = Some("alt+a".into());
+ state.keybinds.next_agent = crate::config::ActionKeybinds::direct("alt+a");
let action = terminal_direct_navigation_action(
&state,
- &KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT),
+ TerminalKey::new(KeyCode::Char('a'), KeyModifiers::ALT),
);
assert_eq!(action, Some(NavigateAction::NextAgent));
@@ -1003,12 +1003,11 @@ mod tests {
#[test]
fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() {
let mut state = state_with_workspaces(&["test"]);
- state.keybinds.focus_pane_left = Some((KeyCode::Left, KeyModifiers::ALT));
- state.keybinds.focus_pane_left_label = Some("alt+left".into());
+ state.keybinds.focus_pane_left = crate::config::ActionKeybinds::direct("alt+left");
let action = terminal_direct_navigation_action(
&state,
- &KeyEvent::new(KeyCode::Left, KeyModifiers::ALT),
+ TerminalKey::new(KeyCode::Left, KeyModifiers::ALT),
);
assert_eq!(action, Some(NavigateAction::FocusPaneLeft));
@@ -1017,18 +1016,203 @@ mod tests {
#[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 config: Config = toml::from_str("[keys]\nswitch_tab = \"ctrl+3\"\n").unwrap();
+ state.keybinds.switch_tab = config.keybinds().switch_tab;
let action = terminal_direct_navigation_action(
&state,
- &KeyEvent::new(KeyCode::Char('3'), KeyModifiers::CONTROL),
+ TerminalKey::new(KeyCode::Char('3'), KeyModifiers::CONTROL),
);
assert_eq!(action, Some(NavigateAction::SwitchTab(2)));
}
#[tokio::test]
+ async fn navigate_mode_runs_prefix_action_rhs_without_pressing_prefix_again() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Navigate;
+
+ app.handle_navigate_key(TerminalKey::new(KeyCode::Char('n'), KeyModifiers::SHIFT));
+
+ assert!(app.state.request_new_workspace);
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[tokio::test]
+ async fn prefix_focus_pane_is_one_shot_and_returns_to_terminal() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ let root = app.state.workspaces[0].tabs[0].root_pane;
+ let right = app.state.workspaces[0].test_split(Direction::Horizontal);
+ app.state.workspaces[0].layout.focus_pane(right);
+ app.state.view.pane_infos = app.state.workspaces[0]
+ .active_tab()
+ .unwrap()
+ .layout
+ .panes(ratatui::layout::Rect::new(0, 0, 80, 24));
+
+ app.handle_key(TerminalKey::new(
+ app.state.prefix_code,
+ app.state.prefix_mods,
+ ))
+ .await;
+ app.handle_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::empty()))
+ .await;
+
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(root));
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[tokio::test]
+ async fn no_op_prefix_action_exits_prefix_mode() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.handle_key(TerminalKey::new(
+ app.state.prefix_code,
+ app.state.prefix_mods,
+ ))
+ .await;
+ app.handle_key(TerminalKey::new(KeyCode::Char('o'), KeyModifiers::empty()))
+ .await;
+
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[tokio::test]
+ async fn unmatched_prefix_rhs_exits_prefix_mode() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.handle_key(TerminalKey::new(
+ app.state.prefix_code,
+ app.state.prefix_mods,
+ ))
+ .await;
+ app.handle_key(TerminalKey::new(KeyCode::F(12), KeyModifiers::empty()))
+ .await;
+
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[tokio::test]
+ async fn prefix_help_matches_enhanced_shifted_question_mark() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.handle_key(TerminalKey::new(
+ app.state.prefix_code,
+ app.state.prefix_mods,
+ ))
+ .await;
+ app.handle_key(
+ TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT)
+ .with_shifted_codepoint('?' as u32),
+ )
+ .await;
+
+ assert_eq!(app.state.mode, Mode::KeybindHelp);
+ }
+
+ #[test]
+ fn navigate_mode_help_is_binding_driven() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.help = crate::config::ActionKeybinds::prefix("f");
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT),
+ );
+ assert_eq!(state.mode, Mode::Navigate);
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()),
+ );
+ assert_eq!(state.mode, Mode::KeybindHelp);
+ }
+
+ #[test]
+ fn modified_navigate_local_key_can_be_bound_as_prefix_rhs() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.toggle_sidebar = crate::config::ActionKeybinds::prefix("shift+h");
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('H'), KeyModifiers::SHIFT),
+ );
+
+ assert!(state.sidebar_collapsed);
+ }
+
+ #[test]
+ fn empty_state_new_tab_is_no_op() {
+ let mut state = crate::app::state::AppState::test_new();
+ state.mode = Mode::Prefix;
+
+ execute_navigate_action_in_context(
+ &mut state,
+ NavigateAction::NewTab,
+ ActionContext::Prefix,
+ );
+
+ assert_eq!(state.mode, Mode::Navigate);
+ assert!(!state.creating_new_tab);
+ assert!(!state.request_new_tab);
+ assert!(state.workspaces.is_empty());
+ }
+
+ #[tokio::test]
async fn custom_command_runs_from_prefix_key_in_navigate_mode() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
@@ -1049,8 +1233,8 @@ mod tests {
output_path.display()
);
app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
- key: (KeyCode::Char('g'), KeyModifiers::empty()),
- label: "g".into(),
+ bindings: crate::config::ActionKeybinds::prefix("g"),
+ label: "prefix+g".into(),
command,
action: crate::config::CustomCommandAction::Shell,
}];
@@ -1060,7 +1244,7 @@ mod tests {
app.state.prefix_mods,
))
.await;
- assert_eq!(app.state.mode, Mode::Navigate);
+ assert_eq!(app.state.mode, Mode::Prefix);
app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()))
.await;
@@ -1110,8 +1294,8 @@ mod tests {
let output_path = unique_temp_path("custom-pane-command");
let command = format!("printf done > '{}'", output_path.display());
app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
- key: (KeyCode::Char('g'), KeyModifiers::empty()),
- label: "g".into(),
+ bindings: crate::config::ActionKeybinds::prefix("g"),
+ label: "prefix+g".into(),
command,
action: crate::config::CustomCommandAction::Pane,
}];
@@ -1164,7 +1348,7 @@ mod tests {
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.selected = 0;
- app.state.mode = Mode::Navigate;
+ app.state.mode = Mode::Terminal;
let output_path = unique_temp_path("edit-scrollback");
let previous_editor = std::env::var_os("EDITOR");
@@ -1172,10 +1356,15 @@ mod tests {
"EDITOR",
format!("sh -c 'cp \"$1\" {}' sh", output_path.display()),
);
- app.state.keybinds.edit_scrollback = Some((KeyCode::Char('g'), KeyModifiers::empty()));
- app.state.keybinds.edit_scrollback_label = Some("g".into());
+ app.state.keybinds.edit_scrollback = crate::config::ActionKeybinds::prefix("g");
- app.handle_navigate_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()));
+ app.handle_key(TerminalKey::new(
+ app.state.prefix_code,
+ app.state.prefix_mods,
+ ))
+ .await;
+ app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()))
+ .await;
match previous_editor {
Some(value) => std::env::set_var("EDITOR", value),
@@ -1194,8 +1383,7 @@ mod tests {
fn zoom_action_exits_navigate_mode() {
let mut state = state_with_workspaces(&["test"]);
state.workspaces[0].test_split(Direction::Horizontal);
- state.keybinds.zoom = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.zoom_label = "g".into();
+ state.keybinds.zoom = crate::config::ActionKeybinds::prefix("g");
handle_navigate_key(
&mut state,
@@ -1265,10 +1453,11 @@ mod tests {
let mut state = crate::app::state::AppState::test_new();
state.quit_detaches = true;
- assert!(handle_navigate_reserved_key(
+ handle_navigate_key(
&mut state,
- KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty())
- ));
+ KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()),
+ );
+
assert!(state.detach_requested);
assert!(!state.should_quit);
}
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index 12fb67b2..41a62e82 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -34,21 +34,44 @@ impl App {
let key_event = key.as_key_event();
- if let Some(action) = super::terminal_direct_navigation_action(&self.state, &key_event) {
+ if let Some(action) = super::terminal_direct_navigation_action(&self.state, key) {
debug!(
code = ?key_event.code,
modifiers = ?key_event.modifiers,
kind = ?key_event.kind,
action = ?action,
- "intercepted terminal direct navigation key before forwarding to pane"
+ "intercepted terminal direct keybinding before forwarding to pane"
);
- super::navigate::execute_navigate_action(&mut self.state, action);
+ if action == super::navigate::NavigateAction::EditScrollback {
+ self.launch_focused_scrollback_editor();
+ } else {
+ super::navigate::execute_navigate_action_in_context(
+ &mut self.state,
+ action,
+ super::navigate::ActionContext::Direct,
+ );
+ }
+ return None;
+ }
+
+ if let Some(binding) = super::navigate::command_for_key(
+ &self.state,
+ key,
+ super::navigate::BindingDispatch::Direct,
+ ) {
+ debug!(
+ code = ?key_event.code,
+ modifiers = ?key_event.modifiers,
+ kind = ?key_event.kind,
+ command = %binding.label,
+ "intercepted terminal direct custom command before forwarding to pane"
+ );
+ self.launch_custom_command(binding, super::navigate::ActionContext::Direct);
return None;
}
- if self.state.is_prefix(&key_event) {
- self.state.mobile_switcher_scroll = 0;
- self.state.mode = Mode::Navigate;
+ if self.state.is_prefix_key(key) {
+ self.state.mode = Mode::Prefix;
return None;
}
@@ -169,7 +192,9 @@ mod tests {
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind};
use ratatui::layout::Rect;
- use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes};
+ use super::super::{
+ app_for_mouse_test, mouse, numbered_lines_bytes, unique_temp_path, wait_for_file,
+ };
use super::*;
use crate::{config::Config, workspace::Workspace};
@@ -475,8 +500,7 @@ mod tests {
.layout
.panes(Rect::new(0, 0, 80, 24));
let focused_before = app.state.workspaces[0].layout.focused();
- app.state.keybinds.focus_pane_left = Some((KeyCode::Char('h'), KeyModifiers::ALT));
- app.state.keybinds.focus_pane_left_label = Some("alt+h".into());
+ app.state.keybinds.focus_pane_left = crate::config::ActionKeybinds::direct("alt+h");
app.handle_terminal_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::ALT))
.await;
@@ -486,6 +510,138 @@ mod tests {
}
#[tokio::test]
+ async fn terminal_direct_edit_scrollback_opens_editor_pane() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ let mut workspace = Workspace::test_new("test");
+ let root_pane = workspace.tabs[0].root_pane;
+ workspace.tabs[0].runtimes.insert(
+ root_pane,
+ crate::pane::PaneRuntime::test_with_scrollback_bytes(20, 5, 4096, b"alpha\nbeta\n"),
+ );
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ let output_path = unique_temp_path("direct-edit-scrollback");
+ let previous_editor = std::env::var_os("EDITOR");
+ std::env::set_var(
+ "EDITOR",
+ format!("sh -c 'cp \"$1\" {}' sh", output_path.display()),
+ );
+ app.state.keybinds.edit_scrollback = crate::config::ActionKeybinds::direct("ctrl+alt+e");
+
+ app.handle_terminal_key(TerminalKey::new(
+ KeyCode::Char('e'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT,
+ ))
+ .await;
+
+ match previous_editor {
+ Some(value) => std::env::set_var("EDITOR", value),
+ None => std::env::remove_var("EDITOR"),
+ }
+
+ let content = wait_for_file(&output_path);
+ assert!(content.contains("alpha"));
+ assert!(content.contains("beta"));
+ assert_eq!(app.state.mode, Mode::Terminal);
+
+ let _ = std::fs::remove_file(output_path);
+ }
+
+ #[tokio::test]
+ async fn direct_custom_command_runs_before_forwarding_to_pane() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ let output_path = unique_temp_path("direct-custom-command");
+ let command = format!("printf direct > '{}'", output_path.display());
+ app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
+ bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
+ label: "ctrl+alt+g".into(),
+ command,
+ action: crate::config::CustomCommandAction::Shell,
+ }];
+
+ app.handle_terminal_key(TerminalKey::new(
+ KeyCode::Char('g'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT,
+ ))
+ .await;
+
+ assert_eq!(wait_for_file(&output_path), "direct");
+ assert_eq!(app.state.mode, Mode::Terminal);
+ let _ = std::fs::remove_file(output_path);
+ }
+
+ #[tokio::test]
+ async fn direct_custom_pane_command_opens_overlay_pane() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ let (workspace, terminal, runtime) = Workspace::new(
+ std::env::current_dir().unwrap_or_else(|_| "/".into()),
+ 24,
+ 80,
+ app.state.pane_scrollback_limit_bytes,
+ app.state.host_terminal_theme,
+ &app.state.default_shell,
+ app.event_tx.clone(),
+ app.render_notify.clone(),
+ app.render_dirty.clone(),
+ )
+ .expect("workspace should spawn");
+ app.state.workspaces = vec![workspace];
+ app.state
+ .terminal_runtimes
+ .insert(terminal.id.clone(), runtime);
+ app.state.terminals.insert(terminal.id.clone(), terminal);
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
+ bindings: crate::config::ActionKeybinds::direct("ctrl+alt+g"),
+ label: "ctrl+alt+g".into(),
+ command: "printf direct-pane".into(),
+ action: crate::config::CustomCommandAction::Pane,
+ }];
+
+ app.handle_terminal_key(TerminalKey::new(
+ KeyCode::Char('g'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT,
+ ))
+ .await;
+
+ assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2);
+ assert!(app.state.workspaces[0].tabs[0].zoomed);
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[tokio::test]
async fn alt_backspace_is_forwarded_to_focused_pane() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
diff --git a/src/app/mod.rs b/src/app/mod.rs
index f33de78f..63bf690d 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -1048,6 +1048,9 @@ impl App {
fn handle_non_terminal_key(&mut self, key: crate::input::TerminalKey) {
let key_event = key.as_key_event();
match self.state.mode {
+ Mode::Prefix => {
+ self.handle_prefix_key(key);
+ }
Mode::Navigate => {
self.handle_navigate_key(key);
}
@@ -1055,7 +1058,7 @@ impl App {
input::handle_rename_key(&mut self.state, key_event);
}
Mode::Resize => {
- input::handle_resize_key(&mut self.state, key_event);
+ input::handle_resize_key(&mut self.state, key);
}
Mode::ConfirmClose => {
input::handle_confirm_close_key(&mut self.state, key_event);
@@ -1104,7 +1107,7 @@ mod tests {
use crate::detect::{Agent, AgentState};
use crate::terminal::TerminalRuntime;
use crate::workspace::Workspace;
- use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
+ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use std::sync::{Mutex, OnceLock};
fn raw_key(
@@ -1330,7 +1333,7 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
- "[terminal]\ndefault_shell = \"nu\"\n[keys]\nnew_workspace = \"g\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n",
+ "[terminal]\ndefault_shell = \"nu\"\n[keys]\nnew_workspace = \"prefix+g\"\nprefix = \"ctrl+a\"\n[ui]\nagent_panel_scope = \"current\"\n[ui.toast]\ndelivery = \"herdr\"\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
@@ -1341,10 +1344,11 @@ mod tests {
assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
assert_eq!(app.state.prefix_code, KeyCode::Char('a'));
assert_eq!(app.state.prefix_mods, KeyModifiers::CONTROL);
- assert_eq!(
- app.state.keybinds.new_workspace,
- (KeyCode::Char('g'), KeyModifiers::empty())
- );
+ assert!(app
+ .state
+ .keybinds
+ .new_workspace
+ .matches_prefix(&KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty())));
assert_eq!(
app.state.toast_config.delivery,
crate::config::ToastDelivery::Herdr
@@ -1523,7 +1527,7 @@ mod tests {
let mut app = test_app();
let original_prefix = (app.state.prefix_code, app.state.prefix_mods);
- let original_keybinds = app.state.keybinds.new_workspace;
+ let original_keybinds = app.state.keybinds.new_workspace.clone();
let report = app.reload_config();
assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial);
@@ -1556,7 +1560,7 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
- "[keys]\nnew_workspace = \"g\"\n[ui.toast]\ndelivery = \"desktop\"\n",
+ "[keys]\nnew_workspace = \"prefix+g\"\n[ui.toast]\ndelivery = \"desktop\"\n",
)
.unwrap();
std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
@@ -1566,10 +1570,11 @@ mod tests {
let report = app.reload_config();
assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial);
- assert_eq!(
- app.state.keybinds.new_workspace,
- (KeyCode::Char('g'), KeyModifiers::empty())
- );
+ assert!(app
+ .state
+ .keybinds
+ .new_workspace
+ .matches_prefix(&KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty())));
assert_eq!(
app.state.toast_config.delivery,
crate::config::ToastDelivery::Herdr
@@ -1650,7 +1655,7 @@ mod tests {
let mut app = test_app();
let original_prefix = (app.state.prefix_code, app.state.prefix_mods);
- let original_keybinds = app.state.keybinds.new_workspace;
+ let original_keybinds = app.state.keybinds.new_workspace.clone();
let original_toast_delivery = app.state.toast_config.delivery;
let report = app.reload_config();
@@ -2547,8 +2552,8 @@ mod tests {
assert_eq!(
app.state.mode,
- Mode::Navigate,
- "prefix key should enter navigate mode"
+ Mode::Prefix,
+ "prefix key should enter prefix mode"
);
assert!(
!app.state.detach_requested,
@@ -2584,7 +2589,7 @@ mod tests {
app.state.prefix_mods = KeyModifiers::CONTROL;
app.route_client_input(vec![0x0c]);
- assert_eq!(app.state.mode, Mode::Navigate);
+ assert_eq!(app.state.mode, Mode::Prefix);
app.route_client_input(vec![0x0c]);
assert_eq!(app.state.mode, Mode::Terminal);
diff --git a/src/app/state.rs b/src/app/state.rs
index 0c38171d..2b078caf 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -590,6 +590,7 @@ pub enum Mode {
ReleaseNotes,
ProductAnnouncement,
Navigate,
+ Prefix,
Terminal,
RenameWorkspace,
RenameTab,
@@ -1023,8 +1024,8 @@ impl AppState {
self.mouse_capture || self.focused_pane_requests_mouse_capture()
}
- pub fn is_prefix(&self, key: &crossterm::event::KeyEvent) -> bool {
- key_matches(key, self.prefix_code, self.prefix_mods)
+ pub fn is_prefix_key(&self, key: crate::input::TerminalKey) -> bool {
+ crate::config::terminal_key_matches_combo(key, (self.prefix_code, self.prefix_mods))
}
pub fn estimate_pane_size(&self) -> (u16, u16) {
@@ -1110,23 +1111,16 @@ impl AppState {
}
}
+#[cfg(test)]
pub fn key_matches(
key: &crossterm::event::KeyEvent,
expected_code: KeyCode,
expected_mods: KeyModifiers,
) -> bool {
- if key.modifiers != expected_mods {
- return false;
- }
-
- match (key.code, expected_code) {
- (KeyCode::Char(actual), KeyCode::Char(expected))
- if actual.is_ascii_alphabetic() && expected.is_ascii_alphabetic() =>
- {
- actual.eq_ignore_ascii_case(&expected)
- }
- (actual, expected) => actual == expected,
- }
+ crate::config::terminal_key_matches_combo(
+ crate::input::TerminalKey::from(*key),
+ (expected_code, expected_mods),
+ )
}
// ---------------------------------------------------------------------------
@@ -1221,69 +1215,7 @@ impl AppState {
},
local_sound_playback: false,
toast_config: ToastConfig::default(),
- keybinds: Keybinds {
- new_workspace: (KeyCode::Char('n'), KeyModifiers::empty()),
- new_workspace_label: "n".into(),
- rename_workspace: (KeyCode::Char('n'), KeyModifiers::SHIFT),
- rename_workspace_label: "shift+n".into(),
- close_workspace: (KeyCode::Char('d'), KeyModifiers::SHIFT),
- close_workspace_label: "shift+d".into(),
- detach: None,
- detach_label: None,
- reload_config: None,
- reload_config_label: None,
- open_notification_target: None,
- open_notification_target_label: None,
- previous_workspace: None,
- previous_workspace_label: None,
- next_workspace: None,
- next_workspace_label: None,
- previous_agent: None,
- 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,
- rename_tab_label: None,
- previous_tab: None,
- previous_tab_label: None,
- next_tab: None,
- next_tab_label: None,
- close_tab: None,
- close_tab_label: None,
- rename_pane: None,
- rename_pane_label: None,
- edit_scrollback: None,
- edit_scrollback_label: None,
- focus_pane_left: None,
- focus_pane_left_label: None,
- focus_pane_down: None,
- focus_pane_down_label: None,
- focus_pane_up: None,
- focus_pane_up_label: None,
- focus_pane_right: None,
- focus_pane_right_label: None,
- split_vertical: (KeyCode::Char('v'), KeyModifiers::empty()),
- split_vertical_label: "v".into(),
- split_horizontal: (KeyCode::Char('-'), KeyModifiers::empty()),
- split_horizontal_label: "-".into(),
- close_pane: (KeyCode::Char('x'), KeyModifiers::empty()),
- close_pane_label: "x".into(),
- zoom: (KeyCode::Char('f'), KeyModifiers::empty()),
- zoom_label: "f".into(),
- resize_mode: (KeyCode::Char('r'), KeyModifiers::empty()),
- resize_mode_label: "r".into(),
- toggle_sidebar: (KeyCode::Char('b'), KeyModifiers::empty()),
- toggle_sidebar_label: "b".into(),
- custom_commands: Vec::new(),
- },
+ keybinds: Keybinds::default(),
spinner_tick: 0,
palette: Palette::catppuccin(),
theme_name: "catppuccin".to_string(),
diff --git a/src/cli.rs b/src/cli.rs
index 3e07ab2c..e9fe6e81 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1,6 +1,6 @@
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
-use std::time::Duration;
+use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::Serialize;
@@ -33,6 +33,7 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
exit_code
}
"status" => run_status_command(&args[2..])?,
+ "config" => run_config_command(&args[2..])?,
"workspace" => run_workspace_command(&args[2..])?,
"tab" => run_tab_command(&args[2..])?,
"agent" => run_agent_command(&args[2..])?,
@@ -95,6 +96,114 @@ fn run_status_command(args: &[String]) -> std::io::Result<i32> {
}
}
+fn run_config_command(args: &[String]) -> std::io::Result<i32> {
+ let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
+ print_config_help();
+ return Ok(2);
+ };
+
+ match subcommand {
+ "reset-keys" => config_reset_keys(&args[1..]),
+ "help" | "--help" | "-h" => {
+ print_config_help();
+ Ok(0)
+ }
+ _ => {
+ print_config_help();
+ Ok(2)
+ }
+ }
+}
+
+fn config_reset_keys(args: &[String]) -> std::io::Result<i32> {
+ if !args.is_empty() {
+ eprintln!("usage: herdr config reset-keys");
+ return Ok(2);
+ }
+
+ let path = crate::config::config_path();
+ if !path.exists() {
+ println!(
+ "No config file found at {}. Built-in v2 keybindings already apply.",
+ path.display()
+ );
+ return Ok(0);
+ }
+
+ let content = std::fs::read_to_string(&path)?;
+ let parsed = match content.parse::<toml::Value>() {
+ Ok(value) => value,
+ Err(err) => {
+ eprintln!(
+ "config file at {} is invalid TOML: {err}. Fix it manually or move it aside to use defaults.",
+ path.display()
+ );
+ return Ok(1);
+ }
+ };
+ let Some(table) = parsed.as_table() else {
+ eprintln!(
+ "config file at {} is invalid TOML: top-level config must be a table.",
+ path.display()
+ );
+ return Ok(1);
+ };
+
+ if !table.contains_key("keys") {
+ println!(
+ "No [keys] config found in {}. Built-in v2 keybindings already apply.",
+ path.display()
+ );
+ return Ok(0);
+ }
+
+ let (updated, removed) = crate::config::remove_keybinding_config_sections(&content);
+ if !removed {
+ eprintln!(
+ "could not safely remove keybinding config from {} without rewriting comments; edit the file manually or remove the top-level keys setting.",
+ path.display()
+ );
+ return Ok(1);
+ }
+ if let Err(err) = updated.parse::<toml::Value>() {
+ eprintln!(
+ "removing keybinding config would make {} invalid TOML: {err}; leaving config unchanged",
+ path.display()
+ );
+ return Ok(1);
+ }
+
+ let backup_path = key_config_backup_path(&path);
+ std::fs::copy(&path, &backup_path)?;
+ std::fs::write(&path, updated)?;
+
+ println!("Created backup: {}", backup_path.display());
+ println!(
+ "Removed [keys], [keys.indexed], and [[keys.command]] from {}.",
+ path.display()
+ );
+ println!("Built-in v2 keybindings will apply after Herdr restarts or reloads config.");
+ println!("If a Herdr server is running, run `herdr server reload-config` to apply this now.");
+ println!(
+ "To restore: cp {} {}",
+ backup_path.display(),
+ path.display()
+ );
+ Ok(0)
+}
+
+fn key_config_backup_path(path: &std::path::Path) -> std::path::PathBuf {
+ let timestamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_secs())
+ .unwrap_or(0);
+ let file_name = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("config.toml");
+ path.with_file_name(format!("{file_name}.bak-keybind-v2-{timestamp}"))
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
enum ServerRuntimeStatus {
Running {
@@ -2071,6 +2180,11 @@ fn print_status_help() {
eprintln!(" herdr status client show local client binary status");
}
+fn print_config_help() {
+ eprintln!("herdr config commands:");
+ eprintln!(" herdr config reset-keys back up config.toml and remove custom keybindings");
+}
+
fn print_workspace_help() {
eprintln!("herdr workspace commands:");
eprintln!(" herdr workspace list");
diff --git a/src/config.rs b/src/config.rs
index 37ba5bbf..48ebb8f6 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -8,12 +8,14 @@ mod theme;
pub use self::{
io::{
- config_diagnostic_summary, config_dir, config_path, load_live_config, remove_section_key,
- state_dir, upsert_section_bool, upsert_section_value,
+ config_diagnostic_summary, config_dir, config_path, load_live_config,
+ remove_keybinding_config_sections, remove_section_key, state_dir, upsert_section_bool,
+ upsert_section_value,
},
keybinds::{
- format_key_combo, CommandKeybindConfig, CustomCommandAction, CustomCommandKeybind,
- Keybinds, LiveKeybindConfig,
+ format_key_combo, normalize_key_combo, terminal_key_matches_combo, ActionKeybinds,
+ BindingConfig, CommandKeybindConfig, CustomCommandAction, CustomCommandKeybind,
+ IndexedKeybind, Keybinds, LiveKeybindConfig,
},
model::{
validated_sidebar_bounds, AgentPanelScopeConfig, Config, ConfigReloadReport,
@@ -42,7 +44,7 @@ impl Config {
self.validated_keybinds().1
}
- /// Parsed keybinds for navigate mode actions.
+ /// Parsed keybinds for Herdr actions.
pub fn keybinds(&self) -> Keybinds {
self.validated_keybinds().3
}
diff --git a/src/config/io.rs b/src/config/io.rs
index 07b63d37..12176815 100644
--- a/src/config/io.rs
+++ b/src/config/io.rs
@@ -293,6 +293,58 @@ pub fn remove_section_key(content: &str, section: &str, key: &str) -> String {
result.join("\n") + "\n"
}
+pub fn remove_keybinding_config_sections(content: &str) -> (String, bool) {
+ let mut result = Vec::new();
+ let mut removed = false;
+ let mut skipping_key_section = false;
+ let mut in_table = false;
+
+ for line in content.lines() {
+ let trimmed = line.trim();
+
+ if let Some(table_name) = toml_table_header_name(trimmed) {
+ in_table = true;
+ skipping_key_section = is_keys_table_name(table_name);
+ if skipping_key_section {
+ removed = true;
+ continue;
+ }
+ } else if skipping_key_section || (!in_table && is_top_level_keys_assignment(trimmed)) {
+ removed = true;
+ continue;
+ }
+
+ result.push(line.to_string());
+ }
+
+ let mut updated = result.join("\n");
+ if content.ends_with('\n') || !updated.is_empty() {
+ updated.push('\n');
+ }
+ (updated, removed)
+}
+
+fn toml_table_header_name(trimmed: &str) -> Option<&str> {
+ if let Some(name) = trimmed
+ .strip_prefix("[[")
+ .and_then(|value| value.strip_suffix("]]"))
+ {
+ return Some(name.trim());
+ }
+ trimmed
+ .strip_prefix('[')
+ .and_then(|value| value.strip_suffix(']'))
+ .map(str::trim)
+}
+
+fn is_keys_table_name(name: &str) -> bool {
+ name == "keys" || name.starts_with("keys.")
+}
+
+fn is_top_level_keys_assignment(trimmed: &str) -> bool {
+ trimmed.starts_with("keys ") || trimmed.starts_with("keys=") || trimmed.starts_with("keys.")
+}
+
fn upsert_section_raw(content: &str, section: &str, key: &str, value: &str) -> String {
let header = format!("[{section}]");
let assignment = format!("{key} = {value}");
@@ -381,4 +433,46 @@ mod tests {
assert!(updated.contains("delivery = \"herdr\""));
assert!(updated.contains("[ui.sound]\nenabled = true"));
}
+
+ #[test]
+ fn remove_keybinding_config_sections_removes_keys_tables_only() {
+ let content = r#"onboarding = false
+
+[theme]
+name = "catppuccin"
+
+[keys]
+prefix = "ctrl+a"
+new_tab = "c"
+
+[[keys.command]]
+key = "g"
+command = "lazygit"
+
+[keys.indexed]
+tabs = "ctrl"
+
+[ui]
+mouse_capture = false
+"#;
+
+ let (updated, removed) = remove_keybinding_config_sections(content);
+
+ assert!(removed);
+ assert!(updated.contains("onboarding = false"));
+ assert!(updated.contains("[theme]\nname = \"catppuccin\""));
+ assert!(updated.contains("[ui]\nmouse_capture = false"));
+ assert!(!updated.contains("[keys]"));
+ assert!(!updated.contains("[[keys.command]]"));
+ assert!(!updated.contains("[keys.indexed]"));
+ assert!(toml::from_str::<toml::Value>(&updated).is_ok());
+ }
+
+ #[test]
+ fn remove_keybinding_config_sections_reports_noop_without_keys() {
+ let content = "[ui]\nmouse_capture = true\n";
+ let (updated, removed) = remove_keybinding_config_sections(content);
+ assert!(!removed);
+ assert_eq!(updated, content);
+ }
}
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs
index 13d65ec3..58c26020 100644
--- a/src/config/keybinds.rs
+++ b/src/config/keybinds.rs
@@ -1,15 +1,50 @@
+#[cfg(test)]
+use crossterm::event::KeyEvent;
use crossterm::event::{KeyCode, KeyModifiers};
use serde::Deserialize;
use tracing::warn;
use super::Config;
+use crate::input::TerminalKey;
+
+pub type KeyCombo = (KeyCode, KeyModifiers);
#[derive(Debug)]
pub struct LiveKeybindConfig {
- pub prefix: (KeyCode, KeyModifiers),
+ pub prefix: KeyCombo,
pub keybinds: Keybinds,
}
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
+#[serde(untagged)]
+pub enum BindingConfig {
+ One(String),
+ Many(Vec<String>),
+}
+
+impl Default for BindingConfig {
+ fn default() -> Self {
+ Self::One(String::new())
+ }
+}
+
+impl BindingConfig {
+ pub fn one(value: impl Into<String>) -> Self {
+ Self::One(value.into())
+ }
+
+ pub fn empty() -> Self {
+ Self::One(String::new())
+ }
+
+ fn values(&self) -> Vec<&str> {
+ match self {
+ Self::One(value) => vec![value.as_str()],
+ Self::Many(values) => values.iter().map(String::as_str).collect(),
+ }
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CommandKeybindType {
@@ -21,8 +56,8 @@ pub enum CommandKeybindType {
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct CommandKeybindConfig {
- /// Navigate-mode key that runs a command after pressing the prefix key.
- pub key: String,
+ /// Key that runs a command. Use `prefix+g` for prefix mode or a modified chord for direct mode.
+ pub key: BindingConfig,
/// Command executed either in the background shell or inside a pane.
pub command: String,
/// Command execution mode. Default: "shell".
@@ -33,7 +68,7 @@ pub struct CommandKeybindConfig {
impl Default for CommandKeybindConfig {
fn default() -> Self {
Self {
- key: String::new(),
+ key: BindingConfig::empty(),
command: String::new(),
action_type: CommandKeybindType::Shell,
}
@@ -46,253 +81,273 @@ pub enum CustomCommandAction {
Pane,
}
-#[derive(Debug, Clone)]
-pub struct CustomCommandKeybind {
- pub key: (KeyCode, KeyModifiers),
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum BindingTrigger {
+ Direct(KeyCombo),
+ Prefix(KeyCombo),
+}
+
+impl BindingTrigger {
+ pub fn combo(self) -> KeyCombo {
+ match self {
+ Self::Direct(combo) | Self::Prefix(combo) => combo,
+ }
+ }
+
+ pub fn is_direct(self) -> bool {
+ matches!(self, Self::Direct(_))
+ }
+
+ pub fn is_prefix(self) -> bool {
+ matches!(self, Self::Prefix(_))
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ResolvedBinding {
+ pub trigger: BindingTrigger,
pub label: String,
- pub command: String,
- pub action: CustomCommandAction,
}
-/// Parsed keybinds for navigate mode actions.
-#[derive(Debug, Clone)]
-pub struct Keybinds {
- pub new_workspace: (KeyCode, KeyModifiers),
- pub new_workspace_label: String,
- pub rename_workspace: (KeyCode, KeyModifiers),
- pub rename_workspace_label: String,
- pub close_workspace: (KeyCode, KeyModifiers),
- pub close_workspace_label: String,
- pub detach: Option<(KeyCode, KeyModifiers)>,
- pub detach_label: Option<String>,
- pub reload_config: Option<(KeyCode, KeyModifiers)>,
- pub reload_config_label: Option<String>,
- pub open_notification_target: Option<(KeyCode, KeyModifiers)>,
- pub open_notification_target_label: Option<String>,
- pub previous_workspace: Option<(KeyCode, KeyModifiers)>,
- pub previous_workspace_label: Option<String>,
- pub next_workspace: Option<(KeyCode, KeyModifiers)>,
- pub next_workspace_label: Option<String>,
- pub previous_agent: Option<(KeyCode, KeyModifiers)>,
- 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)>,
- pub rename_tab_label: Option<String>,
- pub previous_tab: Option<(KeyCode, KeyModifiers)>,
- pub previous_tab_label: Option<String>,
- pub next_tab: Option<(KeyCode, KeyModifiers)>,
- pub next_tab_label: Option<String>,
- pub close_tab: Option<(KeyCode, KeyModifiers)>,
- pub close_tab_label: Option<String>,
- pub rename_pane: Option<(KeyCode, KeyModifiers)>,
- pub rename_pane_label: Option<String>,
- pub edit_scrollback: Option<(KeyCode, KeyModifiers)>,
- pub edit_scrollback_label: Option<String>,
- pub focus_pane_left: Option<(KeyCode, KeyModifiers)>,
- pub focus_pane_left_label: Option<String>,
- pub focus_pane_down: Option<(KeyCode, KeyModifiers)>,
- pub focus_pane_down_label: Option<String>,
- pub focus_pane_up: Option<(KeyCode, KeyModifiers)>,
- pub focus_pane_up_label: Option<String>,
- pub focus_pane_right: Option<(KeyCode, KeyModifiers)>,
- pub focus_pane_right_label: Option<String>,
- pub split_vertical: (KeyCode, KeyModifiers),
- pub split_vertical_label: String,
- pub split_horizontal: (KeyCode, KeyModifiers),
- pub split_horizontal_label: String,
- pub close_pane: (KeyCode, KeyModifiers),
- pub close_pane_label: String,
- pub zoom: (KeyCode, KeyModifiers),
- pub zoom_label: String,
- pub resize_mode: (KeyCode, KeyModifiers),
- pub resize_mode_label: String,
- pub toggle_sidebar: (KeyCode, KeyModifiers),
- pub toggle_sidebar_label: String,
- pub custom_commands: Vec<CustomCommandKeybind>,
+impl ResolvedBinding {
+ #[cfg(test)]
+ fn matches_key_event(&self, key: &KeyEvent) -> bool {
+ key_event_matches_combo(key, self.trigger.combo())
+ }
+
+ fn matches_terminal_key(&self, key: TerminalKey) -> bool {
+ terminal_key_matches_combo(key, self.trigger.combo())
+ }
}
-impl Config {
- pub(super) fn validated_keybinds(
- &self,
- ) -> (
- Option<String>,
- (KeyCode, KeyModifiers),
- Vec<String>,
- Keybinds,
- ) {
- #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
- enum BindingScope {
- Navigate,
- TerminalDirect,
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct ActionKeybinds {
+ pub bindings: Vec<ResolvedBinding>,
+}
+
+impl ActionKeybinds {
+ #[cfg(test)]
+ pub fn prefix(label: &str) -> Self {
+ let raw = if label.starts_with("prefix+") {
+ label.to_string()
+ } else {
+ format!("prefix+{label}")
+ };
+ let trigger = parse_binding_string(&raw)
+ .and_then(|parsed| match parsed {
+ ParsedBinding::Single(binding) => Some(binding),
+ ParsedBinding::Range(_) => None,
+ })
+ .expect("prefix binding should parse");
+ Self {
+ bindings: vec![trigger],
}
+ }
- #[derive(Clone)]
- struct RequiredBinding<'a> {
- scope: BindingScope,
- field: &'a str,
- label: String,
- default_label: &'a str,
- value: (KeyCode, KeyModifiers),
- default: (KeyCode, KeyModifiers),
+ #[cfg(test)]
+ pub fn direct(label: &str) -> Self {
+ let trigger = parse_binding_string(label)
+ .and_then(|parsed| match parsed {
+ ParsedBinding::Single(binding) => Some(binding),
+ ParsedBinding::Range(_) => None,
+ })
+ .expect("direct binding should parse");
+ Self {
+ bindings: vec![trigger],
}
+ }
+
+ #[cfg(test)]
+ pub fn matches_prefix(&self, key: &KeyEvent) -> bool {
+ self.bindings
+ .iter()
+ .any(|binding| binding.trigger.is_prefix() && binding.matches_key_event(key))
+ }
+
+ pub fn matches_prefix_key(&self, key: TerminalKey) -> bool {
+ self.bindings
+ .iter()
+ .any(|binding| binding.trigger.is_prefix() && binding.matches_terminal_key(key))
+ }
- struct OptionalBinding {
- scopes: Vec<BindingScope>,
- field: &'static str,
- value: Option<(KeyCode, KeyModifiers)>,
- label: Option<String>,
+ pub fn matches_direct_key(&self, key: TerminalKey) -> bool {
+ self.bindings
+ .iter()
+ .any(|binding| binding.trigger.is_direct() && binding.matches_terminal_key(key))
+ }
+
+ pub fn labels(&self) -> Vec<String> {
+ self.bindings
+ .iter()
+ .map(|binding| binding.label.clone())
+ .collect()
+ }
+
+ pub fn label(&self) -> Option<String> {
+ let labels = self.labels();
+ if labels.is_empty() {
+ None
+ } else {
+ Some(labels.join(" / "))
}
+ }
- struct IndexedBinding {
- field: &'static str,
- value: Option<KeyModifiers>,
- label: Option<String>,
+ pub fn prefix_rhs_label(&self) -> Option<String> {
+ let labels: Vec<String> = self
+ .bindings
+ .iter()
+ .filter(|binding| binding.trigger.is_prefix())
+ .map(|binding| {
+ binding
+ .label
+ .strip_prefix("prefix+")
+ .unwrap_or(&binding.label)
+ .to_string()
+ })
+ .collect();
+ if labels.is_empty() {
+ None
+ } else {
+ Some(labels.join(" / "))
}
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct IndexedKeybind {
+ pub trigger: BindingTrigger,
+ pub label: String,
+}
- #[derive(Default)]
- struct BindingRegistry {
- seen: std::collections::HashMap<(BindingScope, KeyCode, KeyModifiers), String>,
+impl IndexedKeybind {
+ pub fn matched_index(&self, key: TerminalKey) -> Option<usize> {
+ let KeyCode::Char(c @ '1'..='9') = key.code else {
+ return None;
+ };
+ if terminal_key_matches_combo(key, self.trigger.combo()) {
+ Some((c as usize) - ('1' as usize))
+ } else {
+ None
}
+ }
+}
- impl BindingRegistry {
- fn register(
- &mut self,
- scope: BindingScope,
- binding: (KeyCode, KeyModifiers),
- field: &str,
- ) -> Option<String> {
- self.seen
- .insert((scope, binding.0, binding.1), field.to_string())
- }
+#[derive(Debug, Clone)]
+pub struct CustomCommandKeybind {
+ pub bindings: ActionKeybinds,
+ pub label: String,
+ pub command: String,
+ pub action: CustomCommandAction,
+}
- fn conflict(
- &self,
- scope: BindingScope,
- binding: (KeyCode, KeyModifiers),
- ) -> Option<&str> {
- self.seen
- .get(&(scope, binding.0, binding.1))
- .map(String::as_str)
- }
+/// Parsed keybinds for Herdr actions.
+#[derive(Debug, Clone)]
+pub struct Keybinds {
+ pub help: ActionKeybinds,
+ pub settings: ActionKeybinds,
+ pub quit: ActionKeybinds,
+ pub new_workspace: ActionKeybinds,
+ pub rename_workspace: ActionKeybinds,
+ pub close_workspace: ActionKeybinds,
+ pub workspace_picker: ActionKeybinds,
+ pub detach: ActionKeybinds,
+ pub reload_config: ActionKeybinds,
+ pub open_notification_target: ActionKeybinds,
+ pub previous_workspace: ActionKeybinds,
+ pub next_workspace: ActionKeybinds,
+ pub previous_agent: ActionKeybinds,
+ pub next_agent: ActionKeybinds,
+ pub focus_agent: Vec<IndexedKeybind>,
+ pub new_tab: ActionKeybinds,
+ pub rename_tab: ActionKeybinds,
+ pub previous_tab: ActionKeybinds,
+ pub next_tab: ActionKeybinds,
+ pub switch_tab: Vec<IndexedKeybind>,
+ pub switch_workspace: Vec<IndexedKeybind>,
+ pub close_tab: ActionKeybinds,
+ pub rename_pane: ActionKeybinds,
+ pub edit_scrollback: ActionKeybinds,
+ pub focus_pane_left: ActionKeybinds,
+ pub focus_pane_down: ActionKeybinds,
+ pub focus_pane_up: ActionKeybinds,
+ pub focus_pane_right: ActionKeybinds,
+ pub cycle_pane_next: ActionKeybinds,
+ pub cycle_pane_previous: ActionKeybinds,
+ pub split_vertical: ActionKeybinds,
+ pub split_horizontal: ActionKeybinds,
+ pub close_pane: ActionKeybinds,
+ pub zoom: ActionKeybinds,
+ pub resize_mode: ActionKeybinds,
+ pub toggle_sidebar: ActionKeybinds,
+ pub custom_commands: Vec<CustomCommandKeybind>,
+}
- fn reserve_if_unbound(
- &mut self,
- scope: BindingScope,
- binding: (KeyCode, KeyModifiers),
- field: &str,
- ) {
- self.seen
- .entry((scope, binding.0, binding.1))
- .or_insert_with(|| field.to_string());
- }
- }
+impl Default for Keybinds {
+ fn default() -> Self {
+ Config::default().keybinds()
+ }
+}
- fn required_binding<'a>(
- scope: BindingScope,
- field: &'a str,
- configured_label: &'a str,
- default_label: &'a str,
- default: (KeyCode, KeyModifiers),
- diagnostics: &mut Vec<String>,
- ) -> RequiredBinding<'a> {
- let (value, diag) = parse_key_combo_with_diagnostic(configured_label, field, default);
- let label = if let Some(diag) = diag {
- diagnostics.push(diag);
- default_label.to_string()
- } else {
- configured_label.to_string()
- };
- RequiredBinding {
- scope,
- field,
- label,
- default_label,
- value,
- default,
- }
+#[derive(Clone)]
+enum ParsedBinding {
+ Single(ResolvedBinding),
+ Range(Vec<ResolvedBinding>),
+}
+
+struct BindingRegistry {
+ prefix_combo: KeyCombo,
+ direct: std::collections::HashMap<KeyCombo, String>,
+ prefix: std::collections::HashMap<KeyCombo, String>,
+}
+
+impl BindingRegistry {
+ fn new(prefix_combo: KeyCombo) -> Self {
+ Self {
+ prefix_combo: normalize_key_combo(prefix_combo),
+ direct: std::collections::HashMap::new(),
+ prefix: std::collections::HashMap::new(),
}
+ }
- fn optional_binding(
- scopes: Vec<BindingScope>,
- field: &'static str,
- configured_label: &str,
- diagnostics: &mut Vec<String>,
- ) -> OptionalBinding {
- if configured_label.trim().is_empty() {
- return OptionalBinding {
- scopes,
- field,
- value: None,
- label: None,
- };
- }
- match parse_key_combo(configured_label) {
- Some(value) => OptionalBinding {
- scopes,
- field,
- value: Some(value),
- label: Some(configured_label.to_string()),
- },
- None => {
- let diag = format!(
- "invalid keybinding: {field} = {:?}; disabling binding",
- configured_label
- );
- warn!(message = %diag, "config diagnostic");
- diagnostics.push(diag);
- OptionalBinding {
- scopes,
- field,
- value: None,
- label: None,
- }
- }
- }
+ fn reserve_direct(&mut self, combo: KeyCombo, field: &str) {
+ self.direct
+ .entry(normalize_key_combo(combo))
+ .or_insert_with(|| field.to_string());
+ }
+
+ fn prefix_rhs_is_reserved(&self, combo: KeyCombo) -> bool {
+ normalize_key_combo(combo) == self.prefix_combo
+ }
+
+ fn conflict(&self, binding: &ResolvedBinding) -> Option<&str> {
+ match binding.trigger {
+ BindingTrigger::Direct(combo) => self
+ .direct
+ .get(&normalize_key_combo(combo))
+ .map(String::as_str),
+ BindingTrigger::Prefix(combo) => self
+ .prefix
+ .get(&normalize_key_combo(combo))
+ .map(String::as_str),
}
+ }
- 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,
- };
+ fn register(&mut self, binding: &ResolvedBinding, field: &str) {
+ match binding.trigger {
+ BindingTrigger::Direct(combo) => {
+ self.direct
+ .insert(normalize_key_combo(combo), field.to_string());
}
- 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,
- }
- }
+ BindingTrigger::Prefix(combo) => {
+ self.prefix
+ .insert(normalize_key_combo(combo), field.to_string());
}
}
+ }
+}
+impl Config {
+ pub(super) fn validated_keybinds(&self) -> (Option<String>, KeyCombo, Vec<String>, Keybinds) {
let mut diagnostics = Vec::new();
let (prefix, prefix_diag) = parse_key_combo_with_diagnostic(
&self.keys.prefix,
@@ -303,394 +358,88 @@ impl Config {
warn!(message = %diag, "config diagnostic");
}
- let mut bindings = vec![
- required_binding(
- BindingScope::Navigate,
- "keys.new_workspace",
- &self.keys.new_workspace,
- "n",
- (KeyCode::Char('n'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.rename_workspace",
- &self.keys.rename_workspace,
- "shift+n",
- (KeyCode::Char('n'), KeyModifiers::SHIFT),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.close_workspace",
- &self.keys.close_workspace,
- "shift+d",
- (KeyCode::Char('d'), KeyModifiers::SHIFT),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.new_tab",
- &self.keys.new_tab,
- "c",
- (KeyCode::Char('c'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.split_vertical",
- &self.keys.split_vertical,
- "v",
- (KeyCode::Char('v'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.split_horizontal",
- &self.keys.split_horizontal,
- "-",
- (KeyCode::Char('-'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.close_pane",
- &self.keys.close_pane,
- "x",
- (KeyCode::Char('x'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.zoom",
- &self.keys.zoom,
- "f",
- (KeyCode::Char('f'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.resize_mode",
- &self.keys.resize_mode,
- "r",
- (KeyCode::Char('r'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.toggle_sidebar",
- &self.keys.toggle_sidebar,
- "b",
- (KeyCode::Char('b'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- ];
-
- 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(
- navigate_scope(),
- "keys.detach",
- &self.keys.detach,
- &mut diagnostics,
- ),
- optional_binding(
- navigate_scope(),
- "keys.reload_config",
- &self.keys.reload_config,
- &mut diagnostics,
- ),
- optional_binding(
- navigate_scope(),
- "keys.open_notification_target",
- &self.keys.open_notification_target,
- &mut diagnostics,
- ),
- optional_binding(
- direct_navigation_scopes(),
- "keys.previous_workspace",
- &self.keys.previous_workspace,
- &mut diagnostics,
- ),
- optional_binding(
- direct_navigation_scopes(),
- "keys.next_workspace",
- &self.keys.next_workspace,
- &mut diagnostics,
- ),
- optional_binding(
- direct_navigation_scopes(),
- "keys.previous_agent",
- &self.keys.previous_agent,
- &mut diagnostics,
- ),
- optional_binding(
- direct_navigation_scopes(),
- "keys.next_agent",
- &self.keys.next_agent,
- &mut diagnostics,
- ),
- optional_binding(
- navigate_scope(),
- "keys.rename_tab",
- &self.keys.rename_tab,
- &mut diagnostics,
- ),
- optional_binding(
- direct_navigation_scopes(),
- "keys.previous_tab",
- &self.keys.previous_tab,
- &mut diagnostics,
- ),
- optional_binding(
- direct_navigation_scopes(),
- "keys.next_tab",
- &self.keys.next_tab,
- &mut diagnostics,
- ),
- optional_binding(
- navigate_scope(),
- "keys.close_tab",
- &self.keys.close_tab,
- &mut diagnostics,
- ),
- optional_binding(
- navigate_scope(),
- "keys.rename_pane",
- &self.keys.rename_pane,
- &mut diagnostics,
- ),
- optional_binding(
- navigate_scope(),
- "keys.edit_scrollback",
- &self.keys.edit_scrollback,
- &mut diagnostics,
- ),
- optional_binding(
- terminal_direct_scope(),
- "keys.focus_pane_left",
- &self.keys.focus_pane_left,
- &mut diagnostics,
- ),
- optional_binding(
- terminal_direct_scope(),
- "keys.focus_pane_down",
- &self.keys.focus_pane_down,
- &mut diagnostics,
- ),
- optional_binding(
- terminal_direct_scope(),
- "keys.focus_pane_up",
- &self.keys.focus_pane_up,
- &mut diagnostics,
- ),
- optional_binding(
- 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::new(prefix);
+ registry.reserve_direct(prefix, "keys.prefix");
- let mut registry = BindingRegistry::default();
- for binding in &mut bindings {
- if let Some(first_field) = registry.conflict(binding.scope, binding.value) {
- let diag = format!(
- "duplicate keybinding: {} conflicts with {}; using default {}",
- binding.field, first_field, binding.default_label
- );
- warn!(message = %diag, "config diagnostic");
- diagnostics.push(diag);
- binding.value = binding.default;
- binding.label = binding.default_label.to_string();
- }
- registry.register(binding.scope, binding.value, binding.field);
+ macro_rules! action {
+ ($field:literal, $config:expr) => {
+ parse_action_bindings($field, $config, false, &mut registry, &mut diagnostics)
+ };
}
-
- registry.reserve_if_unbound(BindingScope::TerminalDirect, prefix, "keys.prefix");
-
- for binding in &mut optional_bindings {
- let Some(value) = binding.value else {
- continue;
+ macro_rules! indexed {
+ ($field:literal, $config:expr) => {
+ parse_indexed_bindings($field, $config, &mut registry, &mut diagnostics)
};
-
- 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
- );
- warn!(message = %diag, "config diagnostic");
- diagnostics.push(diag);
- binding.value = None;
- binding.label = None;
- continue;
- }
- for scope in &binding.scopes {
- registry.register(*scope, value, binding.field);
- }
}
- registry.reserve_if_unbound(BindingScope::Navigate, prefix, "keys.prefix");
- for (field, binding) in [
- ("navigate.quit", (KeyCode::Char('q'), KeyModifiers::empty())),
- (
- "navigate.open_workspace",
- (KeyCode::Enter, KeyModifiers::empty()),
- ),
- (
- "navigate.settings",
- (KeyCode::Char('s'), KeyModifiers::empty()),
- ),
- (
- "navigate.keybind_help",
- (KeyCode::Char('?'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_up",
- (KeyCode::Up, KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_down",
- (KeyCode::Down, KeyModifiers::empty()),
- ),
- (
- "navigate.focus_left",
- (KeyCode::Char('h'), KeyModifiers::empty()),
- ),
- (
- "navigate.focus_down",
- (KeyCode::Char('j'), KeyModifiers::empty()),
- ),
- (
- "navigate.focus_up",
- (KeyCode::Char('k'), KeyModifiers::empty()),
- ),
- (
- "navigate.focus_right",
- (KeyCode::Char('l'), KeyModifiers::empty()),
- ),
- (
- "navigate.arrow_left",
- (KeyCode::Left, KeyModifiers::empty()),
- ),
- (
- "navigate.arrow_right",
- (KeyCode::Right, KeyModifiers::empty()),
- ),
- ("navigate.tab_next", (KeyCode::Tab, KeyModifiers::empty())),
- (
- "navigate.tab_prev",
- (KeyCode::BackTab, KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_1",
- (KeyCode::Char('1'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_2",
- (KeyCode::Char('2'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_3",
- (KeyCode::Char('3'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_4",
- (KeyCode::Char('4'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_5",
- (KeyCode::Char('5'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_6",
- (KeyCode::Char('6'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_7",
- (KeyCode::Char('7'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_8",
- (KeyCode::Char('8'), KeyModifiers::empty()),
+ let mut keybinds = Keybinds {
+ help: action!("keys.help", &self.keys.help),
+ settings: action!("keys.settings", &self.keys.settings),
+ quit: action!("keys.quit", &self.keys.quit),
+ new_workspace: action!("keys.new_workspace", &self.keys.new_workspace),
+ rename_workspace: action!("keys.rename_workspace", &self.keys.rename_workspace),
+ close_workspace: action!("keys.close_workspace", &self.keys.close_workspace),
+ workspace_picker: action!("keys.workspace_picker", &self.keys.workspace_picker),
+ detach: action!("keys.detach", &self.keys.detach),
+ reload_config: action!("keys.reload_config", &self.keys.reload_config),
+ open_notification_target: action!(
+ "keys.open_notification_target",
+ &self.keys.open_notification_target
),
- (
- "navigate.workspace_9",
- (KeyCode::Char('9'), KeyModifiers::empty()),
+ previous_workspace: action!("keys.previous_workspace", &self.keys.previous_workspace),
+ next_workspace: action!("keys.next_workspace", &self.keys.next_workspace),
+ previous_agent: action!("keys.previous_agent", &self.keys.previous_agent),
+ next_agent: action!("keys.next_agent", &self.keys.next_agent),
+ focus_agent: indexed!("keys.focus_agent", &self.keys.focus_agent),
+ new_tab: action!("keys.new_tab", &self.keys.new_tab),
+ rename_tab: action!("keys.rename_tab", &self.keys.rename_tab),
+ previous_tab: action!("keys.previous_tab", &self.keys.previous_tab),
+ next_tab: action!("keys.next_tab", &self.keys.next_tab),
+ switch_tab: indexed!("keys.switch_tab", &self.keys.switch_tab),
+ switch_workspace: indexed!("keys.switch_workspace", &self.keys.switch_workspace),
+ close_tab: action!("keys.close_tab", &self.keys.close_tab),
+ rename_pane: action!("keys.rename_pane", &self.keys.rename_pane),
+ edit_scrollback: action!("keys.edit_scrollback", &self.keys.edit_scrollback),
+ focus_pane_left: action!("keys.focus_pane_left", &self.keys.focus_pane_left),
+ focus_pane_down: action!("keys.focus_pane_down", &self.keys.focus_pane_down),
+ focus_pane_up: action!("keys.focus_pane_up", &self.keys.focus_pane_up),
+ focus_pane_right: action!("keys.focus_pane_right", &self.keys.focus_pane_right),
+ cycle_pane_next: action!("keys.cycle_pane_next", &self.keys.cycle_pane_next),
+ cycle_pane_previous: action!(
+ "keys.cycle_pane_previous",
+ &self.keys.cycle_pane_previous
),
- ("navigate.back", (KeyCode::Esc, KeyModifiers::empty())),
- ] {
- 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;
- }
+ split_vertical: action!("keys.split_vertical", &self.keys.split_vertical),
+ split_horizontal: action!("keys.split_horizontal", &self.keys.split_horizontal),
+ close_pane: action!("keys.close_pane", &self.keys.close_pane),
+ zoom: action!("keys.zoom", &self.keys.zoom),
+ resize_mode: action!("keys.resize_mode", &self.keys.resize_mode),
+ toggle_sidebar: action!("keys.toggle_sidebar", &self.keys.toggle_sidebar),
+ custom_commands: Vec::new(),
+ };
- 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);
- }
- }
+ append_legacy_indexed_bindings(
+ &mut keybinds.switch_tab,
+ "keys.indexed.tabs",
+ &self.keys.indexed.tabs,
+ &mut registry,
+ &mut diagnostics,
+ );
+ append_legacy_indexed_bindings(
+ &mut keybinds.switch_workspace,
+ "keys.indexed.workspaces",
+ &self.keys.indexed.workspaces,
+ &mut registry,
+ &mut diagnostics,
+ );
+ append_legacy_indexed_bindings(
+ &mut keybinds.focus_agent,
+ "keys.indexed.agents",
+ &self.keys.indexed.agents,
+ &mut registry,
+ &mut diagnostics,
+ );
- let mut custom_commands = Vec::new();
for (index, command) in self.keys.command.iter().enumerate() {
let key_field = format!("keys.command[{index}].key");
let command_field = format!("keys.command[{index}].command");
@@ -703,108 +452,246 @@ impl Config {
continue;
}
- let Some(binding) = parse_key_combo(&command.key) else {
- let diag = format!(
- "invalid keybinding: {} = {:?}; disabling custom command",
- key_field, command.key
- );
- warn!(message = %diag, "config diagnostic");
- diagnostics.push(diag);
- continue;
- };
-
- if let Some(first_field) = registry.conflict(BindingScope::Navigate, binding) {
- let diag = format!(
- "duplicate custom keybinding: {} conflicts with {}; disabling custom command",
- key_field, first_field
- );
- warn!(message = %diag, "config diagnostic");
- diagnostics.push(diag);
+ let bindings = parse_action_bindings_owned(
+ &key_field,
+ &command.key,
+ false,
+ &mut registry,
+ &mut diagnostics,
+ );
+ if bindings.bindings.is_empty() {
continue;
}
- registry.register(BindingScope::Navigate, binding, &key_field);
let action = match command.action_type {
CommandKeybindType::Shell => CustomCommandAction::Shell,
CommandKeybindType::Pane => CustomCommandAction::Pane,
};
- custom_commands.push(CustomCommandKeybind {
- key: binding,
- label: format_key_combo(binding),
+ let label = bindings.label().unwrap_or_else(|| "unset".to_string());
+ keybinds.custom_commands.push(CustomCommandKeybind {
+ bindings,
+ label,
command: command.command.clone(),
action,
});
}
- let keybinds = Keybinds {
- new_workspace: bindings[0].value,
- new_workspace_label: bindings[0].label.clone(),
- rename_workspace: bindings[1].value,
- rename_workspace_label: bindings[1].label.clone(),
- close_workspace: bindings[2].value,
- close_workspace_label: bindings[2].label.clone(),
- detach: optional_bindings[0].value,
- detach_label: optional_bindings[0].label.clone(),
- reload_config: optional_bindings[1].value,
- reload_config_label: optional_bindings[1].label.clone(),
- open_notification_target: optional_bindings[2].value,
- open_notification_target_label: optional_bindings[2].label.clone(),
- previous_workspace: optional_bindings[3].value,
- previous_workspace_label: optional_bindings[3].label.clone(),
- next_workspace: optional_bindings[4].value,
- next_workspace_label: optional_bindings[4].label.clone(),
- previous_agent: optional_bindings[5].value,
- 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,
- rename_tab_label: optional_bindings[7].label.clone(),
- previous_tab: optional_bindings[8].value,
- previous_tab_label: optional_bindings[8].label.clone(),
- next_tab: optional_bindings[9].value,
- next_tab_label: optional_bindings[9].label.clone(),
- close_tab: optional_bindings[10].value,
- close_tab_label: optional_bindings[10].label.clone(),
- rename_pane: optional_bindings[11].value,
- rename_pane_label: optional_bindings[11].label.clone(),
- edit_scrollback: optional_bindings[12].value,
- edit_scrollback_label: optional_bindings[12].label.clone(),
- focus_pane_left: optional_bindings[13].value,
- focus_pane_left_label: optional_bindings[13].label.clone(),
- focus_pane_down: optional_bindings[14].value,
- focus_pane_down_label: optional_bindings[14].label.clone(),
- focus_pane_up: optional_bindings[15].value,
- focus_pane_up_label: optional_bindings[15].label.clone(),
- focus_pane_right: optional_bindings[16].value,
- focus_pane_right_label: optional_bindings[16].label.clone(),
- split_vertical: bindings[4].value,
- split_vertical_label: bindings[4].label.clone(),
- split_horizontal: bindings[5].value,
- split_horizontal_label: bindings[5].label.clone(),
- close_pane: bindings[6].value,
- close_pane_label: bindings[6].label.clone(),
- zoom: bindings[7].value,
- zoom_label: bindings[7].label.clone(),
- resize_mode: bindings[8].value,
- resize_mode_label: bindings[8].label.clone(),
- toggle_sidebar: bindings[9].value,
- toggle_sidebar_label: bindings[9].label.clone(),
- custom_commands,
+ (prefix_diag, prefix, diagnostics, keybinds)
+ }
+}
+
+fn parse_action_bindings(
+ field: &'static str,
+ config: &BindingConfig,
+ allow_ranges: bool,
+ registry: &mut BindingRegistry,
+ diagnostics: &mut Vec<String>,
+) -> ActionKeybinds {
+ parse_action_bindings_owned(field, config, allow_ranges, registry, diagnostics)
+}
+
+fn parse_action_bindings_owned(
+ field: &str,
+ config: &BindingConfig,
+ allow_ranges: bool,
+ 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_binding(field, &binding, registry, diagnostics) {
+ continue;
+ }
+ registry.register(&binding, field);
+ bindings.push(binding);
+ }
+ Some(ParsedBinding::Range(_)) if !allow_ranges => {
+ let diag = format!("range keybinding is only valid for indexed actions: {field} = {raw:?}; disabling binding");
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ }
+ Some(ParsedBinding::Range(range)) => {
+ for binding in range {
+ if reject_binding(field, &binding, registry, diagnostics) {
+ continue;
+ }
+ registry.register(&binding, field);
+ bindings.push(binding);
+ }
+ }
+ 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,
+ registry: &mut BindingRegistry,
+ diagnostics: &mut Vec<String>,
+) -> Vec<IndexedKeybind> {
+ parse_action_bindings(field, config, true, registry, diagnostics)
+ .bindings
+ .into_iter()
+ .filter_map(|binding| {
+ if matches!(binding.trigger.combo().0, KeyCode::Char('1'..='9')) {
+ Some(IndexedKeybind {
+ trigger: binding.trigger,
+ label: binding.label,
+ })
+ } else {
+ let diag = format!(
+ "indexed keybinding must use 1..9: {field} = {:?}; disabling binding",
+ binding.label
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ None
+ }
+ })
+ .collect()
+}
+
+fn append_legacy_indexed_bindings(
+ target: &mut Vec<IndexedKeybind>,
+ field: &'static str,
+ configured_label: &str,
+ registry: &mut BindingRegistry,
+ diagnostics: &mut Vec<String>,
+) {
+ if configured_label.trim().is_empty() {
+ return;
+ }
+ let Some(modifiers) = parse_modifier_combo(configured_label) else {
+ let diag = format!(
+ "invalid indexed keybinding: {field} = {configured_label:?}; disabling binding"
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ return;
+ };
+
+ for idx in 1..=9 {
+ let combo = (
+ KeyCode::Char(char::from_digit(idx, 10).unwrap_or('1')),
+ modifiers,
+ );
+ let binding = ResolvedBinding {
+ trigger: BindingTrigger::Direct(combo),
+ label: format!("{}+{idx}", configured_label.trim()),
};
+ if reject_binding(field, &binding, registry, diagnostics) {
+ continue;
+ }
+ registry.register(&binding, field);
+ target.push(IndexedKeybind {
+ trigger: binding.trigger,
+ label: binding.label,
+ });
+ }
+}
- (prefix_diag, prefix, diagnostics, keybinds)
+fn reject_binding(
+ field: &str,
+ binding: &ResolvedBinding,
+ registry: &BindingRegistry,
+ diagnostics: &mut Vec<String>,
+) -> bool {
+ if binding.trigger.is_prefix() && registry.prefix_rhs_is_reserved(binding.trigger.combo()) {
+ let diag = format!(
+ "reserved keybinding: {field} = {:?} uses keys.prefix as the prefix-mode key; pressing the prefix twice sends a literal prefix key, so this binding is disabled",
+ binding.label
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ return true;
+ }
+
+ if let Some(first_field) = registry.conflict(binding) {
+ let diag = format!(
+ "duplicate keybinding: {field} = {:?} conflicts with {}; disabling binding",
+ binding.label, first_field
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ return true;
+ }
+
+ if binding.trigger.is_direct() && is_unmodified_printable(binding.trigger.combo()) {
+ let suggestion = format!("prefix+{}", binding.label);
+ let diag = format!(
+ "unsafe direct keybinding: {field} = {:?} would intercept typing; use {:?} to require the prefix; disabling binding",
+ binding.label, suggestion
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ return true;
}
+
+ false
}
-pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String {
+fn parse_binding_string(raw: &str) -> Option<ParsedBinding> {
+ let trimmed = raw.trim();
+ let (trigger_prefix, body) = if let Some(rest) = trimmed.strip_prefix("prefix+") {
+ (true, rest)
+ } else {
+ (false, trimmed)
+ };
+
+ if let Some(range_modifiers) = parse_range_modifiers(body) {
+ let bindings = (1..=9)
+ .map(|idx| {
+ let combo = (
+ KeyCode::Char(char::from_digit(idx, 10).unwrap_or('1')),
+ range_modifiers,
+ );
+ let key_label = format_key_combo(combo);
+ ResolvedBinding {
+ trigger: if trigger_prefix {
+ BindingTrigger::Prefix(combo)
+ } else {
+ BindingTrigger::Direct(combo)
+ },
+ label: if trigger_prefix {
+ format!("prefix+{key_label}")
+ } else {
+ key_label
+ },
+ }
+ })
+ .collect();
+ return Some(ParsedBinding::Range(bindings));
+ }
+
+ let combo = parse_key_combo(body)?;
+ let label = if trigger_prefix {
+ format!("prefix+{}", format_key_combo(combo))
+ } else {
+ format_key_combo(combo)
+ };
+ Some(ParsedBinding::Single(ResolvedBinding {
+ trigger: if trigger_prefix {
+ BindingTrigger::Prefix(combo)
+ } else {
+ BindingTrigger::Direct(combo)
+ },
+ label,
+ }))
+}
+
+pub fn format_key_combo(binding: KeyCombo) -> String {
let (code, modifiers) = binding;
let mut parts = Vec::new();
if modifiers.contains(KeyModifiers::CONTROL) {
@@ -813,7 +700,7 @@ pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String {
if modifiers.contains(KeyModifiers::ALT) {
parts.push("alt".to_string());
}
- if modifiers.contains(KeyModifiers::SHIFT) {
+ if modifiers.contains(KeyModifiers::SHIFT) && !matches!(code, KeyCode::BackTab) {
parts.push("shift".to_string());
}
if modifiers.contains(KeyModifiers::SUPER) {
@@ -846,7 +733,7 @@ pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String {
return if parts.is_empty() {
key
} else {
- format!("{}+tab", parts.join("+"))
+ format!("{}+{key}", parts.join("+"))
};
}
@@ -873,6 +760,23 @@ fn parse_modifier_token(token: &str) -> Option<KeyModifiers> {
}
}
+fn parse_range_modifiers(s: &str) -> Option<KeyModifiers> {
+ let mut modifiers = KeyModifiers::empty();
+ let mut saw_range = false;
+ for part in s.split('+') {
+ let trimmed = part.trim();
+ if trimmed == "1..9" {
+ if saw_range {
+ return None;
+ }
+ saw_range = true;
+ } else {
+ modifiers |= parse_modifier_token(trimmed)?;
+ }
+ }
+ saw_range.then_some(modifiers)
+}
+
fn parse_modifier_combo(s: &str) -> Option<KeyModifiers> {
let mut modifiers = KeyModifiers::empty();
let parts: Vec<&str> = s.split('+').collect();
@@ -895,7 +799,7 @@ fn parse_modifier_combo(s: &str) -> Option<KeyModifiers> {
}
}
-pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> {
+pub(super) fn parse_key_combo(s: &str) -> Option<KeyCombo> {
let parts: Vec<&str> = s.split('+').collect();
let mut modifiers = KeyModifiers::empty();
let mut key_str: Option<&str> = None;
@@ -907,27 +811,42 @@ pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> {
}
if let Some(modifier) = parse_modifier_token(trimmed) {
modifiers |= modifier;
+ } else if key_str.is_some() {
+ return None;
} else {
- if key_str.is_some() {
- return None;
- }
key_str = Some(trimmed);
}
}
let key_str = key_str?;
-
let lower = key_str.to_lowercase();
let code = match lower.as_str() {
"space" | " " => KeyCode::Char(' '),
"enter" | "return" => KeyCode::Enter,
"esc" | "escape" => KeyCode::Esc,
+ "tab" if modifiers.contains(KeyModifiers::SHIFT) => {
+ modifiers.remove(KeyModifiers::SHIFT);
+ KeyCode::BackTab
+ }
"tab" => KeyCode::Tab,
"backspace" | "bs" => KeyCode::Backspace,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
+ "minus" => KeyCode::Char('-'),
+ "comma" => KeyCode::Char(','),
+ "period" => KeyCode::Char('.'),
+ "slash" => KeyCode::Char('/'),
+ "backslash" => KeyCode::Char('\\'),
+ "quote" => KeyCode::Char('\''),
+ "double_quote" | "double-quote" => KeyCode::Char('"'),
+ "semicolon" => KeyCode::Char(';'),
+ "colon" => KeyCode::Char(':'),
+ "percent" => KeyCode::Char('%'),
+ "ampersand" => KeyCode::Char('&'),
+ "backtick" => KeyCode::Char('`'),
+ "plus" => KeyCode::Char('+'),
s if s.len() == 1 => {
let ch = key_str.chars().next().unwrap();
if ch.is_ascii_uppercase() {
@@ -941,14 +860,14 @@ pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> {
_ => return None,
};
- Some((code, modifiers))
+ Some(normalize_key_combo((code, modifiers)))
}
fn parse_key_combo_with_diagnostic(
s: &str,
field: &str,
- fallback: (KeyCode, KeyModifiers),
-) -> ((KeyCode, KeyModifiers), Option<String>) {
+ fallback: KeyCombo,
+) -> (KeyCombo, Option<String>) {
match parse_key_combo(s) {
Some(binding) => (binding, None),
None => {
@@ -959,489 +878,426 @@ fn parse_key_combo_with_diagnostic(
}
}
+pub fn normalize_key_combo((mut code, mut modifiers): KeyCombo) -> KeyCombo {
+ if matches!(code, KeyCode::Tab) && modifiers.contains(KeyModifiers::SHIFT) {
+ code = KeyCode::BackTab;
+ modifiers.remove(KeyModifiers::SHIFT);
+ } else if matches!(code, KeyCode::BackTab) {
+ modifiers.remove(KeyModifiers::SHIFT);
+ }
+ (code, modifiers)
+}
+
#[cfg(test)]
-mod tests {
- use super::*;
- use crate::config::Config;
+pub fn key_event_matches_combo(key: &KeyEvent, combo: KeyCombo) -> bool {
+ key_parts_match_combo(key.code, key.modifiers, None, combo)
+}
- #[test]
- fn parse_simple_char() {
- assert_eq!(
- parse_key_combo("v"),
- Some((KeyCode::Char('v'), KeyModifiers::empty()))
- );
- }
+pub fn terminal_key_matches_combo(key: TerminalKey, combo: KeyCombo) -> bool {
+ key_parts_match_combo(key.code, key.modifiers, key.shifted_codepoint, combo)
+}
- #[test]
- fn parse_ctrl_combo() {
- assert_eq!(
- parse_key_combo("ctrl+b"),
- Some((KeyCode::Char('b'), KeyModifiers::CONTROL))
- );
+fn key_parts_match_combo(
+ actual_code: KeyCode,
+ actual_modifiers: KeyModifiers,
+ shifted_codepoint: Option<u32>,
+ combo: KeyCombo,
+) -> bool {
+ let (actual_code, actual_modifiers) = normalize_key_combo((actual_code, actual_modifiers));
+ let (expected_code, expected_modifiers) = normalize_key_combo(combo);
+
+ if actual_modifiers == expected_modifiers
+ && key_codes_match(actual_code, expected_code, shifted_codepoint)
+ {
+ return true;
}
- #[test]
- fn parse_cmd_combo() {
- assert_eq!(
- parse_key_combo("cmd+1"),
- Some((KeyCode::Char('1'), KeyModifiers::SUPER))
- );
+ let actual_without_shift = actual_modifiers.difference(KeyModifiers::SHIFT);
+ actual_modifiers.contains(KeyModifiers::SHIFT)
+ && actual_without_shift == expected_modifiers
+ && shifted_char_matches_expected(actual_code, shifted_codepoint, expected_code)
+}
+
+fn key_codes_match(actual: KeyCode, expected: KeyCode, shifted_codepoint: Option<u32>) -> bool {
+ match (actual, expected) {
+ (KeyCode::Char(actual), KeyCode::Char(expected))
+ if actual.is_ascii_alphabetic() && expected.is_ascii_alphabetic() =>
+ {
+ actual.eq_ignore_ascii_case(&expected)
+ }
+ (KeyCode::Char(actual), KeyCode::Char(expected)) => {
+ actual == expected
+ || shifted_char_matches_expected(
+ KeyCode::Char(actual),
+ shifted_codepoint,
+ KeyCode::Char(expected),
+ )
+ }
+ (actual, expected) => actual == expected,
}
+}
- #[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);
+fn shifted_char_matches_expected(
+ actual_code: KeyCode,
+ shifted_codepoint: Option<u32>,
+ expected_code: KeyCode,
+) -> bool {
+ let KeyCode::Char(expected) = expected_code else {
+ return false;
+ };
+ if shifted_codepoint.and_then(char::from_u32) == Some(expected) {
+ return true;
}
+ matches!(actual_code, KeyCode::Char(actual) if actual == expected && is_shifted_punctuation(expected))
+}
- #[test]
- fn parse_special_key() {
- assert_eq!(
- parse_key_combo("enter"),
- Some((KeyCode::Enter, KeyModifiers::empty()))
- );
- assert_eq!(
- parse_key_combo("tab"),
- Some((KeyCode::Tab, KeyModifiers::empty()))
- );
- assert_eq!(
- parse_key_combo("esc"),
- Some((KeyCode::Esc, KeyModifiers::empty()))
- );
- assert_eq!(
- parse_key_combo("left"),
- Some((KeyCode::Left, KeyModifiers::empty()))
- );
- assert_eq!(
- parse_key_combo("alt+right"),
- Some((KeyCode::Right, KeyModifiers::ALT))
- );
+fn is_shifted_punctuation(ch: char) -> bool {
+ matches!(
+ ch,
+ '!' | '@'
+ | '#'
+ | '$'
+ | '%'
+ | '^'
+ | '&'
+ | '*'
+ | '('
+ | ')'
+ | '_'
+ | '+'
+ | '{'
+ | '}'
+ | '|'
+ | ':'
+ | '"'
+ | '<'
+ | '>'
+ | '?'
+ | '~'
+ )
+}
+
+fn is_unmodified_printable(combo: KeyCombo) -> bool {
+ matches!(combo.0, KeyCode::Char(ch) if !ch.is_control())
+ && combo.1.difference(KeyModifiers::SHIFT).is_empty()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{config::Config, input::TerminalKey};
+
+ fn binding_triggers(bindings: &ActionKeybinds) -> Vec<BindingTrigger> {
+ bindings
+ .bindings
+ .iter()
+ .map(|binding| binding.trigger)
+ .collect()
}
#[test]
- fn parse_ctrl_shift() {
+ fn parse_simple_char_combo() {
assert_eq!(
- parse_key_combo("ctrl+shift+a"),
- Some((
- KeyCode::Char('a'),
- KeyModifiers::CONTROL | KeyModifiers::SHIFT
- ))
+ parse_key_combo("v"),
+ Some((KeyCode::Char('v'), KeyModifiers::empty()))
);
}
#[test]
- fn parse_f_key() {
+ fn parse_shift_tab_as_backtab() {
assert_eq!(
- parse_key_combo("f5"),
- Some((KeyCode::F(5), KeyModifiers::empty()))
+ parse_key_combo("shift+tab"),
+ Some((KeyCode::BackTab, KeyModifiers::empty()))
);
}
#[test]
- fn parse_punctuation_key() {
+ fn parse_named_punctuation() {
assert_eq!(
- parse_key_combo("ctrl+`"),
- Some((KeyCode::Char('`'), KeyModifiers::CONTROL))
+ parse_key_combo("minus"),
+ Some((KeyCode::Char('-'), KeyModifiers::empty()))
);
- }
-
- #[test]
- fn uppercase_char_implies_shift() {
assert_eq!(
- parse_key_combo("D"),
- Some((KeyCode::Char('d'), KeyModifiers::SHIFT))
+ parse_key_combo("comma"),
+ Some((KeyCode::Char(','), KeyModifiers::empty()))
);
- }
-
- #[test]
- fn explicit_shift_and_uppercase_do_not_double_apply_shift() {
assert_eq!(
- parse_key_combo("shift+D"),
- Some((KeyCode::Char('d'), KeyModifiers::SHIFT))
+ parse_key_combo("ampersand"),
+ Some((KeyCode::Char('&'), KeyModifiers::empty()))
);
}
#[test]
- fn invalid_keybinding_is_rejected() {
- assert_eq!(parse_key_combo("ctrl+foo+bar"), None);
- assert_eq!(parse_key_combo("ctrl+"), None);
- }
-
- #[test]
- fn default_keybinds_parse() {
- let config = Config::default();
+ fn prefix_binding_is_not_direct_binding() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+next_tab = "prefix+n"
+"#,
+ )
+ .unwrap();
let kb = config.keybinds();
- assert_eq!(kb.new_workspace.0, KeyCode::Char('n'));
- assert_eq!(
- kb.rename_workspace,
- (KeyCode::Char('n'), KeyModifiers::SHIFT)
- );
assert_eq!(
- kb.close_workspace,
- (KeyCode::Char('d'), KeyModifiers::SHIFT)
+ binding_triggers(&kb.next_tab),
+ vec![BindingTrigger::Prefix((
+ KeyCode::Char('n'),
+ KeyModifiers::empty()
+ ))]
);
- 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'));
- assert_eq!(kb.edit_scrollback, None);
- assert_eq!(kb.zoom.0, KeyCode::Char('f'));
- assert_eq!(kb.resize_mode.0, KeyCode::Char('r'));
- assert_eq!(kb.toggle_sidebar.0, KeyCode::Char('b'));
- assert!(kb.custom_commands.is_empty());
}
#[test]
- fn custom_keybinds_from_toml() {
- let toml = r#"
+ fn array_bindings_allow_prefix_and_modified_direct() {
+ let config: Config = toml::from_str(
+ r#"
[keys]
-prefix = "ctrl+a"
-new_workspace = "c"
-rename_workspace = "shift+r"
-close_workspace = "ctrl+d"
-split_vertical = "s"
-split_horizontal = "shift+s"
-close_pane = "ctrl+w"
-zoom = "z"
-resize_mode = "ctrl+r"
-toggle_sidebar = "tab"
-previous_agent = "alt+a"
-next_agent = "alt+d"
-edit_scrollback = "e"
-focus_pane_left = "alt+h"
-focus_pane_right = "alt+right"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let (code, mods) = config.prefix_key();
- assert_eq!(code, KeyCode::Char('a'));
- assert_eq!(mods, KeyModifiers::CONTROL);
-
+next_tab = ["prefix+n", "ctrl+alt+]"]
+"#,
+ )
+ .unwrap();
let kb = config.keybinds();
assert_eq!(
- kb.new_workspace,
- (KeyCode::Char('c'), KeyModifiers::empty())
- );
- assert_eq!(
- kb.rename_workspace,
- (KeyCode::Char('r'), KeyModifiers::SHIFT)
- );
- assert_eq!(
- kb.close_workspace,
- (KeyCode::Char('d'), KeyModifiers::CONTROL)
- );
- assert_eq!(kb.split_vertical.0, KeyCode::Char('s'));
- assert_eq!(
- kb.split_horizontal,
- (KeyCode::Char('s'), KeyModifiers::SHIFT)
+ binding_triggers(&kb.next_tab),
+ vec![
+ BindingTrigger::Prefix((KeyCode::Char('n'), KeyModifiers::empty())),
+ BindingTrigger::Direct((
+ KeyCode::Char(']'),
+ KeyModifiers::CONTROL | KeyModifiers::ALT
+ )),
+ ]
);
- assert_eq!(kb.close_pane, (KeyCode::Char('w'), KeyModifiers::CONTROL));
- assert_eq!(kb.zoom.0, KeyCode::Char('z'));
- assert_eq!(kb.resize_mode, (KeyCode::Char('r'), KeyModifiers::CONTROL));
- assert_eq!(kb.toggle_sidebar, (KeyCode::Tab, KeyModifiers::empty()));
- assert_eq!(
- kb.previous_agent,
- Some((KeyCode::Char('a'), KeyModifiers::ALT))
- );
- assert_eq!(kb.next_agent, Some((KeyCode::Char('d'), KeyModifiers::ALT)));
- assert_eq!(
- kb.edit_scrollback,
- Some((KeyCode::Char('e'), KeyModifiers::empty()))
- );
- assert_eq!(
- kb.focus_pane_left,
- Some((KeyCode::Char('h'), KeyModifiers::ALT))
- );
- assert_eq!(
- kb.focus_pane_right,
- Some((KeyCode::Right, KeyModifiers::ALT))
- );
- assert_eq!(kb.focus_pane_down, None);
- assert_eq!(kb.focus_pane_up, None);
+ assert_eq!(kb.next_tab.prefix_rhs_label().as_deref(), Some("n"));
}
#[test]
- fn legacy_fullscreen_keybind_alias_maps_to_zoom() {
- let toml = r#"
+ fn unsafe_direct_printable_binding_is_disabled_with_diagnostic() {
+ let config: Config = toml::from_str(
+ r#"
[keys]
-fullscreen = "z"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let kb = config.keybinds();
- assert_eq!(kb.zoom.0, KeyCode::Char('z'));
+new_tab = "c"
+close_tab = "X"
+"#,
+ )
+ .unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let keybinds = config.keybinds();
+ assert!(keybinds.new_tab.bindings.is_empty());
+ assert!(keybinds.close_tab.bindings.is_empty());
+ assert!(
+ diagnostics
+ .iter()
+ .any(|diag| diag.contains("unsafe direct keybinding")
+ && diag.contains("keys.new_tab"))
+ );
+ assert!(diagnostics.iter().any(
+ |diag| diag.contains("unsafe direct keybinding") && diag.contains("keys.close_tab")
+ ));
}
#[test]
- fn uppercase_keybind_from_toml_flows_into_shift_combo() {
- let toml = r#"
-[keys]
-close_pane = "X"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let kb = config.keybinds();
- assert_eq!(kb.close_pane, (KeyCode::Char('x'), KeyModifiers::SHIFT));
+ fn shifted_letter_binding_matches_uppercase_key_event() {
+ let bindings = ActionKeybinds::prefix("shift+n");
+ assert!(bindings.matches_prefix(&KeyEvent::new(KeyCode::Char('N'), KeyModifiers::SHIFT)));
}
#[test]
- fn invalid_keybinding_produces_diagnostic_and_falls_back() {
- let toml = r#"
-[keys]
-rename_workspace = "wat"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let diagnostics = config.collect_diagnostics();
- let kb = config.keybinds();
-
- assert!(diagnostics
- .iter()
- .any(|d| d.contains("keys.rename_workspace")));
+ fn shifted_tab_inputs_match_backtab_canonical_binding() {
+ let bindings = ActionKeybinds::prefix("shift+tab");
+ assert!(
+ bindings.matches_prefix_key(TerminalKey::new(KeyCode::BackTab, KeyModifiers::empty()))
+ );
+ assert!(
+ bindings.matches_prefix_key(TerminalKey::new(KeyCode::BackTab, KeyModifiers::SHIFT))
+ );
+ assert!(bindings.matches_prefix_key(TerminalKey::new(KeyCode::Tab, KeyModifiers::SHIFT)));
+ assert!(!ActionKeybinds::prefix("tab")
+ .matches_prefix_key(TerminalKey::new(KeyCode::Tab, KeyModifiers::SHIFT)));
assert_eq!(
- kb.rename_workspace,
- (KeyCode::Char('n'), KeyModifiers::SHIFT)
+ normalize_key_combo((KeyCode::Tab, KeyModifiers::CONTROL | KeyModifiers::SHIFT)),
+ (KeyCode::BackTab, KeyModifiers::CONTROL)
);
- assert_eq!(kb.rename_workspace_label, "shift+n");
}
#[test]
- fn duplicate_keybinding_produces_diagnostic_and_falls_back_later_binding() {
- let toml = r#"
-[keys]
-new_workspace = "g"
-rename_workspace = "g"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let diagnostics = config.collect_diagnostics();
- let kb = config.keybinds();
-
- assert!(diagnostics
- .iter()
- .any(|d| d.contains("duplicate keybinding")));
+ fn format_modified_backtab_keeps_shift_label() {
assert_eq!(
- kb.new_workspace,
- (KeyCode::Char('g'), KeyModifiers::empty())
+ format_key_combo((KeyCode::BackTab, KeyModifiers::CONTROL)),
+ "ctrl+shift+tab"
);
assert_eq!(
- kb.rename_workspace,
- (KeyCode::Char('n'), KeyModifiers::SHIFT)
+ format_key_combo((KeyCode::BackTab, KeyModifiers::CONTROL | KeyModifiers::ALT)),
+ "ctrl+alt+shift+tab"
);
- assert_eq!(kb.rename_workspace_label, "shift+n");
}
#[test]
- fn duplicate_optional_keybinding_is_disabled_with_diagnostic() {
- let toml = r#"
-[keys]
-new_workspace = "g"
-rename_tab = "g"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let diagnostics = config.collect_diagnostics();
- let kb = config.keybinds();
-
- assert!(diagnostics
- .iter()
- .any(|d| d.contains("keys.rename_tab") && d.contains("disabling binding")));
- assert_eq!(
- kb.new_workspace,
- (KeyCode::Char('g'), KeyModifiers::empty())
- );
- assert_eq!(kb.rename_tab, None);
+ fn shifted_punctuation_matches_enhanced_input() {
+ let help = ActionKeybinds::prefix("?");
+ assert!(help.matches_prefix_key(TerminalKey::new(KeyCode::Char('?'), KeyModifiers::SHIFT)));
+ assert!(help.matches_prefix_key(
+ TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT)
+ .with_shifted_codepoint('?' as u32)
+ ));
+
+ let bang = ActionKeybinds::prefix("!");
+ assert!(bang.matches_prefix_key(
+ TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT)
+ .with_shifted_codepoint('!' as u32)
+ ));
}
#[test]
- fn custom_command_keybinds_parse_from_toml() {
- let toml = r#"
-[[keys.command]]
-key = "g"
-command = "echo hi"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let kb = config.keybinds();
+ fn prefix_rhs_equal_to_configured_prefix_is_rejected() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+prefix = "ctrl+a"
+help = "prefix+ctrl+a"
+"#,
+ )
+ .unwrap();
+ let diagnostics = config.collect_diagnostics();
+ assert!(config.keybinds().help.bindings.is_empty());
+ assert!(diagnostics.iter().any(|diag| {
+ diag.contains("reserved keybinding")
+ && diag.contains("keys.help")
+ && diag.contains("keys.prefix")
+ }));
- assert_eq!(kb.custom_commands.len(), 1);
- assert_eq!(
- kb.custom_commands[0].key,
- (KeyCode::Char('g'), KeyModifiers::empty())
- );
- assert_eq!(kb.custom_commands[0].label, "g");
- assert_eq!(kb.custom_commands[0].command, "echo hi");
- assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Shell);
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+prefix = "ctrl+a"
+help = "prefix+ctrl+b"
+"#,
+ )
+ .unwrap();
+ assert!(!config.keybinds().help.bindings.is_empty());
}
#[test]
- fn pane_custom_command_keybinds_parse_from_toml() {
- let toml = r#"
-[[keys.command]]
-key = "g"
-type = "pane"
-command = "lazygit"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let kb = config.keybinds();
+ fn custom_command_prefix_rhs_equal_to_configured_prefix_is_rejected() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+prefix = "ctrl+b"
- assert_eq!(kb.custom_commands.len(), 1);
- assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Pane);
+[[keys.command]]
+key = "prefix+ctrl+b"
+command = "echo no"
+"#,
+ )
+ .unwrap();
+ let diagnostics = config.collect_diagnostics();
+ assert!(config.keybinds().custom_commands.is_empty());
+ assert!(diagnostics.iter().any(|diag| {
+ diag.contains("reserved keybinding") && diag.contains("keys.command[0].key")
+ }));
}
#[test]
- fn custom_command_conflicting_with_builtin_is_disabled_with_diagnostic() {
- let toml = r#"
+ fn direct_custom_printable_binding_is_rejected_as_unsafe() {
+ let config: Config = toml::from_str(
+ r#"
[keys]
-new_workspace = "g"
[[keys.command]]
key = "g"
-command = "echo hi"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
+command = "echo no"
+"#,
+ )
+ .unwrap();
let diagnostics = config.collect_diagnostics();
- let kb = config.keybinds();
-
- assert!(diagnostics.iter().any(|d| {
- d.contains("duplicate custom keybinding")
- && d.contains("keys.command[0].key")
- && d.contains("keys.new_workspace")
+ assert!(config.keybinds().custom_commands.is_empty());
+ assert!(diagnostics.iter().any(|diag| {
+ diag.contains("unsafe direct keybinding") && diag.contains("keys.command[0].key")
}));
- assert!(kb.custom_commands.is_empty());
}
#[test]
- fn custom_command_conflicting_with_reserved_navigate_key_is_disabled_with_diagnostic() {
- let toml = r#"
+ fn direct_custom_binding_conflicting_with_builtin_is_disabled() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+new_tab = "ctrl+alt+g"
+
[[keys.command]]
-key = "q"
-command = "echo hi"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
+key = "ctrl+alt+g"
+command = "echo no"
+"#,
+ )
+ .unwrap();
let diagnostics = config.collect_diagnostics();
- let kb = config.keybinds();
-
- assert!(diagnostics.iter().any(|d| {
- d.contains("duplicate custom keybinding")
- && d.contains("keys.command[0].key")
- && d.contains("navigate.quit")
+ let keybinds = config.keybinds();
+ assert!(!keybinds.new_tab.bindings.is_empty());
+ assert!(keybinds.custom_commands.is_empty());
+ assert!(diagnostics.iter().any(|diag| {
+ diag.contains("duplicate keybinding") && diag.contains("keys.command[0].key")
}));
- 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();
+ fn prefixed_indexed_bindings_support_modifiers() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+switch_workspace = "prefix+shift+1..9"
+"#,
+ )
+ .unwrap();
let kb = config.keybinds();
-
- assert_eq!(kb.indexed_tabs, Some(KeyModifiers::CONTROL));
+ assert_eq!(kb.switch_workspace.len(), 9);
assert_eq!(
- kb.indexed_workspaces,
- Some(KeyModifiers::CONTROL | KeyModifiers::SHIFT)
+ kb.switch_workspace[0].trigger,
+ BindingTrigger::Prefix((KeyCode::Char('1'), 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")
- }));
+ assert_eq!(kb.switch_workspace[0].label, "prefix+shift+1");
}
#[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();
-
+ fn default_keymap_is_prefix_first_and_tab_centered() {
+ let kb = Config::default().keybinds();
assert_eq!(
- kb.previous_tab,
- Some((KeyCode::Char('h'), KeyModifiers::ALT))
+ binding_triggers(&kb.next_tab),
+ vec![BindingTrigger::Prefix((
+ KeyCode::Char('n'),
+ KeyModifiers::empty()
+ ))]
);
- assert_eq!(kb.focus_pane_left, None);
- assert!(diagnostics
+ assert_eq!(
+ binding_triggers(&kb.previous_tab),
+ vec![BindingTrigger::Prefix((
+ KeyCode::Char('p'),
+ KeyModifiers::empty()
+ ))]
+ );
+ assert_eq!(kb.switch_tab.len(), 9);
+ assert!(kb
+ .switch_tab
.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
+ .all(|binding| binding.trigger.is_prefix()));
+ assert!(kb
+ .new_tab
+ .bindings
.iter()
- .any(|d| { d.contains("keys.previous_tab") && d.contains("keys.prefix") }));
+ .all(|binding| binding.trigger.is_prefix()));
}
#[test]
- fn live_keybinds_reject_invalid_keybinding() {
+ fn duplicate_prefix_binding_disables_later_binding() {
let config: Config = toml::from_str(
r#"
[keys]
-rename_workspace = "wat"
+next_tab = "prefix+n"
+new_workspace = "prefix+n"
"#,
)
.unwrap();
-
- let diagnostics = config.live_keybinds().unwrap_err();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+ assert!(kb.next_tab.bindings.is_empty() || kb.new_workspace.bindings.is_empty());
assert!(diagnostics
.iter()
- .any(|d| d.contains("keys.rename_workspace")));
- }
-
- #[test]
- fn live_keybinds_ignore_non_key_diagnostics() {
- let config: Config = toml::from_str(
- r#"
-[keys]
-new_workspace = "g"
-
-[ui.sound]
-done_path = "sounds/missing.mp3"
-"#,
- )
- .unwrap();
-
- let live = config.live_keybinds().unwrap();
- assert_eq!(
- live.keybinds.new_workspace,
- (KeyCode::Char('g'), KeyModifiers::empty())
- );
+ .any(|diag| diag.contains("duplicate keybinding")));
}
}
diff --git a/src/config/model.rs b/src/config/model.rs
index ff3a7b22..10e80417 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -1,6 +1,8 @@
use serde::{Deserialize, Deserializer, Serialize};
-use super::{CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_SCROLLBACK_LIMIT_BYTES};
+use super::{
+ BindingConfig, CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_SCROLLBACK_LIMIT_BYTES,
+};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)]
#[serde(rename_all = "lowercase")]
@@ -90,63 +92,81 @@ pub struct LoadedConfig {
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct KeysConfig {
- /// Prefix key to toggle navigate mode (e.g. "ctrl+b", "f12", "esc").
+ /// Prefix key to enter prefix mode (e.g. "ctrl+b", "f12", "esc").
pub prefix: String,
- /// Create a new workspace. Default: "n"
- pub new_workspace: String,
- /// Rename the selected workspace. Default: "shift+n"
- pub rename_workspace: String,
- /// Close the selected workspace. Default: "shift+d"
- pub close_workspace: String,
- /// Optional explicit detach shortcut in server/client mode. Unset by default.
- pub detach: String,
- /// Reload config.toml in the running app/server. Unset by default.
- pub reload_config: String,
- /// Focus the currently visible notification target. Unset by default.
- pub open_notification_target: String,
+ /// Open keybinding help. Default: "prefix+?"
+ pub help: BindingConfig,
+ /// Open settings. Default: "prefix+s"
+ pub settings: BindingConfig,
+ /// Quit or detach. Default: "prefix+q"
+ pub quit: BindingConfig,
+ /// Create a new workspace. Default: "prefix+shift+n"
+ pub new_workspace: BindingConfig,
+ /// Rename the selected workspace. Default: "prefix+shift+w"
+ pub rename_workspace: BindingConfig,
+ /// Close the selected workspace. Default: "prefix+shift+d"
+ pub close_workspace: BindingConfig,
+ /// Open the workspace navigation surface. Default: "prefix+w"
+ pub workspace_picker: BindingConfig,
+ /// Optional explicit detach shortcut in server/client mode. Default: "prefix+d".
+ pub detach: BindingConfig,
+ /// Reload config.toml in the running app/server. Default: "prefix+shift+r".
+ pub reload_config: BindingConfig,
+ /// Focus the currently visible notification target. Default: "prefix+o".
+ pub open_notification_target: BindingConfig,
/// Select the previous workspace. Unset by default.
- pub previous_workspace: String,
+ pub previous_workspace: BindingConfig,
/// Select the next workspace. Unset by default.
- pub next_workspace: String,
+ pub next_workspace: BindingConfig,
/// Focus the previous agent shown in the agent panel. Unset by default.
- pub previous_agent: String,
+ pub previous_agent: BindingConfig,
/// Focus the next agent shown in the agent panel. Unset by default.
- pub next_agent: String,
- /// Create a new tab in the active workspace. Default: "c"
- pub new_tab: String,
- /// Rename the active tab. Unset by default.
- pub rename_tab: String,
- /// Select the previous tab. Unset by default.
- pub previous_tab: String,
- /// Select the next tab. Unset by default.
- pub next_tab: String,
- /// Close the active tab. Unset by default.
- pub close_tab: String,
- /// Rename the focused pane. Unset by default.
- pub rename_pane: String,
- /// Open the focused pane scrollback in $EDITOR. Unset by default.
- pub edit_scrollback: String,
- /// Focus the pane to the left in terminal mode. Unset by default.
- pub focus_pane_left: String,
- /// Focus the pane below in terminal mode. Unset by default.
- pub focus_pane_down: String,
- /// Focus the pane above in terminal mode. Unset by default.
- pub focus_pane_up: String,
- /// Focus the pane to the right in terminal mode. Unset by default.
- pub focus_pane_right: String,
- /// Split pane vertically (side by side). Default: "v"
- pub split_vertical: String,
- /// Split pane horizontally (stacked). Default: "-"
- pub split_horizontal: String,
- /// Close the focused pane. Default: "x"
- pub close_pane: String,
- /// Toggle zoom for the focused pane. Default: "f"
+ pub next_agent: BindingConfig,
+ /// Focus an agent by index 1-9. Unset by default.
+ pub focus_agent: BindingConfig,
+ /// Create a new tab in the active workspace. Default: "prefix+c"
+ pub new_tab: BindingConfig,
+ /// Rename the active tab. Default: "prefix+shift+t".
+ pub rename_tab: BindingConfig,
+ /// Select the previous tab. Default: "prefix+p".
+ pub previous_tab: BindingConfig,
+ /// Select the next tab. Default: "prefix+n".
+ pub next_tab: BindingConfig,
+ /// Switch to tab 1-9. Default: "prefix+1..9".
+ pub switch_tab: BindingConfig,
+ /// Switch to workspace 1-9 from prefix mode. Unset by default.
+ pub switch_workspace: BindingConfig,
+ /// Close the active tab. Default: "prefix+shift+x".
+ pub close_tab: BindingConfig,
+ /// Rename the focused pane. Default: "prefix+shift+p".
+ pub rename_pane: BindingConfig,
+ /// Open the focused pane scrollback in $EDITOR. Default: "prefix+e".
+ pub edit_scrollback: BindingConfig,
+ /// Focus the pane to the left. Default: "prefix+h".
+ pub focus_pane_left: BindingConfig,
+ /// Focus the pane below. Default: "prefix+j".
+ pub focus_pane_down: BindingConfig,
+ /// Focus the pane above. Default: "prefix+k".
+ pub focus_pane_up: BindingConfig,
+ /// Focus the pane to the right. Default: "prefix+l".
+ pub focus_pane_right: BindingConfig,
+ /// Cycle to the next pane. Default: "prefix+tab".
+ pub cycle_pane_next: BindingConfig,
+ /// Cycle to the previous pane. Default: "prefix+shift+tab".
+ pub cycle_pane_previous: BindingConfig,
+ /// Split pane vertically (side by side). Default: "prefix+v"
+ pub split_vertical: BindingConfig,
+ /// Split pane horizontally (stacked). Default: "prefix+minus"
+ pub split_horizontal: BindingConfig,
+ /// Close the focused pane. Default: "prefix+x"
+ pub close_pane: BindingConfig,
+ /// Toggle zoom for the focused pane. Default: "prefix+z"
#[serde(alias = "fullscreen")]
- pub zoom: String,
- /// Enter resize mode. Default: "r"
- pub resize_mode: String,
- /// Toggle sidebar collapse. Default: "b"
- pub toggle_sidebar: String,
+ pub zoom: BindingConfig,
+ /// Enter resize mode. Default: "prefix+r"
+ pub resize_mode: BindingConfig,
+ /// Toggle sidebar collapse. Default: "prefix+b"
+ pub toggle_sidebar: BindingConfig,
/// Optional indexed shortcuts expanded over number keys 1-9.
pub indexed: IndexedKeysConfig,
/// Prefix-mode custom command bindings.
@@ -212,33 +232,42 @@ impl Default for KeysConfig {
fn default() -> Self {
Self {
prefix: "ctrl+b".into(),
- new_workspace: "n".into(),
- rename_workspace: "shift+n".into(),
- close_workspace: "shift+d".into(),
- detach: "".into(),
- reload_config: "".into(),
- open_notification_target: "".into(),
- previous_workspace: "".into(),
- next_workspace: "".into(),
- previous_agent: "".into(),
- next_agent: "".into(),
- new_tab: "c".into(),
- rename_tab: "".into(),
- previous_tab: "".into(),
- next_tab: "".into(),
- close_tab: "".into(),
- rename_pane: "".into(),
- edit_scrollback: "".into(),
- focus_pane_left: "".into(),
- focus_pane_down: "".into(),
- focus_pane_up: "".into(),
- focus_pane_right: "".into(),
- split_vertical: "v".into(),
- split_horizontal: "-".into(),
- close_pane: "x".into(),
- zoom: "f".into(),
- resize_mode: "r".into(),
- toggle_sidebar: "b".into(),
+ help: BindingConfig::one("prefix+?"),
+ settings: BindingConfig::one("prefix+s"),
+ quit: BindingConfig::one("prefix+q"),
+ new_workspace: BindingConfig::one("prefix+shift+n"),
+ rename_workspace: BindingConfig::one("prefix+shift+w"),
+ close_workspace: BindingConfig::one("prefix+shift+d"),
+ workspace_picker: BindingConfig::one("prefix+w"),
+ detach: BindingConfig::one("prefix+d"),
+ reload_config: BindingConfig::one("prefix+shift+r"),
+ open_notification_target: BindingConfig::one("prefix+o"),
+ previous_workspace: BindingConfig::empty(),
+ next_workspace: BindingConfig::empty(),
+ previous_agent: BindingConfig::empty(),
+ next_agent: BindingConfig::empty(),
+ focus_agent: BindingConfig::empty(),
+ new_tab: BindingConfig::one("prefix+c"),
+ rename_tab: BindingConfig::one("prefix+shift+t"),
+ previous_tab: BindingConfig::one("prefix+p"),
+ next_tab: BindingConfig::one("prefix+n"),
+ switch_tab: BindingConfig::one("prefix+1..9"),
+ switch_workspace: BindingConfig::empty(),
+ close_tab: BindingConfig::one("prefix+shift+x"),
+ rename_pane: BindingConfig::one("prefix+shift+p"),
+ edit_scrollback: BindingConfig::one("prefix+e"),
+ focus_pane_left: BindingConfig::one("prefix+h"),
+ focus_pane_down: BindingConfig::one("prefix+j"),
+ focus_pane_up: BindingConfig::one("prefix+k"),
+ focus_pane_right: BindingConfig::one("prefix+l"),
+ cycle_pane_next: BindingConfig::one("prefix+tab"),
+ cycle_pane_previous: BindingConfig::one("prefix+shift+tab"),
+ split_vertical: BindingConfig::one("prefix+v"),
+ split_horizontal: BindingConfig::one("prefix+minus"),
+ close_pane: BindingConfig::one("prefix+x"),
+ zoom: BindingConfig::one("prefix+z"),
+ resize_mode: BindingConfig::one("prefix+r"),
+ toggle_sidebar: BindingConfig::one("prefix+b"),
indexed: IndexedKeysConfig::default(),
command: Vec::new(),
}
diff --git a/src/main.rs b/src/main.rs
index 0814c09e..2120555e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -81,56 +81,68 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# default_shell = ""
[keys]
-# Prefix key to enter navigate mode (default: "ctrl+b")
+# Prefix key to enter prefix mode (default: "ctrl+b")
# Examples: "ctrl+b", "f12", "esc", "-"
-# 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.
+# Action bindings use explicit syntax: "prefix+n" requires the prefix;
+# "ctrl+alt+n" is a direct terminal-mode shortcut.
+# Accepted key syntax: plain keys, ctrl/shift/alt/cmd/super modifiers, and special keys like enter/tab/esc/left/right/up/down.
+# Named punctuation such as minus, comma, ampersand, plus, and backtick is also accepted.
+# Most reliable direct bindings are ctrl+letter, function keys, and explicit modified chords.
# alt+..., cmd/super, and punctuation-with-modifiers may depend on your terminal/tmux setup.
# prefix = "ctrl+b"
-# Navigate-mode actions
-# new_workspace = "n"
-# rename_workspace = "shift+n"
-# close_workspace = "shift+d"
+# Prefix-mode actions
+# help = "prefix+?"
+# settings = "prefix+s"
+# quit = "prefix+q"
+# detach = "prefix+d"
+# reload_config = "prefix+shift+r"
+# open_notification_target = "prefix+o"
+# workspace_picker = "prefix+w"
+# new_workspace = "prefix+shift+n"
+# rename_workspace = "prefix+shift+w"
+# close_workspace = "prefix+shift+d"
# previous_workspace = "" # optional, unset by default
# next_workspace = "" # optional, unset by default
# previous_agent = "" # optional, unset by default
# next_agent = "" # optional, unset by default
-# detach = "" # optional explicit detach shortcut in server/client mode
-# reload_config = "" # optional shortcut to reload config.toml without restarting
-# open_notification_target = "" # optional shortcut to jump to the visible notification target
-# new_tab = "c"
-# rename_tab = "" # optional, unset by default
-# previous_tab = "" # optional, unset by default
-# next_tab = "" # optional, unset by default
-# close_tab = "" # optional, unset by default
-# rename_pane = "" # optional, unset by default
-# edit_scrollback = "" # optional, opens focused pane scrollback in $EDITOR
-# focus_pane_left = "" # optional, unset by default
-# focus_pane_down = "" # optional, unset by default
-# focus_pane_up = "" # optional, unset by default
-# focus_pane_right = "" # optional, unset by default
-# split_vertical = "v"
-# split_horizontal = "-"
-# close_pane = "x"
-# zoom = "f" # legacy alias: fullscreen
-# resize_mode = "r"
-# toggle_sidebar = "b"
-
-# Custom prefix-mode commands. Press prefix, then the configured key.
+# focus_agent = "" # optional indexed binding, e.g. "prefix+alt+1..9"
+# new_tab = "prefix+c"
+# rename_tab = "prefix+shift+t"
+# previous_tab = "prefix+p"
+# next_tab = "prefix+n"
+# switch_tab = "prefix+1..9"
+# switch_workspace = "" # optional indexed binding, e.g. "prefix+shift+1..9"
+# close_tab = "prefix+shift+x"
+# rename_pane = "prefix+shift+p"
+# edit_scrollback = "prefix+e"
+# focus_pane_left = "prefix+h"
+# focus_pane_down = "prefix+j"
+# focus_pane_up = "prefix+k"
+# focus_pane_right = "prefix+l"
+# cycle_pane_next = "prefix+tab"
+# cycle_pane_previous = "prefix+shift+tab"
+# split_vertical = "prefix+v"
+# split_horizontal = "prefix+minus"
+# close_pane = "prefix+x"
+# zoom = "prefix+z" # legacy alias: fullscreen
+# resize_mode = "prefix+r"
+# toggle_sidebar = "prefix+b"
+
+# 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.
# [[keys.command]]
-# key = "g"
+# key = "prefix+g"
# type = "pane"
# command = "lazygit"
-# Optional modifier-only shortcuts expanded over number keys 1-9.
-# Empty means disabled. Examples: "ctrl", "ctrl+shift", "alt".
+# Legacy indexed shortcut config is still parsed for compatibility.
+# Prefer switch_tab, switch_workspace, and focus_agent for new configs.
# [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
+# tabs = "" # e.g. "ctrl" makes ctrl+1..9 switch tabs directly
+# workspaces = "" # e.g. "ctrl+shift" makes ctrl+shift+1..9 switch workspaces directly
+# agents = "" # e.g. "alt" makes alt+1..9 focus agent rows directly
[ui]
# Sidebar width (auto-scaled based on workspace names, this sets the default)
@@ -304,6 +316,7 @@ fn main() -> io::Result<()> {
println!(" herdr update");
println!(" herdr server stop");
println!(" herdr server reload-config");
+ println!(" herdr config <subcommand> ...");
println!(" herdr workspace <subcommand> ...");
println!(" herdr tab <subcommand> ...");
println!(" herdr agent <subcommand> ...");
@@ -329,6 +342,10 @@ fn main() -> io::Result<()> {
"Reload config.toml in the running server",
),
(
+ "herdr config reset-keys",
+ "Back up config.toml and remove custom keybindings",
+ ),
+ (
"herdr workspace <subcommand>",
"Workspace helpers over the socket API",
),
@@ -409,6 +426,7 @@ fn main() -> io::Result<()> {
"remote-client-bridge",
"update",
"status",
+ "config",
"workspace",
"pane",
"wait",
diff --git a/src/remote.rs b/src/remote.rs
index 5ab7e6fd..089772b4 100644
--- a/src/remote.rs
+++ b/src/remote.rs
@@ -284,6 +284,7 @@ fn prepare_remote_herdr(target: &str) -> io::Result<RemoteHerdr> {
)));
}
warn_if_remote_bin_not_on_path(target)?;
+ maybe_copy_local_keybindings_to_remote(target, &remote_herdr)?;
Ok(remote_herdr)
}
@@ -652,6 +653,131 @@ fn confirm_remote_install(
Ok(())
}
+fn maybe_copy_local_keybindings_to_remote(
+ target: &str,
+ remote_herdr: &RemoteHerdr,
+) -> io::Result<()> {
+ let Some(config_toml) = local_keybindings_config_toml()? else {
+ return Ok(());
+ };
+ let remote_config_path = remote_config_path(target, remote_herdr)?;
+ if remote_path_exists(target, &remote_config_path)? {
+ return Ok(());
+ }
+ if !confirm_remote_keybindings_copy(target)? {
+ return Ok(());
+ }
+ upload_remote_config(target, &remote_config_path, config_toml.as_bytes())
+}
+
+fn local_keybindings_config_toml() -> io::Result<Option<String>> {
+ let path = crate::config::config_path();
+ if !path.exists() {
+ return Ok(None);
+ }
+ let content = fs::read_to_string(&path)?;
+ Ok(local_keybindings_config_toml_from_str(&content))
+}
+
+fn local_keybindings_config_toml_from_str(content: &str) -> Option<String> {
+ let mut value = content.parse::<toml::Value>().ok()?;
+ let root = value.as_table_mut()?;
+ let mut keys = root.remove("keys")?.as_table()?.clone();
+ keys.remove("command");
+ if keys.is_empty() {
+ return None;
+ }
+
+ let mut out = toml::map::Map::new();
+ out.insert("keys".to_string(), toml::Value::Table(keys));
+ toml::to_string_pretty(&toml::Value::Table(out)).ok()
+}
+
+fn remote_config_path(target: &str, remote_herdr: &RemoteHerdr) -> io::Result<String> {
+ let command = format!("{} --help", remote_herdr.shell_path);
+ let output = ssh_output(target, &command)?;
+ if !output.status.success() {
+ return Err(command_failed("remote config path probe failed", &output));
+ }
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ stdout
+ .lines()
+ .find_map(|line| line.trim().strip_prefix("Config: ").map(str::to_string))
+ .ok_or_else(|| io::Error::other("remote config path probe did not print a Config line"))
+}
+
+fn remote_path_exists(target: &str, path: &str) -> io::Result<bool> {
+ let command = format!("test -e {}", shell_quote(path));
+ let output = ssh_output(target, &command)?;
+ Ok(output.status.success())
+}
+
+fn confirm_remote_keybindings_copy(target: &str) -> io::Result<bool> {
+ if !io::stdin().is_terminal() {
+ return Ok(false);
+ }
+ eprintln!("remote Herdr config is not present on {target}.");
+ eprintln!(
+ "Herdr can copy your local [keys] settings so the remote server uses the same keybindings."
+ );
+ eprintln!("Custom command keybindings are not copied because they run on the remote host.");
+ eprint!("Copy local Herdr keybindings to {target}? [Y/n] ");
+ io::stderr().flush()?;
+
+ let mut answer = String::new();
+ io::stdin().read_line(&mut answer)?;
+ let answer = answer.trim().to_ascii_lowercase();
+ Ok(!(answer == "n" || answer == "no"))
+}
+
+fn upload_remote_config(target: &str, path: &str, content: &[u8]) -> io::Result<()> {
+ let script = format!(
+ r#"dest={}
+dir="${{dest%/*}}"
+mkdir -p "$dir"
+umask 077
+tmp="${{dest}}.tmp.$$"
+cat > "$tmp"
+mv "$tmp" "$dest"
+"#,
+ shell_quote(path)
+ );
+
+ let mut child = Command::new("ssh")
+ .arg("-T")
+ .arg(target)
+ .arg(format!("sh -eu -c {}", shell_quote(&script)))
+ .stdin(Stdio::piped())
+ .stdout(Stdio::inherit())
+ .stderr(Stdio::inherit())
+ .spawn()
+ .map_err(|err| {
+ io::Error::new(
+ err.kind(),
+ format!("failed to start ssh config upload: {err}"),
+ )
+ })?;
+
+ let copy_result = if let Some(mut stdin) = child.stdin.take() {
+ stdin.write_all(content)
+ } else {
+ Err(io::Error::new(
+ io::ErrorKind::BrokenPipe,
+ "ssh config upload stdin missing",
+ ))
+ };
+ let status = child.wait()?;
+ copy_result?;
+
+ if status.success() {
+ Ok(())
+ } else {
+ Err(io::Error::other(format!(
+ "remote config upload exited with {status}"
+ )))
+ }
+}
+
fn install_remote_herdr(
target: &str,
remote_herdr: &RemoteHerdr,
@@ -1096,6 +1222,43 @@ mod tests {
}
#[test]
+ fn local_keybindings_config_extracts_only_keys_without_commands() {
+ let toml = r#"
+[theme]
+name = "one-dark"
+
+[keys]
+prefix = "ctrl+a"
+new_tab = ["prefix+c", "ctrl+alt+n"]
+next_tab = "prefix+n"
+
+[keys.indexed]
+tabs = "ctrl"
+
+[[keys.command]]
+key = "prefix+g"
+type = "pane"
+command = "lazygit"
+"#;
+
+ let copied = local_keybindings_config_toml_from_str(toml).expect("copied key config");
+ assert!(copied.contains("[keys]"));
+ assert!(copied.contains("prefix = \"ctrl+a\""));
+ assert!(copied.contains("prefix+c"));
+ assert!(copied.contains("ctrl+alt+n"));
+ assert!(copied.contains("[keys.indexed]"));
+ assert!(copied.contains("tabs = \"ctrl\""));
+ assert!(!copied.contains("one-dark"));
+ assert!(!copied.contains("lazygit"));
+ assert!(!copied.contains("[[keys.command]]"));
+ }
+
+ #[test]
+ fn local_keybindings_config_returns_none_without_keys() {
+ assert!(local_keybindings_config_toml_from_str("[theme]\nname = \"one-dark\"\n").is_none());
+ }
+
+ #[test]
fn remote_bridge_command_uses_installed_binary() {
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
os: "linux",
diff --git a/src/ui.rs b/src/ui.rs
index ed52660e..050cf194 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -23,7 +23,7 @@ use self::dialogs::{render_confirm_close_overlay, render_rename_overlay};
use self::keybind_help::render_keybind_help_overlay;
use self::menus::{
render_context_menu, render_global_launcher_menu, render_navigate_overlay,
- render_resize_overlay,
+ render_prefix_overlay, render_resize_overlay,
};
use self::mobile::{
compute_mobile_header_hit_areas, is_mobile_width, mobile_switcher_max_scroll_for_height,
@@ -311,6 +311,7 @@ pub fn render(app: &AppState, frame: &mut Frame) {
render_mobile_panel(app, frame, frame.area())
}
Mode::Navigate => render_navigate_overlay(app, frame, terminal_area),
+ Mode::Prefix => render_prefix_overlay(app, frame, terminal_area),
Mode::Resize => render_resize_overlay(app, frame, terminal_area),
Mode::ConfirmClose => render_confirm_close_overlay(app, frame, terminal_area),
Mode::ContextMenu => {
@@ -851,6 +852,22 @@ mod tests {
}
#[test]
+ fn release_notes_config_inline_code_uses_nonbreaking_spaces() {
+ let palette = Palette::catppuccin();
+ let lines = release_notes_lines("- After: `new_tab = \"prefix+c\"`", &palette);
+
+ assert_eq!(lines.len(), 1);
+ assert_eq!(
+ lines[0].1.spans[2].content.as_ref(),
+ "new_tab\u{00a0}=\u{00a0}\"prefix+c\""
+ );
+ assert_eq!(
+ line_text(&lines[0].1).replace('\u{00a0}', " "),
+ " • After: new_tab = \"prefix+c\""
+ );
+ }
+
+ #[test]
fn release_notes_preview_lines_show_update_steps() {
let palette = Palette::catppuccin();
let lines = release_notes_preview_lines("0.5.0", "herdr update", &palette);
@@ -896,6 +913,28 @@ mod tests {
}
#[test]
+ fn prefix_mode_renders_prefix_indicator() {
+ let mut app = crate::app::state::AppState::test_new();
+ app.mode = Mode::Prefix;
+ app.view.terminal_area = ratatui::layout::Rect::new(0, 0, 60, 4);
+ let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 4))
+ .expect("test terminal");
+
+ terminal
+ .draw(|frame| render_prefix_overlay(&app, frame, app.view.terminal_area))
+ .expect("draw prefix overlay");
+
+ let rendered = terminal
+ .backend()
+ .buffer()
+ .content()
+ .iter()
+ .map(|cell| cell.symbol())
+ .collect::<String>();
+ assert!(rendered.contains("PREFIX"));
+ }
+
+ #[test]
fn keybind_help_shows_unset_for_optional_actions() {
let app = crate::app::state::AppState::test_new();
let groups = keybind_help_groups(&app);
@@ -917,13 +956,19 @@ mod tests {
assert!(workspace_tab.contains(&("unset".to_string(), "next workspace")));
assert!(workspace_tab.contains(&("unset".to_string(), "previous agent")));
assert!(workspace_tab.contains(&("unset".to_string(), "next agent")));
- assert!(workspace_tab.contains(&("unset".to_string(), "rename tab")));
- assert!(workspace_tab.contains(&("unset".to_string(), "previous tab")));
- assert!(workspace_tab.contains(&("unset".to_string(), "next tab")));
- assert!(workspace_tab.contains(&("unset".to_string(), "close tab")));
- assert!(panes.contains(&("unset".to_string(), "focus pane left")));
- assert!(panes.contains(&("unset".to_string(), "focus pane down")));
- assert!(panes.contains(&("unset".to_string(), "focus pane up")));
- assert!(panes.contains(&("unset".to_string(), "focus pane right")));
+ assert!(workspace_tab.contains(&("unset".to_string(), "focus agent 1-9")));
+ assert!(workspace_tab.contains(&("unset".to_string(), "switch workspace 1-9")));
+ assert!(panes
+ .iter()
+ .any(|(key, label)| key == "prefix+h" && *label == "focus pane left"));
+ assert!(panes
+ .iter()
+ .any(|(key, label)| key == "prefix+j" && *label == "focus pane down"));
+ assert!(panes
+ .iter()
+ .any(|(key, label)| key == "prefix+k" && *label == "focus pane up"));
+ assert!(panes
+ .iter()
+ .any(|(key, label)| key == "prefix+l" && *label == "focus pane right"));
}
}
diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs
index e7a76155..fe3d7582 100644
--- a/src/ui/keybind_help.rs
+++ b/src/ui/keybind_help.rs
@@ -14,8 +14,31 @@ use super::widgets::{
};
use crate::app::AppState;
-fn optional_keybind_label(label: &Option<String>) -> String {
- label.clone().unwrap_or_else(|| "unset".to_string())
+fn keybind_label(bindings: &crate::config::ActionKeybinds) -> String {
+ bindings.label().unwrap_or_else(|| "unset".to_string())
+}
+
+fn indexed_label(bindings: &[crate::config::IndexedKeybind]) -> String {
+ if bindings.is_empty() {
+ "unset".to_string()
+ } else if bindings.len() == 9 {
+ let first = &bindings[0].label;
+ if first.ends_with('1') {
+ format!("{}1..9", first.trim_end_matches('1'))
+ } else {
+ bindings
+ .iter()
+ .map(|binding| binding.label.clone())
+ .collect::<Vec<_>>()
+ .join(" / ")
+ }
+ } else {
+ bindings
+ .iter()
+ .map(|binding| binding.label.clone())
+ .collect::<Vec<_>>()
+ .join(" / ")
+ }
}
pub(super) fn keybind_help_groups(
@@ -29,12 +52,16 @@ pub(super) fn keybind_help_groups(
vec![
(
crate::config::format_key_combo((app.prefix_code, app.prefix_mods)),
- "navigate mode",
+ "prefix mode",
),
- ("prefix + ?".to_string(), "keybinds"),
+ (keybind_label(&kb.help), "keybinds"),
+ (keybind_label(&kb.settings), "settings"),
+ (keybind_label(&kb.quit), "quit"),
+ (keybind_label(&kb.detach), "detach from server"),
+ (keybind_label(&kb.reload_config), "reload config"),
(
- optional_keybind_label(&kb.reload_config_label),
- "reload config",
+ keybind_label(&kb.open_notification_target),
+ "open notification target",
),
],
));
@@ -47,85 +74,47 @@ pub(super) fn keybind_help_groups(
("h j k l / arrows".to_string(), "move focus"),
("tab / shift+tab".to_string(), "cycle pane"),
("enter".to_string(), "open workspace"),
- ("s".to_string(), "settings"),
- ("q".to_string(), "quit"),
+ ("1..9".to_string(), "switch workspace"),
],
));
- let mut workspace_tab = vec![
- (kb.new_workspace_label.clone(), "new workspace"),
- (kb.rename_workspace_label.clone(), "rename workspace"),
- (kb.close_workspace_label.clone(), "close workspace"),
- (
- optional_keybind_label(&kb.open_notification_target_label),
- "open notification target",
- ),
- (
- optional_keybind_label(&kb.previous_workspace_label),
- "previous workspace",
- ),
- (
- 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"),
- (
- optional_keybind_label(&kb.previous_tab_label),
- "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"),
+ let workspace_tab = vec![
+ (keybind_label(&kb.workspace_picker), "workspace navigation"),
+ (keybind_label(&kb.new_workspace), "new workspace"),
+ (keybind_label(&kb.rename_workspace), "rename workspace"),
+ (keybind_label(&kb.close_workspace), "close workspace"),
+ (keybind_label(&kb.previous_workspace), "previous workspace"),
+ (keybind_label(&kb.next_workspace), "next workspace"),
+ (indexed_label(&kb.switch_workspace), "switch workspace 1-9"),
+ (keybind_label(&kb.previous_agent), "previous agent"),
+ (keybind_label(&kb.next_agent), "next agent"),
+ (indexed_label(&kb.focus_agent), "focus agent 1-9"),
+ (keybind_label(&kb.new_tab), "new tab"),
+ (keybind_label(&kb.rename_tab), "rename tab"),
+ (keybind_label(&kb.previous_tab), "previous tab"),
+ (keybind_label(&kb.next_tab), "next tab"),
+ (indexed_label(&kb.switch_tab), "switch tab 1-9"),
+ (keybind_label(&kb.close_tab), "close tab"),
];
- if let Some(label) = &kb.detach_label {
- workspace_tab.insert(3, (label.clone(), "detach from server"));
- }
groups.push(("workspaces / tabs", workspace_tab));
let panes = vec![
- (kb.split_vertical_label.clone(), "split vertical"),
- (kb.split_horizontal_label.clone(), "split horizontal"),
- (kb.close_pane_label.clone(), "close pane"),
- (optional_keybind_label(&kb.rename_pane_label), "rename pane"),
- (
- optional_keybind_label(&kb.edit_scrollback_label),
- "edit scrollback",
- ),
- (kb.zoom_label.clone(), "zoom pane"),
- (kb.resize_mode_label.clone(), "resize mode"),
- (kb.toggle_sidebar_label.clone(), "toggle sidebar"),
- (
- optional_keybind_label(&kb.focus_pane_left_label),
- "focus pane left",
- ),
- (
- optional_keybind_label(&kb.focus_pane_down_label),
- "focus pane down",
- ),
- (
- optional_keybind_label(&kb.focus_pane_up_label),
- "focus pane up",
- ),
+ (keybind_label(&kb.split_vertical), "split vertical"),
+ (keybind_label(&kb.split_horizontal), "split horizontal"),
+ (keybind_label(&kb.close_pane), "close pane"),
+ (keybind_label(&kb.rename_pane), "rename pane"),
+ (keybind_label(&kb.edit_scrollback), "edit scrollback"),
+ (keybind_label(&kb.zoom), "zoom pane"),
+ (keybind_label(&kb.resize_mode), "resize mode"),
+ (keybind_label(&kb.toggle_sidebar), "toggle sidebar"),
+ (keybind_label(&kb.focus_pane_left), "focus pane left"),
+ (keybind_label(&kb.focus_pane_down), "focus pane down"),
+ (keybind_label(&kb.focus_pane_up), "focus pane up"),
+ (keybind_label(&kb.focus_pane_right), "focus pane right"),
+ (keybind_label(&kb.cycle_pane_next), "cycle pane next"),
(
- optional_keybind_label(&kb.focus_pane_right_label),
- "focus pane right",
+ keybind_label(&kb.cycle_pane_previous),
+ "cycle pane previous",
),
];
groups.push(("panes", panes));
diff --git a/src/ui/menus.rs b/src/ui/menus.rs
index 7a074c68..d17aa897 100644
--- a/src/ui/menus.rs
+++ b/src/ui/menus.rs
@@ -9,6 +9,12 @@ use ratatui::{
use super::widgets::{panel_contrast_fg, render_panel_shell};
use crate::app::AppState;
+fn prefix_rhs_label(bindings: &crate::config::ActionKeybinds) -> String {
+ bindings
+ .prefix_rhs_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();
@@ -18,6 +24,38 @@ fn render_bottom_bar(frame: &mut Frame, area: Rect, line: Line<'_>, bg: ratatui:
frame.render_widget(Paragraph::new(line), area);
}
+pub(super) fn render_prefix_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let key = Style::default()
+ .fg(app.palette.accent)
+ .add_modifier(Modifier::BOLD);
+ let dim = Style::default().fg(app.palette.overlay0);
+ let mode_style = Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD);
+
+ let workspace_picker = prefix_rhs_label(&app.keybinds.workspace_picker);
+ let help = prefix_rhs_label(&app.keybinds.help);
+ let prefix = crate::config::format_key_combo((app.prefix_code, app.prefix_mods));
+
+ let line = Line::from(vec![
+ Span::styled(" PREFIX ", mode_style),
+ Span::raw(" "),
+ Span::styled("esc", key),
+ Span::styled(" cancel ", dim),
+ Span::styled(prefix, key),
+ Span::styled(" send prefix ", dim),
+ Span::styled(workspace_picker, key),
+ Span::styled(" workspace nav ", dim),
+ Span::styled(help, key),
+ Span::styled(" keybinds", dim),
+ ]);
+
+ let overlay_y = area.y + area.height.saturating_sub(1);
+ let overlay_area = Rect::new(area.x, overlay_y, area.width, 1);
+ render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg);
+}
+
pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
let key = Style::default()
.fg(app.palette.accent)
@@ -30,6 +68,15 @@ pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: R
.add_modifier(Modifier::BOLD);
let kb = &app.keybinds;
+ let new_tab = prefix_rhs_label(&kb.new_tab);
+ let split_vertical = prefix_rhs_label(&kb.split_vertical);
+ let split_horizontal = prefix_rhs_label(&kb.split_horizontal);
+ let close_pane = prefix_rhs_label(&kb.close_pane);
+ let zoom = prefix_rhs_label(&kb.zoom);
+ let resize = prefix_rhs_label(&kb.resize_mode);
+ let help = prefix_rhs_label(&kb.help);
+ let settings = prefix_rhs_label(&kb.settings);
+ let quit = prefix_rhs_label(&kb.quit);
let line = Line::from(vec![
Span::styled(" NAVIGATE ", mode_style),
Span::raw(" "),
@@ -39,23 +86,23 @@ pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: R
Span::styled(" ws ", dim),
Span::styled("⇥", key),
Span::styled(" pane ", dim),
- Span::styled(kb.new_tab_label.as_str(), key),
+ Span::styled(new_tab, key),
Span::styled(" new tab ", dim),
- Span::styled(kb.split_vertical_label.as_str(), key),
+ Span::styled(split_vertical, key),
Span::styled(" split│ ", dim),
- Span::styled(kb.split_horizontal_label.as_str(), key),
+ Span::styled(split_horizontal, key),
Span::styled(" split─ ", dim),
- Span::styled(kb.close_pane_label.as_str(), key),
+ Span::styled(close_pane, key),
Span::styled(" close ", dim),
- Span::styled(kb.zoom_label.as_str(), key),
+ Span::styled(zoom, key),
Span::styled(" zoom ", dim),
- Span::styled(kb.resize_mode_label.as_str(), key),
+ Span::styled(resize, key),
Span::styled(" resize ", dim),
- Span::styled("?", key),
+ Span::styled(help, key),
Span::styled(" keybinds ", dim),
- Span::styled("s", key),
+ Span::styled(settings, key),
Span::styled(" settings ", dim),
- Span::styled("q", key),
+ Span::styled(quit, key),
Span::styled(" quit", dim),
]);
diff --git a/src/ui/onboarding.rs b/src/ui/onboarding.rs
index 104f737f..8829acc7 100644
--- a/src/ui/onboarding.rs
+++ b/src/ui/onboarding.rs
@@ -78,7 +78,7 @@ fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) {
.add_modifier(Modifier::BOLD),
),
Span::styled(
- " enters navigate mode · ",
+ " enters prefix mode · ",
Style::default().fg(app.palette.overlay1),
),
Span::styled(
diff --git a/src/ui/panes.rs b/src/ui/panes.rs
index d9108575..550d34ce 100644
--- a/src/ui/panes.rs
+++ b/src/ui/panes.rs
@@ -355,7 +355,10 @@ fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) {
Line::from(vec![
Span::styled(" Press ", Style::default().fg(p.overlay0)),
Span::styled(
- app.keybinds.new_workspace_label.to_string(),
+ app.keybinds
+ .new_workspace
+ .label()
+ .unwrap_or_else(|| "unset".to_string()),
Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
),
Span::styled(" to create one", Style::default().fg(p.overlay0)),
diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs
index 9d069828..e6d20885 100644
--- a/src/ui/release_notes.rs
+++ b/src/ui/release_notes.rs
@@ -281,7 +281,15 @@ fn release_notes_inline_spans<'a>(
let (code, after_end) = after_start.split_at(end);
width += code.chars().count();
if !code.is_empty() {
- spans.push(Span::styled(code.to_string(), code_style));
+ // Keep short config examples together when Paragraph wraps.
+ // Snippets like `new_tab = "prefix+c"` read poorly when they
+ // split at the spaces around `=` in narrow announcement modals.
+ let display_code = if code.contains('=') {
+ code.replace(' ', "\u{00a0}")
+ } else {
+ code.to_string()
+ };
+ spans.push(Span::styled(display_code, code_style));
}
remaining = &after_end[1..];
}