aboutsummaryrefslogtreecommitdiffstats
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/atuin-client/src/settings.rs141
-rw-r--r--crates/atuin/src/command/client/search.rs1
-rw-r--r--crates/atuin/src/command/client/search/inspector.rs27
-rw-r--r--crates/atuin/src/command/client/search/interactive.rs1144
-rw-r--r--crates/atuin/src/command/client/search/keybindings/actions.rs301
-rw-r--r--crates/atuin/src/command/client/search/keybindings/conditions.rs751
-rw-r--r--crates/atuin/src/command/client/search/keybindings/defaults.rs1162
-rw-r--r--crates/atuin/src/command/client/search/keybindings/key.rs451
-rw-r--r--crates/atuin/src/command/client/search/keybindings/keymap.rs231
-rw-r--r--crates/atuin/src/command/client/search/keybindings/mod.rs14
10 files changed, 3795 insertions, 428 deletions
diff --git a/crates/atuin-client/src/settings.rs b/crates/atuin-client/src/settings.rs
index df629664..cb52c983 100644
--- a/crates/atuin-client/src/settings.rs
+++ b/crates/atuin-client/src/settings.rs
@@ -342,6 +342,80 @@ pub struct Keys {
pub prefix: String,
}
+impl Keys {
+ /// The standard default values for all `[keys]` options.
+ /// These match the config defaults set in `builder_with_data_dir()`.
+ pub fn standard_defaults() -> Self {
+ Keys {
+ scroll_exits: true,
+ exit_past_line_start: true,
+ accept_past_line_end: true,
+ accept_past_line_start: false,
+ accept_with_backspace: false,
+ prefix: "a".to_string(),
+ }
+ }
+
+ /// Returns true if any value differs from the standard defaults.
+ pub fn has_non_default_values(&self) -> bool {
+ let d = Self::standard_defaults();
+ self.scroll_exits != d.scroll_exits
+ || self.exit_past_line_start != d.exit_past_line_start
+ || self.accept_past_line_end != d.accept_past_line_end
+ || self.accept_past_line_start != d.accept_past_line_start
+ || self.accept_with_backspace != d.accept_with_backspace
+ || self.prefix != d.prefix
+ }
+}
+
+/// A single rule within a conditional keybinding config.
+#[derive(Clone, Debug, Deserialize, Serialize)]
+pub struct KeyRuleConfig {
+ /// Optional condition expression (e.g. "cursor-at-start", "input-empty && no-results").
+ /// If absent, the rule always matches.
+ #[serde(default)]
+ pub when: Option<String>,
+ /// The action to perform (e.g. "exit", "cursor-left", "accept").
+ pub action: String,
+}
+
+/// A keybinding config value: either a simple action string or an ordered list of conditional rules.
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(untagged)]
+pub enum KeyBindingConfig {
+ /// Simple unconditional binding: `"ctrl-c" = "return-original"`
+ Simple(String),
+ /// Conditional binding: `"left" = [{ when = "cursor-at-start", action = "exit" }, { action = "cursor-left" }]`
+ Rules(Vec<KeyRuleConfig>),
+}
+
+/// User-facing keymap configuration. Each mode maps key strings to bindings.
+/// Keys present here override the defaults for that key; unmentioned keys keep defaults.
+#[derive(Clone, Debug, Deserialize, Serialize, Default)]
+pub struct KeymapConfig {
+ #[serde(default)]
+ pub emacs: HashMap<String, KeyBindingConfig>,
+ #[serde(default, rename = "vim-normal")]
+ pub vim_normal: HashMap<String, KeyBindingConfig>,
+ #[serde(default, rename = "vim-insert")]
+ pub vim_insert: HashMap<String, KeyBindingConfig>,
+ #[serde(default)]
+ pub inspector: HashMap<String, KeyBindingConfig>,
+ #[serde(default)]
+ pub prefix: HashMap<String, KeyBindingConfig>,
+}
+
+impl KeymapConfig {
+ /// Returns true if no keybinding overrides are configured in any mode.
+ pub fn is_empty(&self) -> bool {
+ self.emacs.is_empty()
+ && self.vim_normal.is_empty()
+ && self.vim_insert.is_empty()
+ && self.inspector.is_empty()
+ && self.prefix.is_empty()
+ }
+}
+
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Preview {
pub strategy: PreviewStrategy,
@@ -717,6 +791,9 @@ pub struct Settings {
pub keys: Keys,
#[serde(default)]
+ pub keymap: KeymapConfig,
+
+ #[serde(default)]
pub preview: Preview,
#[serde(default)]
@@ -1292,4 +1369,68 @@ mod tests {
assert!(effective.to_str().is_some());
assert!(effective.ends_with("atuin") || effective == default);
}
+
+ #[test]
+ fn keymap_config_deserializes_simple_binding() {
+ let json = r#"{"emacs": {"ctrl-c": "exit"}}"#;
+ let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
+ assert_eq!(config.emacs.len(), 1);
+ match &config.emacs["ctrl-c"] {
+ super::KeyBindingConfig::Simple(s) => assert_eq!(s, "exit"),
+ _ => panic!("expected Simple variant"),
+ }
+ }
+
+ #[test]
+ fn keymap_config_deserializes_conditional_binding() {
+ let json = r#"{
+ "emacs": {
+ "left": [
+ {"when": "cursor-at-start", "action": "exit"},
+ {"action": "cursor-left"}
+ ]
+ }
+ }"#;
+ let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
+ match &config.emacs["left"] {
+ super::KeyBindingConfig::Rules(rules) => {
+ assert_eq!(rules.len(), 2);
+ assert_eq!(rules[0].when.as_deref(), Some("cursor-at-start"));
+ assert_eq!(rules[0].action, "exit");
+ assert!(rules[1].when.is_none());
+ assert_eq!(rules[1].action, "cursor-left");
+ }
+ _ => panic!("expected Rules variant"),
+ }
+ }
+
+ #[test]
+ fn keymap_config_deserializes_vim_normal() {
+ let json = r#"{"vim-normal": {"j": "select-next", "k": "select-previous"}}"#;
+ let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
+ assert_eq!(config.vim_normal.len(), 2);
+ assert!(config.emacs.is_empty());
+ }
+
+ #[test]
+ fn keymap_config_is_empty_when_default() {
+ let config = super::KeymapConfig::default();
+ assert!(config.is_empty());
+ }
+
+ #[test]
+ fn keymap_config_mixed_modes() {
+ let json = r#"{
+ "emacs": {"ctrl-c": "exit"},
+ "vim-normal": {"q": "exit"},
+ "inspector": {"d": "delete"}
+ }"#;
+ let config: super::KeymapConfig = serde_json::from_str(json).unwrap();
+ assert!(!config.is_empty());
+ assert_eq!(config.emacs.len(), 1);
+ assert_eq!(config.vim_normal.len(), 1);
+ assert_eq!(config.inspector.len(), 1);
+ assert!(config.vim_insert.is_empty());
+ assert!(config.prefix.is_empty());
+ }
}
diff --git a/crates/atuin/src/command/client/search.rs b/crates/atuin/src/command/client/search.rs
index fd090fbc..70a25ed9 100644
--- a/crates/atuin/src/command/client/search.rs
+++ b/crates/atuin/src/command/client/search.rs
@@ -23,6 +23,7 @@ mod engines;
mod history_list;
mod inspector;
mod interactive;
+pub mod keybindings;
pub use duration::format_duration_into;
diff --git a/crates/atuin/src/command/client/search/inspector.rs b/crates/atuin/src/command/client/search/inspector.rs
index 685ed1da..4c3fece1 100644
--- a/crates/atuin/src/command/client/search/inspector.rs
+++ b/crates/atuin/src/command/client/search/inspector.rs
@@ -8,7 +8,6 @@ use atuin_client::{
use ratatui::{
Frame,
backend::FromCrossterm,
- crossterm::event::{KeyCode, KeyEvent, KeyModifiers},
layout::Rect,
prelude::{Constraint, Direction, Layout},
style::Style,
@@ -19,7 +18,7 @@ use ratatui::{
use super::duration::format_duration;
use super::super::theme::{Meaning, Theme};
-use super::interactive::{Compactness, InputAction, State, to_compactness};
+use super::interactive::{Compactness, to_compactness};
#[allow(clippy::cast_sign_loss)]
fn u64_or_zero(num: i64) -> u64 {
@@ -336,30 +335,6 @@ pub fn draw_full(
draw_stats_charts(f, stats_layout[1], stats, theme);
}
-// I'm going to break this out more, but just starting to move things around before changing
-// structure and making it nicer.
-pub fn input(
- state: &mut State,
- _settings: &Settings,
- selected: usize,
- input: &KeyEvent,
-) -> InputAction {
- let ctrl = input.modifiers.contains(KeyModifiers::CONTROL);
-
- match input.code {
- KeyCode::Char('d') if ctrl => InputAction::Delete(selected),
- KeyCode::Up => {
- state.inspecting_state.move_to_previous();
- InputAction::Redraw
- }
- KeyCode::Down => {
- state.inspecting_state.move_to_next();
- InputAction::Redraw
- }
- _ => InputAction::Continue,
- }
-}
-
#[cfg(test)]
mod tests {
use super::draw_ultracompact;
diff --git a/crates/atuin/src/command/client/search/interactive.rs b/crates/atuin/src/command/client/search/interactive.rs
index 5879bb69..d5bf78f4 100644
--- a/crates/atuin/src/command/client/search/interactive.rs
+++ b/crates/atuin/src/command/client/search/interactive.rs
@@ -24,6 +24,7 @@ use atuin_client::{
};
use crate::command::client::search::history_list::HistoryHighlighter;
+use crate::command::client::search::keybindings::KeymapSet;
use crate::command::client::theme::{Meaning, Theme};
use crate::{VERSION, command::client::search::engines};
@@ -32,10 +33,7 @@ use ratatui::{
backend::{CrosstermBackend, FromCrossterm},
crossterm::{
cursor::SetCursorStyle,
- event::{
- self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers,
- MouseEvent,
- },
+ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEvent, MouseEvent},
execute, terminal,
},
layout::{Alignment, Constraint, Direction, Layout},
@@ -123,6 +121,7 @@ pub struct State {
pub inspecting_state: InspectingState,
+ keymaps: KeymapSet,
search: SearchState,
engine: Box<dyn SearchEngine>,
now: Box<dyn Fn() -> OffsetDateTime + Send>,
@@ -261,466 +260,440 @@ impl State {
}
}
- fn handle_key_input(&mut self, settings: &Settings, input: &KeyEvent) -> InputAction {
- if input.kind == event::KeyEventKind::Release {
- return InputAction::Continue;
+ /// Select the keymap for the current mode (ignoring prefix).
+ fn mode_keymap(&self) -> &super::keybindings::Keymap {
+ if self.tab_index == 1 {
+ &self.keymaps.inspector
+ } else {
+ match self.keymap_mode {
+ KeymapMode::Emacs | KeymapMode::Auto => &self.keymaps.emacs,
+ KeymapMode::VimNormal => &self.keymaps.vim_normal,
+ KeymapMode::VimInsert => &self.keymaps.vim_insert,
+ }
}
+ }
- let ctrl = input.modifiers.contains(KeyModifiers::CONTROL);
- let esc_allow_exit = !(self.tab_index == 0 && self.keymap_mode == KeymapMode::VimInsert);
- let cursor_at_end_of_line =
- self.search.input.position() == UnicodeWidthStr::width(self.search.input.as_str());
- let cursor_at_start_of_line = self.search.input.position() == 0;
+ /// Whether the current mode supports character insertion on unmatched keys.
+ fn is_insert_mode(&self) -> bool {
+ matches!(
+ self.keymap_mode,
+ KeymapMode::Emacs | KeymapMode::Auto | KeymapMode::VimInsert
+ )
+ }
- // support ctrl-a prefix, like screen or tmux
- if !self.prefix
- && ctrl
- && input.code == KeyCode::Char(settings.keys.prefix.chars().next().unwrap_or('a'))
- {
- self.prefix = true;
+ fn handle_key_input(&mut self, settings: &Settings, input: &KeyEvent) -> InputAction {
+ use super::keybindings::Action;
+ use super::keybindings::EvalContext;
+ use super::keybindings::key::{KeyCodeValue, KeyInput, SingleKey};
+
+ // Skip release events
+ if input.kind == event::KeyEventKind::Release {
return InputAction::Continue;
}
- // core input handling, common for all tabs
- let common: Option<InputAction> = match input.code {
- KeyCode::Char('c' | 'g') if ctrl => Some(InputAction::ReturnOriginal),
- KeyCode::Esc if esc_allow_exit => Some(Self::handle_key_exit(settings)),
- KeyCode::Char('[') if ctrl && esc_allow_exit => Some(Self::handle_key_exit(settings)),
- KeyCode::Tab => match self.tab_index {
- 0 => Some(InputAction::Accept(self.results_state.selected())),
-
- 1 => Some(InputAction::AcceptInspecting),
+ // Reset switched_search_mode at start of each key event
+ self.switched_search_mode = false;
- _ => panic!("invalid tab index on input"),
- },
- KeyCode::Right if cursor_at_end_of_line && settings.keys.accept_past_line_end => {
- Some(InputAction::Accept(self.results_state.selected()))
- }
- KeyCode::Left if cursor_at_start_of_line && settings.keys.accept_past_line_start => {
- Some(InputAction::Accept(self.results_state.selected()))
- }
- KeyCode::Left if cursor_at_start_of_line && settings.keys.exit_past_line_start => {
- Some(Self::handle_key_exit(settings))
- }
- KeyCode::Backspace
- if cursor_at_start_of_line && settings.keys.accept_with_backspace =>
- {
- Some(InputAction::Accept(self.results_state.selected()))
- }
- KeyCode::Char('o') if ctrl => {
- self.tab_index = (self.tab_index + 1) % TAB_TITLES.len();
- Some(InputAction::Continue)
- }
- _ => None,
+ // Build evaluation context from current state
+ let ctx = EvalContext {
+ cursor_position: self.search.input.position(),
+ input_width: UnicodeWidthStr::width(self.search.input.as_str()),
+ input_byte_len: self.search.input.as_str().len(),
+ selected_index: self.results_state.selected(),
+ results_len: self.results_len,
};
- if let Some(ret) = common {
- self.prefix = false;
+ // Convert KeyEvent to SingleKey
+ let single = SingleKey::from_event(input);
- return ret;
- }
+ // --- Phase 1: Resolve (take pending key first, then immutable borrows) ---
- // handle tab-specific input
- let action = match self.tab_index {
- 0 => self.handle_search_input(settings, input),
+ // Take pending key before any immutable borrows of self
+ let pending = self.pending_vim_key.take();
- 1 => super::inspector::input(self, settings, self.results_state.selected(), input),
+ // If in prefix mode, try prefix keymap first (single keys only)
+ let prefix_action = if self.prefix {
+ let ki = KeyInput::Single(single.clone());
+ self.keymaps.prefix.resolve(&ki, &ctx)
+ } else {
+ None
+ };
- _ => panic!("invalid tab index on input"),
+ // The if-let/else-if chain here is clearer than map_or_else with nested closures.
+ #[allow(clippy::option_if_let_else)]
+ let (action, new_pending) = if prefix_action.is_some() {
+ (prefix_action, None)
+ } else {
+ // Use mode keymap (handles both single and multi-key sequences)
+ let keymap = self.mode_keymap();
+
+ if let Some(pending_char) = pending {
+ // We have a pending key from a previous press (e.g., first 'g' of 'gg')
+ let pending_single = SingleKey {
+ code: KeyCodeValue::Char(pending_char),
+ ctrl: false,
+ alt: false,
+ shift: false,
+ super_key: false,
+ };
+ let seq = KeyInput::Sequence(vec![pending_single, single.clone()]);
+ let action = keymap
+ .resolve(&seq, &ctx)
+ .or_else(|| keymap.resolve(&KeyInput::Single(single.clone()), &ctx));
+ (action, None)
+ } else if keymap.has_sequence_starting_with(&single)
+ && matches!(single.code, KeyCodeValue::Char(_))
+ && !single.ctrl
+ && !single.alt
+ {
+ // This key starts a multi-key sequence; wait for next key
+ let KeyCodeValue::Char(c) = single.code else {
+ unreachable!()
+ };
+ (Some(Action::Noop), Some(c))
+ } else {
+ (
+ keymap.resolve(&KeyInput::Single(single.clone()), &ctx),
+ None,
+ )
+ }
};
- self.prefix = false;
+ // --- Phase 2: Apply mutations ---
+ self.pending_vim_key = new_pending;
- action
- }
+ // Reset prefix (before execute, so EnterPrefixMode can re-set it)
+ self.prefix = false;
- fn handle_search_scroll_one_line(
- &mut self,
- settings: &Settings,
- enable_exit: bool,
- is_down: bool,
- ) -> InputAction {
- if is_down {
- if settings.keys.scroll_exits && enable_exit && self.results_state.selected() == 0 {
- return Self::handle_key_exit(settings);
- }
- self.scroll_down(1);
+ if let Some(action) = action {
+ self.execute_action(&action, settings)
} else {
- self.scroll_up(1);
+ // No action matched. In insert-capable modes, insert the character.
+ if self.is_insert_mode()
+ && let KeyCodeValue::Char(c) = single.code
+ && !single.ctrl
+ && !single.alt
+ {
+ self.search.input.insert(c);
+ }
+ InputAction::Continue
}
- InputAction::Continue
- }
-
- fn handle_search_up(&mut self, settings: &Settings, enable_exit: bool) -> InputAction {
- self.handle_search_scroll_one_line(settings, enable_exit, settings.invert)
}
- fn handle_search_down(&mut self, settings: &Settings, enable_exit: bool) -> InputAction {
- self.handle_search_scroll_one_line(settings, enable_exit, !settings.invert)
+ fn scroll_down(&mut self, scroll_len: usize) {
+ let i = self.results_state.selected().saturating_sub(scroll_len);
+ self.inspecting_state.reset();
+ self.results_state.select(i);
}
- fn handle_search_accept(&mut self, settings: &Settings) -> InputAction {
- if settings.enter_accept {
- self.accept = true;
- }
- InputAction::Accept(self.results_state.selected())
+ fn scroll_up(&mut self, scroll_len: usize) {
+ let i = self.results_state.selected() + scroll_len;
+ self.results_state
+ .select(i.min(self.results_len.saturating_sub(1)));
+ self.inspecting_state.reset();
}
+ /// Execute a resolved action, performing all side effects and returning the
+ /// appropriate `InputAction` for the event loop.
+ ///
+ /// This is the "do it" half of the resolve+execute pipeline. The resolver
+ /// decides *what* to do (which `Action`), and this function carries it out.
+ ///
+ /// Invert handling: scroll actions (`SelectNext`, `ScrollPageDown`, etc.) account
+ /// for `settings.invert` so that keybindings are always in "visual" terms —
+ /// users never need to think about invert in their keybinding config.
#[allow(clippy::too_many_lines)]
- #[allow(clippy::cognitive_complexity)]
- fn handle_search_input(&mut self, settings: &Settings, input: &KeyEvent) -> InputAction {
- let ctrl = input.modifiers.contains(KeyModifiers::CONTROL);
- let alt = input.modifiers.contains(KeyModifiers::ALT);
+ pub(crate) fn execute_action(
+ &mut self,
+ action: &super::keybindings::Action,
+ settings: &Settings,
+ ) -> InputAction {
+ use crate::command::client::search::keybindings::Action;
- // Use Ctrl-n instead of Alt-n?
- let modfr = if settings.ctrl_n_shortcuts { ctrl } else { alt };
+ match action {
+ // -- Cursor movement --
+ Action::CursorLeft => {
+ self.search.input.left();
+ InputAction::Continue
+ }
+ Action::CursorRight => {
+ self.search.input.right();
+ InputAction::Continue
+ }
+ Action::CursorWordLeft => {
+ self.search
+ .input
+ .prev_word(&settings.word_chars, settings.word_jump_mode);
+ InputAction::Continue
+ }
+ Action::CursorWordRight => {
+ self.search
+ .input
+ .next_word(&settings.word_chars, settings.word_jump_mode);
+ InputAction::Continue
+ }
+ Action::CursorStart => {
+ self.search.input.start();
+ InputAction::Continue
+ }
+ Action::CursorEnd => {
+ self.search.input.end();
+ InputAction::Continue
+ }
- // reset the state, will be set to true later if user really did change it
- self.switched_search_mode = false;
+ // -- Editing --
+ Action::DeleteCharBefore => {
+ self.search.input.back();
+ InputAction::Continue
+ }
+ Action::DeleteCharAfter => {
+ self.search.input.remove();
+ InputAction::Continue
+ }
+ Action::DeleteWordBefore => {
+ self.search
+ .input
+ .remove_prev_word(&settings.word_chars, settings.word_jump_mode);
+ InputAction::Continue
+ }
+ Action::DeleteWordAfter => {
+ self.search
+ .input
+ .remove_next_word(&settings.word_chars, settings.word_jump_mode);
+ InputAction::Continue
+ }
+ Action::DeleteToWordBoundary => {
+ // ctrl-w: remove trailing whitespace, then delete to word boundary
+ while matches!(self.search.input.back(), Some(c) if c.is_whitespace()) {}
+ while self.search.input.left() {
+ if self.search.input.char().unwrap().is_whitespace() {
+ self.search.input.right();
+ break;
+ }
+ self.search.input.remove();
+ }
+ InputAction::Continue
+ }
+ Action::ClearLine => {
+ self.search.input.clear();
+ InputAction::Continue
+ }
- // first up handle prefix mappings. these take precedence over all others
- // eg, if a user types ctrl-a d, delete the history
- if self.prefix {
- // It'll be expanded.
- #[allow(clippy::single_match)]
- match input.code {
- KeyCode::Char('d') => {
- return InputAction::Delete(self.results_state.selected());
+ // -- List navigation (invert-aware) --
+ Action::SelectNext => {
+ if settings.invert {
+ self.scroll_up(1);
+ } else {
+ self.scroll_down(1);
}
- KeyCode::Char('a') => {
- self.search.input.start();
- // This prevents pressing ctrl-a twice while still in prefix mode
- self.prefix = false;
- return InputAction::Continue;
+ InputAction::Continue
+ }
+ Action::SelectPrevious => {
+ if settings.invert {
+ self.scroll_down(1);
+ } else {
+ self.scroll_up(1);
}
- _ => {}
+ InputAction::Continue
}
- }
-
- // handle keymap specific keybindings.
- match self.keymap_mode {
- KeymapMode::VimNormal => {
- // Reset pending key unless this is 'g' (for gg sequence)
- if !matches!(input.code, KeyCode::Char('g')) || ctrl {
- self.pending_vim_key = None;
+ // -- Page/half-page scroll (invert-aware) --
+ Action::ScrollHalfPageUp => {
+ let scroll_len = self
+ .results_state
+ .max_entries()
+ .saturating_sub(settings.scroll_context_lines)
+ / 2;
+ if settings.invert {
+ self.scroll_down(scroll_len);
+ } else {
+ self.scroll_up(scroll_len);
}
-
- match input.code {
- KeyCode::Char('?' | '/') if !ctrl => {
- self.search.input.clear();
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- return InputAction::Continue;
- }
- KeyCode::Char('j') if !ctrl => {
- return self.handle_search_down(settings, true);
- }
- KeyCode::Char('k') if !ctrl => {
- return self.handle_search_up(settings, true);
- }
- KeyCode::Char('h') if !ctrl => {
- self.search.input.left();
- return InputAction::Continue;
- }
- KeyCode::Char('l') if !ctrl => {
- self.search.input.right();
- return InputAction::Continue;
- }
- KeyCode::Char('a') if !ctrl => {
- self.search.input.right();
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- return InputAction::Continue;
- }
- KeyCode::Char('A') if !ctrl => {
- self.search.input.end();
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- return InputAction::Continue;
- }
- KeyCode::Char('i') if !ctrl => {
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- return InputAction::Continue;
- }
- KeyCode::Char('I') if !ctrl => {
- self.search.input.start();
- self.set_keymap_cursor(settings, "vim_insert");
- self.keymap_mode = KeymapMode::VimInsert;
- return InputAction::Continue;
- }
- KeyCode::Char(c @ '1'..='9') => {
- return c.to_digit(10).map_or(InputAction::Continue, |c| {
- InputAction::Accept(self.results_state.selected() + c as usize)
- });
- }
- KeyCode::Char('u') if ctrl => {
- // Half-page up (toward visual top)
- let scroll_len = self
- .results_state
- .max_entries()
- .saturating_sub(settings.scroll_context_lines)
- / 2;
- if settings.invert {
- self.scroll_down(scroll_len);
- } else {
- self.scroll_up(scroll_len);
- }
- return InputAction::Continue;
- }
- KeyCode::Char('d') if ctrl => {
- // Half-page down (toward visual bottom)
- let scroll_len = self
- .results_state
- .max_entries()
- .saturating_sub(settings.scroll_context_lines)
- / 2;
- if settings.invert {
- self.scroll_up(scroll_len);
- } else {
- self.scroll_down(scroll_len);
- }
- return InputAction::Continue;
- }
- KeyCode::Char('b') if ctrl => {
- // Full-page up (toward visual top)
- let scroll_len = self
- .results_state
- .max_entries()
- .saturating_sub(settings.scroll_context_lines);
- if settings.invert {
- self.scroll_down(scroll_len);
- } else {
- self.scroll_up(scroll_len);
- }
- return InputAction::Continue;
- }
- KeyCode::Char('f') if ctrl => {
- // Full-page down (toward visual bottom)
- let scroll_len = self
- .results_state
- .max_entries()
- .saturating_sub(settings.scroll_context_lines);
- if settings.invert {
- self.scroll_up(scroll_len);
- } else {
- self.scroll_down(scroll_len);
- }
- return InputAction::Continue;
- }
- KeyCode::Char('G') if !ctrl => {
- // Jump to visual bottom of history
- if settings.invert {
- let last_idx = self.results_len.saturating_sub(1);
- self.results_state.select(last_idx);
- } else {
- self.results_state.select(0);
- }
- self.inspecting_state.reset();
- return InputAction::Continue;
- }
- KeyCode::Char('g') if !ctrl => {
- if self.pending_vim_key == Some('g') {
- // gg - jump to visual top of history
- if settings.invert {
- self.results_state.select(0);
- } else {
- let last_idx = self.results_len.saturating_sub(1);
- self.results_state.select(last_idx);
- }
- self.inspecting_state.reset();
- self.pending_vim_key = None;
- } else {
- self.pending_vim_key = Some('g');
- }
- return InputAction::Continue;
- }
- KeyCode::Char('H') if !ctrl => {
- // Jump to top of visible screen
- let top = self.results_state.offset();
- let visible = self.results_state.max_entries().min(self.results_len);
- let bottom = top + visible.saturating_sub(1);
- self.results_state
- .select(bottom.min(self.results_len.saturating_sub(1)));
- self.inspecting_state.reset();
- return InputAction::Continue;
- }
- KeyCode::Char('M') if !ctrl => {
- // Jump to middle of visible screen
- let top = self.results_state.offset();
- let visible = self.results_state.max_entries().min(self.results_len);
- let middle = top + visible / 2;
- self.results_state
- .select(middle.min(self.results_len.saturating_sub(1)));
- self.inspecting_state.reset();
- return InputAction::Continue;
- }
- KeyCode::Char('L') if !ctrl => {
- // Jump to bottom of visible screen
- let top_visible = self.results_state.offset();
- self.results_state.select(top_visible);
- self.inspecting_state.reset();
- return InputAction::Continue;
- }
- KeyCode::Char(_) if !ctrl => {
- return InputAction::Continue;
- }
- _ => {}
+ InputAction::Continue
+ }
+ Action::ScrollHalfPageDown => {
+ let scroll_len = self
+ .results_state
+ .max_entries()
+ .saturating_sub(settings.scroll_context_lines)
+ / 2;
+ if settings.invert {
+ self.scroll_up(scroll_len);
+ } else {
+ self.scroll_down(scroll_len);
}
+ InputAction::Continue
}
- KeymapMode::VimInsert => {
- if input.code == KeyCode::Esc || (ctrl && input.code == KeyCode::Char('[')) {
- self.set_keymap_cursor(settings, "vim_normal");
- self.keymap_mode = KeymapMode::VimNormal;
- return InputAction::Continue;
+ Action::ScrollPageUp => {
+ let scroll_len = self
+ .results_state
+ .max_entries()
+ .saturating_sub(settings.scroll_context_lines);
+ if settings.invert {
+ self.scroll_down(scroll_len);
+ } else {
+ self.scroll_up(scroll_len);
}
+ InputAction::Continue
+ }
+ Action::ScrollPageDown => {
+ let scroll_len = self
+ .results_state
+ .max_entries()
+ .saturating_sub(settings.scroll_context_lines);
+ if settings.invert {
+ self.scroll_up(scroll_len);
+ } else {
+ self.scroll_down(scroll_len);
+ }
+ InputAction::Continue
}
- _ => {}
- }
- match input.code {
- KeyCode::Enter => return self.handle_search_accept(settings),
- KeyCode::Char('m') if ctrl => return self.handle_search_accept(settings),
- KeyCode::Char('y') if ctrl => {
- return InputAction::Copy(self.results_state.selected());
+ // -- Absolute jumps (invert-aware) --
+ Action::ScrollToTop => {
+ // Visual top of history
+ if settings.invert {
+ self.results_state.select(0);
+ } else {
+ let last_idx = self.results_len.saturating_sub(1);
+ self.results_state.select(last_idx);
+ }
+ self.inspecting_state.reset();
+ InputAction::Continue
}
- KeyCode::Char(c @ '1'..='9') if modfr => {
- return c.to_digit(10).map_or(InputAction::Continue, |c| {
- InputAction::Accept(self.results_state.selected() + c as usize)
- });
+ Action::ScrollToBottom => {
+ // Visual bottom of history
+ if settings.invert {
+ let last_idx = self.results_len.saturating_sub(1);
+ self.results_state.select(last_idx);
+ } else {
+ self.results_state.select(0);
+ }
+ self.inspecting_state.reset();
+ InputAction::Continue
}
- KeyCode::Left if ctrl => self
- .search
- .input
- .prev_word(&settings.word_chars, settings.word_jump_mode),
- KeyCode::Char('b') if alt => self
- .search
- .input
- .prev_word(&settings.word_chars, settings.word_jump_mode),
- KeyCode::Left => {
- self.search.input.left();
+ Action::ScrollToScreenTop => {
+ // H — jump to top of visible screen
+ let top = self.results_state.offset();
+ let visible = self.results_state.max_entries().min(self.results_len);
+ let bottom = top + visible.saturating_sub(1);
+ self.results_state
+ .select(bottom.min(self.results_len.saturating_sub(1)));
+ self.inspecting_state.reset();
+ InputAction::Continue
}
- KeyCode::Char('b') if ctrl => {
- self.search.input.left();
+ Action::ScrollToScreenMiddle => {
+ // M — jump to middle of visible screen
+ let top = self.results_state.offset();
+ let visible = self.results_state.max_entries().min(self.results_len);
+ let middle = top + visible / 2;
+ self.results_state
+ .select(middle.min(self.results_len.saturating_sub(1)));
+ self.inspecting_state.reset();
+ InputAction::Continue
}
- KeyCode::Right if ctrl => self
- .search
- .input
- .next_word(&settings.word_chars, settings.word_jump_mode),
- KeyCode::Char('f') if alt => self
- .search
- .input
- .next_word(&settings.word_chars, settings.word_jump_mode),
- KeyCode::Right => self.search.input.right(),
- KeyCode::Char('f') if ctrl => self.search.input.right(),
- KeyCode::Home => self.search.input.start(),
- KeyCode::Char('a') if ctrl => self.search.input.start(),
- KeyCode::Char('e') if ctrl => self.search.input.end(),
- KeyCode::End => self.search.input.end(),
- KeyCode::Backspace if ctrl => self
- .search
- .input
- .remove_prev_word(&settings.word_chars, settings.word_jump_mode),
- KeyCode::Backspace => {
- self.search.input.back();
+ Action::ScrollToScreenBottom => {
+ // L — jump to bottom of visible screen
+ let top_visible = self.results_state.offset();
+ self.results_state.select(top_visible);
+ self.inspecting_state.reset();
+ InputAction::Continue
}
- KeyCode::Char('h' | '?') if ctrl => {
- // Depending on the terminal, [Backspace] can be transmitted as
- // \x08 or \x7F. Also, [Ctrl+Backspace] can be transmitted as
- // \x08 or \x7F or \x1F. On the other hand, [Ctrl+h] and
- // [Ctrl+?] are also transmitted as \x08 or \x7F by the
- // terminals.
- //
- // The crossterm library translates \x08 and \x7F to C-h and
- // Backspace, respectively. With the extended keyboard
- // protocol enabled, crossterm can faithfully translate
- // [Ctrl+h] and [Ctrl+?] to C-h and C-?. There is no perfect
- // solution, but we treat C-h and C-? the same as backspace to
- // suppress quirks as much as possible.
- self.search.input.back();
+
+ // -- Commands --
+ Action::Accept => {
+ if self.tab_index == 1 {
+ return InputAction::AcceptInspecting;
+ }
+ self.accept = true;
+ InputAction::Accept(self.results_state.selected())
}
- KeyCode::Delete if ctrl => self
- .search
- .input
- .remove_next_word(&settings.word_chars, settings.word_jump_mode),
- KeyCode::Delete => {
- self.search.input.remove();
+ Action::AcceptNth(n) => {
+ self.accept = true;
+ InputAction::Accept(self.results_state.selected() + *n as usize)
}
- KeyCode::Char('d') if ctrl => {
- if self.search.input.as_str().is_empty() {
- return InputAction::ReturnOriginal;
+ Action::ReturnSelection => {
+ if self.tab_index == 1 {
+ return InputAction::AcceptInspecting;
}
- self.search.input.remove();
+ InputAction::Accept(self.results_state.selected())
}
- KeyCode::Char('w') if ctrl => {
- // remove the first batch of whitespace
- while matches!(self.search.input.back(), Some(c) if c.is_whitespace()) {}
- while self.search.input.left() {
- if self.search.input.char().unwrap().is_whitespace() {
- self.search.input.right(); // found whitespace, go back right
- break;
- }
- self.search.input.remove();
- }
+ Action::ReturnSelectionNth(n) => {
+ InputAction::Accept(self.results_state.selected() + *n as usize)
+ }
+ Action::Copy => InputAction::Copy(self.results_state.selected()),
+ Action::Delete => InputAction::Delete(self.results_state.selected()),
+ Action::ReturnOriginal => InputAction::ReturnOriginal,
+ Action::ReturnQuery => InputAction::ReturnQuery,
+ Action::Exit => Self::handle_key_exit(settings),
+ Action::Redraw => InputAction::Redraw,
+ Action::CycleFilterMode => {
+ self.search.rotate_filter_mode(settings, 1);
+ InputAction::Continue
}
- KeyCode::Char('u') if ctrl => self.search.input.clear(),
- KeyCode::Char('r') if ctrl => self.search.rotate_filter_mode(settings, 1),
- KeyCode::Char('s') if ctrl => {
+ Action::CycleSearchMode => {
self.switched_search_mode = true;
self.search_mode = self.search_mode.next(settings);
self.engine = engines::engine(self.search_mode);
+ InputAction::Continue
}
- KeyCode::Down => {
- return self.handle_search_down(settings, true);
+ Action::ToggleTab => {
+ self.tab_index = (self.tab_index + 1) % TAB_TITLES.len();
+ InputAction::Continue
}
- KeyCode::Up => {
- return self.handle_search_up(settings, true);
+
+ // -- Mode changes --
+ Action::VimEnterNormal => {
+ self.set_keymap_cursor(settings, "vim_normal");
+ self.keymap_mode = KeymapMode::VimNormal;
+ InputAction::Continue
}
- KeyCode::Char('n' | 'j') if ctrl => {
- return self.handle_search_down(settings, false);
+ Action::VimEnterInsert => {
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
}
- KeyCode::Char('p' | 'k') if ctrl => {
- return self.handle_search_up(settings, false);
+ Action::VimEnterInsertAfter => {
+ self.search.input.right();
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
}
- KeyCode::Char('l') if ctrl => {
- return InputAction::Redraw;
+ Action::VimEnterInsertAtStart => {
+ self.search.input.start();
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
}
- KeyCode::Char(c) => {
- self.search.input.insert(c);
+ Action::VimEnterInsertAtEnd => {
+ self.search.input.end();
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
}
- KeyCode::PageDown if !settings.invert => {
- let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines;
- self.scroll_down(scroll_len);
+ Action::VimSearchInsert => {
+ self.search.input.clear();
+ self.set_keymap_cursor(settings, "vim_insert");
+ self.keymap_mode = KeymapMode::VimInsert;
+ InputAction::Continue
}
- KeyCode::PageDown if settings.invert => {
- let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines;
- self.scroll_up(scroll_len);
+ Action::EnterPrefixMode => {
+ self.prefix = true;
+ InputAction::Continue
}
- KeyCode::PageUp if !settings.invert => {
- let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines;
- self.scroll_up(scroll_len);
+
+ // -- Inspector --
+ Action::InspectPrevious => {
+ self.inspecting_state.move_to_previous();
+ InputAction::Redraw
}
- KeyCode::PageUp if settings.invert => {
- let scroll_len = self.results_state.max_entries() - settings.scroll_context_lines;
- self.scroll_down(scroll_len);
+ Action::InspectNext => {
+ self.inspecting_state.move_to_next();
+ InputAction::Redraw
}
- _ => {}
- }
-
- InputAction::Continue
- }
- fn scroll_down(&mut self, scroll_len: usize) {
- let i = self.results_state.selected().saturating_sub(scroll_len);
- self.inspecting_state.reset();
- self.results_state.select(i);
- }
-
- fn scroll_up(&mut self, scroll_len: usize) {
- let i = self.results_state.selected() + scroll_len;
- self.results_state
- .select(i.min(self.results_len.saturating_sub(1)));
- self.inspecting_state.reset();
+ // -- Special --
+ Action::Noop => InputAction::Continue,
+ }
}
#[allow(clippy::cast_possible_truncation)]
@@ -1394,6 +1367,7 @@ pub async fn history(
next: None,
previous: None,
},
+ keymaps: KeymapSet::from_settings(settings),
search: SearchState {
input,
filter_mode: settings
@@ -1635,7 +1609,7 @@ mod tests {
use crate::command::client::search::engines::{self, SearchState};
use crate::command::client::search::history_list::ListState;
- use super::{Compactness, InspectingState, State};
+ use super::{Compactness, InspectingState, KeymapSet, State};
#[test]
#[allow(clippy::too_many_lines)]
@@ -1794,6 +1768,7 @@ mod tests {
// Test when there's no results, scrolling up or down doesn't underflow
#[test]
fn state_scroll_up_underflow() {
+ let settings = Settings::utc();
let mut state = State {
history_count: 0,
update_needed: None,
@@ -1812,6 +1787,7 @@ mod tests {
next: None,
previous: None,
},
+ keymaps: KeymapSet::defaults(&settings),
search: SearchState {
input: String::new().into(),
filter_mode: FilterMode::Directory,
@@ -1864,6 +1840,7 @@ mod tests {
next: None,
previous: None,
},
+ keymaps: KeymapSet::defaults(&settings),
search: SearchState {
input: String::new().into(),
filter_mode: FilterMode::Global,
@@ -1896,12 +1873,14 @@ mod tests {
// Test left arrow with accept_past_line_start enabled (should accept at start of line)
settings.keys.accept_past_line_start = true;
+ state.keymaps = KeymapSet::defaults(&settings);
let result = state.handle_key_input(&settings, &left_event);
assert!(
matches!(result, super::InputAction::Accept(_)),
"Left arrow should accept at start of line when enabled"
);
settings.keys.accept_past_line_start = false;
+ state.keymaps = KeymapSet::defaults(&settings);
let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
let result = state.handle_key_input(&settings, &backspace_event);
@@ -1911,6 +1890,7 @@ mod tests {
);
settings.keys.accept_with_backspace = true;
+ state.keymaps = KeymapSet::defaults(&settings);
let result = state.handle_key_input(&settings, &backspace_event);
assert!(
matches!(result, super::InputAction::Accept(_)),
@@ -1931,6 +1911,7 @@ mod tests {
);
settings.keys.accept_past_line_start = true;
+ state.keymaps = KeymapSet::defaults(&settings);
let left_event = KeyEvent::new(KeyCode::Left, KeyModifiers::NONE);
let result = state.handle_key_input(&settings, &left_event);
assert!(
@@ -1938,8 +1919,10 @@ mod tests {
"Left arrow should continue and end of line, even when enabled"
);
settings.keys.accept_past_line_start = false;
+ state.keymaps = KeymapSet::defaults(&settings);
settings.keys.accept_with_backspace = true;
+ state.keymaps = KeymapSet::defaults(&settings);
let backspace_event = KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE);
let result = state.handle_key_input(&settings, &backspace_event);
assert!(
@@ -1947,6 +1930,7 @@ mod tests {
"Backspace should continue at end of line, even when enabled"
);
settings.keys.accept_with_backspace = false;
+ state.keymaps = KeymapSet::defaults(&settings);
}
#[test]
@@ -1973,6 +1957,7 @@ mod tests {
next: None,
previous: None,
},
+ keymaps: KeymapSet::defaults(&settings),
search: SearchState {
input: String::new().into(),
filter_mode: FilterMode::Global,
@@ -2029,6 +2014,7 @@ mod tests {
next: None,
previous: None,
},
+ keymaps: KeymapSet::defaults(&settings),
search: SearchState {
input: String::new().into(),
filter_mode: FilterMode::Global,
@@ -2081,6 +2067,7 @@ mod tests {
next: None,
previous: None,
},
+ keymaps: KeymapSet::defaults(&settings),
search: SearchState {
input: String::new().into(),
filter_mode: FilterMode::Global,
@@ -2129,6 +2116,7 @@ mod tests {
next: None,
previous: None,
},
+ keymaps: KeymapSet::defaults(&settings),
search: SearchState {
input: String::new().into(),
filter_mode: FilterMode::Global,
@@ -2186,6 +2174,7 @@ mod tests {
next: None,
previous: None,
},
+ keymaps: KeymapSet::defaults(&settings),
search: SearchState {
input: String::new().into(),
filter_mode: FilterMode::Global,
@@ -2218,4 +2207,355 @@ mod tests {
assert!(matches!(result, super::InputAction::Continue));
assert_eq!(state.pending_vim_key, None);
}
+
+ // -----------------------------------------------------------------------
+ // Executor tests (execute_action)
+ // -----------------------------------------------------------------------
+
+ /// Helper to build a State for executor tests.
+ fn make_executor_state(results_len: usize, selected: usize) -> State {
+ let settings = Settings::utc();
+ let mut state = State {
+ history_count: results_len as i64,
+ update_needed: None,
+ results_state: ListState::default(),
+ switched_search_mode: false,
+ search_mode: SearchMode::Fuzzy,
+ results_len,
+ accept: false,
+ keymap_mode: KeymapMode::Emacs,
+ prefix: false,
+ current_cursor: None,
+ tab_index: 0,
+ pending_vim_key: None,
+ inspecting_state: InspectingState {
+ current: None,
+ next: None,
+ previous: None,
+ },
+ keymaps: KeymapSet::defaults(&settings),
+ search: SearchState {
+ input: String::new().into(),
+ filter_mode: FilterMode::Global,
+ context: Context {
+ session: String::new(),
+ cwd: String::new(),
+ hostname: String::new(),
+ host_id: String::new(),
+ git_root: None,
+ },
+ },
+ engine: engines::engine(SearchMode::Fuzzy),
+ now: Box::new(OffsetDateTime::now_utc),
+ };
+ state.results_state.select(selected);
+ state
+ }
+
+ #[test]
+ fn execute_select_next_no_invert() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 50);
+ let settings = Settings::utc();
+ let result = state.execute_action(&Action::SelectNext, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ // Non-inverted: SelectNext = scroll_down = selected - 1
+ assert_eq!(state.results_state.selected(), 49);
+ }
+
+ #[test]
+ fn execute_select_next_with_invert() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 50);
+ let mut settings = Settings::utc();
+ settings.invert = true;
+ let result = state.execute_action(&Action::SelectNext, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ // Inverted: SelectNext = scroll_up = selected + 1
+ assert_eq!(state.results_state.selected(), 51);
+ }
+
+ #[test]
+ fn execute_select_previous_no_invert() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 50);
+ let settings = Settings::utc();
+ let result = state.execute_action(&Action::SelectPrevious, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ // Non-inverted: SelectPrevious = scroll_up = selected + 1
+ assert_eq!(state.results_state.selected(), 51);
+ }
+
+ #[test]
+ fn execute_vim_enter_normal() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ let settings = Settings::utc();
+ let result = state.execute_action(&Action::VimEnterNormal, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.keymap_mode, KeymapMode::VimNormal);
+ }
+
+ #[test]
+ fn execute_vim_enter_insert() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ state.keymap_mode = KeymapMode::VimNormal;
+ let settings = Settings::utc();
+ let result = state.execute_action(&Action::VimEnterInsert, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.keymap_mode, KeymapMode::VimInsert);
+ }
+
+ #[test]
+ fn execute_accept_sets_accept_flag() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 5);
+ let mut settings = Settings::utc();
+ settings.enter_accept = true;
+ let result = state.execute_action(&Action::Accept, &settings);
+ assert!(matches!(result, super::InputAction::Accept(5)));
+ assert!(state.accept);
+ }
+
+ #[test]
+ fn execute_return_selection_does_not_set_accept() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 5);
+ let settings = Settings::utc();
+ let result = state.execute_action(&Action::ReturnSelection, &settings);
+ assert!(matches!(result, super::InputAction::Accept(5)));
+ assert!(!state.accept);
+ }
+
+ #[test]
+ fn execute_accept_nth() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 5);
+ let settings = Settings::utc();
+ let result = state.execute_action(&Action::AcceptNth(3), &settings);
+ assert!(matches!(result, super::InputAction::Accept(8)));
+ }
+
+ #[test]
+ fn execute_scroll_to_top_no_invert() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 50);
+ let settings = Settings::utc();
+ let result = state.execute_action(&Action::ScrollToTop, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ // Non-inverted: visual top = highest index
+ assert_eq!(state.results_state.selected(), 99);
+ }
+
+ #[test]
+ fn execute_scroll_to_top_with_invert() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 50);
+ let mut settings = Settings::utc();
+ settings.invert = true;
+ let result = state.execute_action(&Action::ScrollToTop, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ // Inverted: visual top = index 0
+ assert_eq!(state.results_state.selected(), 0);
+ }
+
+ #[test]
+ fn execute_scroll_to_bottom_no_invert() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 50);
+ let settings = Settings::utc();
+ let result = state.execute_action(&Action::ScrollToBottom, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ // Non-inverted: visual bottom = index 0
+ assert_eq!(state.results_state.selected(), 0);
+ }
+
+ #[test]
+ fn execute_toggle_tab() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ let settings = Settings::utc();
+ assert_eq!(state.tab_index, 0);
+ state.execute_action(&Action::ToggleTab, &settings);
+ assert_eq!(state.tab_index, 1);
+ state.execute_action(&Action::ToggleTab, &settings);
+ assert_eq!(state.tab_index, 0);
+ }
+
+ #[test]
+ fn execute_enter_prefix_mode() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ let settings = Settings::utc();
+ assert!(!state.prefix);
+ state.execute_action(&Action::EnterPrefixMode, &settings);
+ assert!(state.prefix);
+ }
+
+ #[test]
+ fn execute_exit_returns_based_on_exit_mode() {
+ use crate::command::client::search::keybindings::Action;
+ use atuin_client::settings::ExitMode;
+
+ let mut state = make_executor_state(100, 0);
+ let mut settings = Settings::utc();
+
+ settings.exit_mode = ExitMode::ReturnOriginal;
+ let result = state.execute_action(&Action::Exit, &settings);
+ assert!(matches!(result, super::InputAction::ReturnOriginal));
+
+ settings.exit_mode = ExitMode::ReturnQuery;
+ let result = state.execute_action(&Action::Exit, &settings);
+ assert!(matches!(result, super::InputAction::ReturnQuery));
+ }
+
+ #[test]
+ fn execute_return_original() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ let settings = Settings::utc();
+ let result = state.execute_action(&Action::ReturnOriginal, &settings);
+ assert!(matches!(result, super::InputAction::ReturnOriginal));
+ }
+
+ #[test]
+ fn execute_copy() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 7);
+ let settings = Settings::utc();
+ let result = state.execute_action(&Action::Copy, &settings);
+ assert!(matches!(result, super::InputAction::Copy(7)));
+ }
+
+ #[test]
+ fn execute_delete() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 7);
+ let settings = Settings::utc();
+ let result = state.execute_action(&Action::Delete, &settings);
+ assert!(matches!(result, super::InputAction::Delete(7)));
+ }
+
+ #[test]
+ fn execute_noop() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 50);
+ let settings = Settings::utc();
+ let result = state.execute_action(&Action::Noop, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert_eq!(state.results_state.selected(), 50);
+ }
+
+ #[test]
+ fn execute_accept_in_inspector_tab() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 5);
+ state.tab_index = 1;
+ let settings = Settings::utc();
+ let result = state.execute_action(&Action::Accept, &settings);
+ assert!(matches!(result, super::InputAction::AcceptInspecting));
+ }
+
+ #[test]
+ fn execute_cycle_search_mode() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ let settings = Settings::utc();
+ let original_mode = state.search_mode;
+ let result = state.execute_action(&Action::CycleSearchMode, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ assert!(state.switched_search_mode);
+ assert_ne!(state.search_mode, original_mode);
+ }
+
+ #[test]
+ fn execute_vim_search_insert() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ state.search.input.insert('h');
+ state.search.input.insert('i');
+ state.keymap_mode = KeymapMode::VimNormal;
+ let settings = Settings::utc();
+ let result = state.execute_action(&Action::VimSearchInsert, &settings);
+ assert!(matches!(result, super::InputAction::Continue));
+ // Should clear input and switch to insert mode
+ assert_eq!(state.search.input.as_str(), "");
+ assert_eq!(state.keymap_mode, KeymapMode::VimInsert);
+ }
+
+ #[test]
+ fn execute_cursor_movement() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ let settings = Settings::utc();
+
+ // Insert some text
+ state.search.input.insert('h');
+ state.search.input.insert('e');
+ state.search.input.insert('l');
+ state.search.input.insert('l');
+ state.search.input.insert('o');
+ // cursor is at end (position 5)
+
+ // CursorLeft
+ state.execute_action(&Action::CursorLeft, &settings);
+ assert_eq!(state.search.input.position(), 4);
+
+ // CursorStart
+ state.execute_action(&Action::CursorStart, &settings);
+ assert_eq!(state.search.input.position(), 0);
+
+ // CursorEnd
+ state.execute_action(&Action::CursorEnd, &settings);
+ assert_eq!(state.search.input.position(), 5);
+
+ // CursorRight at end does nothing
+ state.execute_action(&Action::CursorRight, &settings);
+ assert_eq!(state.search.input.position(), 5);
+ }
+
+ #[test]
+ fn execute_editing() {
+ use crate::command::client::search::keybindings::Action;
+
+ let mut state = make_executor_state(100, 0);
+ let settings = Settings::utc();
+
+ // Insert "hello"
+ state.search.input.insert('h');
+ state.search.input.insert('e');
+ state.search.input.insert('l');
+ state.search.input.insert('l');
+ state.search.input.insert('o');
+
+ // DeleteCharBefore (backspace)
+ state.execute_action(&Action::DeleteCharBefore, &settings);
+ assert_eq!(state.search.input.as_str(), "hell");
+
+ // ClearLine
+ state.execute_action(&Action::ClearLine, &settings);
+ assert_eq!(state.search.input.as_str(), "");
+ }
}
diff --git a/crates/atuin/src/command/client/search/keybindings/actions.rs b/crates/atuin/src/command/client/search/keybindings/actions.rs
new file mode 100644
index 00000000..da8a1d42
--- /dev/null
+++ b/crates/atuin/src/command/client/search/keybindings/actions.rs
@@ -0,0 +1,301 @@
+use std::fmt;
+
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+/// All possible actions that can be triggered by a keybinding.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Action {
+ // Cursor movement
+ CursorLeft,
+ CursorRight,
+ CursorWordLeft,
+ CursorWordRight,
+ CursorStart,
+ CursorEnd,
+
+ // Editing
+ DeleteCharBefore,
+ DeleteCharAfter,
+ DeleteWordBefore,
+ DeleteWordAfter,
+ DeleteToWordBoundary,
+ ClearLine,
+
+ // List navigation
+ SelectNext,
+ SelectPrevious,
+ ScrollHalfPageUp,
+ ScrollHalfPageDown,
+ ScrollPageUp,
+ ScrollPageDown,
+ ScrollToTop,
+ ScrollToBottom,
+ ScrollToScreenTop,
+ ScrollToScreenMiddle,
+ ScrollToScreenBottom,
+
+ // Commands — accept selection and execute immediately
+ Accept,
+ AcceptNth(u8),
+ // Commands — return selection to command line without executing
+ ReturnSelection,
+ ReturnSelectionNth(u8),
+ // Commands — other
+ Copy,
+ Delete,
+ ReturnOriginal,
+ ReturnQuery,
+ Exit,
+ Redraw,
+ CycleFilterMode,
+ CycleSearchMode,
+ ToggleTab,
+
+ // Mode changes
+ VimEnterNormal,
+ VimEnterInsert,
+ VimEnterInsertAfter,
+ VimEnterInsertAtStart,
+ VimEnterInsertAtEnd,
+ VimSearchInsert,
+ EnterPrefixMode,
+
+ // Inspector
+ InspectPrevious,
+ InspectNext,
+
+ // Special
+ Noop,
+}
+
+impl Action {
+ /// Convert from a kebab-case string.
+ pub fn from_str(s: &str) -> Result<Self, String> {
+ // Handle accept-N and return-selection-N patterns
+ if let Some(rest) = s.strip_prefix("accept-")
+ && let Ok(n) = rest.parse::<u8>()
+ && (1..=9).contains(&n)
+ {
+ return Ok(Action::AcceptNth(n));
+ }
+ if let Some(rest) = s.strip_prefix("return-selection-")
+ && let Ok(n) = rest.parse::<u8>()
+ && (1..=9).contains(&n)
+ {
+ return Ok(Action::ReturnSelectionNth(n));
+ }
+
+ match s {
+ "cursor-left" => Ok(Action::CursorLeft),
+ "cursor-right" => Ok(Action::CursorRight),
+ "cursor-word-left" => Ok(Action::CursorWordLeft),
+ "cursor-word-right" => Ok(Action::CursorWordRight),
+ "cursor-start" => Ok(Action::CursorStart),
+ "cursor-end" => Ok(Action::CursorEnd),
+
+ "delete-char-before" => Ok(Action::DeleteCharBefore),
+ "delete-char-after" => Ok(Action::DeleteCharAfter),
+ "delete-word-before" => Ok(Action::DeleteWordBefore),
+ "delete-word-after" => Ok(Action::DeleteWordAfter),
+ "delete-to-word-boundary" => Ok(Action::DeleteToWordBoundary),
+ "clear-line" => Ok(Action::ClearLine),
+
+ "select-next" => Ok(Action::SelectNext),
+ "select-previous" => Ok(Action::SelectPrevious),
+ "scroll-half-page-up" => Ok(Action::ScrollHalfPageUp),
+ "scroll-half-page-down" => Ok(Action::ScrollHalfPageDown),
+ "scroll-page-up" => Ok(Action::ScrollPageUp),
+ "scroll-page-down" => Ok(Action::ScrollPageDown),
+ "scroll-to-top" => Ok(Action::ScrollToTop),
+ "scroll-to-bottom" => Ok(Action::ScrollToBottom),
+ "scroll-to-screen-top" => Ok(Action::ScrollToScreenTop),
+ "scroll-to-screen-middle" => Ok(Action::ScrollToScreenMiddle),
+ "scroll-to-screen-bottom" => Ok(Action::ScrollToScreenBottom),
+
+ "accept" => Ok(Action::Accept),
+ "return-selection" => Ok(Action::ReturnSelection),
+ "copy" => Ok(Action::Copy),
+ "delete" => Ok(Action::Delete),
+ "return-original" => Ok(Action::ReturnOriginal),
+ "return-query" => Ok(Action::ReturnQuery),
+ "exit" => Ok(Action::Exit),
+ "redraw" => Ok(Action::Redraw),
+ "cycle-filter-mode" => Ok(Action::CycleFilterMode),
+ "cycle-search-mode" => Ok(Action::CycleSearchMode),
+ "toggle-tab" => Ok(Action::ToggleTab),
+
+ "vim-enter-normal" => Ok(Action::VimEnterNormal),
+ "vim-enter-insert" => Ok(Action::VimEnterInsert),
+ "vim-enter-insert-after" => Ok(Action::VimEnterInsertAfter),
+ "vim-enter-insert-at-start" => Ok(Action::VimEnterInsertAtStart),
+ "vim-enter-insert-at-end" => Ok(Action::VimEnterInsertAtEnd),
+ "vim-search-insert" => Ok(Action::VimSearchInsert),
+ "enter-prefix-mode" => Ok(Action::EnterPrefixMode),
+
+ "inspect-previous" => Ok(Action::InspectPrevious),
+ "inspect-next" => Ok(Action::InspectNext),
+
+ "noop" => Ok(Action::Noop),
+
+ _ => Err(format!("unknown action: {s}")),
+ }
+ }
+
+ /// Convert to a kebab-case string.
+ pub fn as_str(&self) -> String {
+ match self {
+ Action::CursorLeft => "cursor-left".to_string(),
+ Action::CursorRight => "cursor-right".to_string(),
+ Action::CursorWordLeft => "cursor-word-left".to_string(),
+ Action::CursorWordRight => "cursor-word-right".to_string(),
+ Action::CursorStart => "cursor-start".to_string(),
+ Action::CursorEnd => "cursor-end".to_string(),
+
+ Action::DeleteCharBefore => "delete-char-before".to_string(),
+ Action::DeleteCharAfter => "delete-char-after".to_string(),
+ Action::DeleteWordBefore => "delete-word-before".to_string(),
+ Action::DeleteWordAfter => "delete-word-after".to_string(),
+ Action::DeleteToWordBoundary => "delete-to-word-boundary".to_string(),
+ Action::ClearLine => "clear-line".to_string(),
+
+ Action::SelectNext => "select-next".to_string(),
+ Action::SelectPrevious => "select-previous".to_string(),
+ Action::ScrollHalfPageUp => "scroll-half-page-up".to_string(),
+ Action::ScrollHalfPageDown => "scroll-half-page-down".to_string(),
+ Action::ScrollPageUp => "scroll-page-up".to_string(),
+ Action::ScrollPageDown => "scroll-page-down".to_string(),
+ Action::ScrollToTop => "scroll-to-top".to_string(),
+ Action::ScrollToBottom => "scroll-to-bottom".to_string(),
+ Action::ScrollToScreenTop => "scroll-to-screen-top".to_string(),
+ Action::ScrollToScreenMiddle => "scroll-to-screen-middle".to_string(),
+ Action::ScrollToScreenBottom => "scroll-to-screen-bottom".to_string(),
+
+ Action::Accept => "accept".to_string(),
+ Action::AcceptNth(n) => format!("accept-{n}"),
+ Action::ReturnSelection => "return-selection".to_string(),
+ Action::ReturnSelectionNth(n) => format!("return-selection-{n}"),
+ Action::Copy => "copy".to_string(),
+ Action::Delete => "delete".to_string(),
+ Action::ReturnOriginal => "return-original".to_string(),
+ Action::ReturnQuery => "return-query".to_string(),
+ Action::Exit => "exit".to_string(),
+ Action::Redraw => "redraw".to_string(),
+ Action::CycleFilterMode => "cycle-filter-mode".to_string(),
+ Action::CycleSearchMode => "cycle-search-mode".to_string(),
+ Action::ToggleTab => "toggle-tab".to_string(),
+
+ Action::VimEnterNormal => "vim-enter-normal".to_string(),
+ Action::VimEnterInsert => "vim-enter-insert".to_string(),
+ Action::VimEnterInsertAfter => "vim-enter-insert-after".to_string(),
+ Action::VimEnterInsertAtStart => "vim-enter-insert-at-start".to_string(),
+ Action::VimEnterInsertAtEnd => "vim-enter-insert-at-end".to_string(),
+ Action::VimSearchInsert => "vim-search-insert".to_string(),
+ Action::EnterPrefixMode => "enter-prefix-mode".to_string(),
+
+ Action::InspectPrevious => "inspect-previous".to_string(),
+ Action::InspectNext => "inspect-next".to_string(),
+
+ Action::Noop => "noop".to_string(),
+ }
+ }
+}
+
+impl fmt::Display for Action {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.as_str())
+ }
+}
+
+impl Serialize for Action {
+ fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+ serializer.serialize_str(&self.as_str())
+ }
+}
+
+impl<'de> Deserialize<'de> for Action {
+ fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+ let s = String::deserialize(deserializer)?;
+ Action::from_str(&s).map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_basic_actions() {
+ assert_eq!(Action::from_str("cursor-left").unwrap(), Action::CursorLeft);
+ assert_eq!(Action::from_str("accept").unwrap(), Action::Accept);
+ assert_eq!(Action::from_str("exit").unwrap(), Action::Exit);
+ assert_eq!(Action::from_str("noop").unwrap(), Action::Noop);
+ assert_eq!(
+ Action::from_str("vim-enter-normal").unwrap(),
+ Action::VimEnterNormal
+ );
+ }
+
+ #[test]
+ fn parse_accept_nth() {
+ assert_eq!(Action::from_str("accept-1").unwrap(), Action::AcceptNth(1));
+ assert_eq!(Action::from_str("accept-9").unwrap(), Action::AcceptNth(9));
+ }
+
+ #[test]
+ fn parse_return_selection() {
+ assert_eq!(
+ Action::from_str("return-selection").unwrap(),
+ Action::ReturnSelection
+ );
+ assert_eq!(
+ Action::from_str("return-selection-1").unwrap(),
+ Action::ReturnSelectionNth(1)
+ );
+ assert_eq!(
+ Action::from_str("return-selection-9").unwrap(),
+ Action::ReturnSelectionNth(9)
+ );
+ }
+
+ #[test]
+ fn parse_unknown_action() {
+ assert!(Action::from_str("unknown-action").is_err());
+ assert!(Action::from_str("accept-0").is_err());
+ assert!(Action::from_str("accept-10").is_err());
+ assert!(Action::from_str("return-selection-0").is_err());
+ assert!(Action::from_str("return-selection-10").is_err());
+ }
+
+ #[test]
+ fn round_trip() {
+ let actions = vec![
+ Action::CursorLeft,
+ Action::Accept,
+ Action::AcceptNth(5),
+ Action::ReturnSelection,
+ Action::ReturnSelectionNth(3),
+ Action::VimSearchInsert,
+ Action::ScrollToScreenMiddle,
+ ];
+ for action in actions {
+ let s = action.as_str();
+ let parsed = Action::from_str(&s).unwrap();
+ assert_eq!(action, parsed);
+ }
+ }
+
+ #[test]
+ fn serde_round_trip() {
+ let action = Action::CursorLeft;
+ let json = serde_json::to_string(&action).unwrap();
+ assert_eq!(json, "\"cursor-left\"");
+ let parsed: Action = serde_json::from_str(&json).unwrap();
+ assert_eq!(parsed, Action::CursorLeft);
+
+ let action = Action::AcceptNth(3);
+ let json = serde_json::to_string(&action).unwrap();
+ assert_eq!(json, "\"accept-3\"");
+ let parsed: Action = serde_json::from_str(&json).unwrap();
+ assert_eq!(parsed, Action::AcceptNth(3));
+ }
+}
diff --git a/crates/atuin/src/command/client/search/keybindings/conditions.rs b/crates/atuin/src/command/client/search/keybindings/conditions.rs
new file mode 100644
index 00000000..ed414300
--- /dev/null
+++ b/crates/atuin/src/command/client/search/keybindings/conditions.rs
@@ -0,0 +1,751 @@
+use std::fmt;
+
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+/// Atomic (leaf) conditions that can be evaluated against state.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ConditionAtom {
+ CursorAtStart,
+ CursorAtEnd,
+ InputEmpty,
+ ListAtEnd,
+ ListAtStart,
+ NoResults,
+ HasResults,
+}
+
+/// Boolean expression tree over condition atoms.
+///
+/// Supports negation, conjunction, and disjunction with standard precedence:
+/// `!` binds tightest, then `&&`, then `||`.
+///
+/// Examples of valid expression strings:
+/// - `"cursor-at-start"` (bare atom)
+/// - `"!no-results"` (negation)
+/// - `"cursor-at-start && input-empty"` (conjunction)
+/// - `"list-at-start || no-results"` (disjunction)
+/// - `"(cursor-at-start && !input-empty) || no-results"` (grouping)
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ConditionExpr {
+ Atom(ConditionAtom),
+ Not(Box<ConditionExpr>),
+ And(Box<ConditionExpr>, Box<ConditionExpr>),
+ Or(Box<ConditionExpr>, Box<ConditionExpr>),
+}
+
+/// Context needed to evaluate conditions. This is a pure snapshot of state —
+/// no references to mutable data.
+pub struct EvalContext {
+ /// Current cursor position (unicode width units).
+ pub cursor_position: usize,
+ /// Width of the input string in unicode width units.
+ pub input_width: usize,
+ /// Byte length of the input string.
+ pub input_byte_len: usize,
+ /// Currently selected index in the results list.
+ pub selected_index: usize,
+ /// Total number of results.
+ pub results_len: usize,
+}
+
+// ---------------------------------------------------------------------------
+// ConditionAtom
+// ---------------------------------------------------------------------------
+
+impl ConditionAtom {
+ /// Evaluate this atom against the given context.
+ pub fn evaluate(&self, ctx: &EvalContext) -> bool {
+ match self {
+ ConditionAtom::CursorAtStart => ctx.cursor_position == 0,
+ ConditionAtom::CursorAtEnd => ctx.cursor_position == ctx.input_width,
+ ConditionAtom::InputEmpty => ctx.input_byte_len == 0,
+ ConditionAtom::ListAtEnd => {
+ ctx.results_len == 0 || ctx.selected_index >= ctx.results_len.saturating_sub(1)
+ }
+ ConditionAtom::ListAtStart => ctx.results_len == 0 || ctx.selected_index == 0,
+ ConditionAtom::NoResults => ctx.results_len == 0,
+ ConditionAtom::HasResults => ctx.results_len > 0,
+ }
+ }
+
+ /// Parse from a kebab-case string.
+ pub fn from_str(s: &str) -> Result<Self, String> {
+ match s {
+ "cursor-at-start" => Ok(ConditionAtom::CursorAtStart),
+ "cursor-at-end" => Ok(ConditionAtom::CursorAtEnd),
+ "input-empty" => Ok(ConditionAtom::InputEmpty),
+ "list-at-end" => Ok(ConditionAtom::ListAtEnd),
+ "list-at-start" => Ok(ConditionAtom::ListAtStart),
+ "no-results" => Ok(ConditionAtom::NoResults),
+ "has-results" => Ok(ConditionAtom::HasResults),
+ _ => Err(format!("unknown condition: {s}")),
+ }
+ }
+
+ /// Convert to a kebab-case string.
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ ConditionAtom::CursorAtStart => "cursor-at-start",
+ ConditionAtom::CursorAtEnd => "cursor-at-end",
+ ConditionAtom::InputEmpty => "input-empty",
+ ConditionAtom::ListAtEnd => "list-at-end",
+ ConditionAtom::ListAtStart => "list-at-start",
+ ConditionAtom::NoResults => "no-results",
+ ConditionAtom::HasResults => "has-results",
+ }
+ }
+}
+
+impl fmt::Display for ConditionAtom {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.as_str())
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ConditionExpr — evaluation
+// ---------------------------------------------------------------------------
+
+impl ConditionExpr {
+ /// Evaluate this expression against the given context.
+ pub fn evaluate(&self, ctx: &EvalContext) -> bool {
+ match self {
+ ConditionExpr::Atom(atom) => atom.evaluate(ctx),
+ ConditionExpr::Not(inner) => !inner.evaluate(ctx),
+ ConditionExpr::And(lhs, rhs) => lhs.evaluate(ctx) && rhs.evaluate(ctx),
+ ConditionExpr::Or(lhs, rhs) => lhs.evaluate(ctx) || rhs.evaluate(ctx),
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ConditionExpr — ergonomic builders
+// ---------------------------------------------------------------------------
+
+impl From<ConditionAtom> for ConditionExpr {
+ fn from(atom: ConditionAtom) -> Self {
+ ConditionExpr::Atom(atom)
+ }
+}
+
+#[allow(dead_code)]
+impl ConditionExpr {
+ /// Negate this expression: `!self`.
+ pub fn not(self) -> Self {
+ ConditionExpr::Not(Box::new(self))
+ }
+
+ /// Conjoin with another expression: `self && other`.
+ pub fn and(self, other: ConditionExpr) -> Self {
+ ConditionExpr::And(Box::new(self), Box::new(other))
+ }
+
+ /// Disjoin with another expression: `self || other`.
+ pub fn or(self, other: ConditionExpr) -> Self {
+ ConditionExpr::Or(Box::new(self), Box::new(other))
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ConditionExpr — parser
+// ---------------------------------------------------------------------------
+
+/// Recursive descent parser for boolean condition expressions.
+///
+/// Grammar (standard boolean precedence):
+/// ```text
+/// expr = or_expr
+/// or_expr = and_expr ("||" and_expr)*
+/// and_expr = unary ("&&" unary)*
+/// unary = "!" unary | primary
+/// primary = atom | "(" expr ")"
+/// atom = [a-z][a-z0-9-]*
+/// ```
+struct ExprParser<'a> {
+ input: &'a str,
+ pos: usize,
+}
+
+impl<'a> ExprParser<'a> {
+ fn new(input: &'a str) -> Self {
+ Self { input, pos: 0 }
+ }
+
+ fn skip_whitespace(&mut self) {
+ while self.pos < self.input.len() && self.input.as_bytes()[self.pos].is_ascii_whitespace() {
+ self.pos += 1;
+ }
+ }
+
+ fn starts_with(&mut self, s: &str) -> bool {
+ self.skip_whitespace();
+ self.input[self.pos..].starts_with(s)
+ }
+
+ fn consume(&mut self, s: &str) -> bool {
+ self.skip_whitespace();
+ if self.input[self.pos..].starts_with(s) {
+ self.pos += s.len();
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Parse a full expression, expecting to consume all input.
+ fn parse(mut self) -> Result<ConditionExpr, String> {
+ let expr = self.parse_or()?;
+ self.skip_whitespace();
+ if self.pos < self.input.len() {
+ return Err(format!(
+ "unexpected input at position {}: {:?}",
+ self.pos,
+ &self.input[self.pos..]
+ ));
+ }
+ Ok(expr)
+ }
+
+ /// `or_expr` = `and_expr` ("||" `and_expr`)*
+ fn parse_or(&mut self) -> Result<ConditionExpr, String> {
+ let mut left = self.parse_and()?;
+ while self.starts_with("||") {
+ self.consume("||");
+ let right = self.parse_and()?;
+ left = ConditionExpr::Or(Box::new(left), Box::new(right));
+ }
+ Ok(left)
+ }
+
+ /// `and_expr` = unary ("&&" unary)*
+ fn parse_and(&mut self) -> Result<ConditionExpr, String> {
+ let mut left = self.parse_unary()?;
+ while self.starts_with("&&") {
+ self.consume("&&");
+ let right = self.parse_unary()?;
+ left = ConditionExpr::And(Box::new(left), Box::new(right));
+ }
+ Ok(left)
+ }
+
+ /// unary = "!" unary | primary
+ fn parse_unary(&mut self) -> Result<ConditionExpr, String> {
+ if self.consume("!") {
+ let inner = self.parse_unary()?;
+ Ok(ConditionExpr::Not(Box::new(inner)))
+ } else {
+ self.parse_primary()
+ }
+ }
+
+ /// primary = "(" expr ")" | atom
+ fn parse_primary(&mut self) -> Result<ConditionExpr, String> {
+ if self.consume("(") {
+ let expr = self.parse_or()?;
+ if !self.consume(")") {
+ return Err(format!("expected ')' at position {}", self.pos));
+ }
+ Ok(expr)
+ } else {
+ self.parse_atom()
+ }
+ }
+
+ /// atom = [a-z][a-z0-9-]*
+ fn parse_atom(&mut self) -> Result<ConditionExpr, String> {
+ self.skip_whitespace();
+ let start = self.pos;
+ while self.pos < self.input.len() {
+ let b = self.input.as_bytes()[self.pos];
+ if b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' {
+ self.pos += 1;
+ } else {
+ break;
+ }
+ }
+ if self.pos == start {
+ return Err(format!("expected condition name at position {}", self.pos));
+ }
+ let name = &self.input[start..self.pos];
+ let atom = ConditionAtom::from_str(name)?;
+ Ok(ConditionExpr::Atom(atom))
+ }
+}
+
+impl ConditionExpr {
+ /// Parse a condition expression from a string.
+ pub fn parse(s: &str) -> Result<Self, String> {
+ let parser = ExprParser::new(s);
+ parser.parse()
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ConditionExpr — Display
+// ---------------------------------------------------------------------------
+
+/// Precedence levels for minimal-parentheses display.
+#[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy)]
+enum Prec {
+ Or = 0,
+ And = 1,
+ Not = 2,
+ Atom = 3,
+}
+
+impl ConditionExpr {
+ fn prec(&self) -> Prec {
+ match self {
+ ConditionExpr::Or(..) => Prec::Or,
+ ConditionExpr::And(..) => Prec::And,
+ ConditionExpr::Not(..) => Prec::Not,
+ ConditionExpr::Atom(..) => Prec::Atom,
+ }
+ }
+
+ fn fmt_with_prec(&self, f: &mut fmt::Formatter<'_>, parent_prec: Prec) -> fmt::Result {
+ let needs_parens = self.prec() < parent_prec;
+ if needs_parens {
+ write!(f, "(")?;
+ }
+ match self {
+ ConditionExpr::Atom(atom) => write!(f, "{atom}")?,
+ ConditionExpr::Not(inner) => {
+ write!(f, "!")?;
+ inner.fmt_with_prec(f, Prec::Not)?;
+ }
+ ConditionExpr::And(lhs, rhs) => {
+ lhs.fmt_with_prec(f, Prec::And)?;
+ write!(f, " && ")?;
+ rhs.fmt_with_prec(f, Prec::And)?;
+ }
+ ConditionExpr::Or(lhs, rhs) => {
+ lhs.fmt_with_prec(f, Prec::Or)?;
+ write!(f, " || ")?;
+ rhs.fmt_with_prec(f, Prec::Or)?;
+ }
+ }
+ if needs_parens {
+ write!(f, ")")?;
+ }
+ Ok(())
+ }
+}
+
+impl fmt::Display for ConditionExpr {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.fmt_with_prec(f, Prec::Or)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Serde
+// ---------------------------------------------------------------------------
+
+impl Serialize for ConditionExpr {
+ fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+ serializer.serialize_str(&self.to_string())
+ }
+}
+
+impl<'de> Deserialize<'de> for ConditionExpr {
+ fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+ let s = String::deserialize(deserializer)?;
+ ConditionExpr::parse(&s).map_err(serde::de::Error::custom)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn ctx(
+ cursor: usize,
+ width: usize,
+ byte_len: usize,
+ selected: usize,
+ len: usize,
+ ) -> EvalContext {
+ EvalContext {
+ cursor_position: cursor,
+ input_width: width,
+ input_byte_len: byte_len,
+ selected_index: selected,
+ results_len: len,
+ }
+ }
+
+ // -- Atom evaluation (carried over from Phase 0) --
+
+ #[test]
+ fn atom_cursor_at_start() {
+ assert!(ConditionAtom::CursorAtStart.evaluate(&ctx(0, 5, 5, 0, 10)));
+ assert!(!ConditionAtom::CursorAtStart.evaluate(&ctx(3, 5, 5, 0, 10)));
+ }
+
+ #[test]
+ fn atom_cursor_at_end() {
+ assert!(ConditionAtom::CursorAtEnd.evaluate(&ctx(5, 5, 5, 0, 10)));
+ assert!(!ConditionAtom::CursorAtEnd.evaluate(&ctx(3, 5, 5, 0, 10)));
+ assert!(ConditionAtom::CursorAtEnd.evaluate(&ctx(0, 0, 0, 0, 10)));
+ }
+
+ #[test]
+ fn atom_input_empty() {
+ assert!(ConditionAtom::InputEmpty.evaluate(&ctx(0, 0, 0, 0, 10)));
+ assert!(!ConditionAtom::InputEmpty.evaluate(&ctx(0, 5, 5, 0, 10)));
+ }
+
+ #[test]
+ fn atom_list_at_end() {
+ assert!(ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 99, 100)));
+ assert!(!ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 50, 100)));
+ assert!(ConditionAtom::ListAtEnd.evaluate(&ctx(0, 0, 0, 0, 0)));
+ }
+
+ #[test]
+ fn atom_list_at_start() {
+ assert!(ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 0, 100)));
+ assert!(!ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 50, 100)));
+ assert!(ConditionAtom::ListAtStart.evaluate(&ctx(0, 0, 0, 0, 0)));
+ }
+
+ #[test]
+ fn atom_no_results_and_has_results() {
+ assert!(ConditionAtom::NoResults.evaluate(&ctx(0, 0, 0, 0, 0)));
+ assert!(!ConditionAtom::NoResults.evaluate(&ctx(0, 0, 0, 0, 5)));
+ assert!(ConditionAtom::HasResults.evaluate(&ctx(0, 0, 0, 0, 5)));
+ assert!(!ConditionAtom::HasResults.evaluate(&ctx(0, 0, 0, 0, 0)));
+ }
+
+ #[test]
+ fn atom_parse_round_trip() {
+ let conditions = [
+ "cursor-at-start",
+ "cursor-at-end",
+ "input-empty",
+ "list-at-end",
+ "list-at-start",
+ "no-results",
+ "has-results",
+ ];
+ for s in conditions {
+ let c = ConditionAtom::from_str(s).unwrap();
+ assert_eq!(c.as_str(), s);
+ }
+ }
+
+ #[test]
+ fn atom_parse_unknown() {
+ assert!(ConditionAtom::from_str("unknown-condition").is_err());
+ }
+
+ // -- Parser tests --
+
+ #[test]
+ fn parse_bare_atom() {
+ let expr = ConditionExpr::parse("cursor-at-start").unwrap();
+ assert_eq!(expr, ConditionExpr::Atom(ConditionAtom::CursorAtStart));
+ }
+
+ #[test]
+ fn parse_negation() {
+ let expr = ConditionExpr::parse("!no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::Not(Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)))
+ );
+ }
+
+ #[test]
+ fn parse_double_negation() {
+ let expr = ConditionExpr::parse("!!no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::Not(Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(
+ ConditionAtom::NoResults
+ )))))
+ );
+ }
+
+ #[test]
+ fn parse_and() {
+ let expr = ConditionExpr::parse("cursor-at-start && input-empty").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::And(
+ Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
+ Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),
+ )
+ );
+ }
+
+ #[test]
+ fn parse_or() {
+ let expr = ConditionExpr::parse("list-at-start || no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::Or(
+ Box::new(ConditionExpr::Atom(ConditionAtom::ListAtStart)),
+ Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
+ )
+ );
+ }
+
+ #[test]
+ fn parse_precedence_and_binds_tighter_than_or() {
+ // "a || b && c" should parse as "a || (b && c)"
+ let expr = ConditionExpr::parse("cursor-at-start || input-empty && no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::Or(
+ Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
+ Box::new(ConditionExpr::And(
+ Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),
+ Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
+ )),
+ )
+ );
+ }
+
+ #[test]
+ fn parse_parens_override_precedence() {
+ // "(a || b) && c"
+ let expr = ConditionExpr::parse("(cursor-at-start || input-empty) && no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::And(
+ Box::new(ConditionExpr::Or(
+ Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
+ Box::new(ConditionExpr::Atom(ConditionAtom::InputEmpty)),
+ )),
+ Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
+ )
+ );
+ }
+
+ #[test]
+ fn parse_complex_nested() {
+ // "(a && !b) || c"
+ let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap();
+ assert_eq!(
+ expr,
+ ConditionExpr::Or(
+ Box::new(ConditionExpr::And(
+ Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
+ Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(
+ ConditionAtom::InputEmpty
+ )))),
+ )),
+ Box::new(ConditionExpr::Atom(ConditionAtom::NoResults)),
+ )
+ );
+ }
+
+ #[test]
+ fn parse_whitespace_tolerance() {
+ let a = ConditionExpr::parse("cursor-at-start||input-empty").unwrap();
+ let b = ConditionExpr::parse("cursor-at-start || input-empty").unwrap();
+ let c = ConditionExpr::parse(" cursor-at-start || input-empty ").unwrap();
+ assert_eq!(a, b);
+ assert_eq!(b, c);
+ }
+
+ #[test]
+ fn parse_error_unknown_atom() {
+ assert!(ConditionExpr::parse("unknown-thing").is_err());
+ }
+
+ #[test]
+ fn parse_error_trailing_input() {
+ assert!(ConditionExpr::parse("cursor-at-start blah").is_err());
+ }
+
+ #[test]
+ fn parse_error_unmatched_paren() {
+ assert!(ConditionExpr::parse("(cursor-at-start").is_err());
+ }
+
+ #[test]
+ fn parse_error_empty() {
+ assert!(ConditionExpr::parse("").is_err());
+ }
+
+ // -- Expression evaluation --
+
+ #[test]
+ fn eval_not() {
+ let expr = ConditionExpr::parse("!no-results").unwrap();
+ // Has results → !no-results is true
+ assert!(expr.evaluate(&ctx(0, 0, 0, 0, 5)));
+ // No results → !no-results is false
+ assert!(!expr.evaluate(&ctx(0, 0, 0, 0, 0)));
+ }
+
+ #[test]
+ fn eval_and() {
+ let expr = ConditionExpr::parse("cursor-at-start && input-empty").unwrap();
+ // Both true
+ assert!(expr.evaluate(&ctx(0, 0, 0, 0, 10)));
+ // First true, second false (non-empty input)
+ assert!(!expr.evaluate(&ctx(0, 5, 5, 0, 10)));
+ // First false (cursor not at start)
+ assert!(!expr.evaluate(&ctx(3, 5, 5, 0, 10)));
+ }
+
+ #[test]
+ fn eval_or() {
+ let expr = ConditionExpr::parse("list-at-start || no-results").unwrap();
+ // list at bottom (selected=0)
+ assert!(expr.evaluate(&ctx(0, 0, 0, 0, 10)));
+ // no results
+ assert!(expr.evaluate(&ctx(0, 0, 0, 0, 0)));
+ // neither
+ assert!(!expr.evaluate(&ctx(0, 0, 0, 5, 10)));
+ }
+
+ #[test]
+ fn eval_complex_nested() {
+ // (cursor-at-start && !input-empty) || no-results
+ let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap();
+
+ // cursor at start, input not empty → true (left branch)
+ assert!(expr.evaluate(&ctx(0, 5, 5, 0, 10)));
+ // no results → true (right branch)
+ assert!(expr.evaluate(&ctx(3, 5, 5, 0, 0)));
+ // cursor not at start, has results → false
+ assert!(!expr.evaluate(&ctx(3, 5, 5, 0, 10)));
+ // cursor at start, input empty → false (left: && fails; right: has results)
+ assert!(!expr.evaluate(&ctx(0, 0, 0, 0, 10)));
+ }
+
+ // -- Display --
+
+ #[test]
+ fn display_atom() {
+ let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart);
+ assert_eq!(expr.to_string(), "cursor-at-start");
+ }
+
+ #[test]
+ fn display_not() {
+ let expr = ConditionExpr::Atom(ConditionAtom::NoResults).not();
+ assert_eq!(expr.to_string(), "!no-results");
+ }
+
+ #[test]
+ fn display_and() {
+ let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart)
+ .and(ConditionExpr::Atom(ConditionAtom::InputEmpty));
+ assert_eq!(expr.to_string(), "cursor-at-start && input-empty");
+ }
+
+ #[test]
+ fn display_or() {
+ let expr = ConditionExpr::Atom(ConditionAtom::ListAtStart)
+ .or(ConditionExpr::Atom(ConditionAtom::NoResults));
+ assert_eq!(expr.to_string(), "list-at-start || no-results");
+ }
+
+ #[test]
+ fn display_parens_when_needed() {
+ // (a || b) && c — the Or inside And needs parens
+ let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart)
+ .or(ConditionExpr::Atom(ConditionAtom::InputEmpty))
+ .and(ConditionExpr::Atom(ConditionAtom::NoResults));
+ assert_eq!(
+ expr.to_string(),
+ "(cursor-at-start || input-empty) && no-results"
+ );
+ }
+
+ #[test]
+ fn display_no_parens_when_not_needed() {
+ // a || b && c — no parens needed (and binds tighter)
+ let inner_and = ConditionExpr::Atom(ConditionAtom::InputEmpty)
+ .and(ConditionExpr::Atom(ConditionAtom::NoResults));
+ let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart).or(inner_and);
+ assert_eq!(
+ expr.to_string(),
+ "cursor-at-start || input-empty && no-results"
+ );
+ }
+
+ // -- Display round-trip --
+
+ #[test]
+ fn display_round_trip() {
+ let cases = [
+ "cursor-at-start",
+ "!no-results",
+ "cursor-at-start && input-empty",
+ "list-at-start || no-results",
+ "(cursor-at-start || input-empty) && no-results",
+ "(cursor-at-start && !input-empty) || no-results",
+ ];
+ for s in cases {
+ let expr = ConditionExpr::parse(s).unwrap();
+ let displayed = expr.to_string();
+ let reparsed = ConditionExpr::parse(&displayed).unwrap();
+ assert_eq!(expr, reparsed, "round-trip failed for: {s}");
+ }
+ }
+
+ // -- Serde --
+
+ #[test]
+ fn serde_simple_atom() {
+ let expr = ConditionExpr::Atom(ConditionAtom::CursorAtStart);
+ let json = serde_json::to_string(&expr).unwrap();
+ assert_eq!(json, "\"cursor-at-start\"");
+ let parsed: ConditionExpr = serde_json::from_str(&json).unwrap();
+ assert_eq!(parsed, expr);
+ }
+
+ #[test]
+ fn serde_compound_expression() {
+ let json = "\"cursor-at-start && !input-empty\"";
+ let parsed: ConditionExpr = serde_json::from_str(json).unwrap();
+ let expected = ConditionExpr::And(
+ Box::new(ConditionExpr::Atom(ConditionAtom::CursorAtStart)),
+ Box::new(ConditionExpr::Not(Box::new(ConditionExpr::Atom(
+ ConditionAtom::InputEmpty,
+ )))),
+ );
+ assert_eq!(parsed, expected);
+ }
+
+ #[test]
+ fn serde_round_trip() {
+ let expr = ConditionExpr::parse("(cursor-at-start && !input-empty) || no-results").unwrap();
+ let json = serde_json::to_string(&expr).unwrap();
+ let parsed: ConditionExpr = serde_json::from_str(&json).unwrap();
+ assert_eq!(expr, parsed);
+ }
+
+ // -- From<ConditionAtom> --
+
+ #[test]
+ fn from_atom_into_expr() {
+ let expr: ConditionExpr = ConditionAtom::CursorAtStart.into();
+ assert_eq!(expr, ConditionExpr::Atom(ConditionAtom::CursorAtStart));
+ }
+
+ // -- Builder helpers --
+
+ #[test]
+ fn builder_chain() {
+ let expr = ConditionExpr::from(ConditionAtom::CursorAtStart)
+ .and(ConditionExpr::from(ConditionAtom::InputEmpty).not())
+ .or(ConditionExpr::from(ConditionAtom::NoResults));
+ // And binds tighter than Or, so no parens needed around the And
+ assert_eq!(
+ expr.to_string(),
+ "cursor-at-start && !input-empty || no-results"
+ );
+ }
+}
diff --git a/crates/atuin/src/command/client/search/keybindings/defaults.rs b/crates/atuin/src/command/client/search/keybindings/defaults.rs
new file mode 100644
index 00000000..0e666a40
--- /dev/null
+++ b/crates/atuin/src/command/client/search/keybindings/defaults.rs
@@ -0,0 +1,1162 @@
+use std::collections::HashMap;
+
+use atuin_client::settings::{KeyBindingConfig, Settings};
+
+use super::actions::Action;
+use super::conditions::{ConditionAtom, ConditionExpr};
+use super::key::KeyInput;
+use super::keymap::{KeyBinding, KeyRule, Keymap};
+
+/// Helper to bind a scroll key with optional exit behavior.
+///
+/// When `scroll_exits` is true AND the key scrolls toward index 0 (the newest
+/// entry), we add a conditional rule: at `ListAtStart` → `Exit`, otherwise →
+/// the scroll action.
+///
+/// Whether a key scrolls toward index 0 depends on the `invert` setting:
+/// - Non-inverted: "down" / "j" move toward index 0, "up" / "k" move away
+/// - Inverted: "up" / "k" move toward index 0, "down" / "j" move away
+///
+/// If `toward_index_zero` is false, or `scroll_exits` is false, we just bind
+/// the key to the plain scroll action (no exit).
+fn bind_scroll_key(
+ km: &mut Keymap,
+ key_str: &str,
+ action: Action,
+ toward_index_zero: bool,
+ scroll_exits: bool,
+) {
+ let k = key(key_str);
+ if scroll_exits && toward_index_zero {
+ km.bind_conditional(
+ k,
+ vec![
+ KeyRule::when(ConditionAtom::ListAtStart, Action::Exit),
+ KeyRule::always(action),
+ ],
+ );
+ } else {
+ km.bind(k, action);
+ }
+}
+
+/// Helper to parse a key string, panicking on invalid keys (these are all
+/// compile-time-known strings).
+fn key(s: &str) -> KeyInput {
+ KeyInput::parse(s).unwrap_or_else(|e| panic!("invalid default key {s:?}: {e}"))
+}
+
+/// All five keymaps bundled together.
+#[derive(Debug, Clone)]
+pub struct KeymapSet {
+ pub emacs: Keymap,
+ pub vim_normal: Keymap,
+ pub vim_insert: Keymap,
+ pub inspector: Keymap,
+ pub prefix: Keymap,
+}
+
+// ---------------------------------------------------------------------------
+// Common bindings shared across search-tab keymaps
+// ---------------------------------------------------------------------------
+
+/// Add the bindings that are common to all search-tab keymaps:
+/// ctrl-c, ctrl-g, ctrl-o, and tab (respecting `enter_accept`).
+///
+/// Note: `esc`/`ctrl-[` are NOT included here because their behavior differs
+/// between emacs (exit), vim-normal (exit), and vim-insert (enter normal mode).
+fn add_common_bindings(km: &mut Keymap, settings: &Settings) {
+ km.bind(key("ctrl-c"), Action::ReturnOriginal);
+ km.bind(key("ctrl-g"), Action::ReturnOriginal);
+ km.bind(key("ctrl-o"), Action::ToggleTab);
+
+ // Tab: accept and execute (enter_accept) or return selection
+ let accept = accept_action(settings);
+ km.bind(key("tab"), accept);
+}
+
+/// Returns `Accept` or `ReturnSelection` based on the `enter_accept` setting.
+fn accept_action(settings: &Settings) -> Action {
+ if settings.enter_accept {
+ Action::Accept
+ } else {
+ Action::ReturnSelection
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Emacs keymap (also base for vim-insert)
+// ---------------------------------------------------------------------------
+
+/// Build the default emacs keymap. This encodes the behavior from
+/// `handle_key_input` common section + `handle_search_input` shared section.
+///
+/// The `settings` parameter is used for:
+/// - `keys.prefix` — which ctrl-key enters prefix mode
+/// - `keys.scroll_exits`, `invert` — scroll-at-boundary exit behavior
+/// - `keys.accept_past_line_end` — right arrow at end of line accepts
+/// - `keys.exit_past_line_start` — left arrow at start of line exits
+/// - `keys.accept_past_line_start` — left arrow at start accepts (overrides exit)
+/// - `keys.accept_with_backspace` — backspace at start of line accepts
+/// - `ctrl_n_shortcuts` — whether alt or ctrl is used for numeric shortcuts
+// Keymap builder that enumerates every default binding; not worth splitting.
+#[allow(clippy::too_many_lines)]
+pub fn default_emacs_keymap(settings: &Settings) -> Keymap {
+ let mut km = Keymap::new();
+ add_common_bindings(&mut km, settings);
+
+ let accept = accept_action(settings);
+
+ // esc / ctrl-[ → exit
+ km.bind(key("esc"), Action::Exit);
+ km.bind(key("ctrl-["), Action::Exit);
+
+ // Prefix key: ctrl-<prefix_char> → enter prefix mode
+ let prefix_char = settings.keys.prefix.chars().next().unwrap_or('a');
+ km.bind(key(&format!("ctrl-{prefix_char}")), Action::EnterPrefixMode);
+
+ // --- Accept / navigation edge behaviors (from [keys] settings) ---
+
+ // right: behavior at end of line
+ if settings.keys.accept_past_line_end {
+ km.bind_conditional(
+ key("right"),
+ vec![
+ KeyRule::when(ConditionAtom::CursorAtEnd, accept.clone()),
+ KeyRule::always(Action::CursorRight),
+ ],
+ );
+ } else {
+ km.bind(key("right"), Action::CursorRight);
+ }
+
+ // left: behavior at start of line
+ // accept_past_line_start takes precedence over exit_past_line_start
+ if settings.keys.accept_past_line_start {
+ km.bind_conditional(
+ key("left"),
+ vec![
+ KeyRule::when(ConditionAtom::CursorAtStart, accept.clone()),
+ KeyRule::always(Action::CursorLeft),
+ ],
+ );
+ } else if settings.keys.exit_past_line_start {
+ km.bind_conditional(
+ key("left"),
+ vec![
+ KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit),
+ KeyRule::always(Action::CursorLeft),
+ ],
+ );
+ } else {
+ km.bind(key("left"), Action::CursorLeft);
+ }
+
+ // down/up: scroll with optional exit at boundary.
+ // Non-inverted: down moves toward index 0 (can exit); up moves away (no exit).
+ // Inverted: up moves toward index 0 (can exit); down moves away (no exit).
+ let scroll_exits = settings.keys.scroll_exits;
+ let invert = settings.invert;
+ bind_scroll_key(&mut km, "down", Action::SelectNext, !invert, scroll_exits);
+ bind_scroll_key(&mut km, "up", Action::SelectPrevious, invert, scroll_exits);
+
+ // backspace: behavior at start of line
+ if settings.keys.accept_with_backspace {
+ km.bind_conditional(
+ key("backspace"),
+ vec![
+ KeyRule::when(ConditionAtom::CursorAtStart, accept.clone()),
+ KeyRule::always(Action::DeleteCharBefore),
+ ],
+ );
+ } else {
+ km.bind(key("backspace"), Action::DeleteCharBefore);
+ }
+
+ // --- Accept ---
+ km.bind(key("enter"), accept.clone());
+ km.bind(key("ctrl-m"), accept);
+
+ // --- Copy ---
+ km.bind(key("ctrl-y"), Action::Copy);
+
+ // --- Numeric shortcuts (alt-1..9 by default, ctrl-1..9 if ctrl_n_shortcuts) ---
+ // These return the selection without executing, regardless of enter_accept.
+ let num_mod = if settings.ctrl_n_shortcuts {
+ "ctrl"
+ } else {
+ "alt"
+ };
+ for n in 1..=9u8 {
+ km.bind(
+ key(&format!("{num_mod}-{n}")),
+ Action::ReturnSelectionNth(n),
+ );
+ }
+
+ // --- Cursor movement ---
+ km.bind(key("ctrl-left"), Action::CursorWordLeft);
+ km.bind(key("alt-b"), Action::CursorWordLeft);
+ km.bind(key("ctrl-b"), Action::CursorLeft);
+ km.bind(key("ctrl-right"), Action::CursorWordRight);
+ km.bind(key("alt-f"), Action::CursorWordRight);
+ km.bind(key("ctrl-f"), Action::CursorRight);
+ km.bind(key("home"), Action::CursorStart);
+ // ctrl-a → CursorStart only if prefix char is NOT 'a'
+ // (otherwise ctrl-a is already bound to EnterPrefixMode above)
+ if prefix_char != 'a' {
+ km.bind(key("ctrl-a"), Action::CursorStart);
+ }
+ km.bind(key("ctrl-e"), Action::CursorEnd);
+ km.bind(key("end"), Action::CursorEnd);
+
+ // --- Editing ---
+ km.bind(key("ctrl-backspace"), Action::DeleteWordBefore);
+ km.bind(key("ctrl-h"), Action::DeleteCharBefore);
+ km.bind(key("ctrl-?"), Action::DeleteCharBefore);
+ km.bind(key("ctrl-delete"), Action::DeleteWordAfter);
+ km.bind(key("delete"), Action::DeleteCharAfter);
+ // ctrl-d: if input empty → return original, otherwise delete char
+ km.bind_conditional(
+ key("ctrl-d"),
+ vec![
+ KeyRule::when(ConditionAtom::InputEmpty, Action::ReturnOriginal),
+ KeyRule::always(Action::DeleteCharAfter),
+ ],
+ );
+ km.bind(key("ctrl-w"), Action::DeleteToWordBoundary);
+ km.bind(key("ctrl-u"), Action::ClearLine);
+
+ // --- Search mode ---
+ km.bind(key("ctrl-r"), Action::CycleFilterMode);
+ km.bind(key("ctrl-s"), Action::CycleSearchMode);
+
+ // --- Scroll (no exit) ---
+ km.bind(key("ctrl-n"), Action::SelectNext);
+ km.bind(key("ctrl-j"), Action::SelectNext);
+ km.bind(key("ctrl-p"), Action::SelectPrevious);
+ km.bind(key("ctrl-k"), Action::SelectPrevious);
+
+ // --- Redraw ---
+ km.bind(key("ctrl-l"), Action::Redraw);
+
+ // --- Page scroll ---
+ km.bind(key("pagedown"), Action::ScrollPageDown);
+ km.bind(key("pageup"), Action::ScrollPageUp);
+
+ km
+}
+
+// ---------------------------------------------------------------------------
+// Vim Normal keymap
+// ---------------------------------------------------------------------------
+
+/// Build the default vim-normal keymap.
+pub fn default_vim_normal_keymap(settings: &Settings) -> Keymap {
+ let mut km = Keymap::new();
+ add_common_bindings(&mut km, settings);
+
+ // esc / ctrl-[ → exit (vim-normal exits, unlike vim-insert)
+ km.bind(key("esc"), Action::Exit);
+ km.bind(key("ctrl-["), Action::Exit);
+
+ // Prefix key
+ let prefix_char = settings.keys.prefix.chars().next().unwrap_or('a');
+ km.bind(key(&format!("ctrl-{prefix_char}")), Action::EnterPrefixMode);
+
+ // --- Vim navigation ---
+ // j/k: scroll with optional exit at boundary.
+ let scroll_exits = settings.keys.scroll_exits;
+ let invert = settings.invert;
+ bind_scroll_key(&mut km, "j", Action::SelectNext, !invert, scroll_exits);
+ bind_scroll_key(&mut km, "k", Action::SelectPrevious, invert, scroll_exits);
+ km.bind(key("h"), Action::CursorLeft);
+ km.bind(key("l"), Action::CursorRight);
+
+ // --- Mode switching ---
+ km.bind(key("?"), Action::VimSearchInsert);
+ km.bind(key("/"), Action::VimSearchInsert);
+ km.bind(key("a"), Action::VimEnterInsertAfter);
+ km.bind(key("A"), Action::VimEnterInsertAtEnd);
+ km.bind(key("i"), Action::VimEnterInsert);
+ km.bind(key("I"), Action::VimEnterInsertAtStart);
+
+ // --- Numeric shortcuts (return selection without executing) ---
+ for n in 1..=9u8 {
+ km.bind(key(&n.to_string()), Action::ReturnSelectionNth(n));
+ }
+
+ // --- Half/full page scroll ---
+ km.bind(key("ctrl-u"), Action::ScrollHalfPageUp);
+ km.bind(key("ctrl-d"), Action::ScrollHalfPageDown);
+ km.bind(key("ctrl-b"), Action::ScrollPageUp);
+ km.bind(key("ctrl-f"), Action::ScrollPageDown);
+
+ // --- Jump ---
+ km.bind(key("G"), Action::ScrollToBottom);
+ km.bind(key("g g"), Action::ScrollToTop);
+ km.bind(key("H"), Action::ScrollToScreenTop);
+ km.bind(key("M"), Action::ScrollToScreenMiddle);
+ km.bind(key("L"), Action::ScrollToScreenBottom);
+
+ // --- Arrow keys (same as emacs for convenience) ---
+ bind_scroll_key(&mut km, "down", Action::SelectNext, !invert, scroll_exits);
+ bind_scroll_key(&mut km, "up", Action::SelectPrevious, invert, scroll_exits);
+
+ // --- Page scroll ---
+ km.bind(key("pagedown"), Action::ScrollPageDown);
+ km.bind(key("pageup"), Action::ScrollPageUp);
+
+ km
+}
+
+// ---------------------------------------------------------------------------
+// Vim Insert keymap
+// ---------------------------------------------------------------------------
+
+/// Build the default vim-insert keymap. This clones the emacs keymap and
+/// overlays vim-insert-specific bindings (esc → enter normal mode).
+pub fn default_vim_insert_keymap(settings: &Settings) -> Keymap {
+ let mut km = default_emacs_keymap(settings);
+
+ // Override esc and ctrl-[ to enter normal mode instead of exiting
+ km.bind(key("esc"), Action::VimEnterNormal);
+ km.bind(key("ctrl-["), Action::VimEnterNormal);
+
+ km
+}
+
+// ---------------------------------------------------------------------------
+// Inspector keymap
+// ---------------------------------------------------------------------------
+
+/// Build the default inspector keymap (tab index 1).
+pub fn default_inspector_keymap(settings: &Settings) -> Keymap {
+ let mut km = Keymap::new();
+ let accept = accept_action(settings);
+
+ // Common bindings
+ km.bind(key("ctrl-c"), Action::ReturnOriginal);
+ km.bind(key("ctrl-g"), Action::ReturnOriginal);
+ km.bind(key("esc"), Action::Exit);
+ km.bind(key("ctrl-["), Action::Exit);
+ km.bind(key("tab"), accept);
+ km.bind(key("ctrl-o"), Action::ToggleTab);
+
+ // Inspector-specific
+ km.bind(key("ctrl-d"), Action::Delete);
+ km.bind(key("up"), Action::InspectPrevious);
+ km.bind(key("down"), Action::InspectNext);
+
+ km
+}
+
+// ---------------------------------------------------------------------------
+// Prefix keymap
+// ---------------------------------------------------------------------------
+
+/// Build the default prefix keymap (active after ctrl-a prefix).
+pub fn default_prefix_keymap() -> Keymap {
+ let mut km = Keymap::new();
+
+ km.bind(key("d"), Action::Delete);
+ km.bind(key("a"), Action::CursorStart);
+
+ km
+}
+
+// ---------------------------------------------------------------------------
+// KeymapSet construction
+// ---------------------------------------------------------------------------
+
+// ---------------------------------------------------------------------------
+// Config → Keymap conversion
+// ---------------------------------------------------------------------------
+
+/// Convert a `KeyBindingConfig` (from TOML) into a `KeyBinding`.
+/// Returns `Err` if an action name or condition expression is invalid.
+fn parse_binding_config(config: &KeyBindingConfig) -> Result<KeyBinding, String> {
+ match config {
+ KeyBindingConfig::Simple(action_str) => {
+ let action = Action::from_str(action_str)?;
+ Ok(KeyBinding::simple(action))
+ }
+ KeyBindingConfig::Rules(rules) => {
+ let mut parsed_rules = Vec::with_capacity(rules.len());
+ for rule_cfg in rules {
+ let action = Action::from_str(&rule_cfg.action)?;
+ let rule = match &rule_cfg.when {
+ None => KeyRule::always(action),
+ Some(cond_str) => {
+ let cond = ConditionExpr::parse(cond_str)?;
+ KeyRule::when(cond, action)
+ }
+ };
+ parsed_rules.push(rule);
+ }
+ Ok(KeyBinding::conditional(parsed_rules))
+ }
+ }
+}
+
+/// Apply a map of key-string → binding-config overrides to a keymap.
+/// Per-key override replaces the entire rule list for that key.
+/// Invalid keys or action names are logged and skipped.
+fn apply_config_to_keymap(keymap: &mut Keymap, overrides: &HashMap<String, KeyBindingConfig>) {
+ for (key_str, binding_cfg) in overrides {
+ let key = match KeyInput::parse(key_str) {
+ Ok(k) => k,
+ Err(e) => {
+ eprintln!("[atuin] warning: invalid key in keymap config: {key_str:?}: {e}");
+ continue;
+ }
+ };
+ match parse_binding_config(binding_cfg) {
+ Ok(binding) => {
+ keymap.bindings.insert(key, binding);
+ }
+ Err(e) => {
+ eprintln!("[atuin] warning: invalid binding for {key_str:?} in keymap config: {e}");
+ }
+ }
+ }
+}
+
+impl KeymapSet {
+ /// Build the complete set of default keymaps from settings.
+ pub fn defaults(settings: &Settings) -> Self {
+ KeymapSet {
+ emacs: default_emacs_keymap(settings),
+ vim_normal: default_vim_normal_keymap(settings),
+ vim_insert: default_vim_insert_keymap(settings),
+ inspector: default_inspector_keymap(settings),
+ prefix: default_prefix_keymap(),
+ }
+ }
+
+ /// Build keymaps from settings, applying any user `[keymap]` overrides.
+ ///
+ /// Precedence rules:
+ /// - If `[keymap]` has any entries, `[keys]` is **ignored entirely**.
+ /// Defaults are built with standard `[keys]` values, then `[keymap]`
+ /// overrides are applied per-key.
+ /// - If `[keymap]` is empty/absent, `[keys]` customizes the defaults
+ /// (current behavior for backward compatibility).
+ pub fn from_settings(settings: &Settings) -> Self {
+ use atuin_client::settings::Keys;
+
+ if settings.keymap.is_empty() {
+ // No [keymap] section → use [keys] to customize defaults
+ Self::defaults(settings)
+ } else {
+ // [keymap] present → ignore [keys], use standard defaults as base
+ if settings.keys.has_non_default_values() {
+ eprintln!(
+ "[atuin] warning: [keys] section is ignored when [keymap] is present. \
+ Consider migrating your [keys] settings to [keymap]."
+ );
+ }
+ let mut base_settings = settings.clone();
+ base_settings.keys = Keys::standard_defaults();
+ let mut set = Self::defaults(&base_settings);
+ set.apply_config(settings);
+ set
+ }
+ }
+
+ /// Apply user keymap config overrides to all modes.
+ fn apply_config(&mut self, settings: &Settings) {
+ let config = &settings.keymap;
+ apply_config_to_keymap(&mut self.emacs, &config.emacs);
+ apply_config_to_keymap(&mut self.vim_normal, &config.vim_normal);
+ apply_config_to_keymap(&mut self.vim_insert, &config.vim_insert);
+ apply_config_to_keymap(&mut self.inspector, &config.inspector);
+ apply_config_to_keymap(&mut self.prefix, &config.prefix);
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::command::client::search::keybindings::conditions::EvalContext;
+
+ fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext {
+ EvalContext {
+ cursor_position: cursor,
+ input_width: width,
+ input_byte_len: width,
+ selected_index: selected,
+ results_len: len,
+ }
+ }
+
+ fn default_settings() -> Settings {
+ Settings::utc()
+ }
+
+ // -- Emacs keymap tests --
+
+ #[test]
+ fn emacs_ctrl_c_returns_original() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("ctrl-c"), &ctx),
+ Some(Action::ReturnOriginal)
+ );
+ }
+
+ #[test]
+ fn emacs_esc_exits() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn emacs_tab_returns_selection() {
+ // enter_accept=false in test defaults → ReturnSelection
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection));
+ }
+
+ #[test]
+ fn emacs_enter_returns_selection() {
+ // enter_accept=false in test defaults → ReturnSelection
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("enter"), &ctx),
+ Some(Action::ReturnSelection)
+ );
+ }
+
+ #[test]
+ fn emacs_enter_accept_true_uses_accept() {
+ let mut settings = default_settings();
+ settings.enter_accept = true;
+ let km = default_emacs_keymap(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("enter"), &ctx), Some(Action::Accept));
+ assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::Accept));
+ }
+
+ #[test]
+ fn emacs_right_at_end_returns_selection() {
+ let km = default_emacs_keymap(&default_settings());
+ // cursor at end of "hello" (width 5)
+ let ctx = make_ctx(5, 5, 0, 10);
+ assert_eq!(
+ km.resolve(&key("right"), &ctx),
+ Some(Action::ReturnSelection)
+ );
+ }
+
+ #[test]
+ fn emacs_right_not_at_end_moves() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(2, 5, 0, 10);
+ assert_eq!(km.resolve(&key("right"), &ctx), Some(Action::CursorRight));
+ }
+
+ #[test]
+ fn emacs_left_at_start_exits() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 5, 0, 10);
+ assert_eq!(km.resolve(&key("left"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn emacs_left_not_at_start_moves() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(3, 5, 0, 10);
+ assert_eq!(km.resolve(&key("left"), &ctx), Some(Action::CursorLeft));
+ }
+
+ #[test]
+ fn emacs_down_at_start_exits() {
+ let km = default_emacs_keymap(&default_settings());
+ // selected=0 → ListAtStart → Exit
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn emacs_down_not_at_start_selects_next() {
+ let km = default_emacs_keymap(&default_settings());
+ // selected=5 → not at start → SelectNext
+ let ctx = make_ctx(0, 0, 5, 10);
+ assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::SelectNext));
+ }
+
+ #[test]
+ fn emacs_up_selects_previous() {
+ let km = default_emacs_keymap(&default_settings());
+ // Non-inverted: up never exits (moves away from index 0)
+ let ctx = make_ctx(0, 0, 5, 10);
+ assert_eq!(km.resolve(&key("up"), &ctx), Some(Action::SelectPrevious));
+ }
+
+ #[test]
+ fn emacs_ctrl_d_empty_returns_original() {
+ let km = default_emacs_keymap(&default_settings());
+ // input empty (byte_len = 0)
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("ctrl-d"), &ctx),
+ Some(Action::ReturnOriginal)
+ );
+ }
+
+ #[test]
+ fn emacs_ctrl_d_nonempty_deletes() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(2, 5, 0, 10);
+ assert_eq!(
+ km.resolve(&key("ctrl-d"), &ctx),
+ Some(Action::DeleteCharAfter)
+ );
+ }
+
+ #[test]
+ fn emacs_ctrl_n_selects_next_no_exit_condition() {
+ let km = default_emacs_keymap(&default_settings());
+ // at start, but ctrl-n should NOT exit (no exit condition bound)
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("ctrl-n"), &ctx), Some(Action::SelectNext));
+ }
+
+ #[test]
+ fn emacs_prefix_key_enters_prefix() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("ctrl-a"), &ctx),
+ Some(Action::EnterPrefixMode)
+ );
+ }
+
+ #[test]
+ fn emacs_home_cursor_start() {
+ let km = default_emacs_keymap(&default_settings());
+ let ctx = make_ctx(5, 10, 0, 10);
+ assert_eq!(km.resolve(&key("home"), &ctx), Some(Action::CursorStart));
+ }
+
+ // -- Vim Normal keymap tests --
+
+ #[test]
+ fn vim_normal_j_at_start_exits() {
+ let km = default_vim_normal_keymap(&default_settings());
+ // selected=0 → ListAtStart → Exit (non-inverted: j moves toward index 0)
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("j"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn vim_normal_j_not_at_start_selects_next() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 5, 10);
+ assert_eq!(km.resolve(&key("j"), &ctx), Some(Action::SelectNext));
+ }
+
+ #[test]
+ fn vim_normal_k_selects_previous() {
+ let km = default_vim_normal_keymap(&default_settings());
+ // Non-inverted: k never exits (moves away from index 0)
+ let ctx = make_ctx(0, 0, 5, 10);
+ assert_eq!(km.resolve(&key("k"), &ctx), Some(Action::SelectPrevious));
+ }
+
+ #[test]
+ fn vim_normal_i_enters_insert() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("i"), &ctx), Some(Action::VimEnterInsert));
+ }
+
+ #[test]
+ fn vim_normal_slash_search_insert() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("/"), &ctx), Some(Action::VimSearchInsert));
+ }
+
+ #[test]
+ fn vim_normal_gg_scroll_to_top() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 50, 100);
+ assert_eq!(km.resolve(&key("g g"), &ctx), Some(Action::ScrollToTop));
+ }
+
+ #[test]
+ fn vim_normal_big_g_scroll_to_bottom() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 50, 100);
+ assert_eq!(km.resolve(&key("G"), &ctx), Some(Action::ScrollToBottom));
+ }
+
+ #[test]
+ fn vim_normal_numeric_returns_selection() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("3"), &ctx),
+ Some(Action::ReturnSelectionNth(3))
+ );
+ }
+
+ #[test]
+ fn vim_normal_ctrl_u_half_page_up() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 50, 100);
+ assert_eq!(
+ km.resolve(&key("ctrl-u"), &ctx),
+ Some(Action::ScrollHalfPageUp)
+ );
+ }
+
+ #[test]
+ fn vim_normal_screen_jumps() {
+ let km = default_vim_normal_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 50, 100);
+ assert_eq!(km.resolve(&key("H"), &ctx), Some(Action::ScrollToScreenTop));
+ assert_eq!(
+ km.resolve(&key("M"), &ctx),
+ Some(Action::ScrollToScreenMiddle)
+ );
+ assert_eq!(
+ km.resolve(&key("L"), &ctx),
+ Some(Action::ScrollToScreenBottom)
+ );
+ }
+
+ // -- Vim Insert keymap tests --
+
+ #[test]
+ fn vim_insert_inherits_emacs_enter() {
+ let km = default_vim_insert_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ // enter_accept=false → ReturnSelection
+ assert_eq!(
+ km.resolve(&key("enter"), &ctx),
+ Some(Action::ReturnSelection)
+ );
+ }
+
+ #[test]
+ fn vim_insert_esc_enters_normal() {
+ let km = default_vim_insert_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::VimEnterNormal));
+ }
+
+ #[test]
+ fn vim_insert_ctrl_bracket_enters_normal() {
+ let km = default_vim_insert_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ km.resolve(&key("ctrl-["), &ctx),
+ Some(Action::VimEnterNormal)
+ );
+ }
+
+ #[test]
+ fn vim_insert_inherits_emacs_ctrl_d() {
+ let km = default_vim_insert_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ // input empty → return original
+ assert_eq!(
+ km.resolve(&key("ctrl-d"), &ctx),
+ Some(Action::ReturnOriginal)
+ );
+ }
+
+ // -- Inspector keymap tests --
+
+ #[test]
+ fn inspector_ctrl_d_deletes() {
+ let km = default_inspector_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("ctrl-d"), &ctx), Some(Action::Delete));
+ }
+
+ #[test]
+ fn inspector_up_inspects_previous() {
+ let km = default_inspector_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("up"), &ctx), Some(Action::InspectPrevious));
+ }
+
+ #[test]
+ fn inspector_down_inspects_next() {
+ let km = default_inspector_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("down"), &ctx), Some(Action::InspectNext));
+ }
+
+ #[test]
+ fn inspector_esc_exits() {
+ let km = default_inspector_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("esc"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn inspector_tab_returns_selection() {
+ // enter_accept=false → ReturnSelection
+ let km = default_inspector_keymap(&default_settings());
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("tab"), &ctx), Some(Action::ReturnSelection));
+ }
+
+ // -- Prefix keymap tests --
+
+ #[test]
+ fn prefix_d_deletes() {
+ let km = default_prefix_keymap();
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("d"), &ctx), Some(Action::Delete));
+ }
+
+ #[test]
+ fn prefix_a_cursor_start() {
+ let km = default_prefix_keymap();
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("a"), &ctx), Some(Action::CursorStart));
+ }
+
+ #[test]
+ fn prefix_unknown_key_returns_none() {
+ let km = default_prefix_keymap();
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(km.resolve(&key("x"), &ctx), None);
+ }
+
+ // -- KeymapSet tests --
+
+ #[test]
+ fn keymap_set_defaults_builds() {
+ let settings = default_settings();
+ let set = KeymapSet::defaults(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+
+ // Sanity check each keymap has bindings
+ assert!(set.emacs.resolve(&key("ctrl-c"), &ctx).is_some());
+ assert!(set.vim_normal.resolve(&key("ctrl-c"), &ctx).is_some());
+ assert!(set.vim_insert.resolve(&key("ctrl-c"), &ctx).is_some());
+ assert!(set.inspector.resolve(&key("ctrl-c"), &ctx).is_some());
+ assert!(set.prefix.resolve(&key("d"), &ctx).is_some());
+ }
+
+ // -- Settings-dependent behavior --
+
+ #[test]
+ fn custom_prefix_char() {
+ let mut settings = default_settings();
+ settings.keys.prefix = "x".to_string();
+ let km = default_emacs_keymap(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+
+ // ctrl-x should be prefix mode
+ assert_eq!(
+ km.resolve(&key("ctrl-x"), &ctx),
+ Some(Action::EnterPrefixMode)
+ );
+ // ctrl-a should now be CursorStart (not prefix)
+ assert_eq!(km.resolve(&key("ctrl-a"), &ctx), Some(Action::CursorStart));
+ }
+
+ #[test]
+ fn ctrl_n_shortcuts_changes_numeric_modifier() {
+ let mut settings = default_settings();
+ settings.ctrl_n_shortcuts = true;
+ let km = default_emacs_keymap(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+
+ // ctrl-1 should work
+ assert_eq!(
+ km.resolve(&key("ctrl-1"), &ctx),
+ Some(Action::ReturnSelectionNth(1))
+ );
+ // alt-1 should NOT be bound
+ assert_eq!(km.resolve(&key("alt-1"), &ctx), None);
+ }
+
+ #[test]
+ fn default_alt_numeric_shortcuts() {
+ let settings = default_settings();
+ let km = default_emacs_keymap(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+
+ // alt-1 should work by default
+ assert_eq!(
+ km.resolve(&key("alt-1"), &ctx),
+ Some(Action::ReturnSelectionNth(1))
+ );
+ }
+
+ // -----------------------------------------------------------------------
+ // Config parsing and merging tests
+ // -----------------------------------------------------------------------
+
+ #[test]
+ fn parse_simple_binding_config() {
+ use atuin_client::settings::KeyBindingConfig;
+ let cfg = KeyBindingConfig::Simple("accept".to_string());
+ let binding = super::parse_binding_config(&cfg).unwrap();
+ assert_eq!(binding.rules.len(), 1);
+ assert!(binding.rules[0].condition.is_none());
+ assert_eq!(binding.rules[0].action, Action::Accept);
+ }
+
+ #[test]
+ fn parse_conditional_binding_config() {
+ use atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
+ let cfg = KeyBindingConfig::Rules(vec![
+ KeyRuleConfig {
+ when: Some("cursor-at-start".to_string()),
+ action: "exit".to_string(),
+ },
+ KeyRuleConfig {
+ when: None,
+ action: "cursor-left".to_string(),
+ },
+ ]);
+ let binding = super::parse_binding_config(&cfg).unwrap();
+ assert_eq!(binding.rules.len(), 2);
+ assert!(binding.rules[0].condition.is_some());
+ assert_eq!(binding.rules[0].action, Action::Exit);
+ assert!(binding.rules[1].condition.is_none());
+ assert_eq!(binding.rules[1].action, Action::CursorLeft);
+ }
+
+ #[test]
+ fn parse_binding_config_invalid_action() {
+ use atuin_client::settings::KeyBindingConfig;
+ let cfg = KeyBindingConfig::Simple("not-a-real-action".to_string());
+ assert!(super::parse_binding_config(&cfg).is_err());
+ }
+
+ #[test]
+ fn parse_binding_config_invalid_condition() {
+ use atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
+ let cfg = KeyBindingConfig::Rules(vec![KeyRuleConfig {
+ when: Some("not-a-real-condition".to_string()),
+ action: "exit".to_string(),
+ }]);
+ assert!(super::parse_binding_config(&cfg).is_err());
+ }
+
+ #[test]
+ fn config_override_replaces_key() {
+ use atuin_client::settings::KeyBindingConfig;
+ use std::collections::HashMap;
+
+ let mut settings = default_settings();
+ let set = KeymapSet::defaults(&settings);
+
+ // Default: ctrl-c → ReturnOriginal
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ set.emacs.resolve(&key("ctrl-c"), &ctx),
+ Some(Action::ReturnOriginal)
+ );
+
+ // Override ctrl-c → Exit via config
+ settings.keymap.emacs = HashMap::from([(
+ "ctrl-c".to_string(),
+ KeyBindingConfig::Simple("exit".to_string()),
+ )]);
+
+ let set = KeymapSet::from_settings(&settings);
+ assert_eq!(set.emacs.resolve(&key("ctrl-c"), &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn config_override_preserves_unoverridden_keys() {
+ use atuin_client::settings::KeyBindingConfig;
+ use std::collections::HashMap;
+
+ let mut settings = default_settings();
+ // Override only ctrl-c; enter should keep its default
+ settings.keymap.emacs = HashMap::from([(
+ "ctrl-c".to_string(),
+ KeyBindingConfig::Simple("exit".to_string()),
+ )]);
+
+ let set = KeymapSet::from_settings(&settings);
+ let ctx = make_ctx(0, 0, 0, 10);
+
+ // ctrl-c overridden
+ assert_eq!(set.emacs.resolve(&key("ctrl-c"), &ctx), Some(Action::Exit));
+ // enter still has default (enter_accept=false → ReturnSelection)
+ assert_eq!(
+ set.emacs.resolve(&key("enter"), &ctx),
+ Some(Action::ReturnSelection)
+ );
+ }
+
+ #[test]
+ fn config_conditional_override() {
+ use atuin_client::settings::{KeyBindingConfig, KeyRuleConfig};
+ use std::collections::HashMap;
+
+ let mut settings = default_settings();
+ // Override "up" with a custom conditional
+ settings.keymap.emacs = HashMap::from([(
+ "up".to_string(),
+ KeyBindingConfig::Rules(vec![
+ KeyRuleConfig {
+ when: Some("no-results".to_string()),
+ action: "exit".to_string(),
+ },
+ KeyRuleConfig {
+ when: None,
+ action: "select-previous".to_string(),
+ },
+ ]),
+ )]);
+
+ let set = KeymapSet::from_settings(&settings);
+
+ // With no results → exit
+ let ctx = make_ctx(0, 0, 0, 0);
+ assert_eq!(set.emacs.resolve(&key("up"), &ctx), Some(Action::Exit));
+
+ // With results → select-previous
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ set.emacs.resolve(&key("up"), &ctx),
+ Some(Action::SelectPrevious)
+ );
+ }
+
+ #[test]
+ fn from_settings_with_empty_config_equals_defaults() {
+ let settings = default_settings();
+ let defaults = KeymapSet::defaults(&settings);
+ let from_settings = KeymapSet::from_settings(&settings);
+
+ // Verify a sample of keys produce the same results
+ let ctx = make_ctx(0, 0, 0, 10);
+ let test_keys = [
+ "ctrl-c", "enter", "esc", "tab", "up", "down", "left", "right",
+ ];
+ for k in &test_keys {
+ assert_eq!(
+ defaults.emacs.resolve(&key(k), &ctx),
+ from_settings.emacs.resolve(&key(k), &ctx),
+ "mismatch for emacs key {k}"
+ );
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // Phase 5: [keys] vs [keymap] backward compatibility
+ // -----------------------------------------------------------------------
+
+ #[test]
+ fn keymap_overrides_ignore_keys_section() {
+ use atuin_client::settings::KeyBindingConfig;
+
+ // Set up: [keys] disables scroll_exits, but [keymap] is present
+ let mut settings = default_settings();
+ settings.keys.scroll_exits = false;
+
+ // Without [keymap], scroll_exits=false means no exit condition on down
+ let set_legacy = KeymapSet::defaults(&settings);
+ // At list-at-start (selected=0), down should still be SelectNext (no exit)
+ let ctx_at_boundary = make_ctx(0, 0, 0, 10);
+ assert_eq!(
+ set_legacy.emacs.resolve(&key("down"), &ctx_at_boundary),
+ Some(Action::SelectNext),
+ "legacy: down at boundary should be SelectNext with scroll_exits=false"
+ );
+
+ // With [keymap] present (even just one override), [keys] is ignored
+ // so the standard defaults (scroll_exits=true) apply
+ settings.keymap.emacs = HashMap::from([(
+ "ctrl-c".to_string(),
+ KeyBindingConfig::Simple("exit".to_string()),
+ )]);
+ let set_keymap = KeymapSet::from_settings(&settings);
+
+ // Not at boundary (selected=5): should SelectNext normally
+ let ctx_not_at_boundary = make_ctx(0, 0, 5, 10);
+ assert_eq!(
+ set_keymap.emacs.resolve(&key("down"), &ctx_not_at_boundary),
+ Some(Action::SelectNext),
+ "keymap: down not at boundary should SelectNext"
+ );
+ // At list-at-start (selected=0): should Exit (standard scroll_exits=true)
+ assert_eq!(
+ set_keymap.emacs.resolve(&key("down"), &ctx_at_boundary),
+ Some(Action::Exit),
+ "keymap: down at boundary should Exit (standard defaults restored)"
+ );
+ }
+
+ #[test]
+ fn keymap_present_resets_to_standard_keys_defaults() {
+ use atuin_client::settings::KeyBindingConfig;
+
+ let mut settings = default_settings();
+ // Disable all [keys] behaviors
+ settings.keys.exit_past_line_start = false;
+ settings.keys.accept_past_line_end = false;
+
+ // Without [keymap], left should be plain CursorLeft
+ let set_legacy = KeymapSet::defaults(&settings);
+ let ctx_at_start = make_ctx(0, 5, 0, 10);
+ assert_eq!(
+ set_legacy.emacs.resolve(&key("left"), &ctx_at_start),
+ Some(Action::CursorLeft),
+ "legacy: left should be plain CursorLeft without exit_past_line_start"
+ );
+
+ // Add a [keymap] entry (for a different key)
+ settings.keymap.emacs = HashMap::from([(
+ "ctrl-c".to_string(),
+ KeyBindingConfig::Simple("exit".to_string()),
+ )]);
+ let set_keymap = KeymapSet::from_settings(&settings);
+
+ // Now left should use standard defaults (exit_past_line_start=true)
+ // At cursor start → Exit
+ assert_eq!(
+ set_keymap.emacs.resolve(&key("left"), &ctx_at_start),
+ Some(Action::Exit),
+ "keymap: left at cursor start should exit (standard defaults)"
+ );
+
+ // Right at cursor end should return selection (standard defaults: accept_past_line_end=true, enter_accept=false)
+ let ctx_at_end = make_ctx(5, 5, 0, 10);
+ assert_eq!(
+ set_keymap.emacs.resolve(&key("right"), &ctx_at_end),
+ Some(Action::ReturnSelection),
+ "keymap: right at cursor end should return selection (standard defaults)"
+ );
+ }
+
+ #[test]
+ fn keys_has_non_default_values_detection() {
+ use atuin_client::settings::Keys;
+
+ let standard = Keys::standard_defaults();
+ assert!(!standard.has_non_default_values());
+
+ let mut modified = Keys::standard_defaults();
+ modified.scroll_exits = false;
+ assert!(modified.has_non_default_values());
+
+ let mut modified = Keys::standard_defaults();
+ modified.prefix = "x".to_string();
+ assert!(modified.has_non_default_values());
+ }
+}
diff --git a/crates/atuin/src/command/client/search/keybindings/key.rs b/crates/atuin/src/command/client/search/keybindings/key.rs
new file mode 100644
index 00000000..945e4156
--- /dev/null
+++ b/crates/atuin/src/command/client/search/keybindings/key.rs
@@ -0,0 +1,451 @@
+use std::fmt;
+
+use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+
+/// A single key press with modifiers (e.g. `ctrl-c`, `alt-f`, `enter`).
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+#[allow(clippy::struct_excessive_bools)]
+pub struct SingleKey {
+ pub code: KeyCodeValue,
+ pub ctrl: bool,
+ pub alt: bool,
+ pub shift: bool,
+ pub super_key: bool,
+}
+
+/// The key code portion of a key press.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum KeyCodeValue {
+ Char(char),
+ Enter,
+ Esc,
+ Tab,
+ Backspace,
+ Delete,
+ Up,
+ Down,
+ Left,
+ Right,
+ Home,
+ End,
+ PageUp,
+ PageDown,
+ Space,
+}
+
+/// A key input that may be a single key or a multi-key sequence (e.g. `g g`).
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum KeyInput {
+ Single(SingleKey),
+ Sequence(Vec<SingleKey>),
+}
+
+impl SingleKey {
+ /// Convert a crossterm `KeyEvent` into a `SingleKey`.
+ pub fn from_event(event: &KeyEvent) -> Self {
+ let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
+ let alt = event.modifiers.contains(KeyModifiers::ALT);
+ let shift = event.modifiers.contains(KeyModifiers::SHIFT);
+ let super_key = event.modifiers.contains(KeyModifiers::SUPER);
+
+ let code = match event.code {
+ KeyCode::Char(' ') => KeyCodeValue::Space,
+ KeyCode::Char(c) => {
+ // If shift is the only modifier and it's an uppercase letter,
+ // we store the uppercase char directly and clear the shift flag
+ // since the case already encodes it.
+ if shift && !ctrl && !alt && !super_key && c.is_ascii_uppercase() {
+ return SingleKey {
+ code: KeyCodeValue::Char(c),
+ ctrl: false,
+ alt: false,
+ shift: false,
+ super_key: false,
+ };
+ }
+ KeyCodeValue::Char(c)
+ }
+ KeyCode::Enter => KeyCodeValue::Enter,
+ KeyCode::Esc => KeyCodeValue::Esc,
+ KeyCode::Tab => KeyCodeValue::Tab,
+ KeyCode::Backspace => KeyCodeValue::Backspace,
+ KeyCode::Delete => KeyCodeValue::Delete,
+ KeyCode::Up => KeyCodeValue::Up,
+ KeyCode::Down => KeyCodeValue::Down,
+ KeyCode::Left => KeyCodeValue::Left,
+ KeyCode::Right => KeyCodeValue::Right,
+ KeyCode::Home => KeyCodeValue::Home,
+ KeyCode::End => KeyCodeValue::End,
+ KeyCode::PageUp => KeyCodeValue::PageUp,
+ KeyCode::PageDown => KeyCodeValue::PageDown,
+ // For keys we don't handle, store them as a null char
+ _ => KeyCodeValue::Char('\0'),
+ };
+
+ SingleKey {
+ code,
+ ctrl,
+ alt,
+ // Clear shift for plain chars since case encodes it
+ shift: if matches!(code, KeyCodeValue::Char(_)) {
+ false
+ } else {
+ shift
+ },
+ super_key,
+ }
+ }
+
+ /// Parse a key string like `"ctrl-c"`, `"alt-f"`, `"enter"`, `"G"`.
+ pub fn parse(s: &str) -> Result<Self, String> {
+ let s = s.trim();
+ let parts: Vec<&str> = s.split('-').collect();
+
+ let mut ctrl = false;
+ let mut alt = false;
+ let mut shift = false;
+ let mut super_key = false;
+
+ // All parts except the last are modifiers
+ for &part in &parts[..parts.len() - 1] {
+ match part.to_lowercase().as_str() {
+ "ctrl" => ctrl = true,
+ "alt" => alt = true,
+ "shift" => shift = true,
+ "super" | "cmd" | "win" => super_key = true,
+ _ => return Err(format!("unknown modifier: {part}")),
+ }
+ }
+
+ let key_part = parts[parts.len() - 1];
+ let code = match key_part.to_lowercase().as_str() {
+ "enter" | "return" => KeyCodeValue::Enter,
+ "esc" | "escape" => KeyCodeValue::Esc,
+ "tab" => KeyCodeValue::Tab,
+ "backspace" => KeyCodeValue::Backspace,
+ "delete" | "del" => KeyCodeValue::Delete,
+ "up" => KeyCodeValue::Up,
+ "down" => KeyCodeValue::Down,
+ "left" => KeyCodeValue::Left,
+ "right" => KeyCodeValue::Right,
+ "home" => KeyCodeValue::Home,
+ "end" => KeyCodeValue::End,
+ "pageup" => KeyCodeValue::PageUp,
+ "pagedown" => KeyCodeValue::PageDown,
+ "space" => KeyCodeValue::Space,
+ "[" => KeyCodeValue::Char('['),
+ "]" => KeyCodeValue::Char(']'),
+ "?" => KeyCodeValue::Char('?'),
+ "/" => KeyCodeValue::Char('/'),
+ _ => {
+ let chars: Vec<char> = key_part.chars().collect();
+ if chars.len() == 1 {
+ let c = chars[0];
+ // An uppercase letter implies shift (unless shift already specified)
+ if c.is_ascii_uppercase() && !ctrl && !alt && !super_key {
+ return Ok(SingleKey {
+ code: KeyCodeValue::Char(c),
+ ctrl: false,
+ alt: false,
+ shift: false,
+ super_key: false,
+ });
+ }
+ KeyCodeValue::Char(c)
+ } else {
+ return Err(format!("unknown key: {key_part}"));
+ }
+ }
+ };
+
+ Ok(SingleKey {
+ code,
+ ctrl,
+ alt,
+ shift,
+ super_key,
+ })
+ }
+}
+
+impl fmt::Display for SingleKey {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ if self.super_key {
+ write!(f, "super-")?;
+ }
+ if self.ctrl {
+ write!(f, "ctrl-")?;
+ }
+ if self.alt {
+ write!(f, "alt-")?;
+ }
+ if self.shift {
+ write!(f, "shift-")?;
+ }
+ match &self.code {
+ KeyCodeValue::Char(c) => write!(f, "{c}"),
+ KeyCodeValue::Enter => write!(f, "enter"),
+ KeyCodeValue::Esc => write!(f, "esc"),
+ KeyCodeValue::Tab => write!(f, "tab"),
+ KeyCodeValue::Backspace => write!(f, "backspace"),
+ KeyCodeValue::Delete => write!(f, "delete"),
+ KeyCodeValue::Up => write!(f, "up"),
+ KeyCodeValue::Down => write!(f, "down"),
+ KeyCodeValue::Left => write!(f, "left"),
+ KeyCodeValue::Right => write!(f, "right"),
+ KeyCodeValue::Home => write!(f, "home"),
+ KeyCodeValue::End => write!(f, "end"),
+ KeyCodeValue::PageUp => write!(f, "pageup"),
+ KeyCodeValue::PageDown => write!(f, "pagedown"),
+ KeyCodeValue::Space => write!(f, "space"),
+ }
+ }
+}
+
+impl KeyInput {
+ /// Parse a key input string. Supports multi-key sequences separated by spaces
+ /// (e.g. `"g g"`).
+ pub fn parse(s: &str) -> Result<Self, String> {
+ let s = s.trim();
+ // Check for space-separated multi-key sequences
+ // But don't split "space" or modifier combos like "ctrl-a"
+ let parts: Vec<&str> = s.split_whitespace().collect();
+ if parts.len() > 1 {
+ let keys: Result<Vec<SingleKey>, String> =
+ parts.iter().map(|p| SingleKey::parse(p)).collect();
+ Ok(KeyInput::Sequence(keys?))
+ } else {
+ Ok(KeyInput::Single(SingleKey::parse(s)?))
+ }
+ }
+}
+
+impl fmt::Display for KeyInput {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ KeyInput::Single(k) => write!(f, "{k}"),
+ KeyInput::Sequence(keys) => {
+ for (i, k) in keys.iter().enumerate() {
+ if i > 0 {
+ write!(f, " ")?;
+ }
+ write!(f, "{k}")?;
+ }
+ Ok(())
+ }
+ }
+ }
+}
+
+impl Serialize for KeyInput {
+ fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+ serializer.serialize_str(&self.to_string())
+ }
+}
+
+impl<'de> Deserialize<'de> for KeyInput {
+ fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+ let s = String::deserialize(deserializer)?;
+ KeyInput::parse(&s).map_err(serde::de::Error::custom)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ #[test]
+ fn parse_simple_keys() {
+ let k = SingleKey::parse("a").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('a'));
+ assert!(!k.ctrl && !k.alt && !k.shift);
+
+ let k = SingleKey::parse("enter").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Enter);
+
+ let k = SingleKey::parse("esc").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Esc);
+
+ let k = SingleKey::parse("tab").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Tab);
+
+ let k = SingleKey::parse("space").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Space);
+ }
+
+ #[test]
+ fn parse_modifiers() {
+ let k = SingleKey::parse("ctrl-c").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('c'));
+ assert!(k.ctrl);
+ assert!(!k.alt);
+
+ let k = SingleKey::parse("alt-f").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('f'));
+ assert!(k.alt);
+ assert!(!k.ctrl);
+
+ let k = SingleKey::parse("ctrl-alt-x").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('x'));
+ assert!(k.ctrl && k.alt);
+ }
+
+ #[test]
+ fn parse_uppercase_implies_no_shift_flag() {
+ let k = SingleKey::parse("G").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('G'));
+ assert!(!k.shift);
+ assert!(!k.ctrl);
+ }
+
+ #[test]
+ fn parse_special_chars() {
+ let k = SingleKey::parse("ctrl-[").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('['));
+ assert!(k.ctrl);
+
+ let k = SingleKey::parse("?").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('?'));
+
+ let k = SingleKey::parse("/").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('/'));
+ }
+
+ #[test]
+ fn parse_multi_key_sequence() {
+ let ki = KeyInput::parse("g g").unwrap();
+ match ki {
+ KeyInput::Sequence(keys) => {
+ assert_eq!(keys.len(), 2);
+ assert_eq!(keys[0].code, KeyCodeValue::Char('g'));
+ assert_eq!(keys[1].code, KeyCodeValue::Char('g'));
+ }
+ _ => panic!("expected sequence"),
+ }
+ }
+
+ #[test]
+ fn display_round_trip() {
+ let cases = ["ctrl-c", "alt-f", "enter", "G", "tab", "pageup"];
+ for s in cases {
+ let k = KeyInput::parse(s).unwrap();
+ let display = k.to_string();
+ let k2 = KeyInput::parse(&display).unwrap();
+ assert_eq!(k, k2, "round-trip failed for {s}");
+ }
+
+ let ki = KeyInput::parse("g g").unwrap();
+ assert_eq!(ki.to_string(), "g g");
+ }
+
+ #[test]
+ fn from_event_basic() {
+ let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
+ let k = SingleKey::from_event(&event);
+ assert_eq!(k.code, KeyCodeValue::Char('c'));
+ assert!(k.ctrl);
+ assert!(!k.alt);
+
+ let event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
+ let k = SingleKey::from_event(&event);
+ assert_eq!(k.code, KeyCodeValue::Enter);
+ }
+
+ #[test]
+ fn from_event_uppercase() {
+ // Crossterm sends uppercase chars with SHIFT modifier
+ let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT);
+ let k = SingleKey::from_event(&event);
+ assert_eq!(k.code, KeyCodeValue::Char('G'));
+ // shift flag should be cleared since the case encodes it
+ assert!(!k.shift);
+ }
+
+ #[test]
+ fn from_event_matches_parsed() {
+ // Verify that from_event and parse produce the same SingleKey
+ let event = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
+ let from_event = SingleKey::from_event(&event);
+ let parsed = SingleKey::parse("ctrl-c").unwrap();
+ assert_eq!(from_event, parsed);
+
+ let event = KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT);
+ let from_event = SingleKey::from_event(&event);
+ let parsed = SingleKey::parse("G").unwrap();
+ assert_eq!(from_event, parsed);
+ }
+
+ #[test]
+ fn parse_super_modifier() {
+ let k = SingleKey::parse("super-a").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('a'));
+ assert!(k.super_key);
+ assert!(!k.ctrl && !k.alt && !k.shift);
+
+ // "cmd" is an alias for "super"
+ let k2 = SingleKey::parse("cmd-a").unwrap();
+ assert_eq!(k, k2);
+
+ // "win" is an alias for "super"
+ let k3 = SingleKey::parse("win-a").unwrap();
+ assert_eq!(k, k3);
+ }
+
+ #[test]
+ fn parse_super_with_other_modifiers() {
+ let k = SingleKey::parse("super-ctrl-c").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('c'));
+ assert!(k.super_key && k.ctrl);
+ assert!(!k.alt && !k.shift);
+ }
+
+ #[test]
+ fn display_super_modifier() {
+ let k = SingleKey::parse("super-a").unwrap();
+ assert_eq!(k.to_string(), "super-a");
+
+ let k = SingleKey::parse("super-ctrl-x").unwrap();
+ assert_eq!(k.to_string(), "super-ctrl-x");
+ }
+
+ #[test]
+ fn display_round_trip_super() {
+ let k = KeyInput::parse("super-a").unwrap();
+ let display = k.to_string();
+ let k2 = KeyInput::parse(&display).unwrap();
+ assert_eq!(k, k2, "round-trip failed for super-a");
+ }
+
+ #[test]
+ fn from_event_super() {
+ let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER);
+ let k = SingleKey::from_event(&event);
+ assert_eq!(k.code, KeyCodeValue::Char('a'));
+ assert!(k.super_key);
+ assert!(!k.ctrl && !k.alt && !k.shift);
+ }
+
+ #[test]
+ fn from_event_super_matches_parsed() {
+ let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SUPER);
+ let from_event = SingleKey::from_event(&event);
+ let parsed = SingleKey::parse("super-a").unwrap();
+ assert_eq!(from_event, parsed);
+ }
+
+ #[test]
+ fn super_uppercase_preserves_super() {
+ // super-G should keep the super flag (unlike bare "G" which clears shift)
+ let k = SingleKey::parse("super-G").unwrap();
+ assert_eq!(k.code, KeyCodeValue::Char('G'));
+ assert!(k.super_key);
+ }
+
+ #[test]
+ fn parse_errors() {
+ assert!(SingleKey::parse("ctrl-alt-shift-xxx").is_err());
+ assert!(SingleKey::parse("foobar-a").is_err());
+ }
+}
diff --git a/crates/atuin/src/command/client/search/keybindings/keymap.rs b/crates/atuin/src/command/client/search/keybindings/keymap.rs
new file mode 100644
index 00000000..4d91e180
--- /dev/null
+++ b/crates/atuin/src/command/client/search/keybindings/keymap.rs
@@ -0,0 +1,231 @@
+use std::collections::HashMap;
+
+use super::actions::Action;
+use super::conditions::{ConditionExpr, EvalContext};
+use super::key::{KeyInput, SingleKey};
+
+/// A single rule within a keybinding: an optional condition and an action.
+/// If the condition is `None`, the rule always matches.
+#[derive(Debug, Clone)]
+pub struct KeyRule {
+ pub condition: Option<ConditionExpr>,
+ pub action: Action,
+}
+
+/// A keybinding is an ordered list of rules. The first rule whose condition
+/// matches (or has no condition) wins.
+#[derive(Debug, Clone)]
+pub struct KeyBinding {
+ pub rules: Vec<KeyRule>,
+}
+
+/// A keymap is a collection of keybindings indexed by key input.
+#[derive(Debug, Clone)]
+pub struct Keymap {
+ pub bindings: HashMap<KeyInput, KeyBinding>,
+}
+
+impl KeyRule {
+ /// Create an unconditional rule.
+ pub fn always(action: Action) -> Self {
+ KeyRule {
+ condition: None,
+ action,
+ }
+ }
+
+ /// Create a conditional rule. Accepts any type convertible to `ConditionExpr`,
+ /// including bare `ConditionAtom` values.
+ pub fn when(condition: impl Into<ConditionExpr>, action: Action) -> Self {
+ KeyRule {
+ condition: Some(condition.into()),
+ action,
+ }
+ }
+}
+
+impl KeyBinding {
+ /// Create a simple (unconditional) binding.
+ pub fn simple(action: Action) -> Self {
+ KeyBinding {
+ rules: vec![KeyRule::always(action)],
+ }
+ }
+
+ /// Create a conditional binding from a list of rules.
+ pub fn conditional(rules: Vec<KeyRule>) -> Self {
+ KeyBinding { rules }
+ }
+}
+
+impl Keymap {
+ /// Create an empty keymap.
+ pub fn new() -> Self {
+ Keymap {
+ bindings: HashMap::new(),
+ }
+ }
+
+ /// Bind a key input to a simple (unconditional) action.
+ pub fn bind(&mut self, key: KeyInput, action: Action) {
+ self.bindings.insert(key, KeyBinding::simple(action));
+ }
+
+ /// Bind a key input to a conditional set of rules.
+ pub fn bind_conditional(&mut self, key: KeyInput, rules: Vec<KeyRule>) {
+ self.bindings.insert(key, KeyBinding::conditional(rules));
+ }
+
+ /// Resolve a key input to an action given the current evaluation context.
+ /// Returns `None` if the key has no binding or no rule's condition matches.
+ pub fn resolve(&self, key: &KeyInput, ctx: &EvalContext) -> Option<Action> {
+ let binding = self.bindings.get(key)?;
+ for rule in &binding.rules {
+ match &rule.condition {
+ None => return Some(rule.action.clone()),
+ Some(cond) if cond.evaluate(ctx) => return Some(rule.action.clone()),
+ Some(_) => {}
+ }
+ }
+ None
+ }
+
+ /// Check if any binding starts with the given single key as the first key
+ /// of a multi-key sequence. Used to detect pending multi-key sequences.
+ pub fn has_sequence_starting_with(&self, prefix: &SingleKey) -> bool {
+ self.bindings.keys().any(|ki| match ki {
+ KeyInput::Sequence(keys) => keys.first() == Some(prefix),
+ KeyInput::Single(_) => false,
+ })
+ }
+
+ /// Merge another keymap into this one. Keys from `other` override keys in `self`.
+ #[allow(dead_code)]
+ pub fn merge(&mut self, other: &Keymap) {
+ for (key, binding) in &other.bindings {
+ self.bindings.insert(key.clone(), binding.clone());
+ }
+ }
+}
+
+impl Default for Keymap {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::super::conditions::ConditionAtom;
+ use super::*;
+
+ fn make_ctx(cursor: usize, width: usize, selected: usize, len: usize) -> EvalContext {
+ EvalContext {
+ cursor_position: cursor,
+ input_width: width,
+ input_byte_len: width,
+ selected_index: selected,
+ results_len: len,
+ }
+ }
+
+ #[test]
+ fn simple_binding_resolves() {
+ let mut keymap = Keymap::new();
+ let key = KeyInput::parse("ctrl-c").unwrap();
+ keymap.bind(key.clone(), Action::ReturnOriginal);
+
+ let ctx = make_ctx(0, 0, 0, 10);
+ assert_eq!(keymap.resolve(&key, &ctx), Some(Action::ReturnOriginal));
+ }
+
+ #[test]
+ fn conditional_first_match_wins() {
+ let mut keymap = Keymap::new();
+ let key = KeyInput::parse("left").unwrap();
+ keymap.bind_conditional(
+ key.clone(),
+ vec![
+ KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit),
+ KeyRule::always(Action::CursorLeft),
+ ],
+ );
+
+ // Cursor at start → Exit
+ let ctx = make_ctx(0, 5, 0, 10);
+ assert_eq!(keymap.resolve(&key, &ctx), Some(Action::Exit));
+
+ // Cursor not at start → CursorLeft
+ let ctx = make_ctx(3, 5, 0, 10);
+ assert_eq!(keymap.resolve(&key, &ctx), Some(Action::CursorLeft));
+ }
+
+ #[test]
+ fn no_match_returns_none() {
+ let keymap = Keymap::new();
+ let key = KeyInput::parse("ctrl-c").unwrap();
+ let ctx = make_ctx(0, 0, 0, 0);
+ assert_eq!(keymap.resolve(&key, &ctx), None);
+ }
+
+ #[test]
+ fn conditional_no_condition_matches_returns_none() {
+ let mut keymap = Keymap::new();
+ let key = KeyInput::parse("left").unwrap();
+ // Only one rule with a condition that won't match
+ keymap.bind_conditional(
+ key.clone(),
+ vec![KeyRule::when(ConditionAtom::CursorAtStart, Action::Exit)],
+ );
+
+ // Cursor not at start → no match
+ let ctx = make_ctx(3, 5, 0, 10);
+ assert_eq!(keymap.resolve(&key, &ctx), None);
+ }
+
+ #[test]
+ fn has_sequence_starting_with() {
+ let mut keymap = Keymap::new();
+ let seq = KeyInput::parse("g g").unwrap();
+ keymap.bind(seq, Action::ScrollToTop);
+
+ let g = SingleKey::parse("g").unwrap();
+ assert!(keymap.has_sequence_starting_with(&g));
+
+ let h = SingleKey::parse("h").unwrap();
+ assert!(!keymap.has_sequence_starting_with(&h));
+ }
+
+ #[test]
+ fn merge_overrides() {
+ let mut base = Keymap::new();
+ let key = KeyInput::parse("ctrl-c").unwrap();
+ base.bind(key.clone(), Action::ReturnOriginal);
+
+ let mut overlay = Keymap::new();
+ overlay.bind(key.clone(), Action::Exit);
+
+ base.merge(&overlay);
+
+ let ctx = make_ctx(0, 0, 0, 0);
+ assert_eq!(base.resolve(&key, &ctx), Some(Action::Exit));
+ }
+
+ #[test]
+ fn merge_preserves_unoverridden() {
+ let mut base = Keymap::new();
+ let key1 = KeyInput::parse("ctrl-c").unwrap();
+ let key2 = KeyInput::parse("ctrl-d").unwrap();
+ base.bind(key1.clone(), Action::ReturnOriginal);
+ base.bind(key2.clone(), Action::DeleteCharAfter);
+
+ let mut overlay = Keymap::new();
+ overlay.bind(key1.clone(), Action::Exit);
+
+ base.merge(&overlay);
+
+ let ctx = make_ctx(0, 0, 0, 0);
+ assert_eq!(base.resolve(&key1, &ctx), Some(Action::Exit));
+ assert_eq!(base.resolve(&key2, &ctx), Some(Action::DeleteCharAfter));
+ }
+}
diff --git a/crates/atuin/src/command/client/search/keybindings/mod.rs b/crates/atuin/src/command/client/search/keybindings/mod.rs
new file mode 100644
index 00000000..a9454b0d
--- /dev/null
+++ b/crates/atuin/src/command/client/search/keybindings/mod.rs
@@ -0,0 +1,14 @@
+pub mod actions;
+pub mod conditions;
+pub mod defaults;
+pub mod key;
+pub mod keymap;
+
+pub use actions::Action;
+#[allow(unused_imports)]
+pub use conditions::{ConditionAtom, ConditionExpr, EvalContext};
+pub use defaults::KeymapSet;
+#[allow(unused_imports)]
+pub use key::{KeyCodeValue, KeyInput, SingleKey};
+#[allow(unused_imports)]
+pub use keymap::{KeyBinding, KeyRule, Keymap};